mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 07:45:47 +00:00
Merge branch 'dev/restu' into 'development'
[FEAT/FE] Add Customer Payment Control Report Page See merge request mbugroup/lti-web-client!150
This commit is contained in:
@@ -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 > 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,115 @@
|
|||||||
|
'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: formatNumber(item.aging || 0),
|
||||||
|
Referensi: item.reference || '',
|
||||||
|
'Nomor Polisi': item.vehicle_plate || '',
|
||||||
|
'Ekor/Qty': formatNumber(item.qty || 0),
|
||||||
|
'Berat (Kg)': formatNumber(item.weight || 0),
|
||||||
|
AVG: formatNumber(item.average_weight || 0),
|
||||||
|
'Harga Awal': formatCurrency(item.price || 0),
|
||||||
|
CN: formatCurrency(item.credit_note || 0),
|
||||||
|
'Harga Akhir': formatCurrency(item.final_price || 0),
|
||||||
|
'PPN (%)': formatNumber(item.ppn || 0),
|
||||||
|
Total: formatCurrency(item.total || 0),
|
||||||
|
Pembayaran: formatCurrency(item.payment || 0),
|
||||||
|
'Saldo Piutang': formatCurrency(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': formatNumber(customerReport.summary.total_qty || 0),
|
||||||
|
'Berat (Kg)': formatNumber(customerReport.summary.total_weight || 0),
|
||||||
|
AVG: '',
|
||||||
|
'Harga Awal': formatCurrency(
|
||||||
|
customerReport.summary.total_initial_amount || 0
|
||||||
|
),
|
||||||
|
CN: formatCurrency(customerReport.summary.total_credit_note || 0),
|
||||||
|
'Harga Akhir': formatCurrency(
|
||||||
|
customerReport.summary.total_final_amount || 0
|
||||||
|
),
|
||||||
|
'PPN (%)': '',
|
||||||
|
Total: formatCurrency(customerReport.summary.total_grand_amount || 0),
|
||||||
|
Pembayaran: formatCurrency(customerReport.summary.total_payment || 0),
|
||||||
|
'Saldo Piutang': formatCurrency(
|
||||||
|
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,717 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
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 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 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, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
// ===== SUBMISSION STATE =====
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
|
||||||
|
// ===== FILTER STATE =====
|
||||||
|
const [filterCustomer, setFilterCustomer] = useState<OptionType[]>([]);
|
||||||
|
const [filterSales, setFilterSales] = useState<OptionType[]>([]);
|
||||||
|
const [filterStartDate, setFilterStartDate] = useState('');
|
||||||
|
const [filterEndDate, setFilterEndDate] = useState('');
|
||||||
|
const [filterErrors, setFilterErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const filterModal = useModal();
|
||||||
|
|
||||||
|
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' }],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== FILTER HANDLERS =====
|
||||||
|
const handleResetFilters = useCallback(() => {
|
||||||
|
setIsSubmitted(false);
|
||||||
|
setFilterCustomer([]);
|
||||||
|
setFilterSales([]);
|
||||||
|
setFilterStartDate('');
|
||||||
|
setFilterEndDate('');
|
||||||
|
setFilterErrors({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyFilters = useCallback(() => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!filterStartDate) {
|
||||||
|
errors.start_date = 'Tanggal mulai wajib diisi';
|
||||||
|
}
|
||||||
|
if (!filterEndDate) {
|
||||||
|
errors.end_date = 'Tanggal akhir wajib diisi';
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilterErrors(errors);
|
||||||
|
|
||||||
|
if (Object.keys(errors).length === 0) {
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
filterModal.closeModal();
|
||||||
|
}
|
||||||
|
}, [filterModal, filterStartDate, filterEndDate]);
|
||||||
|
|
||||||
|
// ===== DATA FETCHING =====
|
||||||
|
const { data: customerPayment, isLoading } = useSWR(
|
||||||
|
isSubmitted
|
||||||
|
? () => {
|
||||||
|
const params = {
|
||||||
|
customer_id:
|
||||||
|
filterCustomer.length > 0
|
||||||
|
? filterCustomer.map((v) => String(v.value)).join(',')
|
||||||
|
: undefined,
|
||||||
|
sales:
|
||||||
|
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_id,
|
||||||
|
params.sales,
|
||||||
|
params.filter_by,
|
||||||
|
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:
|
||||||
|
filterCustomer.length > 0
|
||||||
|
? filterCustomer.map((v) => String(v.value)).join(',')
|
||||||
|
: undefined,
|
||||||
|
sales:
|
||||||
|
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,
|
||||||
|
limit: 100,
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await FinanceApi.getCustomerPaymentReport(
|
||||||
|
params.customer_id,
|
||||||
|
params.sales,
|
||||||
|
params.filter_by,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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='Customer'
|
||||||
|
placeholder='Pilih Customer'
|
||||||
|
isMulti
|
||||||
|
options={customerOptions}
|
||||||
|
value={filterCustomer}
|
||||||
|
onChange={(val) => {
|
||||||
|
setFilterCustomer(
|
||||||
|
Array.isArray(val) ? val : val ? [val] : []
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
isLoading={isLoadingCustomers}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SelectInput
|
||||||
|
label='Sales'
|
||||||
|
placeholder='Pilih Sales'
|
||||||
|
isMulti
|
||||||
|
options={salesOptions}
|
||||||
|
value={filterSales}
|
||||||
|
onChange={(val) => {
|
||||||
|
setFilterSales(Array.isArray(val) ? val : val ? [val] : []);
|
||||||
|
}}
|
||||||
|
isClearable
|
||||||
|
className={{ wrapper: 'w-full' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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_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;
|
||||||
@@ -127,6 +127,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
|||||||
link: '/report',
|
link: '/report',
|
||||||
icon: 'mdi:chart-box-outline',
|
icon: 'mdi:chart-box-outline',
|
||||||
submenu: [
|
submenu: [
|
||||||
|
{
|
||||||
|
text: 'Keuangan',
|
||||||
|
link: '/report/finance',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Logistik & Persediaan',
|
text: 'Logistik & Persediaan',
|
||||||
link: '/report/logistic-stock',
|
link: '/report/logistic-stock',
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
|
|||||||
'/report/expense/': ['lti.repport.expense.list'],
|
'/report/expense/': ['lti.repport.expense.list'],
|
||||||
'/report/marketing/': ['lti.repport.delivery.list'],
|
'/report/marketing/': ['lti.repport.delivery.list'],
|
||||||
'/report/production-result/': ['lti.repport.production_result.list'],
|
'/report/production-result/': ['lti.repport.production_result.list'],
|
||||||
|
'/report/finance/': ['lti.repport.finance.list'],
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
'/inventory/adjustment/': ['lti.inventory.list'],
|
'/inventory/adjustment/': ['lti.inventory.list'],
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { BaseApiService } from '@/services/api/base';
|
||||||
|
import { BaseApiResponse } from '@/types/api/api-general';
|
||||||
|
import { CustomerPaymentReport } from '@/types/api/report/customer-payment';
|
||||||
|
|
||||||
|
export class FinanceApiService extends BaseApiService<
|
||||||
|
CustomerPaymentReport,
|
||||||
|
unknown,
|
||||||
|
unknown
|
||||||
|
> {
|
||||||
|
constructor(basePath: string) {
|
||||||
|
super(basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCustomerPaymentReport(
|
||||||
|
customer_id?: string,
|
||||||
|
sales?: string,
|
||||||
|
filter_by?: 'do_date',
|
||||||
|
start_date?: string,
|
||||||
|
end_date?: string,
|
||||||
|
page?: number,
|
||||||
|
limit?: number
|
||||||
|
): Promise<BaseApiResponse<CustomerPaymentReport> | undefined> {
|
||||||
|
return await this.customRequest<BaseApiResponse<CustomerPaymentReport>>(
|
||||||
|
`customer-payment`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
params: {
|
||||||
|
customer_id: customer_id,
|
||||||
|
sales: sales,
|
||||||
|
filter_by: filter_by,
|
||||||
|
start_date: start_date,
|
||||||
|
end_date: end_date,
|
||||||
|
page: page,
|
||||||
|
limit: limit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FinanceApi = new FinanceApiService('reports');
|
||||||
|
|
||||||
|
// export const FinanceApi = new FinanceApiService(
|
||||||
|
// 'http://localhost:4010/api/reports/finance'
|
||||||
|
// );
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { BaseMetadata } from '@/types/api/api-general';
|
||||||
|
import { BaseCustomer } from '@/types/api/master-data/customer';
|
||||||
|
import { BaseProduct } from '@/types/api/master-data/product';
|
||||||
|
|
||||||
|
export type CustomerPaymentRow = {
|
||||||
|
no: number;
|
||||||
|
do_date: string;
|
||||||
|
payment_date: string;
|
||||||
|
realization_date: string;
|
||||||
|
aging: number;
|
||||||
|
reference: string;
|
||||||
|
vehicle_plate: string;
|
||||||
|
qty: number;
|
||||||
|
weight: number;
|
||||||
|
average_weight: number;
|
||||||
|
price: number;
|
||||||
|
credit_note: number;
|
||||||
|
final_price: number;
|
||||||
|
ppn: number;
|
||||||
|
total: number;
|
||||||
|
payment: number;
|
||||||
|
accounts_receivable: number;
|
||||||
|
notes: string;
|
||||||
|
pickup_info: string;
|
||||||
|
sales_marketing: string;
|
||||||
|
product?: BaseProduct;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomerPaymentSummary = {
|
||||||
|
total_qty: number;
|
||||||
|
total_weight: number;
|
||||||
|
total_initial_amount: number;
|
||||||
|
total_credit_note: number;
|
||||||
|
total_final_amount: number;
|
||||||
|
total_ppn: number;
|
||||||
|
total_grand_amount: number;
|
||||||
|
total_payment: number;
|
||||||
|
total_accounts_receivable: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomerPaymentReport = BaseMetadata & {
|
||||||
|
customer: BaseCustomer;
|
||||||
|
customer_npwp: string;
|
||||||
|
customer_address: string;
|
||||||
|
rows: CustomerPaymentRow[];
|
||||||
|
summary: CustomerPaymentSummary;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user