mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 15:55:48 +00:00
feat(FE-361,363): Add export dropdown to PurchasesPerSupplier tab
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user