diff --git a/.gitignore b/.gitignore index 7d6264e6..e47b8ec3 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,4 @@ next-env.d.ts .idea # claude -.claude \ No newline at end of file +.claude diff --git a/package-lock.json b/package-lock.json index 01bff9ef..f960d1c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1855,6 +1855,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1924,6 +1925,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -2447,6 +2449,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3060,7 +3063,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/daisyui": { "version": "5.5.8", @@ -3516,6 +3520,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3689,6 +3694,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6167,6 +6173,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6197,6 +6204,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7083,6 +7091,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7250,6 +7259,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/app/closing/detail/page.tsx b/src/app/closing/detail/page.tsx index 6225b8dd..487533be 100644 --- a/src/app/closing/detail/page.tsx +++ b/src/app/closing/detail/page.tsx @@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; import ClosingDetail from '@/components/pages/closing/ClosingDetail'; +import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable'; import { ClosingApi } from '@/services/api/closing'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; @@ -19,6 +20,11 @@ const ClosingDetailPage = () => { (id: number) => ClosingApi.getGeneralInfo(id) ); + const { data: salesReport, isLoading: isLoadingSalesReport } = useSWR( + closingId, + (id: number) => ClosingApi.getPenjualan(id) + ); + if (!closingId) { router.back(); @@ -43,6 +49,9 @@ const ClosingDetailPage = () => { {!isLoadingClosing && isResponseSuccess(closing) && ( )} + {!isLoadingSalesReport && isResponseSuccess(salesReport) && ( + + )} ); }; diff --git a/src/app/production/project-flock/layout.tsx b/src/app/production/project-flock/layout.tsx index 698064cf..b74ef612 100644 --- a/src/app/production/project-flock/layout.tsx +++ b/src/app/production/project-flock/layout.tsx @@ -52,6 +52,7 @@ export default function ProjectFlockLayout({ closeOnBackdropClick={isDetail ? true : false} onBackdropClick={handleBackdropClick} variant='right' + zIndex='99999' sidebarContent={isOpen &&
{children}
} /> diff --git a/src/components/FloatingActionsButton.tsx b/src/components/FloatingActionsButton.tsx index c0033d72..c9ca3454 100644 --- a/src/components/FloatingActionsButton.tsx +++ b/src/components/FloatingActionsButton.tsx @@ -54,7 +54,7 @@ const FloatingActionsButton = ({
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 973bf031..bee92a57 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -7,6 +7,7 @@ import { Icon } from '@iconify/react'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import Button from '@/components/Button'; +import Dropdown from '@/components/dropdown/Dropdown'; import { useAuth } from '@/services/hooks/useAuth'; import { AuthApi } from '@/services/api/auth'; @@ -52,21 +53,21 @@ const Navbar = ({ title, toggleSidebar }: NavbarProps) => {
-
-
-
- + +
+ +
-
- - + } + contentClassName='w-52 mt-3' + > + -
+
); diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 970c5bc1..9feb33e2 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -14,6 +14,7 @@ import { SortingState, OnChangeFn, Row, + HeaderContext, } from '@tanstack/react-table'; import { rankItem } from '@tanstack/match-sorter-utils'; import { Icon } from '@iconify/react'; @@ -31,6 +32,9 @@ interface TableClassNames { tableBodyClassName?: string; bodyRowClassName?: string; bodyColumnClassName?: string; + tableFooterClassName?: string; + footerRowClassName?: string; + footerColumnClassName?: string; paginationClassName?: string; } @@ -53,6 +57,7 @@ export interface TableProps { rowSelection?: Record; setRowSelection?: OnChangeFn>; enableRowSelection?: boolean | ((row: Row) => boolean); + renderFooter?: boolean; withCheckbox?: boolean; rowOptions?: number[]; } @@ -67,18 +72,22 @@ const emptyContentDefaultValue = ( ); -const TABLE_DEFAULT_STYLING = { +export const TABLE_DEFAULT_STYLING = { containerClassName: 'w-full mb-20', tableWrapperClassName: 'overflow-x-auto border border-solid border-base-content/10 rounded-lg', tableClassName: 'font-inter w-full table-auto text-sm font-medium', tableHeaderClassName: '', headerRowClassName: '', - headerColumnClassName: 'px-4 py-3 text-base-content/50', + headerColumnClassName: + 'px-4 py-3 border-base-content/10 text-base-content/50', tableBodyClassName: '', - bodyRowClassName: 'border-t border-t-base-content/10', + bodyRowClassName: 'border-t border-base-content/10', bodyColumnClassName: 'px-4 py-3 text-base-content', paginationClassName: '', + tableFooterClassName: 'font-semibold border-base-content/10', + footerRowClassName: 'bg-base-200 border-t-2 border-base-content/10', + footerColumnClassName: 'p-4 text-base-content whitespace-nowrap', }; const Table = ({ @@ -100,6 +109,7 @@ const Table = ({ rowSelection, setRowSelection, enableRowSelection, + renderFooter = false, withCheckbox = false, rowOptions = [10, 20, 50, 100], }: TableProps) => { @@ -214,58 +224,82 @@ const Table = ({ key={headerGroup.id} className={tableClassNames.headerRowClassName} > - {headerGroup.headers.map((header) => ( - -
- {flexRender( - header.column.columnDef.header, - header.getContext() + {headerGroup.headers.map((header) => { + const columnRelativeDepth = + header.depth - header.column.depth; + if ( + !header.isPlaceholder && + columnRelativeDepth > 1 && + header.id === header.column.id + ) { + return null; + } + let rowSpan = 1; + if (header.isPlaceholder) { + const leafs = header.getLeafHeaders(); + rowSpan = leafs[leafs.length - 1].depth - header.depth; + } + return ( + 1, + }, + tableClassNames.headerColumnClassName )} + > +
1, + })} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} - {header.column.getCanSort() && ( -
- - -
- )} -
- - ))} + {header.column.getCanSort() && ( +
+ + +
+ )} +
+ + ); + })} ))} @@ -290,6 +324,28 @@ const Table = ({ ))} + + {renderFooter && ( + + {table.getAllLeafColumns().map((column) => ( + + {column.columnDef.footer && + flexRender(column.columnDef.footer, { + column, + header: column.columnDef, + table, + } as HeaderContext)} + + ))} + + )} + diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx new file mode 100644 index 00000000..4489231d --- /dev/null +++ b/src/components/dropdown/Dropdown.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { ReactNode, useRef, useEffect, useState } from 'react'; +import { cn } from '@/lib/helper'; + +interface DropdownProps { + trigger: ReactNode; + children: ReactNode; + position?: + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'top-start' + | 'top-end' + | 'bottom-start' + | 'bottom-end' + | 'left-start' + | 'left-end' + | 'right-start' + | 'right-end'; + align?: 'start' | 'center' | 'end'; + hover?: boolean; + className?: string; + contentClassName?: string; +} + +const Dropdown = ({ + trigger, + children, + position = 'bottom', + align = 'start', + hover = false, + className, + contentClassName, +}: DropdownProps) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Build position classes + const getPositionClasses = () => { + const classes: string[] = []; + + // Handle combined positions like 'top-start' + if (position.includes('-')) { + const [pos, al] = position.split('-'); + classes.push(`dropdown-${pos}`); + classes.push(`dropdown-${al}`); + } else { + classes.push(`dropdown-${position}`); + if (align !== 'start') { + classes.push(`dropdown-${align}`); + } + } + + return classes.join(' '); + }; + + const handleToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // alert('clicked'); + setIsOpen(!isOpen); + }; + + return ( +
+ {/* Trigger Button */} +
+ {trigger} +
+ + {/* Dropdown Content - Only render when open */} + {isOpen && ( +
setIsOpen(false)} // Close on item click + > + {children} +
+ )} +
+ ); +}; + +export default Dropdown; diff --git a/src/components/pages/closing/sale/SalesReportTable.tsx b/src/components/pages/closing/sale/SalesReportTable.tsx new file mode 100644 index 00000000..e509eb7d --- /dev/null +++ b/src/components/pages/closing/sale/SalesReportTable.tsx @@ -0,0 +1,285 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { ColumnDef } from '@tanstack/react-table'; +import Table from '@/components/Table'; +import Card from '@/components/Card'; +import Badge from '@/components/Badge'; +import { formatCurrency, formatNumber, formatDate } from '@/lib/helper'; +import { BaseClosingSales, BaseSales } from '@/types/api/closing'; +import { Product } from '@/types/api/master-data/product'; +import { Customer } from '@/types/api/master-data/customer'; +import { Kandang } from '@/types/api/master-data/kandang'; + +interface SalesReportTableProps { + type?: 'detail'; + initialValues?: BaseClosingSales; +} + +const SalesReportTable = ({ + type = 'detail', + initialValues, +}: SalesReportTableProps) => { + const salesData: BaseSales[] = useMemo(() => { + return initialValues?.sales || []; + }, [initialValues]); + + const totals = useMemo(() => { + if (salesData.length === 0) { + return { + totalQuantity: 0, + totalWeight: 0, + avgWeight: 0, + avgPricePartner: 0, + totalPartner: 0, + }; + } + + const totalQuantity = salesData.reduce( + (sum, item) => sum + (item.qty || 0), + 0 + ); + const totalWeight = salesData.reduce( + (sum, item) => sum + (item.weight || 0), + 0 + ); + const avgWeight = totalQuantity > 0 ? totalWeight / totalQuantity : 0; + + const validPriceItems = salesData.filter( + (item) => item.price != null && item.price > 0 + ); + const avgPricePartner = + validPriceItems.length > 0 + ? validPriceItems.reduce((sum, item) => sum + item.price, 0) / + validPriceItems.length + : 0; + + const totalPartner = salesData.reduce( + (sum, item) => sum + (item.total_price || 0), + 0 + ); + + return { + totalQuantity, + totalWeight, + avgWeight, + avgPricePartner, + totalPartner, + }; + }, [salesData]); + + const salesColumns: ColumnDef[] = useMemo( + () => [ + { + id: 'realization_date', + accessorKey: 'realization_date', + header: 'Tanggal Realisasi', + cell: (props) => { + const date = props.row.original.realization_date; + return date ? formatDate(date, 'DD MMM YYYY') : '-'; + }, + footer: () => ( +
Total Penjualan
+ ), + }, + { + id: 'age', + accessorKey: 'age', + header: 'Umur', + cell: (props) => props.getValue() || '-', + }, + { + id: 'do_number', + accessorKey: 'do_number', + header: 'No. DO', + cell: (props) => props.getValue() || '-', + }, + { + id: 'product', + accessorKey: 'product', + header: 'Produk', + cell: (props) => { + const product = props.getValue() as Product; + return product?.name || '-'; + }, + }, + { + id: 'customer', + accessorKey: 'customer', + header: 'Customer', + cell: (props) => { + const customer = props.getValue() as Customer; + return customer?.name || '-'; + }, + }, + { + id: 'jumlah', + header: 'Jumlah', + columns: [ + { + id: 'qty', + accessorKey: 'qty', + header: 'Kuantitas', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.totalQuantity)} +
+ ), + }, + { + id: 'weight', + accessorKey: 'weight', + header: 'Kg', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.totalWeight)} +
+ ), + }, + ], + }, + { + id: 'avg_weight', + accessorKey: 'avg_weight', + header: 'AVG (Kg)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatNumber(value)}
; + }, + footer: () => ( +
+ {formatNumber(totals.avgWeight)} +
+ ), + }, + { + id: 'price_partner', + accessorKey: 'price', + header: 'Harga Mitra (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + footer: () => ( +
+ {formatCurrency(totals.avgPricePartner)} +
+ ), + }, + { + id: 'total_mitra', + accessorKey: 'total_price', + header: 'Total Mitra (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + footer: () => ( +
+ {formatCurrency(totals.totalPartner)} +
+ ), + }, + { + id: 'price_act', + accessorKey: 'price', + header: 'Harga Act (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + }, + { + id: 'total_act', + accessorKey: 'total_price', + header: 'Total Act (Rp)', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + }, + { + id: 'kandang', + accessorKey: 'kandang', + header: 'Kandang', + cell: (props) => { + const kandang = props.getValue() as Kandang; + return kandang?.name || '-'; + }, + }, + { + id: 'payment_status', + accessorKey: 'payment_status', + header: 'Status Pembayaran', + cell: (props) => { + const status = props.getValue() as string; + const getStatusColor = (status: string) => { + if (!status) return 'neutral'; + switch (status.toLowerCase()) { + case 'paid': + return 'success'; + case 'tempo': + return 'warning'; + default: + return 'neutral'; + } + }; + + return ( + + {status || '-'} + + ); + }, + }, + ], + [] + ); + + return ( + <> +
+
+

Penjualan

+ + 0} + className={{ + tableWrapperClassName: 'overflow-x-auto', + tableClassName: 'w-full table-auto text-sm', + headerColumnClassName: + 'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0', + bodyRowClassName: + 'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r border-r-gray-200', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', + tableFooterClassName: + 'bg-gray-100 font-semibold border border-gray-200', + footerRowClassName: 'border-t-2 border-gray-300', + footerColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', + }} + /> + + + + + ); +}; + +export default SalesReportTable; diff --git a/src/components/pages/production/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index 6cf254e7..4a413bc4 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -370,7 +370,7 @@ const RecordingTable = () => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false); - const [approvalNotes, setApprovalNotes] = useState(''); + const [, setApprovalNotes] = useState(''); const singleDeleteModal = useModal(); const approveModal = useModal(); diff --git a/src/services/api/closing.ts b/src/services/api/closing.ts index 041108d0..fe2c2d50 100644 --- a/src/services/api/closing.ts +++ b/src/services/api/closing.ts @@ -9,12 +9,29 @@ import { } from '@/types/api/closing'; import { httpClient, httpClientFetcher } from '@/services/http/client'; import { BaseApiResponse } from '@/types/api/api-general'; +import { ClosingSales } from '@/types/api/closing'; export class ClosingApiService extends BaseApiService { constructor(basePath: string) { super(basePath); } + async getPenjualan( + id: number + ): Promise | undefined> { + try { + const getPenjualanPath = `${id}/penjualan`; + return await this.customRequest>( + getPenjualanPath + ); + } catch (error) { + if (axios.isAxiosError>(error)) { + return error.response?.data; + } + return undefined; + } + } + async getAllIncomingSapronakFetcher( endpoint: string ): Promise> { @@ -51,4 +68,4 @@ export class ClosingApiService extends BaseApiService { } } -export const ClosingApi = new ClosingApiService('/closing'); +export const ClosingApi = new ClosingApiService('/closings'); diff --git a/src/types/api/closing.d.ts b/src/types/api/closing.d.ts index 3f7ba816..95b2f57f 100644 --- a/src/types/api/closing.d.ts +++ b/src/types/api/closing.d.ts @@ -1,9 +1,34 @@ import { Area } from '@/types/api/master-data/area'; import { Fcr } from '@/types/api/master-data/fcr'; import { Flock } from '@/types/api/master-data/flock'; -import { Kandang } from '@/types/api/master-data/kandang'; import { Location } from '@/types/api/master-data/location'; -import { BaseApproval, BaseMetadata } from '@/types/api/api-general'; +import { Kandang } from '@/types/api/master-data/kandang'; +import { Product } from '@type/api/master-data/product'; +import { Customer } from '@type/api/master-data/customer'; +import { BaseMetadata } from '@/types/api/api-general'; + +export type BaseSales = { + id: number; + realization_date: string; + age: number; + do_number: string; + product: Product; + customer: Customer; + qty: number; + weight: number; + avg_weight: number; + price: number; + total_price: number; + kandang: Kandang; + payment_status: string; +}; + +export type BaseClosingSales = { + project_type: string; + flock_id: number; + period: number; + sales: BaseSales[]; +}; export type BaseClosing = { id: number; @@ -53,3 +78,4 @@ export type ClosingIncomingSapronak = { }; export type ClosingOutgoingSapronak = ClosingIncomingSapronak; +export type ClosingSales = BaseMetadata & BaseClosingSales;