Files
lti-web-client/src/components/pages/report/finance/tab/DebtSupplierTab.tsx
T
2026-01-29 20:59:16 +07:00

787 lines
24 KiB
TypeScript

import Button from '@/components/Button';
import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown';
import DateInput from '@/components/input/DateInput';
import { 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, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, 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, useEffect, 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 ButtonFilter from '@/components/helper/ButtonFilter';
import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store';
import StatusBadge from '@/components/helper/StatusBadge';
const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error',
'Belum Jatuh Tempo': 'success',
'Mendekati Jatuh Tempo': 'warning',
};
const paymentStatus: Record<string, Color> = {
'Belum Lunas': 'warning',
Lunas: 'primary',
Pembayaran: 'success',
};
const getPillBadge = (
statusText: string,
type: 'due' | 'payment' = 'payment'
) => {
// Get color based on type
const color =
type === 'due'
? dueStatus[statusText] || 'neutral'
: paymentStatus[statusText] || 'neutral';
return <StatusBadge color={color as Color} text={statusText} />;
};
interface DebtSupplierTabProps {
tabId: string;
}
const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== 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,
params: {
supplier_name: formik.values.supplierIds
?.map((v) => v.label)
.join(', '),
filter_by: formik.values.filterBy?.label,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [debtSupplierExport]);
// ===== REGISTER TAB ACTIONS TO STORE =====
const setTabActions = useFinanceTabStore((state) => state.setTabActions);
const clearTabActions = useFinanceTabStore((state) => state.clearTabActions);
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3 '>
<ButtonFilter
values={formik.values}
onClick={handleFilterModalOpen}
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className={cn(
'px-3 py-2.5',
'rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon icon='heroicons:cloud-arrow-down' width={20} height={20} />
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon width={14} height={14} icon='heroicons:chevron-down' />
</div>
</Button>
}
align='end'
className={{
content:
'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg',
}}
>
<Menu className='p-0 w-full'>
<MenuItem
className='text-sm p-3'
title='Excel'
onClick={handleExportExcel}
/>
<MenuItem
className='text-sm p-3'
title='PDF'
onClick={handleExportPdf}
/>
</Menu>
</Dropdown>
</div>
);
}, [
tabId,
formik.values,
isAnyExportLoading,
handleExportExcel,
handleExportPdf,
setTabActions,
]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [tabId, clearTabActions]);
const getTableColumns = (supplier: DebtSupplier): ColumnDef<DebtRow>[] => [
{
id: 'no',
header: 'No',
enableSorting: false,
cell: (props) => props.row.index,
footer: () => 'Total',
},
{
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 ? (value != '-' ? getPillBadge(value, 'due') : '-') : '-';
},
},
{
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
? value != '-'
? getPillBadge(value, 'payment')
: '-'
: '-';
},
},
{
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-3 flex flex-col gap-3'>
{!isSubmitted ? (
<div className='mt-6 text-center text-base-content/50'>
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-base-content/50'>
Tidak ada data yang dapat ditampilkan...
</div>
) : (
data.map((supplierReport) => {
return (
<Card
key={supplierReport.supplier.id}
title={supplierReport.supplier.name}
className={{
wrapper: 'w-full rounded-lg border-none',
body: 'p-0',
title:
'px-2 py-1.5 font-normal text-sm bg-primary text-white',
collapsible: 'rounded-lg',
}}
variant='bordered'
collapsible={true}
defaultCollapsed={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 mb-0',
tableWrapperClassName:
'overflow-x-auto rounded-tr-none rounded-tl-none',
headerColumnClassName: cn(
TABLE_DEFAULT_STYLING.headerColumnClassName,
'whitespace-nowrap'
),
bodyColumnClassName: cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName,
'whitespace-nowrap'
),
footerRowClassName: cn(
TABLE_DEFAULT_STYLING.footerRowClassName,
'bg-white'
),
footerColumnClassName: cn(
TABLE_DEFAULT_STYLING.footerColumnClassName,
'whitespace-nowrap p-3'
),
paginationClassName: 'hidden',
}}
renderCustomRow={(row) => {
if (row.index == 0) {
return (
<tr
className={cn(TABLE_DEFAULT_STYLING.bodyRowClassName)}
key={row.index}
>
<td
className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
colSpan={12}
></td>
<td
className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
>
<div
className={`text-right ${row.original.balance < 0 ? 'text-red-500' : ''}`}
>
{formatCurrency(row.original.balance)}
</div>
</td>
<td
className={cn(
TABLE_DEFAULT_STYLING.bodyColumnClassName
)}
colSpan={2}
></td>
</tr>
);
}
}}
/>
</Card>
);
})
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
type='button'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
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}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<DateInput
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}
isNestedModal
/>
</div>
</div>
<div>
<SelectInputCheckbox
label='Supplier'
placeholder='Pilih Supplier'
isMulti
options={supplierOptions}
value={
(formik.values.supplierIds as
| { value: number; label: string }
| { value: number; label: string }[]
| null
| undefined) || []
}
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>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={
(formik.values.filterBy as
| { value: string; label: string }
| { value: string; label: string }[]
| null
| undefined) || 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 items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
variant='soft'
color='none'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
type='reset'
>
Reset Filter
</Button>
<Button
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
type='submit'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default DebtSupplierTab;