feat(FE-361,363): Add export dropdown to PurchasesPerSupplier tab

This commit is contained in:
rstubryan
2025-12-15 15:10:22 +07:00
parent 3c3c2345c7
commit 45f1e923b7
3 changed files with 23 additions and 4 deletions
@@ -0,0 +1,640 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Card from '@/components/Card';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import { AreaApi } from '@/services/api/master-data';
import { SupplierApi } from '@/services/api/master-data';
import { ProductApi } from '@/services/api/master-data';
import { LogisticApi } from '@/services/api/logistic';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate } from '@/lib/helper';
import {
LogisticPurchasePerSupplier,
LogisticPurchasePerSupplierItems,
} from '@/types/api/report/logistic-stock';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
interface Totals {
totalQty: number;
totalPrice: number;
totalPurchaseAmount: number;
totalTransport: number;
totalValueTransport: number;
totalJumlah: number;
}
const PurchasesPerSupplierTab = () => {
const [dataType, setDataType] = useState<'received_date' | 'po_date'>(
'received_date'
);
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
area_id: '',
supplier_id: '',
product_id: '',
received_date: '',
po_date: '',
start_date: '',
end_date: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const updateDataType = useCallback(
(newDataType: 'received_date' | 'po_date') => {
setDataType(newDataType);
updateFilter('received_date', '');
updateFilter('po_date', '');
setIsSubmitted(false);
},
[updateFilter]
);
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 dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
const areaChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
updateFilter('area_id', newVal?.value ? String(newVal.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const supplierChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
updateFilter('supplier_id', newVal?.value ? String(newVal.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const productChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
updateFilter('product_id', newVal?.value ? String(newVal.value) : '');
setIsSubmitted(false);
},
[updateFilter]
);
const dataTypeChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
updateDataType(
(newVal?.value as 'received_date' | 'po_date') || 'received_date'
);
setIsSubmitted(false);
},
[updateDataType]
);
const startDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('start_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const endDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('end_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const resetFilters = useCallback(() => {
updateFilter('area_id', '');
updateFilter('supplier_id', '');
updateFilter('product_id', '');
updateFilter('received_date', '');
updateFilter('po_date', '');
updateFilter('start_date', '');
updateFilter('end_date', '');
setDataType('received_date');
setIsSubmitted(false);
}, [updateFilter]);
const handleSubmit = useCallback(() => {
setIsSubmitted(true);
setCurrentPage(1);
}, []);
// ===== DATA FETCHING =====
const { data: purchasePerSupplier, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
area_id: tableFilterState.area_id
? Number(tableFilterState.area_id)
: undefined,
supplier_id: tableFilterState.supplier_id
? Number(tableFilterState.supplier_id)
: undefined,
product_id: tableFilterState.product_id
? Number(tableFilterState.product_id)
: undefined,
received_date: tableFilterState.received_date || undefined,
po_date: tableFilterState.po_date || undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
page: currentPage,
limit: pageSize,
};
return ['logistic-purchase-report', params];
}
: null,
([, params]) =>
LogisticApi.getLogisticStockReport(
params.area_id,
params.supplier_id,
params.product_id,
params.received_date,
params.po_date,
params.start_date,
params.end_date,
params.page,
params.limit
)
);
const data: LogisticPurchasePerSupplier[] = isResponseSuccess(
purchasePerSupplier
)
? (purchasePerSupplier?.data as unknown as LogisticPurchasePerSupplier[])
: [];
const meta =
isResponseSuccess(purchasePerSupplier) && 'meta' in purchasePerSupplier
? purchasePerSupplier.meta
: undefined;
const handleExportExcel = useCallback(() => {
alert('Export to Excel functionality to be implemented.');
}, []);
const handleExportPdf = useCallback(() => {
alert('Export to PDF functionality to be implemented.');
}, []);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleRowChange = (pageSize: number) => {
setPageSize(pageSize);
};
const handleNextPage = () => {
if (meta && currentPage < meta.total_pages) {
setCurrentPage(currentPage + 1);
}
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const getTableColumns = (
totals: Totals
): ColumnDef<LogisticPurchasePerSupplierItems>[] => {
const tableColumns: ColumnDef<LogisticPurchasePerSupplierItems>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
},
{
id: 'received_date',
header: 'Tanggal Terima',
accessorKey: 'received_date',
cell: (props) => {
const value = props.row.original.received_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'po_date',
header: 'Tanggal PO',
accessorKey: 'po_date',
cell: (props) => {
const value = props.row.original.po_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'po_number',
header: 'No. Referensi',
accessorKey: 'po_number',
cell: (props) => {
const value = props.row.original.po_number;
return value || '-';
},
},
{
id: 'product_name',
header: 'Nama Produk',
accessorKey: 'product.name',
cell: (props) => {
const product = props.row.original.product;
return product?.name || '-';
},
},
{
id: 'destination_warehouse',
header: 'Tujuan',
accessorKey: 'destination_warehouse',
cell: (props) => {
const value = props.row.original.destination_warehouse;
return value || '-';
},
},
{
id: 'qty',
header: 'QTY',
accessorKey: 'qty',
cell: (props) => {
const value = props.row.original.qty;
return <div className='text-right'>{value.toLocaleString()}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{totals.totalQty.toLocaleString()}
</div>
),
},
{
id: 'price',
header: 'Harga Beli (Rp)',
accessorKey: 'price',
cell: (props) => {
const value = props.row.original.price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalPrice)}
</div>
),
},
{
id: 'purchase_amount',
header: 'Value Harga Beli (Rp)',
cell: (props) => {
const item = props.row.original;
const value = (item.price || 0) * (item.qty || 0);
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalPurchaseAmount)}
</div>
),
},
{
id: 'transport',
header: 'Transport (Rp)',
accessorKey: 'transport_per_item',
cell: (props) => {
const value = props.row.original.transport_per_item;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalTransport)}
</div>
),
},
{
id: 'value_transport',
header: 'Value Transport (Rp)',
accessorKey: 'transport_total',
cell: (props) => {
const value = props.row.original.transport_total;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalValueTransport)}
</div>
),
},
{
id: 'total',
header: 'Jumlah (Rp)',
cell: (props) => {
const item = props.row.original;
const value =
(item.price || 0) * (item.qty || 0) + (item.transport_total || 0);
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(totals.totalJumlah)}
</div>
),
},
{
id: 'expedition_vendor_name',
header: 'Ekspedisi',
accessorKey: 'expedition_vendor_name',
cell: (props) => {
const value = props.row.original.expedition_vendor_name;
return value || '-';
},
},
{
id: 'travel_number',
header: 'Surat Jalan',
accessorKey: 'travel_number',
cell: (props) => {
const value = props.row.original.travel_number;
return value || '-';
},
},
];
return tableColumns;
};
return (
<div className='w-full p-0 sm:p-4'>
<Card
subtitle='Laporan > Rekapitulasi Pembelian Per Supplier'
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button color='primary' onClick={handleSubmit}>
Cari
</Button>
<Button color='warning' onClick={resetFilters}>
Reset
</Button>
<Dropdown
trigger={<Button color='success'>Export</Button>}
align='end'
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
<div className='grid grid-cols-12 gap-4'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={
tableFilterState.area_id
? areaOptions.find(
(option) =>
option.value === Number(tableFilterState.area_id)
) || null
: null
}
onChange={areaChangeHandler}
isLoading={isLoadingAreas}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<SelectInput
label='Supplier'
placeholder='Pilih Supplier'
options={supplierOptions}
value={
tableFilterState.supplier_id
? supplierOptions.find(
(option) =>
option.value === Number(tableFilterState.supplier_id)
) || null
: null
}
onChange={supplierChangeHandler}
isLoading={isLoadingSuppliers}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<SelectInput
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={
tableFilterState.product_id
? productOptions.find(
(option) =>
option.value === Number(tableFilterState.product_id)
) || null
: null
}
onChange={productChangeHandler}
isLoading={isLoadingProducts}
isClearable
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<SelectInput
label='Tipe Data'
placeholder='Pilih Tipe Data'
options={dataTypeOptions}
value={
dataTypeOptions?.find((option) => option.value === dataType) ||
null
}
onChange={dataTypeChangeHandler}
isLoading={false}
isClearable={false}
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<DateInput
label='Tanggal Awal'
name='start_date'
placeholder='Pilih Tanggal Awal'
value={tableFilterState.start_date}
onChange={startDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
<DateInput
label='Tanggal Akhir'
name='end_date'
placeholder='Pilih Tanggal Akhir'
value={tableFilterState.end_date}
onChange={endDateChangeHandler}
className={{
wrapper: 'col-span-12 sm:col-span-4',
}}
/>
</div>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Submit untuk menampilkan data.
</div>
) : isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : data.length === 0 ? (
<div className='mt-6 text-center text-gray-500'>
Tidak ada data yang dapat 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) * (item.qty || 0),
0
);
const totalTransport = supplier.items.reduce(
(sum, item) =>
sum + (item.transport_per_item || 0) * (item.qty || 0),
0
);
const totalValueTransport = supplier.items.reduce(
(sum, item) => sum + (item.transport_total || 0),
0
);
const totalJumlah = supplier.items.reduce(
(sum, item) =>
sum +
(item.price || 0) * (item.qty || 0) +
(item.transport_total || 0),
0
);
const totals = {
totalQty,
totalPrice,
totalPurchaseAmount: totalPrice,
totalTransport,
totalValueTransport,
totalJumlah,
};
const totalPurchase = totals.totalJumlah;
const tableColumns = getTableColumns(totals);
return (
<Card
key={supplier.id}
title={supplier.supplier.name}
subtitle={`Total Pembelian: ${formatCurrency(totalPurchase)}`}
className={{ wrapper: 'w-full' }}
collapsible={true}
>
<Table
data={supplier.items}
columns={tableColumns}
pageSize={10}
renderFooter={supplier.items.length > 0}
className={{
containerClassName: 'w-full',
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>
{meta && data.length > 0 && (
<div className='mt-6'>
<Pagination
currentPage={meta.page}
totalItems={meta.total_results}
onPageChange={handlePageChange}
onRowChange={handleRowChange}
onNextPage={handleNextPage}
onPrevPage={handlePrevPage}
rowOptions={[10, 25, 50, 100]}
itemsPerPage={meta.limit}
/>
</div>
)}
</div>
);
};
export default PurchasesPerSupplierTab;