Files
lti-web-client/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx
T

839 lines
27 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 { useState, useMemo, useCallback } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import Badge from '@/components/Badge';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
import { UserApi } from '@/services/api/user';
import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import {
CustomerPaymentReport,
CustomerPaymentSummary,
} from '@/types/api/report/customer-payment';
import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import Modal from '@/components/Modal';
import { useModal } from '@/components/Modal';
import toast from 'react-hot-toast';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
const CustomerPaymentTab = () => {
// ===== 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] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== FILTER STATE =====
const [filterCustomer, setFilterCustomer] = useState<typeof customerOptions>(
[]
);
// TODO: Uncomment when BE is ready
// const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterSales, setFilterSales] = useState<typeof salesOptions>([]);
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
const filterModal = useModal();
const {
options: customerOptions,
setInputValue: setCustomerInputValue,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
hasMore: hasMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// TODO: Uncomment when BE is ready
const {
options: salesOptions,
setInputValue: setSalesInputValue,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
hasMore: hasMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const dataTypeOptions = useMemo(
() => [{ value: 'do_date', label: 'Tanggal Jual' }],
[]
);
const getPaymentStatusColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info/10 text-black border-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning/10 text-black border-warning';
}
return 'bg-gray-100 text-black border-gray-300';
};
const getPaymentStatusIndicatorColor = (notes: string) => {
const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') {
return 'bg-info';
}
if (normalizedValue.includes('belum')) {
return 'bg-warning';
}
return 'bg-gray-400';
};
const getPaymentStatusText = (notes: string) => {
return notes
.toLowerCase()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
// ===== FILTER HANDLERS =====
const handleResetFilters = useCallback(() => {
setIsSubmitted(false);
setFilterCustomer([]);
setFilterSales([]);
setFilterStartDate('');
setFilterEndDate('');
}, []);
const handleApplyFilters = useCallback(() => {
setIsSubmitted(true);
setCurrentPage(1);
filterModal.closeModal();
}, [filterModal]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
// Date filter (start_date + end_date = 1 filter)
if (filterStartDate || filterEndDate) {
count += 1;
}
// Customer filter
if (filterCustomer.length > 0) {
count += 1;
}
// TODO: Uncomment when BE is ready
// // Sales filter
// if (filterSales.length > 0) {
// count += 1;
// }
return count;
}, [
filterStartDate,
filterEndDate,
filterCustomer,
// filterSales,
]);
const hasFilters = activeFiltersCount > 0;
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
customer_ids:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
// TODO: Uncomment when BE is ready
// sales_id:
// filterSales.length > 0
// ? filterSales.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 ['customer-payment-report', params];
}
: null,
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_ids,
undefined, // TODO: Change to params.sales_id when BE is ready
undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date,
params.end_date,
params.page,
params.limit
)
);
const data: CustomerPaymentReport[] = useMemo(
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [],
[customerPayment]
);
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
> => {
const params = {
customer_ids:
filterCustomer.length > 0
? filterCustomer.map((v) => String(v.value)).join(',')
: undefined,
// TODO: Uncomment when BE is ready
// sales_id:
// filterSales.length > 0
// ? filterSales.map((v) => String(v.value)).join(',')
// : undefined,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
limit: 100,
page: 1,
};
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_ids,
undefined, // TODO: Change to params.sales_id when BE is ready
undefined, // TODO: Change to params.filter_by when BE is ready
params.start_date,
params.end_date,
params.page,
params.limit
);
return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[])
: null;
}, [filterCustomer, filterSales, filterStartDate, filterEndDate]);
// ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const allDataForExport = await customerPaymentExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateCustomerPaymentExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [customerPaymentExport]);
const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true);
try {
const allDataForExport = await customerPaymentExport();
if (
!allDataForExport ||
!Array.isArray(allDataForExport) ||
allDataForExport.length === 0
) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
await generateCustomerPaymentPDF({
data: allDataForExport,
params: {
customer_name:
filterCustomer.length > 0
? filterCustomer.map((c) => c.label).join(', ')
: undefined,
// TODO: Uncomment when BE is ready
// sales:
// filterSales.length > 0
// ? filterSales.map((s) => s.label).join(', ')
// : undefined,
start_date: filterStartDate || undefined,
end_date: filterEndDate || undefined,
// TODO: Uncomment when BE is ready
// filter_by: 'do_date' as const,
},
});
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [customerPaymentExport]);
const getTableColumns = (
summary: CustomerPaymentSummary
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
const tableColumns: ColumnDef<CustomerPaymentReport['rows'][0]>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index,
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
},
{
id: 'do_date_or_payment_date',
header: 'Tanggal Jual/Bayar',
accessorKey: 'trans_date',
enableSorting: false,
cell: (props) => {
const value = props.row.original.trans_date;
return value ? formatDate(value, 'DD MMM YYYY') : '-';
},
},
{
id: 'realization_date',
header: 'Tanggal Realisasi',
accessorKey: 'delivery_date',
enableSorting: false,
cell: (props) => {
const value = props.row.original.delivery_date;
return value ? formatDate(value, 'DD MMM YYYY') : '-';
},
},
{
id: 'aging',
header: 'Aging',
accessorKey: 'aging_day',
enableSorting: false,
cell: (props) => {
const value = props.row.original.aging_day;
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${formatNumber(value)} hari`
: '-'}
</div>
);
},
},
{
id: 'reference',
header: 'Referensi',
accessorKey: 'reference',
enableSorting: false,
cell: (props) => {
const value = props.row.original.reference;
return value || '-';
},
},
{
id: 'vehicle_plate',
header: 'Nomor Polisi',
accessorKey: 'vehicle_numbers',
enableSorting: false,
cell: (props) => {
const value = props.row.original.vehicle_numbers;
return Array.isArray(value)
? value.length > 0
? value.join(', ')
: '-'
: '-';
},
},
{
id: 'qty',
header: 'Qty',
accessorKey: 'qty',
enableSorting: false,
cell: (props) => {
const value = props.row.original.qty;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary.total_qty) || '-'}
</div>
),
},
{
id: 'weight',
header: 'Berat (Kg)',
accessorKey: 'weight',
enableSorting: false,
cell: (props) => {
const value = props.row.original.weight;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary.total_weight) || '-'}
</div>
),
},
{
id: 'average_weight',
header: 'AVG',
accessorKey: 'average_weight',
enableSorting: false,
cell: (props) => {
const value = props.row.original.average_weight;
return <div className='text-right'>{formatNumber(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'unit_price',
header: 'Harga/Unit',
accessorKey: 'unit_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.unit_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'final_price',
header: 'Harga Akhir',
accessorKey: 'final_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.final_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_final_amount) || '-'}
</div>
),
},
{
id: 'total',
header: 'Total',
accessorKey: 'total_price',
enableSorting: false,
cell: (props) => {
const value = props.row.original.total_price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_grand_amount) || '-'}
</div>
),
},
{
id: 'payment',
header: 'Pembayaran',
accessorKey: 'payment_amount',
enableSorting: false,
cell: (props) => {
const value = props.row.original.payment_amount;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_payment) || '-'}
</div>
),
},
{
id: 'accounts_receivable',
header: 'Saldo Piutang',
accessorKey: 'accounts_receivable',
enableSorting: false,
cell: (props) => {
const value = props.row.original.accounts_receivable;
return (
<div
className={`text-right font-semibold ${
value < 0 ? 'text-error' : ''
}`}
>
{formatCurrency(value)}
</div>
);
},
footer: () => (
<div
className={`text-right font-semibold ${
summary.total_accounts_receivable < 0 ? 'text-error' : ''
}`}
>
{formatCurrency(summary.total_accounts_receivable) || '-'}
</div>
),
},
{
id: 'notes',
header: 'Keterangan',
accessorKey: 'status',
enableSorting: false,
cell: (props) => {
const value = props.row.original.status;
if (!value) {
return '-';
}
return (
<Badge
statusIndicator={true}
size='sm'
variant='soft'
className={{
badge: `py-2.5 px-2 font-thin text-xs border border-gray-200 rounded-xl justify-start ${getPaymentStatusColor(value)}`,
status: getPaymentStatusIndicatorColor(value),
}}
>
{getPaymentStatusText(value)}
</Badge>
);
},
},
{
id: 'pickup_info',
header: 'Pengambilan',
accessorKey: 'pickup_info',
enableSorting: false,
cell: (props) => {
const value = props.row.original.pickup_info;
return Array.isArray(value)
? value.length > 0
? value.join(', ')
: '-'
: '-';
},
},
{
id: 'sales_marketing',
header: 'Sales/Marketing',
accessorKey: 'sales_person',
enableSorting: false,
cell: (props) => {
const value = props.row.original.sales_person;
return value || '-';
},
},
];
return tableColumns;
};
return (
<div className='w-full p-0 sm:p-4'>
<Card
subtitle='Laporan > Kontrol Pembayaran Customer'
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}
className={
hasFilters
? 'bg-linear-to-b from-[#0069E0]/40 to-white text-[#0069E0] rounded-lg'
: 'rounded-lg'
}
>
<Icon icon='heroicons:funnel' width={18} height={18} />
Filter
{hasFilters && (
<Badge
variant='default'
className={{
badge:
'rounded-lg px-1.5 py-2.5 text-xs font-semibold bg-error text-white',
}}
>
{activeFiltersCount}
</Badge>
)}
</Button>
<Dropdown
trigger={
<Button
variant='outline'
isLoading={isAnyExportLoading}
className='rounded-lg'
>
<Icon
icon='heroicons:cloud-arrow-down'
width={18}
height={18}
/>
Export
</Button>
}
align='end'
>
<Menu className={'w-full'}>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</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);
}}
className={{ wrapper: 'w-full' }}
/>
</div>
<div>
<DateInput
label=' '
name='end_date'
value={filterEndDate}
onChange={(e) => {
setFilterEndDate(e.target.value);
}}
className={{ wrapper: 'w-full' }}
/>
</div>
</div>
<div>
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={filterCustomer}
onChange={(val) => {
setFilterCustomer(
Array.isArray(val) ? val : val ? [val] : []
);
}}
onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={filterSales}
onChange={(val) => {
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
}}
onInputChange={setSalesInputValue}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
</div> */}
{/* TODO: Uncomment when BE is ready */}
{/* <div>
<SelectInput
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={dataTypeOptions[0]}
isDisabled={true}
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>
{!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((customerReport) => {
const summary = customerReport.summary || {
total_qty: 0,
total_weight: 0,
total_final_amount: 0,
total_grand_amount: 0,
total_payment: 0,
total_accounts_receivable: 0,
};
const tableColumns = getTableColumns(summary);
return (
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`(${customerReport.customer.address})`}
className={{
wrapper: 'w-full rounded-2xl',
body: 'p-0',
title:
'py-1.5 px-3 bg-primary text-white text-lg font-normal',
subtitle:
'px-3 pb-1 bg-primary text-white text-sm font-normal',
}}
variant='bordered'
collapsible={true}
>
<Table
data={[
{
accounts_receivable: customerReport.initial_balance,
} as CustomerPaymentReport['rows'][0],
...customerReport.rows,
]}
columns={tableColumns}
pageSize={customerReport.rows.length + 1}
renderFooter={customerReport.rows.length > 0}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
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={13}
></td>
<td className='px-4 py-3 text-xs whitespace-nowrap'>
<div
className={`text-right ${
row.original.accounts_receivable < 0
? 'text-error'
: ''
}`}
>
{formatCurrency(row.original.accounts_receivable)}
</div>
</td>
<td
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap'
colSpan={4}
></td>
</tr>
);
}
}}
/>
</Card>
);
})
)}
</Card>
</div>
);
};
export default CustomerPaymentTab;