From 615d4d5ffe15f5e7df327f351b48d6661722d88f Mon Sep 17 00:00:00 2001 From: rstubryan Date: Tue, 16 Dec 2025 12:00:56 +0700 Subject: [PATCH] feat(FE-364): Add PDF export for purchases per supplier --- .../export/PurchasesPerSupplierExport.tsx | 493 ++++++++++++++++++ .../tab/PurchasesPerSupplierTab.tsx | 127 ++++- 2 files changed, 615 insertions(+), 5 deletions(-) diff --git a/src/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport.tsx b/src/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport.tsx index e69de29b..b96b093a 100644 --- a/src/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport.tsx +++ b/src/components/pages/report/logistic-stock/export/PurchasesPerSupplierExport.tsx @@ -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'] +) => ( + + + {/* Title and Parameters */} + + + Laporan > Rekapitulasi Pembelian Per Supplier + + + Jenis Tanggal:{' '} + {params.filter_by === 'received_date' + ? 'Tanggal Terima' + : 'Tanggal PO'}{' '} + | {getParameterText(params)} + + + + {/* Supplier Sections */} + {groupedData.map( + (supplierGroup: GroupedSupplierData, supplierIndex: number) => { + return ( + + + {supplierGroup.supplier.name} + + + + {/* Table Header */} + + + No + + + Tanggal Terima + + + Tanggal PO + + + Referensi + + + Produk + + + Tujuan + + + Qty + + + Harga Beli + + + Nilai Pembelian + + + Biaya Transport + + + Total + + + Armada + + + Surat Jalan + + + + {/* Table Body */} + {supplierGroup.items.map( + ( + item: LogisticPurchasePerSupplierReport['rows'][number], + index: number + ) => ( + + + {index + 1} + + + + {formatDate(item.receive_date, 'DD-MMM-YYYY')} + + + + {formatDate(item.po_date, 'DD-MMM-YYYY')} + + + {item.po_number || '-'} + + + {item.product?.name || '-'} + + + {item.warehouse?.name || '-'} + + + {formatNumber(item.qty || 0)} + + + {formatNumber(item.unit_price || 0)} + + + {formatNumber(item.purchase_value || 0)} + + + + {formatNumber(item.transport_unit_price || 0)} + + + + {formatNumber(item.total_amount || 0)} + + + + {item.expedition || '-'} + + + + {item.delivery_number || '-'} + + + ) + )} + + + ); + } + )} + + +); + +export const generatePurchasesPerSupplierPDF = async ( + data: LogisticPurchasePerSupplierReport['rows'], + params: PurchasesPerSupplierExportParams['params'] +): Promise => { + 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; + } +}; diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index c8a57fd7..ac0f3359 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -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 Export} + trigger={ + + } align='end' >