mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-24 15:25:46 +00:00
627 lines
20 KiB
TypeScript
627 lines
20 KiB
TypeScript
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 { 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 { DebtSupplierApi } from '@/services/api/report/debt-supplier';
|
||
|
||
const DebtSupplierTab = () => {
|
||
// ===== STATE MANAGEMENT =====
|
||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||
|
||
// ===== SUBMISSION STATE =====
|
||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||
|
||
// ===== FILTER STATE =====
|
||
const [filterSupplier, setFilterSupplier] = useState<OptionType[]>([]);
|
||
const [filterStartDate, setFilterStartDate] = useState('');
|
||
const [filterEndDate, setFilterEndDate] = useState('');
|
||
const [filterDateType, setFilterDateType] = 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: 'received_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> = {};
|
||
|
||
setFilterErrors(errors);
|
||
|
||
if (Object.keys(errors).length === 0) {
|
||
setIsSubmitted(true);
|
||
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: filterDateType?.value?.toString() || undefined,
|
||
start_date: filterStartDate || undefined,
|
||
end_date: filterEndDate || undefined,
|
||
};
|
||
|
||
return ['debt-supplier-report', params];
|
||
}
|
||
: null,
|
||
([, params]) =>
|
||
DebtSupplierApi.getDebtSupplierReport(
|
||
params.supplier_ids,
|
||
params.filter_by?.toString() || undefined,
|
||
params.start_date,
|
||
params.end_date
|
||
)
|
||
);
|
||
|
||
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: filterDateType?.value?.toString() || undefined,
|
||
start_date: filterStartDate || undefined,
|
||
end_date: filterEndDate || undefined,
|
||
date_type: filterDateType ? filterDateType.value : undefined,
|
||
limit: 100,
|
||
page: 1,
|
||
};
|
||
|
||
const response = await DebtSupplierApi.getDebtSupplierReport(
|
||
params.supplier_ids,
|
||
params.filter_by,
|
||
params.start_date,
|
||
params.end_date
|
||
);
|
||
|
||
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]);
|
||
|
||
const getTableColumns = (supplier: DebtSupplier): ColumnDef<DebtRow>[] => [
|
||
{
|
||
id: 'no',
|
||
header: 'No',
|
||
enableSorting: false,
|
||
cell: (props) => props.row.index,
|
||
},
|
||
{
|
||
id: 'pr_number',
|
||
header: 'Nomor PR',
|
||
accessorKey: 'pr_number',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.pr_number;
|
||
return value || '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'po_number',
|
||
header: 'Nomor PO',
|
||
accessorKey: 'po_number',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.po_number;
|
||
return value || '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'received_date',
|
||
header: 'Tanggal Terima/Bayar',
|
||
accessorKey: 'received_date',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.received_date;
|
||
return value
|
||
? value != '-'
|
||
? formatDate(value, 'DD MMM YYYY')
|
||
: '-'
|
||
: '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'po_date',
|
||
header: 'Tanggal PO',
|
||
accessorKey: 'po_date',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.po_date;
|
||
return value
|
||
? value != '-'
|
||
? formatDate(value, 'DD MMM YYYY')
|
||
: '-'
|
||
: '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'aging',
|
||
header: 'Aging',
|
||
accessorKey: 'aging',
|
||
enableSorting: false,
|
||
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',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.area?.name;
|
||
return value || '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'warehouse',
|
||
header: 'Gudang',
|
||
accessorKey: 'warehouse',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.warehouse?.name;
|
||
return value || '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'due_date',
|
||
header: 'Jatuh Tempo',
|
||
accessorKey: 'due_date',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.due_date;
|
||
return value
|
||
? value != '-'
|
||
? formatDate(value, 'DD MMM YYYY')
|
||
: '-'
|
||
: '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'due_status',
|
||
header: 'Status Jatuh Tempo',
|
||
accessorKey: 'due_status',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.due_status;
|
||
return value || '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'total_price',
|
||
header: 'Nominal Pembelian',
|
||
accessorKey: 'total_price',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.total_price;
|
||
return (
|
||
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
|
||
{formatCurrency(value)}
|
||
</div>
|
||
);
|
||
},
|
||
footer: () => {
|
||
const value = supplier.total.total_price;
|
||
return (
|
||
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
|
||
{formatCurrency(value)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'payment_price',
|
||
header: 'Pembayaran',
|
||
accessorKey: 'payment_price',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.payment_price;
|
||
return (
|
||
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
|
||
{formatCurrency(value)}
|
||
</div>
|
||
);
|
||
},
|
||
footer: () => {
|
||
const value = supplier.total.payment_price;
|
||
return (
|
||
<div className={`text-right ${value < 0 ? 'text-red-500' : ''}`}>
|
||
{formatCurrency(value)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'balance',
|
||
header: 'Sisa Saldo Hutang',
|
||
accessorKey: 'balance',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.balance;
|
||
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',
|
||
enableSorting: false,
|
||
cell: (props) => {
|
||
const value = props.row.original.status;
|
||
return value || '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'travel_number',
|
||
header: 'Nomor Perjalanan',
|
||
accessorKey: 'travel_number',
|
||
enableSorting: false,
|
||
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 > Rekapitulasi Hutang ke 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={[
|
||
{
|
||
balance: supplierReport.initial_balance,
|
||
} as DebtRow,
|
||
...supplierReport.rows,
|
||
]}
|
||
columns={getTableColumns(supplierReport)}
|
||
pageSize={supplierReport.rows.length + 1}
|
||
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',
|
||
}}
|
||
renderCustomRow={(row) => {
|
||
if (row.index == 0) {
|
||
return (
|
||
<tr
|
||
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
|
||
key={row.index}
|
||
>
|
||
<td
|
||
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
|
||
colSpan={12}
|
||
></td>
|
||
<td className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'>
|
||
<div
|
||
className={`text-right ${row.original.balance < 0 ? 'text-red-500' : ''}`}
|
||
>
|
||
{formatCurrency(row.original.balance)}
|
||
</div>
|
||
</td>
|
||
<td
|
||
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
|
||
colSpan={2}
|
||
></td>
|
||
</tr>
|
||
);
|
||
}
|
||
}}
|
||
/>
|
||
</Card>
|
||
);
|
||
})
|
||
)}
|
||
</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={filterDateType}
|
||
onChange={(val) => {
|
||
setFilterDateType(val ? (val as OptionType) : undefined);
|
||
}}
|
||
className={{ wrapper: 'w-full' }}
|
||
isClearable
|
||
/>
|
||
</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;
|