From b039ec832b191445e35493d8760e39f22a00c697 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 9 Dec 2025 15:49:59 +0700 Subject: [PATCH] feat(FE-361): Add logistic-stock report page and table footer --- src/app/report/logistic-stock/layout.tsx | 11 + src/app/report/logistic-stock/page.tsx | 22 + src/components/Table.tsx | 40 ++ .../PurchasesPerSupplierTab.tsx | 521 ++++++++++++++++++ 4 files changed, 594 insertions(+) create mode 100644 src/app/report/logistic-stock/layout.tsx create mode 100644 src/app/report/logistic-stock/page.tsx create mode 100644 src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx diff --git a/src/app/report/logistic-stock/layout.tsx b/src/app/report/logistic-stock/layout.tsx new file mode 100644 index 00000000..7220dfa1 --- /dev/null +++ b/src/app/report/logistic-stock/layout.tsx @@ -0,0 +1,11 @@ +import SuspenseHelper from '@/components/helper/SuspenseHelper'; + +const Layout = ({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) => { + return {children}; +}; + +export default Layout; diff --git a/src/app/report/logistic-stock/page.tsx b/src/app/report/logistic-stock/page.tsx new file mode 100644 index 00000000..5dce741c --- /dev/null +++ b/src/app/report/logistic-stock/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import Tabs from '@/components/Tabs'; +import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/PurchasesPerSupplierTab'; + +const LogisticStock = () => { + const tabs = [ + { + id: '1', + label: 'Rekapitulasi Pembelian Per Supplier', + content: , + }, + ]; + + return ( +
+ +
+ ); +}; + +export default LogisticStock; diff --git a/src/components/Table.tsx b/src/components/Table.tsx index b02dd3b5..5c76f44e 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -31,6 +31,9 @@ interface TableClassNames { tableBodyClassName?: string; bodyRowClassName?: string; bodyColumnClassName?: string; + tableFooterClassName?: string; + footerRowClassName?: string; + footerColumnClassName?: string; paginationClassName?: string; } @@ -52,6 +55,9 @@ export interface TableProps { rowSelection?: Record; setRowSelection?: OnChangeFn>; enableRowSelection?: boolean | ((row: Row) => boolean); + renderFooter?: boolean; + footerContent?: ReactNode; + footerData?: TData[]; } const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}]; @@ -84,6 +90,9 @@ const Table = ({ tableBodyClassName: '', bodyRowClassName: '', bodyColumnClassName: '', + tableFooterClassName: '', + footerRowClassName: '', + footerColumnClassName: '', paginationClassName: '', }, emptyContent = emptyContentDefaultValue, @@ -93,6 +102,9 @@ const Table = ({ rowSelection, setRowSelection, enableRowSelection, + renderFooter = false, + footerContent, + footerData = [], }: TableProps) => { const isServerSideTable = totalItems !== undefined && @@ -160,6 +172,14 @@ const Table = ({ const table = useReactTable(tableOptions); const { setPageSize } = table; + const footerTableOptions: TableOptions = { + columns, + data: footerData, + getCoreRowModel: getCoreRowModel(), + }; + + const footerTable = useReactTable(footerTableOptions); + const prevPageClickHandler = () => { table.previousPage(); @@ -262,6 +282,26 @@ const Table = ({ ))} + + {renderFooter && + (footerData && footerData.length > 0 + ? footerTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + : footerContent)} + diff --git a/src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx new file mode 100644 index 00000000..a6f5f364 --- /dev/null +++ b/src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx @@ -0,0 +1,521 @@ +import { useState, useMemo } from 'react'; +import Card from '@/components/Card'; +import SelectInput, { useSelect } from '@/components/input/SelectInput'; +import { AreaApi } from '@/services/api/master-data'; +import { SupplierApi } from '@/services/api/master-data'; +import { ProductApi } from '@/services/api/master-data'; +import Table from '@/components/Table'; +import { ColumnDef } from '@tanstack/react-table'; +import { formatCurrency, formatDate } from '@/lib/helper'; + +const PurchasesPerSupplierTab = () => { + const [selectedArea, setSelectedArea] = useState(null); + const [selectedSupplier, setSelectedSupplier] = useState(null); + const [selectedProduct, setSelectedProduct] = useState(null); + + const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( + AreaApi.basePath, + 'id', + 'name', + 'search' + ); + + const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } = + useSelect(SupplierApi.basePath, 'id', 'name', 'search'); + + const { options: productOptions, isLoadingOptions: isLoadingProducts } = + useSelect(ProductApi.basePath, 'id', 'name', 'search'); + + const data = useMemo( + () => [ + { + id: 1, + supplier: 'PT. RAJAWALI MITRA PAKANINDO', + items: [ + { + id: 1, + received_date: '2025-09-26', + po_date: '2025-09-30', + po_number: 'PO-MBU-00670', + product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', + destination_warehouse: 'GUDANG CIMARAGAS 1', + qty: 5, + price: 45000, + purchase_amount: 225000, + transport: 0, + value_transport: 0, + total: 225000, + expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', + travel_number: '-', + }, + { + id: 2, + received_date: '2025-09-30', + po_date: '2025-09-30', + po_number: 'PO-MBU-00670', + product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', + destination_warehouse: 'GUDANG CIMARAGAS 2', + qty: 5, + price: 45000, + purchase_amount: 225000, + transport: 0, + value_transport: 0, + total: 225000, + expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', + travel_number: '-', + }, + { + id: 3, + received_date: '2025-09-30', + po_date: '2025-09-30', + po_number: 'PO-MBU-00670', + product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', + destination_warehouse: 'GUDANG CIMARAGAS 3', + qty: 5, + price: 45000, + purchase_amount: 225000, + transport: 0, + value_transport: 0, + total: 225000, + expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', + travel_number: '-', + }, + { + id: 4, + received_date: '2025-09-30', + po_date: '2025-09-30', + po_number: 'PO-MBU-00670', + product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', + destination_warehouse: 'GUDANG CIMARAGAS 4', + qty: 5, + price: 45000, + purchase_amount: 225000, + transport: 0, + value_transport: 0, + total: 225000, + expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', + travel_number: '-', + }, + { + id: 5, + received_date: '2025-09-04', + po_date: '2025-09-30', + po_number: 'PO-MBU-00606', + product_name: 'DESINFEKTAN C 100 @20L', + destination_warehouse: 'GUDANG MANDALAWANGI 1', + qty: 1, + price: 800000, + purchase_amount: 800000, + transport: 0, + value_transport: 0, + total: 800000, + expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', + travel_number: '-', + }, + ], + }, + { + id: 2, + supplier: 'Supplier B', + items: [ + { + id: 6, + received_date: '2024-01-18', + po_date: '2024-01-13', + po_number: 'PO-2024-004', + product_name: 'Produk D', + destination_warehouse: 'Gudang Pusat', + qty: 200, + price: 25000, + purchase_amount: 5000000, + transport: 200000, + value_transport: 200000, + total: 5200000, + expedition_vendor_name: 'Ekspedisi GHI', + travel_number: 'SJ-004', + }, + ], + }, + { + id: 3, + supplier: 'Supplier C', + items: [ + { + id: 7, + received_date: '2024-01-20', + po_date: '2024-01-15', + po_number: 'PO-2024-006', + product_name: 'Produk F', + destination_warehouse: 'Gudang Cabang', + qty: 80, + price: 55000, + purchase_amount: 4400000, + transport: 80000, + value_transport: 80000, + total: 4480000, + expedition_vendor_name: 'Ekspedisi MNO', + travel_number: 'SJ-006', + }, + ], + }, + ], + [] + ); + + // TODO START + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tableColumns: ColumnDef[] = [ + { + header: 'No', + accessorKey: 'no', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) { + return ( +
+ {props.row.original.no} +
+ ); + } + return props.row.index + 1; + }, + }, + { + header: 'Tanggal Terima', + accessorKey: 'received_date', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) { + return ( +
+ {props.row.original.received_date} +
+ ); + } + return formatDate(props.row.original.received_date, 'DD MMM YYYY'); + }, + }, + { + header: 'Tanggal PO', + accessorKey: 'po_date', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) return null; + return formatDate(props.row.original.po_date, 'DD MMM YYYY'); + }, + }, + { + header: 'No. Referensi', + accessorKey: 'po_number', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) return null; + return props.row.original.po_number; + }, + }, + { + header: 'Nama Produk', + accessorKey: 'product_name', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) return null; + return props.row.original.product_name; + }, + }, + { + header: 'Tujuan', + accessorKey: 'destination_warehouse', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) return null; + return props.row.original.destination_warehouse; + }, + }, + { + header: 'QTY', + accessorKey: 'qty', + cell: (props) => { + const value = props.getValue() as number; + const isFooter = '_isFooter' in props.row.original; + return ( +
+ {value.toLocaleString()} +
+ ); + }, + }, + { + header: 'Harga Beli (Rp)', + accessorKey: 'price', + cell: (props) => { + const value = props.getValue() as number; + const isFooter = '_isFooter' in props.row.original; + if (isFooter) { + return ( +
+ {formatCurrency(value)} +
+ ); + } + return ( +
+ {formatCurrency(props.row.original.price)} +
+ ); + }, + }, + { + header: 'Value Harga Beli (Rp)', + accessorKey: 'purchase_amount', + cell: (props) => { + const value = props.getValue() as number; + const isFooter = '_isFooter' in props.row.original; + return ( +
+ {formatCurrency(value)} +
+ ); + }, + }, + { + header: 'Transport (Rp)', + accessorKey: 'transport', + cell: (props) => { + const value = props.getValue() as number; + const isFooter = '_isFooter' in props.row.original; + if (isFooter) { + return ( +
+ {formatCurrency(value)} +
+ ); + } + return ( +
+ {formatCurrency(props.row.original.transport)} +
+ ); + }, + }, + { + header: 'Value Transport (Rp)', + accessorKey: 'value_transport', + cell: (props) => { + const value = props.getValue() as number; + const isFooter = '_isFooter' in props.row.original; + return ( +
+ {formatCurrency(value)} +
+ ); + }, + }, + { + header: 'Jumlah (Rp)', + accessorKey: 'total', + cell: (props) => { + const value = props.getValue() as number; + const isFooter = '_isFooter' in props.row.original; + return ( +
+ {formatCurrency(value)} +
+ ); + }, + }, + { + header: 'Ekspedisi', + accessorKey: 'expedition_vendor_name', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) return null; + return props.row.original.expedition_vendor_name; + }, + }, + { + header: 'Surat Jalan', + accessorKey: 'travel_number', + cell: (props) => { + const isFooter = '_isFooter' in props.row.original; + if (isFooter) return null; + return props.row.original.travel_number; + }, + }, + ]; + + return ( + <> + +
+ {/* TODO START */} + option.value === selectedArea) || + null + } + // @ts-expect-error TS2345 + onChange={(val) => setSelectedArea(val?.value || null)} + isLoading={isLoadingAreas} + isClearable + /> + option.value === selectedSupplier + ) || null + } + // @ts-expect-error TS2345 + onChange={(val) => setSelectedSupplier(val?.value || null)} + isLoading={isLoadingSuppliers} + isClearable + /> + option.value === selectedProduct + ) || null + } + // @ts-expect-error TS2345 + onChange={(val) => setSelectedProduct(val?.value || null)} + isLoading={isLoadingProducts} + isClearable + /> + {/* TODO END */} +
+ + {data.length === 0 ? ( +
+ Tidak ada data untuk ditampilkan. +
+ ) : ( + data.map((supplier) => { + const totalQty = supplier.items.reduce( + (sum, item) => sum + (item.qty || 0), + 0 + ); + const totalPrice = supplier.items.reduce( + (sum, item) => sum + (item.price || 0), + 0 + ); + const totalPurchaseAmount = supplier.items.reduce( + (sum, item) => sum + (item.purchase_amount || 0), + 0 + ); + const totalTransport = supplier.items.reduce( + (sum, item) => sum + (item.transport || 0), + 0 + ); + const totalValueTransport = supplier.items.reduce( + (sum, item) => sum + (item.value_transport || 0), + 0 + ); + const totalJumlah = supplier.items.reduce( + (sum, item) => sum + (item.total || 0), + 0 + ); + + const totals = { + totalQty, + totalPrice, + totalPurchaseAmount, + totalTransport, + totalValueTransport, + totalJumlah, + }; + + const footerData = + supplier.items.length > 0 + ? [ + { + id: -999, + no: 'Total', + received_date: '', + po_date: null, + po_number: null, + product_name: null, + destination_warehouse: null, + qty: totals.totalQty, + price: totals.totalPrice, + purchase_amount: totals.totalPurchaseAmount, + transport: totals.totalTransport, + value_transport: totals.totalValueTransport, + total: totals.totalJumlah, + expedition_vendor_name: null, + travel_number: null, + _isFooter: true, + }, + ] + : []; + + const totalPurchase = totals.totalJumlah; + + return ( + + 0} + className={{ + tableWrapperClassName: 'overflow-x-auto mt-4', + tableClassName: 'w-full table-auto text-sm', + headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', + headerColumnClassName: + 'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200', + bodyRowClassName: + 'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 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', + paginationClassName: 'hidden', + }} + /> + + ); + }) + )} + + + ); +}; + +export default PurchasesPerSupplierTab;