feat(FE-364): Add PDF export for purchases per supplier

This commit is contained in:
rstubryan
2025-12-16 12:00:56 +07:00
parent 2a00da0298
commit 615d4d5ffe
2 changed files with 615 additions and 5 deletions
@@ -0,0 +1,493 @@
'use client';
import {
Page,
Text,
View,
Document,
StyleSheet,
Font,
pdf,
} from '@react-pdf/renderer';
import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock';
import { formatDate, formatNumber } from '@/lib/helper';
Font.register({
family: 'Helvetica',
src: 'helvetica',
});
const pdfStyles = StyleSheet.create({
page: {
fontSize: 10,
fontFamily: 'Helvetica',
padding: 20,
backgroundColor: '#FFFFFF',
},
header: {
marginBottom: 20,
},
logo: {
width: 120,
height: 30,
marginBottom: 8,
},
companyInfo: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
color: '#1f74bf',
},
address: {
fontSize: 8,
color: '#666666',
maxWidth: 400,
marginBottom: 10,
},
divider: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
marginBottom: 15,
},
titleSection: {
marginBottom: 10,
},
mainTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 5,
color: '#1f74bf',
},
parameterSection: {
fontSize: 9,
color: '#666666',
marginBottom: 15,
},
supplierTitle: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 8,
color: '#1f74bf',
},
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: 8,
},
tableCellLast: {
flex: 1,
padding: 4,
fontSize: 8,
},
tableCellHeader: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellHeaderCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'center',
},
tableCellHeaderRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
},
tableCellHeaderRightLast: {
flex: 1,
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'right',
},
tableCellHeaderLast: {
flex: 1,
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
},
tableCellHeaderCenterLast: {
flex: 1,
padding: 4,
fontSize: 8,
fontWeight: 'bold',
backgroundColor: '#F5F5F5',
textAlign: 'center',
},
tableCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'right',
},
tableCellCenter: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableCellRightLast: {
flex: 1,
padding: 4,
fontSize: 8,
textAlign: 'right',
},
tableCellCenterLast: {
flex: 1,
padding: 4,
fontSize: 8,
textAlign: 'center',
},
tableBorderBottom: {
borderBottomWidth: 1,
borderBottomColor: '#000000',
borderBottomStyle: 'solid',
},
totalRow: {
backgroundColor: '#F5F5F5',
borderTopWidth: 1,
borderTopColor: '#000000',
borderTopStyle: 'solid',
},
totalCell: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
},
totalCellRight: {
flex: 1,
borderRightWidth: 1,
borderRightColor: '#000000',
borderRightStyle: 'solid',
padding: 4,
fontSize: 8,
fontWeight: 'bold',
textAlign: 'right',
},
totalCellRightLast: {
flex: 1,
padding: 4,
fontSize: 8,
fontWeight: 'bold',
textAlign: 'right',
},
totalCellLast: {
flex: 1,
padding: 4,
fontSize: 8,
fontWeight: 'bold',
},
supplierSection: {
marginBottom: 20,
},
supplierSectionBreak: {
marginBottom: 25,
},
badge: {
backgroundColor: '#1f74bf',
color: '#FFFFFF',
padding: 2,
borderRadius: 2,
fontSize: 7,
fontWeight: 'bold',
alignSelf: 'flex-start',
},
});
interface PurchasesPerSupplierExportParams {
data: LogisticPurchasePerSupplierReport['rows'];
params: {
area_name?: string;
supplier_name?: string;
product_name?: string;
product_category_name?: string;
received_date?: string;
po_date?: string;
start_date?: string;
end_date?: string;
sort_by?: string;
filter_by?: string;
};
}
interface GroupedSupplierData {
id: number;
supplier: LogisticPurchasePerSupplierReport['rows'][number]['supplier'];
items: LogisticPurchasePerSupplierReport['rows'][number][];
}
const groupDataBySupplier = (
data: LogisticPurchasePerSupplierReport['rows']
): GroupedSupplierData[] => {
const groups: {
[key: number]: GroupedSupplierData;
} = {};
data.forEach((item) => {
const supplierId = item.supplier?.id;
if (supplierId && !groups[supplierId]) {
groups[supplierId] = {
id: supplierId,
supplier: item.supplier,
items: [],
};
}
if (groups[supplierId]) {
groups[supplierId].items.push(item);
}
});
return Object.values(groups) as GroupedSupplierData[];
};
const getParameterText = (
params: PurchasesPerSupplierExportParams['params']
) => {
const paramsText = [];
if (params.filter_by === 'received_date') {
paramsText.push('Tanggal Terima');
} else if (params.filter_by === 'po_date') {
paramsText.push('Tanggal PO');
}
if (params.supplier_name) {
paramsText.push(`Supplier: ${params.supplier_name}`);
} else {
paramsText.push('Semua Supplier');
}
if (params.start_date && params.end_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
const endDate = formatDate(params.end_date, 'DD MMM YYYY');
paramsText.push(`Periode: ${startDate} - ${endDate}`);
} else if (params.start_date) {
const startDate = formatDate(params.start_date, 'DD MMM YYYY');
paramsText.push(`Tanggal: ${startDate}`);
}
const currentDate = formatDate(new Date(), 'DD MMM YYYY HH:mm');
paramsText.push(`Dicetak: ${currentDate}`);
return paramsText.join(' | ');
};
const createPDFDocument = (
groupedData: GroupedSupplierData[],
params: PurchasesPerSupplierExportParams['params']
) => (
<Document>
<Page size='A3' orientation='landscape' style={pdfStyles.page}>
{/* Title and Parameters */}
<View style={pdfStyles.titleSection}>
<Text style={pdfStyles.mainTitle}>
Laporan &gt; Rekapitulasi Pembelian Per Supplier
</Text>
<Text style={pdfStyles.parameterSection}>
Jenis Tanggal:{' '}
{params.filter_by === 'received_date'
? 'Tanggal Terima'
: 'Tanggal PO'}{' '}
| {getParameterText(params)}
</Text>
</View>
{/* Supplier Sections */}
{groupedData.map(
(supplierGroup: GroupedSupplierData, supplierIndex: number) => {
return (
<View
key={supplierGroup.id}
style={[
pdfStyles.supplierSection,
supplierIndex < groupedData.length - 1
? pdfStyles.supplierSectionBreak
: {},
]}
>
<Text style={pdfStyles.supplierTitle}>
{supplierGroup.supplier.name}
</Text>
<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}>
<Text>Tanggal Terima</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tanggal PO</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Referensi</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Produk</Text>
</View>
<View style={pdfStyles.tableCellHeader}>
<Text>Tujuan</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 0.8 }]}>
<Text>Qty</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Harga Beli</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Nilai Pembelian</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.2 }]}>
<Text>Biaya Transport</Text>
</View>
<View style={[pdfStyles.tableCellHeaderRight, { flex: 1.5 }]}>
<Text>Total</Text>
</View>
<View style={[pdfStyles.tableCellHeader, { flex: 1.2 }]}>
<Text>Armada</Text>
</View>
<View style={pdfStyles.tableCellHeaderLast}>
<Text>Surat Jalan</Text>
</View>
</View>
{/* Table Body */}
{supplierGroup.items.map(
(
item: LogisticPurchasePerSupplierReport['rows'][number],
index: number
) => (
<View
key={index}
style={[
pdfStyles.tableRow,
index < supplierGroup.items.length - 1
? pdfStyles.tableBorderBottom
: {},
]}
>
<View style={[pdfStyles.tableCell, { flex: 0.5 }]}>
<Text>{index + 1}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>
{formatDate(item.receive_date, 'DD-MMM-YYYY')}
</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{formatDate(item.po_date, 'DD-MMM-YYYY')}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.po_number || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.product?.name || '-'}</Text>
</View>
<View style={pdfStyles.tableCell}>
<Text>{item.warehouse?.name || '-'}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 0.8 }]}>
<Text>{formatNumber(item.qty || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>{formatNumber(item.unit_price || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text>{formatNumber(item.purchase_value || 0)}</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.2 }]}>
<Text>
{formatNumber(item.transport_unit_price || 0)}
</Text>
</View>
<View style={[pdfStyles.tableCellRight, { flex: 1.5 }]}>
<Text>{formatNumber(item.total_amount || 0)}</Text>
</View>
<View style={[pdfStyles.tableCell, { flex: 1.2 }]}>
<View style={pdfStyles.badge}>
<Text>{item.expedition || '-'}</Text>
</View>
</View>
<View style={pdfStyles.tableCellLast}>
<Text>{item.delivery_number || '-'}</Text>
</View>
</View>
)
)}
</View>
</View>
);
}
)}
</Page>
</Document>
);
export const generatePurchasesPerSupplierPDF = async (
data: LogisticPurchasePerSupplierReport['rows'],
params: PurchasesPerSupplierExportParams['params']
): Promise<void> => {
const groupedData = groupDataBySupplier(data);
const PDFDocument = createPDFDocument(groupedData, params);
try {
const blob = await pdf(PDFDocument).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `laporan-pembelian-per-supplier-${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;
}
};
@@ -23,6 +23,8 @@ import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown';
import MenuItem from '@/components/menu/MenuItem';
import Menu from '@/components/menu/Menu';
import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport';
import toast from 'react-hot-toast';
interface Totals {
totalQty: number;
@@ -34,6 +36,9 @@ interface Totals {
}
const PurchasesPerSupplierTab = () => {
// ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -247,13 +252,121 @@ const PurchasesPerSupplierTab = () => {
? purchasePerSupplier.meta
: undefined;
const { data: allDataForExport } = useSWR(
isSubmitted
? () => {
const params = {
area_id: tableFilterState.area_id
? Number(tableFilterState.area_id)
: undefined,
supplier_id: tableFilterState.supplier_id
? Number(tableFilterState.supplier_id)
: undefined,
product_id: tableFilterState.product_id
? Number(tableFilterState.product_id)
: undefined,
product_category_id: tableFilterState.product_category_id
? Number(tableFilterState.product_category_id)
: undefined,
received_date:
tableFilterState.filter_by === 'received_date'
? tableFilterState.start_date || undefined
: undefined,
po_date:
tableFilterState.filter_by === 'po_date'
? tableFilterState.start_date || undefined
: undefined,
start_date: tableFilterState.start_date || undefined,
end_date: tableFilterState.end_date || undefined,
sort_by: tableFilterState.sort_by || undefined,
filter_by: tableFilterState.filter_by || undefined,
limit: 10000,
page: 1,
};
return ['logistic-purchase-report-export', params];
}
: null,
([, params]) =>
LogisticApi.getLogisticStockReport(
params.area_id,
params.supplier_id,
params.product_id,
params.product_category_id,
params.received_date,
params.po_date,
params.start_date,
params.end_date,
params.sort_by,
params.filter_by,
params.page,
params.limit
)
);
const allExportData: LogisticPurchasePerSupplierReport['rows'] = useMemo(
() =>
isResponseSuccess(allDataForExport)
? (allDataForExport?.data
?.rows as LogisticPurchasePerSupplierReport['rows']) || []
: [],
[allDataForExport]
);
const handleExportExcel = useCallback(() => {
alert('Export to Excel functionality to be implemented.');
toast.error('Export to Excel functionality will be implemented.');
}, []);
const handleExportPdf = useCallback(() => {
alert('Export to PDF functionality to be implemented.');
}, []);
const handleExportPdf = useCallback(async () => {
if (allExportData.length === 0) {
toast.error('Tidak ada data untuk diekspor.');
return;
}
setIsPdfExportLoading(true);
try {
const exportParams = {
area_name: tableFilterState.area_id
? areaOptions.find(
(opt) => opt.value === Number(tableFilterState.area_id)
)?.label || ''
: 'Semua Area',
supplier_name: tableFilterState.supplier_id
? supplierOptions.find(
(opt) => opt.value === Number(tableFilterState.supplier_id)
)?.label || ''
: 'Semua Supplier',
product_name: tableFilterState.product_id
? productOptions.find(
(opt) => opt.value === Number(tableFilterState.product_id)
)?.label || ''
: 'Semua Produk',
product_category_name: tableFilterState.product_category_id
? productCategoryOptions.find(
(opt) =>
opt.value === Number(tableFilterState.product_category_id)
)?.label || ''
: 'Semua Kategori Produk',
filter_by: tableFilterState.filter_by || 'received_date',
start_date: tableFilterState.start_date || '',
end_date: tableFilterState.end_date || '',
};
await generatePurchasesPerSupplierPDF(allExportData, exportParams);
toast.success('PDF berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally {
setIsPdfExportLoading(false);
}
}, [
allExportData,
tableFilterState,
areaOptions,
supplierOptions,
productOptions,
productCategoryOptions,
]);
// ===== PAGINATION HANDLERS =====
const handlePageChange = (page: number) => {
@@ -483,7 +596,11 @@ const PurchasesPerSupplierTab = () => {
Reset
</Button>
<Dropdown
trigger={<Button color='success'>Export</Button>}
trigger={
<Button color='success' isLoading={isPdfExportLoading}>
Export
</Button>
}
align='end'
>
<Menu className='w-32'>