feat(FE-361): Add logistic-stock report page and table footer

This commit is contained in:
rstubryan
2025-12-09 15:49:59 +07:00
parent d7384752a0
commit b039ec832b
4 changed files with 594 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+22
View File
@@ -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: <PurchasesPerSupplierTab />,
},
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='boxed' />
</section>
);
};
export default LogisticStock;
+40
View File
@@ -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<TData extends object> {
rowSelection?: Record<string, boolean>;
setRowSelection?: OnChangeFn<Record<string, boolean>>;
enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
renderFooter?: boolean;
footerContent?: ReactNode;
footerData?: TData[];
}
const DUMMY_SKELETON_DATA = [{}, {}, {}, {}, {}];
@@ -84,6 +90,9 @@ const Table = <TData extends object>({
tableBodyClassName: '',
bodyRowClassName: '',
bodyColumnClassName: '',
tableFooterClassName: '',
footerRowClassName: '',
footerColumnClassName: '',
paginationClassName: '',
},
emptyContent = emptyContentDefaultValue,
@@ -93,6 +102,9 @@ const Table = <TData extends object>({
rowSelection,
setRowSelection,
enableRowSelection,
renderFooter = false,
footerContent,
footerData = [],
}: TableProps<TData>) => {
const isServerSideTable =
totalItems !== undefined &&
@@ -160,6 +172,14 @@ const Table = <TData extends object>({
const table = useReactTable(tableOptions);
const { setPageSize } = table;
const footerTableOptions: TableOptions<TData> = {
columns,
data: footerData,
getCoreRowModel: getCoreRowModel(),
};
const footerTable = useReactTable(footerTableOptions);
const prevPageClickHandler = () => {
table.previousPage();
@@ -262,6 +282,26 @@ const Table = <TData extends object>({
</tr>
))}
</tbody>
<tfoot className={cn(className.tableFooterClassName)}>
{renderFooter &&
(footerData && footerData.length > 0
? footerTable.getRowModel().rows.map((row) => (
<tr key={row.id} className={className.footerRowClassName}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={className.footerColumnClassName}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
: footerContent)}
</tfoot>
</table>
</div>
@@ -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<number | null>(null);
const [selectedSupplier, setSelectedSupplier] = useState<number | null>(null);
const [selectedProduct, setSelectedProduct] = useState<number | null>(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<any>[] = [
{
header: 'No',
accessorKey: 'no',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) {
return (
<div className='font-semibold text-gray-900'>
{props.row.original.no}
</div>
);
}
return props.row.index + 1;
},
},
{
header: 'Tanggal Terima',
accessorKey: 'received_date',
cell: (props) => {
const isFooter = '_isFooter' in props.row.original;
if (isFooter) {
return (
<div className='font-semibold text-gray-900'>
{props.row.original.received_date}
</div>
);
}
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 (
<div
className={
isFooter ? 'text-right font-semibold text-gray-900' : 'text-right'
}
>
{value.toLocaleString()}
</div>
);
},
},
{
header: 'Harga Beli (Rp)',
accessorKey: 'price',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
if (isFooter) {
return (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(value)}
</div>
);
}
return (
<div className='text-right'>
{formatCurrency(props.row.original.price)}
</div>
);
},
},
{
header: 'Value Harga Beli (Rp)',
accessorKey: 'purchase_amount',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter ? 'text-right font-semibold text-gray-900' : 'text-right'
}
>
{formatCurrency(value)}
</div>
);
},
},
{
header: 'Transport (Rp)',
accessorKey: 'transport',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
if (isFooter) {
return (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(value)}
</div>
);
}
return (
<div className='text-right'>
{formatCurrency(props.row.original.transport)}
</div>
);
},
},
{
header: 'Value Transport (Rp)',
accessorKey: 'value_transport',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter ? 'text-right font-semibold text-gray-900' : 'text-right'
}
>
{formatCurrency(value)}
</div>
);
},
},
{
header: 'Jumlah (Rp)',
accessorKey: 'total',
cell: (props) => {
const value = props.getValue() as number;
const isFooter = '_isFooter' in props.row.original;
return (
<div
className={
isFooter ? 'text-right font-semibold text-gray-900' : 'text-right'
}
>
{formatCurrency(value)}
</div>
);
},
},
{
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 (
<>
<Card
subtitle='Laporan > Rekapitulasi Pembelian Per Supplier'
className={{ wrapper: 'w-full' }}
>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{/* TODO START */}
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={
areaOptions.find((option) => option.value === selectedArea) ||
null
}
// @ts-expect-error TS2345
onChange={(val) => setSelectedArea(val?.value || null)}
isLoading={isLoadingAreas}
isClearable
/>
<SelectInput
label='Supplier'
placeholder='Pilih Supplier'
options={supplierOptions}
value={
supplierOptions.find(
(option) => option.value === selectedSupplier
) || null
}
// @ts-expect-error TS2345
onChange={(val) => setSelectedSupplier(val?.value || null)}
isLoading={isLoadingSuppliers}
isClearable
/>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={
productOptions.find(
(option) => option.value === selectedProduct
) || null
}
// @ts-expect-error TS2345
onChange={(val) => setSelectedProduct(val?.value || null)}
isLoading={isLoadingProducts}
isClearable
/>
{/* TODO END */}
</div>
{data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data untuk ditampilkan.
</div>
) : (
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 (
<Card
key={supplier.id}
title={supplier.supplier}
subtitle={`Total Pembelian: ${formatCurrency(totalPurchase)}`}
className={{ wrapper: 'mt-6 w-full' }}
collapsible={true}
>
<Table
data={supplier.items}
columns={tableColumns}
pageSize={10}
footerData={footerData}
renderFooter={supplier.items.length > 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',
}}
/>
</Card>
);
})
)}
</Card>
</>
);
};
export default PurchasesPerSupplierTab;