From aae1d49721f68db24dfdd037594a47fa5574e402 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 9 Oct 2025 19:10:45 +0700 Subject: [PATCH] update report --- package-lock.json | 10 + package.json | 1 + .../dashboards/daily-report/page.tsx | 435 +++++++++++++----- 3 files changed, 322 insertions(+), 124 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4275235..d421165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "html2canvas": "^1.4.1", "input-otp": "1.4.1", "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "keen-slider": "6.8.6", "lucide-react": "^0.544.0", "mapbox-gl": "3.9.0", @@ -8295,6 +8296,15 @@ "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/jspdf-autotable": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz", + "integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2 || ^3" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", diff --git a/package.json b/package.json index c958205..3b800b2 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "html2canvas": "^1.4.1", "input-otp": "1.4.1", "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "keen-slider": "6.8.6", "lucide-react": "^0.544.0", "mapbox-gl": "3.9.0", diff --git a/src/app/[lang]/(dashboard)/(private)/dashboards/daily-report/page.tsx b/src/app/[lang]/(dashboard)/(private)/dashboards/daily-report/page.tsx index be669f6..083c309 100644 --- a/src/app/[lang]/(dashboard)/(private)/dashboards/daily-report/page.tsx +++ b/src/app/[lang]/(dashboard)/(private)/dashboards/daily-report/page.tsx @@ -25,6 +25,25 @@ const DailyPOSReport = () => { const [filterType, setFilterType] = useState<'single' | 'range'>('single') const [isGeneratingPDF, setIsGeneratingPDF] = useState(false) + // PDF Font Size Configuration + const PDF_FONT_SIZES = { + heading: 20, + subheading: 20, + tableContent: 14, + tableHeader: 14, + tableFooter: 14, + grandTotal: 18, + footer: 11 + } + + // PDF Spacing Configuration + const PDF_SPACING = { + cellPadding: 5, + cellPaddingLarge: 6, + sectionGap: 20, + headerGap: 15 + } + const getDateParams = () => { if (filterType === 'single') { return { @@ -98,29 +117,7 @@ const DailyPOSReport = () => { try { const jsPDF = (await import('jspdf')).default const html2canvas = (await import('html2canvas')).default - - const originalOverflow = reportElement.style.overflow - reportElement.style.overflow = 'visible' - - await new Promise(resolve => setTimeout(resolve, 300)) - - const canvas = await html2canvas(reportElement, { - scale: 1.5, - useCORS: true, - allowTaint: true, - backgroundColor: '#ffffff', - logging: false, - removeContainer: true, - imageTimeout: 0, - height: reportElement.scrollHeight, - width: reportElement.scrollWidth, - scrollX: 0, - scrollY: 0, - windowWidth: Math.max(reportElement.scrollWidth, window.innerWidth), - windowHeight: Math.max(reportElement.scrollHeight, window.innerHeight) - }) - - reportElement.style.overflow = originalOverflow + const autoTable = (await import('jspdf-autotable')).default const pdf = new jsPDF({ orientation: 'portrait', @@ -129,62 +126,280 @@ const DailyPOSReport = () => { compress: true }) - const pdfWidth = 210 - const pdfHeight = 297 - const margin = 5 + // Capture header section only (tanpa tabel kategori) + const headerElement = reportElement.querySelector('.report-header-section') as HTMLElement + if (headerElement) { + const headerCanvas = await html2canvas(headerElement, { + scale: 2, + useCORS: true, + backgroundColor: '#ffffff', + logging: false, + windowWidth: 794 + }) - const availableWidth = pdfWidth - 2 * margin - const availableHeight = pdfHeight - 2 * margin + const headerImgWidth = 190 + const headerImgHeight = (headerCanvas.height * headerImgWidth) / headerCanvas.width + const headerImgData = headerCanvas.toDataURL('image/jpeg', 0.95) - const imgWidth = availableWidth - const imgHeight = (canvas.height * availableWidth) / canvas.width - - let yPosition = margin - let sourceY = 0 - let pageCount = 1 - let remainingHeight = imgHeight - - while (remainingHeight > 0) { - if (pageCount > 1) { - pdf.addPage() - yPosition = margin - } - - const heightForThisPage = Math.min(remainingHeight, availableHeight) - const sourceHeight = (heightForThisPage * canvas.height) / imgHeight - - const tempCanvas = document.createElement('canvas') - const tempCtx = tempCanvas.getContext('2d') - - if (!tempCtx) { - throw new Error('Unable to get 2D context from canvas') - } - - tempCanvas.width = canvas.width - tempCanvas.height = sourceHeight - - tempCtx.drawImage(canvas, 0, sourceY, canvas.width, sourceHeight, 0, 0, canvas.width, sourceHeight) - - const pageImageData = tempCanvas.toDataURL('image/jpeg', 0.9) - pdf.addImage(pageImageData, 'JPEG', margin, yPosition, imgWidth, heightForThisPage) - - sourceY += sourceHeight - remainingHeight -= heightForThisPage - pageCount++ - - if (pageCount > 20) { - console.warn('Too many pages, breaking loop') - break - } + pdf.addImage(headerImgData, 'JPEG', 10, 10, headerImgWidth, headerImgHeight) } - pdf.setProperties({ - title: getReportTitle(), - subject: 'Transaction Report', - author: 'Apskel POS System', - creator: 'Apskel' + let currentY = 80 // Start position after header + + // Add summary sections with autoTable + pdf.setFontSize(PDF_FONT_SIZES.heading) + pdf.setTextColor(54, 23, 94) + pdf.setFont('helvetica', 'bold') + pdf.text('Ringkasan', 14, currentY) + currentY += 15 + + // Summary table + autoTable(pdf, { + startY: currentY, + head: [], + body: [ + ['Total Penjualan', formatCurrency(profitLoss?.summary.total_revenue ?? 0)], + ['Total Diskon', formatCurrency(profitLoss?.summary.total_discount ?? 0)], + ['Total Pajak', formatCurrency(profitLoss?.summary.total_tax ?? 0)], + ['Total', formatCurrency(profitLoss?.summary.total_revenue ?? 0)] + ], + theme: 'plain', + styles: { fontSize: PDF_FONT_SIZES.tableContent, cellPadding: PDF_SPACING.cellPadding }, + columnStyles: { + 0: { fontStyle: 'normal', textColor: [60, 60, 60] }, + 1: { halign: 'right', fontStyle: 'bold', textColor: [60, 60, 60] } + }, + margin: { left: 14, right: 14 } }) + currentY = (pdf as any).lastAutoTable.finalY + 20 + + // Invoice section + pdf.setFontSize(PDF_FONT_SIZES.heading) + pdf.text('Invoice', 14, currentY) + currentY += 15 + + autoTable(pdf, { + startY: currentY, + head: [], + body: [['Total Invoice', String(profitLoss?.summary.total_orders ?? 0)]], + theme: 'plain', + styles: { fontSize: PDF_FONT_SIZES.tableContent, cellPadding: PDF_SPACING.cellPadding }, + columnStyles: { + 0: { fontStyle: 'normal', textColor: [60, 60, 60] }, + 1: { halign: 'right', fontStyle: 'bold', textColor: [60, 60, 60] } + }, + margin: { left: 14, right: 14 } + }) + + pdf.addPage() + currentY = 20 + // Payment Method Summary + pdf.setFontSize(PDF_FONT_SIZES.heading) + pdf.text('Ringkasan Metode Pembayaran', 14, currentY) + currentY += 15 + + const paymentBody = + paymentAnalytics?.data?.map(payment => [ + payment.payment_method_name, + payment.payment_method_type.toUpperCase(), + String(payment.order_count), + formatCurrency(payment.total_amount), + `${(payment.percentage ?? 0).toFixed(1)}%` + ]) || [] + + autoTable(pdf, { + startY: currentY, + head: [['Metode Pembayaran', 'Tipe', 'Jumlah Order', 'Total Amount', 'Persentase']], + body: paymentBody, + foot: [ + [ + 'TOTAL', + '', + String(paymentAnalytics?.summary.total_orders ?? 0), + formatCurrency(paymentAnalytics?.summary.total_amount ?? 0), + '' + ] + ], + theme: 'striped', + styles: { fontSize: PDF_FONT_SIZES.tableContent, cellPadding: PDF_SPACING.cellPadding }, + headStyles: { + fillColor: [54, 23, 94], + textColor: 255, + fontStyle: 'bold', + fontSize: PDF_FONT_SIZES.tableHeader + }, + footStyles: { + fillColor: [220, 220, 220], + textColor: [60, 60, 60], + fontStyle: 'bold', + fontSize: PDF_FONT_SIZES.tableFooter + }, + columnStyles: { + 1: { halign: 'center' }, + 2: { halign: 'center' }, + 3: { halign: 'right' }, + 4: { halign: 'center' } + }, + margin: { left: 14, right: 14 } + }) + + currentY = (pdf as any).lastAutoTable.finalY + 20 + + // Category Summary + pdf.setFontSize(PDF_FONT_SIZES.heading) + pdf.text('Ringkasan Kategori', 14, currentY) + currentY += 15 + + const categoryBody = + category?.data?.map(c => [c.category_name, String(c.total_quantity), formatCurrency(c.total_revenue)]) || [] + + autoTable(pdf, { + startY: currentY, + head: [['Nama', 'Qty', 'Pendapatan']], + body: categoryBody, + foot: [ + ['TOTAL', String(categorySummary?.totalQuantity ?? 0), formatCurrency(categorySummary?.totalRevenue ?? 0)] + ], + theme: 'striped', + styles: { fontSize: PDF_FONT_SIZES.tableContent, cellPadding: PDF_SPACING.cellPadding }, + headStyles: { + fillColor: [54, 23, 94], + textColor: 255, + fontStyle: 'bold', + fontSize: PDF_FONT_SIZES.tableHeader + }, + footStyles: { + fillColor: [220, 220, 220], + textColor: [60, 60, 60], + fontStyle: 'bold', + fontSize: PDF_FONT_SIZES.tableFooter + }, + columnStyles: { + 1: { halign: 'center' }, + 2: { halign: 'right' } + }, + margin: { left: 14, right: 14 } + }) + + // Group products by category + const groupedProducts = + products?.data?.reduce( + (acc, item) => { + const categoryName = item.category_name || 'Tidak Berkategori' + if (!acc[categoryName]) { + acc[categoryName] = [] + } + acc[categoryName].push(item) + return acc + }, + {} as Record + ) || {} + + // Add new page for product details + pdf.addPage() + currentY = 20 + + pdf.setFontSize(PDF_FONT_SIZES.heading) + pdf.text('Ringkasan Item Per Kategori', 14, currentY) + currentY += 15 + + // Loop through each category + Object.keys(groupedProducts) + .sort((a, b) => { + const productsA = groupedProducts[a] + const productsB = groupedProducts[b] + const orderA = productsA[0]?.category_order ?? 999 + const orderB = productsB[0]?.category_order ?? 999 + return orderA - orderB + }) + .forEach(categoryName => { + const categoryProducts = groupedProducts[categoryName] + const categoryTotalQty = categoryProducts.reduce((sum, item) => sum + (item.quantity_sold || 0), 0) + const categoryTotalRevenue = categoryProducts.reduce((sum, item) => sum + (item.revenue || 0), 0) + + const productBody = categoryProducts.map(item => [ + item.product_name, + String(item.quantity_sold), + formatCurrency(item.revenue) + ]) + + // Check if we need a new page + const estimatedHeight = (productBody.length + 3) * 12 // Adjusted for larger font + if (currentY + estimatedHeight > 270) { + pdf.addPage() + currentY = 20 + } + + // Category header + pdf.setFontSize(20) + pdf.setFont('helvetica', 'bold') + pdf.setTextColor(54, 23, 94) + pdf.text(categoryName.toUpperCase(), 16, currentY) + currentY += 15 + + // Category table + autoTable(pdf, { + startY: currentY, + head: [['Produk', 'Qty', 'Pendapatan']], + body: productBody, + foot: [[`Subtotal ${categoryName}`, String(categoryTotalQty), formatCurrency(categoryTotalRevenue)]], + theme: 'striped', + styles: { fontSize: PDF_FONT_SIZES.tableContent, cellPadding: PDF_SPACING.cellPadding }, + headStyles: { + fillColor: [54, 23, 94], + textColor: 255, + fontStyle: 'bold', + fontSize: PDF_FONT_SIZES.tableHeader + }, + footStyles: { fillColor: [200, 200, 200], textColor: [60, 60, 60], fontStyle: 'bold', fontSize: 20 }, + columnStyles: { + 0: { cellWidth: 90 }, + 1: { halign: 'center', cellWidth: 40 }, + 2: { halign: 'right', cellWidth: 52 } + }, + margin: { left: 14, right: 14 } + }) + + currentY = (pdf as any).lastAutoTable.finalY + 15 + }) + + // Grand Total + if (currentY > 250) { + pdf.addPage() + currentY = 20 + } + + autoTable(pdf, { + startY: currentY, + head: [], + body: [ + ['TOTAL KESELURUHAN', String(productSummary.totalQuantitySold), formatCurrency(productSummary.totalRevenue)] + ], + theme: 'plain', + styles: { fontSize: PDF_FONT_SIZES.grandTotal, cellPadding: 6, fontStyle: 'bold', textColor: [54, 23, 94] }, + columnStyles: { + 0: { cellWidth: 90 }, + 1: { halign: 'center', cellWidth: 40 }, + 2: { halign: 'right', cellWidth: 52 } + }, + margin: { left: 14, right: 14 }, + didDrawCell: data => { + pdf.setDrawColor(54, 23, 94) + pdf.setLineWidth(0.5) + } + }) + + // Footer + const pageCount = pdf.getNumberOfPages() + for (let i = 1; i <= pageCount; i++) { + pdf.setPage(i) + pdf.setFontSize(11) + pdf.setTextColor(120, 120, 120) + pdf.text('© 2025 Apskel - Sistem POS Terpadu', 14, 287) + pdf.text(`Dicetak pada: ${now.toLocaleDateString('id-ID')}`, 190, 287, { align: 'right' }) + } + const fileName = filterType === 'single' ? `laporan-transaksi-${formatDateForInput(selectedDate)}.pdf` @@ -259,14 +474,17 @@ const DailyPOSReport = () => { />
- + {/* Wrap header in a section for easy capture */} +
+ +
{/* Performance Summary */}
@@ -414,7 +632,7 @@ const DailyPOSReport = () => { {/* Product Summary - Dipisah per kategori dengan tabel terpisah */}
-

+

Ringkasan Item Per Kategori

@@ -427,46 +645,25 @@ const DailyPOSReport = () => { const orderB = productsB[0]?.category_order ?? 999 return orderA - orderB }) - .map(categoryName => { + .map((categoryName, catIndex) => { const categoryProducts = groupedProducts[categoryName] const categoryTotalQty = categoryProducts.reduce((sum, item) => sum + (item.quantity_sold || 0), 0) const categoryTotalRevenue = categoryProducts.reduce((sum, item) => sum + (item.revenue || 0), 0) return ( -
- {/* Category Title */} +

{categoryName.toUpperCase()}

- {/* Category Table */} -
- +
+
@@ -481,11 +678,7 @@ const DailyPOSReport = () => { {categoryProducts.map((item, index) => ( - + @@ -499,7 +692,7 @@ const DailyPOSReport = () => { ))} - + @@ -518,13 +711,7 @@ const DailyPOSReport = () => { })} {/* Grand Total */} -
+
{item.product_name}
Subtotal {categoryName}