mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-23 23:05:46 +00:00
668 lines
21 KiB
TypeScript
668 lines
21 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,
|
||
DebtSupplierFilter,
|
||
} 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';
|
||
import { useFormik } from 'formik';
|
||
import {
|
||
DebtSupplierFilterSchema,
|
||
DebtSupplierFilterType,
|
||
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
|
||
import { getFilledFormikValuesCount } from '@/lib/formik-helper';
|
||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||
import { Supplier } from '@/types/api/master-data/supplier';
|
||
|
||
const DebtSupplierTab = () => {
|
||
// ===== STATE MANAGEMENT =====
|
||
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
|
||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||
|
||
// ===== SUBMISSION STATE =====
|
||
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
|
||
start_date: undefined,
|
||
end_date: undefined,
|
||
supplier_ids: undefined,
|
||
filter_by: undefined,
|
||
});
|
||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||
|
||
const filterModal = useModal();
|
||
|
||
const {
|
||
setInputValue: setSupplierInputValue,
|
||
options: supplierOptions,
|
||
isLoadingOptions: isLoadingSupplierOptions,
|
||
loadMore: loadMoreSuppliers,
|
||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||
|
||
const dataTypeOptions = useMemo(
|
||
() => [
|
||
{ value: 'received_date', label: 'Tanggal Terima' },
|
||
{ value: 'po_date', label: 'Tanggal PO' },
|
||
],
|
||
[]
|
||
);
|
||
|
||
const handleFilterModalOpen = () => {
|
||
filterModal.openModal();
|
||
};
|
||
|
||
// ===== FORMIK SETUP =====
|
||
const formik = useFormik<DebtSupplierFilterType>({
|
||
initialValues: {
|
||
startDate: null,
|
||
endDate: null,
|
||
supplierIds: null,
|
||
filterBy: null,
|
||
},
|
||
validationSchema: DebtSupplierFilterSchema,
|
||
onSubmit: (values) => {
|
||
setFilterParams({
|
||
start_date: values.startDate?.toString() || undefined,
|
||
end_date: values.endDate?.toString() || undefined,
|
||
supplier_ids:
|
||
values.supplierIds?.map((v) => String(v.value)).join(',') ||
|
||
undefined,
|
||
filter_by: values.filterBy?.value?.toString() || undefined,
|
||
});
|
||
filterModal.closeModal();
|
||
setIsSubmitted(true);
|
||
},
|
||
onReset: (values) => {
|
||
setFilterParams({
|
||
start_date: undefined,
|
||
end_date: undefined,
|
||
supplier_ids: undefined,
|
||
filter_by: undefined,
|
||
});
|
||
setIsSubmitted(false);
|
||
},
|
||
});
|
||
|
||
// ===== DATA FETCHING =====
|
||
const { data: debtSupplier, isLoading } = useSWR(
|
||
isSubmitted
|
||
? () => {
|
||
const params = {
|
||
supplier_ids: filterParams.supplier_ids,
|
||
filter_by: filterParams.filter_by,
|
||
start_date: filterParams.start_date,
|
||
end_date: filterParams.end_date,
|
||
};
|
||
|
||
return ['debt-supplier-report', params];
|
||
}
|
||
: null,
|
||
([, params]) =>
|
||
DebtSupplierApi.getDebtSupplierReport(
|
||
params.supplier_ids,
|
||
params.filter_by,
|
||
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:
|
||
formik.values.supplierIds && formik.values.supplierIds.length > 0
|
||
? formik.values.supplierIds.map((v) => String(v.value)).join(',')
|
||
: undefined,
|
||
filter_by: formik.values.filterBy?.value?.toString() || undefined,
|
||
start_date: formik.values.startDate || undefined,
|
||
end_date: formik.values.endDate || undefined,
|
||
date_type: formik.values.filterBy
|
||
? formik.values.filterBy.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;
|
||
}, [
|
||
formik.values.supplierIds,
|
||
formik.values.startDate,
|
||
formik.values.endDate,
|
||
formik.values.filterBy,
|
||
]);
|
||
|
||
// ===== 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'>
|
||
<ButtonFilter
|
||
values={formik.values}
|
||
onClick={handleFilterModalOpen}
|
||
variant='outline'
|
||
/>
|
||
|
||
<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',
|
||
}}
|
||
>
|
||
<form
|
||
className='space-y-6'
|
||
onSubmit={formik.handleSubmit}
|
||
onReset={formik.handleReset}
|
||
>
|
||
{/* 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='startDate'
|
||
value={formik.values.startDate || ''}
|
||
onChange={(e) => {
|
||
formik.setFieldValue('startDate', e.target.value || null);
|
||
}}
|
||
className={{ wrapper: 'w-full' }}
|
||
isError={
|
||
formik.touched.startDate && !!formik.errors.startDate
|
||
}
|
||
errorMessage={formik.errors.startDate}
|
||
/>
|
||
</div>
|
||
|
||
<div className='mt-auto'>
|
||
<DateInput
|
||
label=' '
|
||
name='endDate'
|
||
value={formik.values.endDate || ''}
|
||
onChange={(e) => {
|
||
formik.setFieldValue('endDate', e.target.value || null);
|
||
}}
|
||
className={{ wrapper: 'w-full' }}
|
||
isError={formik.touched.endDate && !!formik.errors.endDate}
|
||
errorMessage={formik.errors.endDate}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<SelectInput
|
||
label='Supplier'
|
||
placeholder='Pilih Supplier'
|
||
isMulti
|
||
options={supplierOptions}
|
||
value={formik.values.supplierIds || []}
|
||
onChange={(val) => {
|
||
formik.setFieldValue(
|
||
'supplierIds',
|
||
Array.isArray(val) ? val : val ? [val] : null
|
||
);
|
||
}}
|
||
onInputChange={setSupplierInputValue}
|
||
onMenuScrollToBottom={loadMoreSuppliers}
|
||
isLoading={isLoadingSupplierOptions}
|
||
isClearable
|
||
className={{ wrapper: 'w-full' }}
|
||
isError={
|
||
formik.touched.supplierIds && !!formik.errors.supplierIds
|
||
}
|
||
errorMessage={formik.errors.supplierIds as string}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<SelectInput
|
||
label='Filter Berdasarkan'
|
||
placeholder='Pilih Filter Berdasarkan'
|
||
options={dataTypeOptions}
|
||
value={formik.values.filterBy || null}
|
||
onChange={(val) => {
|
||
formik.setFieldValue(
|
||
'filterBy',
|
||
val ? (val as OptionType) : null
|
||
);
|
||
}}
|
||
className={{ wrapper: 'w-full' }}
|
||
isClearable
|
||
isError={formik.touched.filterBy && !!formik.errors.filterBy}
|
||
errorMessage={formik.errors.filterBy as string}
|
||
/>
|
||
</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'
|
||
type='reset'
|
||
>
|
||
Reset Filter
|
||
</Button>
|
||
<Button className='me-4 min-w-36 rounded-lg' type='submit'>
|
||
Apply Filter
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default DebtSupplierTab;
|