feat(FE): Add Customer Payment report with export features

This commit is contained in:
rstubryan
2026-01-09 14:42:23 +07:00
parent e6cee4a93a
commit 7a6b003cb9
5 changed files with 1261 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import FinanceTabs from '@/components/pages/report/finance/FinanceTabs';
const Finance = () => {
return <FinanceTabs />;
};
export default Finance;
@@ -0,0 +1,23 @@
'use client';
import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
const FinanceTabs = () => {
const tabs = [
{
id: '1',
label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab />,
},
];
return (
<section className='w-full p-4'>
<Tabs tabs={tabs} variant='lifted' />
</section>
);
};
export default FinanceTabs;
@@ -0,0 +1,425 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
supplierInfo: {
fontSize: 9,
marginBottom: 5,
color: '#333333',
},
table: {
borderWidth: 1,
borderColor: '#000000',
marginBottom: 15,
},
tableRow: {
flexDirection: 'row',
},
tableHeader: {
backgroundColor: '#F5F5F5',
},
tableCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'left',
},
tableCellNo: {
flex: 0.5,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 7,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
paddingVertical: 12,
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 7,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
summaryRow: {
backgroundColor: '#F0F0F0',
fontWeight: 'bold',
},
});
interface CustomerPaymentExportPDFParams {
data: CustomerPaymentReport[];
}
const createPDFDocument = (params: CustomerPaymentExportPDFParams) => {
return (
<Document>
{params.data.map((customerReport, customerIndex) => (
<Page
key={customerIndex}
size='A4'
orientation='landscape'
style={pdfStyles.page}
>
{/* Title and Customer Info */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Kontrol Pembayaran Customer
</Text>
<Text style={pdfStyles.supplierTitle}>
{customerReport.customer.name}
</Text>
<Text style={pdfStyles.supplierInfo}>
{customerReport.customer_address || ''}
</Text>
<Text style={pdfStyles.supplierInfo}>
NPWP: {customerReport.customer_npwp || '-'}
</Text>
{customerReport.summary && (
<Text style={pdfStyles.supplierInfo}>
Total Saldo Piutang:{' '}
{formatCurrency(
customerReport.summary.total_accounts_receivable
)}
</Text>
)}
</View>
{/* Table */}
<View style={pdfStyles.table}>
{/* Table Header */}
<View style={[pdfStyles.tableRow, pdfStyles.tableHeader]}>
<View style={[pdfStyles.tableCellHeader, { flex: 0.5 }]}>
<Text>No</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl DO/Bayar</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Tgl Realisasi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 0.8 }]}>
<Text>Aging</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Referensi</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>No. Polisi</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>Berat (Kg)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>AVG</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Awal</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1 }]}>
<Text>CN</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Akhir</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>PPN (%)</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Pembayaran</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Saldo Piutang</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Ket</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1 }]}>
<Text>Pengambilan</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.5 }]}>
<Text>Sales</Text>
</View>
</View>
{/* Table Body */}
{customerReport.rows.map((item, index) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < customerReport.rows.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>
{item.do_date ? formatDate(item.do_date, 'DD MMM YY') : '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 1.2 }]}>
<Text>
{item.realization_date
? formatDate(item.realization_date, 'DD MMM YY')
: '-'}
</Text>
</View>
<View style={[pdfStyles.tableCellCenter, { flex: 0.8 }]}>
<Text>{formatNumber(item.aging)} hari</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.reference || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text>{item.vehicle_plate || '-'}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.qty)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatNumber(item.weight)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.average_weight)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>{formatCurrency(item.credit_note)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.final_price)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.ppn)}%</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.total)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.payment)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatCurrency(item.accounts_receivable)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.notes || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text>{item.pickup_info || '-'}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text>{item.sales_marketing || '-'}</Text>
</View>
</View>
))}
{/* Summary Row */}
{customerReport.summary && (
<View style={[pdfStyles.tableRow, pdfStyles.summaryRow]}>
<View style={[pdfStyles.tableCellNo, { flex: 0.5 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(customerReport.summary.total_qty)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatNumber(customerReport.summary.total_weight)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(
customerReport.summary.total_initial_amount
)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1 }]}>
<Text>
{formatCurrency(customerReport.summary.total_credit_note)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_final_amount)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_grand_amount)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(customerReport.summary.total_payment)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatCurrency(
customerReport.summary.total_accounts_receivable
)}
</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.5 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1 }]}>
<Text></Text>
</View>
<View style={[pdfStyles.tableCellLast, { flex: 1.5 }]}>
<Text></Text>
</View>
</View>
)}
</View>
</Page>
))}
</Document>
);
};
export const generateCustomerPaymentPDF = async (
params: CustomerPaymentExportPDFParams
): Promise<void> => {
const PDFDocument = createPDFDocument(params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `laporan-kontrol-pembayaran-customer-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw error;
}
};
@@ -0,0 +1,109 @@
'use client';
import * as XLSX from 'xlsx';
import { formatDate, formatCurrency, formatNumber } from '@/lib/helper';
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
interface CustomerPaymentExportExcelParams {
data: CustomerPaymentReport[];
}
export const generateCustomerPaymentExcel = (
params: CustomerPaymentExportExcelParams
): void => {
if (!params.data || params.data.length === 0) {
return;
}
const workbook = XLSX.utils.book_new();
params.data.forEach((customerReport) => {
const customerData = customerReport.rows;
const customerName = customerReport.customer.name || 'Unknown Customer';
const excelData: { [key: string]: string | number }[] = customerData.map(
(item, index) => ({
No: index + 1,
'Tanggal DO/Bayar': item.do_date
? formatDate(item.do_date, 'DD MMM YYYY')
: '',
'Tanggal Realisasi': item.realization_date
? formatDate(item.realization_date, 'DD MMM YYYY')
: '',
Aging: item.aging || 0,
Referensi: item.reference || '',
'Nomor Polisi': item.vehicle_plate || '',
'Ekor/Qty': item.qty || 0,
'Berat (Kg)': item.weight || 0,
AVG: item.average_weight || 0,
'Harga Awal': item.price || 0,
CN: item.credit_note || 0,
'Harga Akhir': item.final_price || 0,
'PPN (%)': item.ppn || 0,
Total: item.total || 0,
Pembayaran: item.payment || 0,
'Saldo Piutang': item.accounts_receivable || 0,
Keterangan: item.notes || '',
Pengambilan: item.pickup_info || '',
'Sales/Marketing': item.sales_marketing || '',
})
);
if (customerReport.summary) {
excelData.push({
No: 'Total',
'Tanggal DO/Bayar': '',
'Tanggal Realisasi': '',
Aging: '',
Referensi: '',
'Nomor Polisi': '',
'Ekor/Qty': customerReport.summary.total_qty || 0,
'Berat (Kg)': customerReport.summary.total_weight || 0,
AVG: '',
'Harga Awal': customerReport.summary.total_initial_amount || 0,
CN: customerReport.summary.total_credit_note || 0,
'Harga Akhir': customerReport.summary.total_final_amount || 0,
'PPN (%)': '',
Total: customerReport.summary.total_grand_amount || 0,
Pembayaran: customerReport.summary.total_payment || 0,
'Saldo Piutang': customerReport.summary.total_accounts_receivable || 0,
Keterangan: '',
Pengambilan: '',
'Sales/Marketing': '',
});
}
const worksheet = XLSX.utils.json_to_sheet(excelData);
const colWidths = [
{ wch: 5 }, // No
{ wch: 15 }, // Tanggal DO/Bayar
{ wch: 15 }, // Tanggal Realisasi
{ wch: 8 }, // Aging
{ wch: 12 }, // Referensi
{ wch: 15 }, // Nomor Polisi
{ wch: 10 }, // Ekor/Qty
{ wch: 12 }, // Berat
{ wch: 10 }, // AVG
{ wch: 15 }, // Harga Awal
{ wch: 10 }, // CN
{ wch: 15 }, // Harga Akhir
{ wch: 10 }, // PPN
{ wch: 15 }, // Total
{ wch: 15 }, // Pembayaran
{ wch: 15 }, // Saldo Piutang
{ wch: 20 }, // Keterangan
{ wch: 15 }, // Pengambilan
{ wch: 20 }, // Sales/Marketing
];
worksheet['!cols'] = colWidths;
const sheetName =
customerName.length > 31 ? customerName.substring(0, 31) : customerName;
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
const filename = `laporan-kontrol-pembayaran-customer-dicetak-pada-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`;
XLSX.writeFile(workbook, filename);
};
@@ -0,0 +1,697 @@
import { useState, useMemo, useCallback } from 'react';
import { ChangeEventHandler } from 'react';
import useSWR from 'swr';
import Card from '@/components/Card';
import SelectInput, {
useSelect,
OptionType,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report';
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 { useTableFilter } from '@/services/hooks/useTableFilter';
import Pagination from '@/components/Pagination';
import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
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, setPageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [isSubmitted, setIsSubmitted] = useState(false);
// ===== TABLE FILTER STATE =====
const { state: tableFilterState, updateFilter } = useTableFilter({
initial: {
customer_id: [] as string[],
sales: [] as string[],
date_type: 'do_date' as 'do_date' | 'payment_date',
start_date: '',
end_date: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
});
const { options: customerOptions, isLoadingOptions: isLoadingCustomers } =
useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const salesOptions = useMemo(
() => [
{ value: 'Sales A', label: 'Sales A' },
{ value: 'Sales B', label: 'Sales B' },
{ value: 'Sales C', label: 'Sales C' },
// TODO: Fetch sales options from API
],
[]
);
const dataTypeOptions = useMemo(
() => [
{ value: 'do_date', label: 'Tanggal Jual' },
{ value: 'payment_date', label: 'Tanggal Bayar' },
],
[]
);
const customerChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'customer_id',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const salesChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const arr = Array.isArray(val) ? val : val ? [val] : [];
updateFilter(
'sales',
arr.map((v) => String((v as OptionType).value))
);
setIsSubmitted(false);
},
[updateFilter]
);
const dataTypeChangeHandler = useCallback(
(val: OptionType | OptionType[] | null) => {
const newVal = val as OptionType;
const dateType =
(newVal?.value as 'do_date' | 'payment_date') || 'do_date';
updateFilter('date_type', dateType);
setIsSubmitted(false);
},
[updateFilter]
);
const startDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('start_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const endDateChangeHandler = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(e) => {
const val = e.target.value;
updateFilter('end_date', val || '');
setIsSubmitted(false);
},
[updateFilter]
);
const resetFilters = useCallback(() => {
updateFilter('customer_id', []);
updateFilter('sales', []);
updateFilter('date_type', 'do_date');
updateFilter('start_date', '');
updateFilter('end_date', '');
setIsSubmitted(false);
}, [updateFilter]);
const handleSubmit = useCallback(() => {
setIsSubmitted(true);
setCurrentPage(1);
}, []);
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
isSubmitted
? () => {
const params = {
customer_id:
tableFilterState.customer_id.length > 0
? tableFilterState.customer_id.join(',')
: undefined,
sales:
tableFilterState.sales.length > 0
? tableFilterState.sales.join(',')
: undefined,
date_type: tableFilterState.date_type || undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
page: currentPage,
limit: pageSize,
};
return ['customer-payment-report', params];
}
: null,
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.date_type,
params.start_date,
params.end_date,
params.page,
params.limit
)
);
const data: CustomerPaymentReport[] = useMemo(
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [],
[customerPayment]
);
const meta =
isResponseSuccess(customerPayment) && customerPayment?.meta
? customerPayment.meta
: null;
// ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null
> => {
const params = {
customer_id:
tableFilterState.customer_id.length > 0
? tableFilterState.customer_id.join(',')
: undefined,
sales:
tableFilterState.sales.length > 0
? tableFilterState.sales.join(',')
: undefined,
date_type: tableFilterState.date_type || undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
limit: 100,
page: 1,
};
const response = await FinanceApi.getCustomerPaymentReport(
params.customer_id,
params.sales,
params.date_type,
params.start_date,
params.end_date,
params.page,
params.limit
);
return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[])
: null;
}, [tableFilterState]);
// ===== 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;
}
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 });
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [customerPaymentExport]);
// ===== 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 = (
summary: CustomerPaymentSummary
): ColumnDef<CustomerPaymentReport['rows'][0]>[] => {
const tableColumns: ColumnDef<CustomerPaymentReport['rows'][0]>[] = [
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>Total</div>,
},
{
id: 'do_date_or_payment_date',
header: 'Tanggal DO/Bayar',
accessorKey: 'do_date',
cell: (props) => {
const value = props.row.original.do_date;
return formatDate(value, 'DD MMM YYYY');
},
},
{
id: 'realization_date',
header: 'Tanggal Realisasi',
accessorKey: 'realization_date',
cell: (props) => {
const value = props.row.original.realization_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>;
},
},
{
id: 'reference',
header: 'Referensi',
accessorKey: 'reference',
cell: (props) => {
const value = props.row.original.reference;
return value || '-';
},
},
{
id: 'vehicle_plate',
header: 'Nomor Polisi',
accessorKey: 'vehicle_plate',
cell: (props) => {
const value = props.row.original.vehicle_plate;
return value || '-';
},
},
{
id: 'qty',
header: 'Ekor/Qty',
accessorKey: 'qty',
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',
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',
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: 'price',
header: 'Harga Awal',
accessorKey: 'price',
cell: (props) => {
const value = props.row.original.price;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_initial_amount) || '-'}
</div>
),
},
{
id: 'credit_note',
header: 'CN',
accessorKey: 'credit_note',
cell: (props) => {
const value = props.row.original.credit_note;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_credit_note) || '-'}
</div>
),
},
{
id: 'final_price',
header: 'Harga Akhir',
accessorKey: 'final_price',
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: 'ppn',
header: 'PPN (%)',
accessorKey: 'ppn',
cell: (props) => {
const value = props.row.original.ppn;
return <div className='text-right'>{formatNumber(value)}%</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'total',
header: 'Total',
accessorKey: 'total',
cell: (props) => {
const value = props.row.original.total;
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',
cell: (props) => {
const value = props.row.original.payment;
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',
cell: (props) => {
const value = props.row.original.accounts_receivable;
return <div className='text-right'>{formatCurrency(value)}</div>;
},
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary.total_accounts_receivable) || '-'}
</div>
),
},
{
id: 'notes',
header: 'Keterangan',
accessorKey: 'notes',
cell: (props) => {
const value = props.row.original.notes;
return value || '-';
},
},
{
id: 'pickup_info',
header: 'Pengambilan',
accessorKey: 'pickup_info',
cell: (props) => {
const value = props.row.original.pickup_info;
return value || '-';
},
},
{
id: 'sales_marketing',
header: 'Sales/Marketing',
accessorKey: 'sales_marketing',
cell: (props) => {
const value = props.row.original.sales_marketing;
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 color='primary' onClick={handleSubmit}>
Cari
</Button>
<Button color='warning' onClick={resetFilters}>
Reset
</Button>
<Dropdown
trigger={
<Button color='success' isLoading={isAnyExportLoading}>
Export
</Button>
}
align='end'
>
<Menu className='w-32'>
<MenuItem title='Excel' onClick={handleExportExcel} />
<MenuItem title='PDF' onClick={handleExportPdf} />
</Menu>
</Dropdown>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<SelectInput
label='Customer'
placeholder='Pilih Customer'
isMulti
options={customerOptions}
value={customerOptions.filter((opt) =>
(tableFilterState.customer_id || [])
.map(String)
.includes(String(opt.value))
)}
onChange={customerChangeHandler}
isLoading={isLoadingCustomers}
isClearable
/>
<SelectInput
label='Sales'
placeholder='Pilih Sales'
isMulti
options={salesOptions}
value={salesOptions.filter((opt) =>
(tableFilterState.sales || [])
.map(String)
.includes(String(opt.value))
)}
onChange={salesChangeHandler}
isLoading={false}
isClearable
/>
<SelectInput
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions}
value={
dataTypeOptions?.find(
(option) => option.value === tableFilterState.date_type
) || null
}
onChange={dataTypeChangeHandler}
isLoading={false}
isClearable={false}
/>
</div>
<div className='grid md:grid-cols-3 grid-cols-1 gap-4'>
<div className='md:flex md:flex-row grid grid-cols-1 gap-4'>
<DateInput
label='Tanggal Awal'
name='start_date'
placeholder='Pilih Tanggal Awal'
value={tableFilterState.start_date}
onChange={startDateChangeHandler}
/>
<DateInput
label='Tanggal Akhir'
name='end_date'
placeholder='Pilih Tanggal Akhir'
value={tableFilterState.end_date}
onChange={endDateChangeHandler}
/>
</div>
</div>
{!isSubmitted ? (
<div className='mt-6 text-center text-gray-500'>
Silakan pilih filter dan klik tombol Submit untuk 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_initial_amount: 0,
total_credit_note: 0,
total_final_amount: 0,
total_ppn: 0,
total_grand_amount: 0,
total_payment: 0,
total_accounts_receivable: 0,
};
const totalAccountsReceivable = summary.total_accounts_receivable;
const tableColumns = getTableColumns(summary);
return (
<Card
key={customerReport.customer.id}
title={customerReport.customer.name}
subtitle={`NPWP: ${customerReport.customer_npwp || '-'} | ${customerReport.customer_address || ''}\nSaldo Piutang: ${formatCurrency(totalAccountsReceivable)}`}
className={{ wrapper: 'w-full' }}
variant='bordered'
collapsible={true}
>
<Table
data={customerReport.rows}
columns={tableColumns}
pageSize={10}
renderFooter={customerReport.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>
);
})
)}
</Card>
{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>
)}
</div>
);
};
export default CustomerPaymentTab;