Files
lti-web-client/src/components/pages/report/finance/tab/DebtSupplierTab.tsx
T
2026-01-12 14:01:03 +07:00

609 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import Menu from '@/components/menu/Menu';
import MenuItem from '@/components/menu/MenuItem';
import Modal, { useModal } from '@/components/Modal';
import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import Pagination from '@/components/Pagination';
const DebtSupplierTab = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [filterSupplier, setFilterSupplier] = useState<OptionType[]>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const [filterDataType, setFilterDataType] = useState<OptionType>();
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
const filterModal = useModal();
const { options: supplierOptions, isLoadingOptions: isLoadingSuppliers } =
useSelect(SupplierApi.basePath, 'id', 'name', '', {
limit: 'limit',
});
const dataTypeOptions = useMemo(
() => [
{ value: 'do_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
// ===== FILTER HANDLERS =====
const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
setFilterSupplier([]);
setFilterStartDate('');
setFilterEndDate('');
setFilterErrors({});
}, []);
const handleApplyFilters = useCallback(() => {
const errors: Record<string, string> = {};
if (!filterStartDate) {
errors.start_date = 'Tanggal mulai wajib diisi';
}
if (!filterEndDate) {
errors.end_date = 'Tanggal akhir wajib diisi';
}
setFilterErrors(errors);
if (Object.keys(errors).length === 0) {
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
}
}, [filterModal, filterStartDate, filterEndDate]);
// ===== DATA FETCHING =====
const { data: debtSupplier, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
supplier_ids:
filterSupplier.length > 0
? filterSupplier.map((v) => String(v.value)).join(',')
: undefined,
filter_by: 'do_date' as const,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
page: currentPage,
limit: pageSize,
};
return ['debt-supplier-report', params];
}
: null,
([, params]) =>
FinanceApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
)
);
// const { data: debtSupplier, isLoading } = useSWR(FinanceApi.basePath, () =>
// FinanceApi.getDebtSupplierReport()
// );
const data: DebtSupplier[] = useMemo(
() =>
isResponseSuccess(debtSupplier)
? (debtSupplier?.data as unknown as DebtSupplier[]) || []
: [],
[debtSupplier]
);
const meta =
isResponseSuccess(debtSupplier) && debtSupplier?.meta
? debtSupplier.meta
: null;
// ===== EXPORT DATA FETCHER =====
const debtSupplierExport = useCallback(async (): Promise<
DebtSupplier[] | null
> => {
const params = {
supplier_ids:
filterSupplier.length > 0
? filterSupplier.map((v) => String(v.value)).join(',')
: undefined,
filter_by: 'do_date' as const,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
date_type: filterDataType ? filterDataType.value : undefined,
limit: 100,
page: 1,
};
const response = await FinanceApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
);
return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[])
: null;
}, [filterSupplier, filterStartDate, filterEndDate]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await debtSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
generateDebtSupplierExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [debtSupplierExport]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await debtSupplierExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateDebtSupplierPDF({ data: allDataForExport });
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [debtSupplierExport]);
// ===== 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 = (supplier: DebtSupplier): ColumnDef<DebtRow>[] => [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
},
{
id: 'pr_number',
header: 'Nomor PR',
accessorKey: 'pr_number',
cell: (props) => {
const value = props.row.original.pr_number;
return value || '-';
},
},
{
id: 'po_number',
header: 'Nomor PO',
accessorKey: 'po_number',
cell: (props) => {
const value = props.row.original.po_number;
return value || '-';
},
},
{
id: 'pr_date',
header: 'Tanggal PR',
accessorKey: 'pr_date',
cell: (props) => {
const value = props.row.original.pr_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: 'aging',
header: 'Aging',
accessorKey: 'aging',
cell: (props) => {
const value = props.row.original.aging;
return <div className='text-center'>{formatNumber(value)} Hari</div>;
},
footer: () => {
const value = supplier.total.aging;
return <div className='text-center'>{formatNumber(value)} Hari</div>;
},
},
{
id: 'area',
header: 'Area',
accessorKey: 'area',
cell: (props) => {
const value = props.row.original.area?.name;
return value || '-';
},
},
{
id: 'warehouse',
header: 'Gudang',
accessorKey: 'warehouse',
cell: (props) => {
const value = props.row.original.warehouse?.name;
return value || '-';
},
},
{
id: 'due_date',
header: 'Tanggal Jatuh Tempo',
accessorKey: 'due_date',
cell: (props) => {
const value = props.row.original.due_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'due_status',
header: 'Status Jatuh Tempo',
accessorKey: 'due_status',
cell: (props) => {
const value = props.row.original.due_status;
return value || '-';
},
},
{
id: 'total_price',
header: 'Total Harga',
accessorKey: 'total_price',
cell: (props) => {
const value = props.row.original.total_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => {
const value = supplier.total.total_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'payment_price',
header: 'Harga Pembayaran',
accessorKey: 'payment_price',
cell: (props) => {
const value = props.row.original.payment_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => {
const value = supplier.total.payment_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
},
{
id: 'debt_price',
header: 'Harga Hutang',
accessorKey: 'debt_price',
cell: (props) => {
const value = props.row.original.debt_price;
return (
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
{formatCurrency(value)}
</div>
);
},
footer: () => {
const value = supplier.total.debt_price;
return (
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
{formatCurrency(value)}
</div>
);
},
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: (props) => {
const value = props.row.original.status;
return value || '-';
},
},
{
id: 'travel_number',
header: 'Nomor Perjalanan',
accessorKey: 'travel_number',
cell: (props) => {
const value = props.row.original.travel_number;
return value || '-';
},
},
];
return (
<>
<div className='w-full p-0 sm:p-4 flex flex-col gap-4'>
<Card
subtitle='Laporan > Kontrol Hutang Supplier'
className={{ wrapper: 'w-full', body: 'p-1!' }}
>
<div className='mb-4 flex justify-end gap-2 [&_button]:px-4'>
<Button variant='outline' onClick={filterModal.openModal}>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
</Button>
<Dropdown
trigger={
<Button variant='outline' isLoading={isAnyExportLoading}>
<Icon
icon='heroicons:cloud-arrow-down'
width={18}
height={18}
/>
Export
</Button>
}
align='end'
>
<Menu>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
</Card>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan klik tombol Filter untuk mengatur filter dan 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((supplierReport) => {
return (
<Card
key={supplierReport.supplier.id}
title={supplierReport.supplier.name}
className={{ wrapper: 'w-full' }}
variant='bordered'
collapsible={true}
>
<Table
data={supplierReport.rows}
columns={getTableColumns(supplierReport)}
pageSize={supplierReport.rows.length}
renderFooter={supplierReport.rows.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>
);
})
)}
</div>
{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>
)}
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-2xl xl:max-w-4/12 max-w-sm',
}}
>
<div className='space-y-6'>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 py-3 border-b border-gray-300 px-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-semibold'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-gray-500 hover:text-gray-700 transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='space-y-4 px-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 sm:gap-4'>
<div>
<DateInput
label='Tanggal'
name='start_date'
value={filterStartDate}
onChange={(e) => {
setFilterStartDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, start_date: '' }));
}}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.start_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.start_date}
</p>
)}
</div>
<div className='mt-auto'>
<DateInput
label=' '
name='end_date'
value={filterEndDate}
onChange={(e) => {
setFilterEndDate(e.target.value);
setFilterErrors((prev) => ({ ...prev, end_date: '' }));
}}
className={{ wrapper: 'w-full' }}
/>
{filterErrors.end_date && (
<p className='text-red-500 text-sm mt-1'>
{filterErrors.end_date}
</p>
)}
</div>
</div>
<div>
<SelectInput
label='Supplier'
placeholder='Pilih Supplier'
isMulti
options={supplierOptions}
value={filterSupplier}
onChange={(val) => {
setFilterSupplier(
Array.isArray(val) ? val : val ? [val] : []
);
}}
isLoading={isLoadingSuppliers}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
<div>
<SelectInput
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={filterDataType}
onChange={(val) => {
setFilterDataType(val ? (val as OptionType) : undefined);
}}
className={{ wrapper: 'w-full' }}
/>
</div>
</div>
{/* Action Buttons */}
<div className='flex justify-between gap-4 py-4 mt-8 border-t border-gray-300 bg-gray-100'>
<Button
variant='soft'
className='ms-4 min-w-36 rounded-lg'
onClick={handleResetFilters}
>
Reset Filter
</Button>
<Button
className='me-4 min-w-36 rounded-lg'
onClick={handleApplyFilters}
>
Apply Filter
</Button>
</div>
</div>
</Modal>
</>
);
};
export default DebtSupplierTab;