From e6bcf287eafa9a5f47fd20a2b89d188748e5523d Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 25 Sep 2025 19:35:22 +0700 Subject: [PATCH] profit loss pdf --- package-lock.json | 11 + package.json | 1 + .../export/pdf/PDFExportProfitLossService.ts | 577 ++++++++++++++++++ .../profit-loss/ReportProfitLossContent.tsx | 86 ++- 4 files changed, 663 insertions(+), 12 deletions(-) create mode 100644 src/services/export/pdf/PDFExportProfitLossService.ts diff --git a/package-lock.json b/package-lock.json index 6eec3bd..c599ac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "emoji-mart": "5.6.0", "fs-extra": "11.2.0", "html2canvas": "^1.4.1", + "html2pdf.js": "^0.12.1", "input-otp": "1.4.1", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", @@ -7511,6 +7512,16 @@ "node": ">=8.0.0" } }, + "node_modules/html2pdf.js": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.12.1.tgz", + "integrity": "sha512-3rBWQ96H5oOU9jtoz3MnE/epGi27ig9h8aonBk4JTpvUERM3lMRxhIRckhJZEi4wE0YfRINoYOIDY0hLY0CHgQ==", + "license": "MIT", + "dependencies": { + "html2canvas": "^1.0.0", + "jspdf": "^3.0.0" + } + }, "node_modules/htmlparser2": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", diff --git a/package.json b/package.json index 504dbd6..8e1a9b4 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "emoji-mart": "5.6.0", "fs-extra": "11.2.0", "html2canvas": "^1.4.1", + "html2pdf.js": "^0.12.1", "input-otp": "1.4.1", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", diff --git a/src/services/export/pdf/PDFExportProfitLossService.ts b/src/services/export/pdf/PDFExportProfitLossService.ts new file mode 100644 index 0000000..ac60a73 --- /dev/null +++ b/src/services/export/pdf/PDFExportProfitLossService.ts @@ -0,0 +1,577 @@ +// services/pdfExportService.ts +import type { ProfitLossReport } from '@/types/services/analytic' + +export class PDFExportProfitLossService { + /** + * Export Profit Loss Report to PDF (Simple approach) + */ + static async exportProfitLossToPDF(profitData: ProfitLossReport, filename?: string) { + try { + // Dynamic import untuk jsPDF + const jsPDFModule = await import('jspdf') + const jsPDF = jsPDFModule.default + + // Create new PDF document - PORTRAIT A4 + const pdf = new jsPDF('p', 'mm', 'a4') // portrait + + // Add content + this.addBasicContent(pdf, profitData) + + // Generate filename + const exportFilename = filename || this.generateFilename('Laba_Rugi', 'pdf') + + // Save PDF + pdf.save(exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting to PDF:', error) + return { success: false, error: `PDF export failed: ${(error as Error).message}` } + } + } + + /** + * Add basic content to PDF with proper page management + */ + private static addBasicContent(pdf: any, profitData: ProfitLossReport) { + let yPos = 20 // Reduced from 30 + const pageWidth = pdf.internal.pageSize.getWidth() + const pageHeight = pdf.internal.pageSize.getHeight() + const marginBottom = 15 // Reduced from 20 + + // Title - Center aligned + pdf.setFontSize(18) // Reduced from 20 + pdf.setFont('helvetica', 'bold') + pdf.text('Laporan Laba Rugi', pageWidth / 2, yPos, { align: 'center' }) + yPos += 10 // Reduced from 15 + + // Period - Center aligned + pdf.setFontSize(11) // Reduced from 12 + pdf.setFont('helvetica', 'normal') + const periodText = `${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}` + pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' }) + yPos += 12 // Reduced from 20 + + // Purple line separator + pdf.setDrawColor(102, 45, 145) // Purple color + pdf.setLineWidth(1.5) // Reduced from 2 + pdf.line(20, yPos, pageWidth - 20, yPos) + yPos += 15 // Reduced from 25 + + // Ringkasan section + pdf.setFontSize(14) // Reduced from 16 + pdf.setFont('helvetica', 'bold') + pdf.setTextColor(102, 45, 145) // Purple color + pdf.text('Ringkasan', 20, yPos) + yPos += 12 // Reduced from 20 + + // Reset text color to black + pdf.setTextColor(0, 0, 0) + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(11) // Reduced from 12 + + // Summary items with consistent spacing + const summaryItems = [ + { label: 'Total Penjualan', value: this.formatCurrency(profitData.summary.total_revenue) }, + { label: 'Total Biaya', value: this.formatCurrency(profitData.summary.total_cost) }, + { label: 'Total Diskon', value: this.formatCurrency(profitData.summary.total_discount) }, + { label: 'Total Pajak', value: this.formatCurrency(profitData.summary.total_tax) }, + { label: 'Laba Kotor', value: this.formatCurrency(profitData.summary.gross_profit) }, + { label: 'Laba Bersih', value: this.formatCurrency(profitData.summary.net_profit) } + ] + + summaryItems.forEach((item, index) => { + // Add some spacing between items + if (index > 0) yPos += 8 // Reduced from 12 + + // Check if we need new page for summary items + if (yPos > pageHeight - marginBottom - 15) { + pdf.addPage() + yPos = 20 // Reduced from 30 + } + + // Label on left + pdf.text(item.label, 20, yPos) + + // Value on right + pdf.text(item.value, pageWidth - 20, yPos, { align: 'right' }) + + // Light gray line separator + pdf.setDrawColor(230, 230, 230) + pdf.setLineWidth(0.3) + pdf.line(20, yPos + 3, pageWidth - 20, yPos + 3) + }) + + yPos += 20 // Reduced from 30 + + // Check if we need new page before daily breakdown + if (yPos > pageHeight - marginBottom - 40) { + pdf.addPage() + yPos = 20 // Reduced from 30 + } + + // Daily breakdown section + pdf.setFontSize(14) // Reduced from 16 + pdf.setFont('helvetica', 'bold') + pdf.setTextColor(102, 45, 145) // Purple color + pdf.text('Rincian Harian', 20, yPos) + yPos += 12 // Reduced from 20 + + // Reset text color + pdf.setTextColor(0, 0, 0) + + // Create simple daily breakdown with page management + profitData.data.forEach((daily, index) => { + // Estimate space needed for this daily section (approx 100mm) + const estimatedSpace = 100 + + // Check if we need new page before adding daily section + if (yPos + estimatedSpace > pageHeight - marginBottom) { + pdf.addPage() + yPos = 20 // Reduced from 30 + } + + yPos = this.addCleanDailySection(pdf, daily, yPos, pageWidth, pageHeight, marginBottom) + yPos += 10 // Reduced from 15 - Space between daily sections + }) + } + + /** + * Add clean daily section with page break management + */ + private static addCleanDailySection( + pdf: any, + dailyData: any, + startY: number, + pageWidth: number, + pageHeight: number, + marginBottom: number + ) { + const date = new Date(dailyData.date).toLocaleDateString('id-ID', { + day: '2-digit', + month: 'long', + year: 'numeric' + }) + + let yPos = startY + + // Check if we have enough space for the header + if (yPos > pageHeight - marginBottom - 20) { + pdf.addPage() + yPos = 20 // Reduced from 30 + } + + // Date header + pdf.setFontSize(12) // Reduced from 14 + pdf.setFont('helvetica', 'bold') + pdf.text(date, 20, yPos) + yPos += 10 // Reduced from 15 + + // Daily data items + pdf.setFontSize(10) // Reduced from 11 + pdf.setFont('helvetica', 'normal') + + const dailyItems = [ + { label: 'Penjualan', value: this.formatCurrency(dailyData.revenue) }, + { label: 'HPP (Biaya Pokok)', value: this.formatCurrency(dailyData.cost) }, + { label: 'Laba Kotor', value: this.formatCurrency(dailyData.gross_profit) }, + { label: 'Pajak', value: this.formatCurrency(dailyData.tax) }, + { label: 'Diskon', value: this.formatCurrency(dailyData.discount) }, + { label: 'Jumlah Order', value: dailyData.orders.toString() + ' transaksi' }, + { label: 'Laba Bersih', value: this.formatCurrency(dailyData.net_profit), isTotal: true } + ] + + dailyItems.forEach((item, index) => { + if (index > 0) yPos += 7 // Reduced from 10 + + // Check if we need new page for each item + if (yPos > pageHeight - marginBottom - 15) { + pdf.addPage() + yPos = 20 // Reduced from 30 + } + + // Special styling for total (Laba Bersih) + if (item.isTotal) { + pdf.setFont('helvetica', 'bold') + + // Light background for total row - adjusted to center with text + pdf.setFillColor(248, 248, 248) + pdf.rect(20, yPos - 4, pageWidth - 40, 9, 'F') // Slightly bigger and better positioned + } else { + pdf.setFont('helvetica', 'normal') + } + + // Label on left - consistent with other rows + pdf.text(item.label, 25, yPos) + + // Value on right - consistent with other rows + pdf.text(item.value, pageWidth - 25, yPos, { align: 'right' }) + + // Subtle line separator (except for last item) + if (index < dailyItems.length - 1) { + pdf.setDrawColor(245, 245, 245) + pdf.setLineWidth(0.2) + pdf.line(25, yPos + 1.5, pageWidth - 25, yPos + 1.5) // Reduced from yPos + 2 + } + }) + + return yPos + 6 // Reduced from 8 + } + + /** + * Format currency for display + */ + private static formatCurrency(amount: number): string { + return `Rp ${amount.toLocaleString('id-ID')}` + } + + /** + * Generate filename with timestamp + */ + private static generateFilename(prefix: string, extension: string): string { + const now = new Date() + const year = now.getFullYear() + const month = (now.getMonth() + 1).toString().padStart(2, '0') + const day = now.getDate().toString().padStart(2, '0') + const hour = now.getHours().toString().padStart(2, '0') + const minute = now.getMinutes().toString().padStart(2, '0') + + return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}` + } + + /** + * Alternative: More precise page break management + */ + static async exportWithBetterPageBreaks(profitData: ProfitLossReport, filename?: string) { + try { + const jsPDFModule = await import('jspdf') + const jsPDF = jsPDFModule.default + const pdf = new jsPDF('p', 'mm', 'a4') + + // Use more precise measurements + const pageHeight = pdf.internal.pageSize.getHeight() // ~297mm for A4 + const safeHeight = pageHeight - 30 // Keep 30mm margin from bottom + + let currentY = 30 + + // Helper function to check and add new page + const checkPageBreak = (neededSpace: number) => { + if (currentY + neededSpace > safeHeight) { + pdf.addPage() + currentY = 30 + return true + } + return false + } + + // Add title and header + currentY = this.addTitleSection(pdf, profitData, currentY) + + // Add summary with page break check + checkPageBreak(80) // Estimate 80mm needed for summary + currentY = this.addSummarySection(pdf, profitData, currentY, checkPageBreak) + + // Add daily breakdown + checkPageBreak(40) // Space for section header + currentY = this.addDailyBreakdownSection(pdf, profitData, currentY, checkPageBreak) + + const exportFilename = filename || this.generateFilename('Laba_Rugi', 'pdf') + pdf.save(exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting to PDF:', error) + return { success: false, error: `PDF export failed: ${(error as Error).message}` } + } + } + + /** + * Add title section + */ + private static addTitleSection(pdf: any, profitData: ProfitLossReport, startY: number): number { + let yPos = startY + const pageWidth = pdf.internal.pageSize.getWidth() + + // Title + pdf.setFontSize(20) + pdf.setFont('helvetica', 'bold') + pdf.text('Laporan Laba Rugi', pageWidth / 2, yPos, { align: 'center' }) + yPos += 15 + + // Period + pdf.setFontSize(12) + pdf.setFont('helvetica', 'normal') + const periodText = `${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}` + pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' }) + yPos += 20 + + // Separator line + pdf.setDrawColor(102, 45, 145) + pdf.setLineWidth(2) + pdf.line(20, yPos, pageWidth - 20, yPos) + yPos += 25 + + return yPos + } + + /** + * Add summary section with page break callback + */ + private static addSummarySection( + pdf: any, + profitData: ProfitLossReport, + startY: number, + checkPageBreak: (space: number) => boolean + ): number { + let yPos = startY + const pageWidth = pdf.internal.pageSize.getWidth() + + // Section title + pdf.setFontSize(16) + pdf.setFont('helvetica', 'bold') + pdf.setTextColor(102, 45, 145) + pdf.text('Ringkasan', 20, yPos) + yPos += 20 + + // Reset formatting + pdf.setTextColor(0, 0, 0) + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(12) + + const summaryItems = [ + { label: 'Total Penjualan', value: this.formatCurrency(profitData.summary.total_revenue) }, + { label: 'Total Biaya', value: this.formatCurrency(profitData.summary.total_cost) }, + { label: 'Total Diskon', value: this.formatCurrency(profitData.summary.total_discount) }, + { label: 'Total Pajak', value: this.formatCurrency(profitData.summary.total_tax) }, + { label: 'Laba Kotor', value: this.formatCurrency(profitData.summary.gross_profit) }, + { label: 'Laba Bersih', value: this.formatCurrency(profitData.summary.net_profit) } + ] + + summaryItems.forEach((item, index) => { + if (index > 0) yPos += 12 + + // Check page break for each item + if (checkPageBreak(15)) { + yPos = 30 + } + + pdf.text(item.label, 20, yPos) + pdf.text(item.value, pageWidth - 20, yPos, { align: 'right' }) + + // Separator line + pdf.setDrawColor(230, 230, 230) + pdf.setLineWidth(0.3) + pdf.line(20, yPos + 3, pageWidth - 20, yPos + 3) + }) + + return yPos + 30 + } + + /** + * Add daily breakdown section with page break management + */ + private static addDailyBreakdownSection( + pdf: any, + profitData: ProfitLossReport, + startY: number, + checkPageBreak: (space: number) => boolean + ): number { + let yPos = startY + + // Section title + pdf.setFontSize(16) + pdf.setFont('helvetica', 'bold') + pdf.setTextColor(102, 45, 145) + pdf.text('Rincian Harian', 20, yPos) + yPos += 20 + + pdf.setTextColor(0, 0, 0) + + profitData.data.forEach((daily, index) => { + // Check if we need space for this daily section (estimate ~90mm) + if (checkPageBreak(90)) { + yPos = 30 + } + + yPos = this.addSingleDayData(pdf, daily, yPos) + yPos += 15 + }) + + return yPos + } + + /** + * Add single day data + */ + private static addSingleDayData(pdf: any, dailyData: any, startY: number): number { + const pageWidth = pdf.internal.pageSize.getWidth() + let yPos = startY + + const date = new Date(dailyData.date).toLocaleDateString('id-ID', { + day: '2-digit', + month: 'long', + year: 'numeric' + }) + + // Date header + pdf.setFontSize(14) + pdf.setFont('helvetica', 'bold') + pdf.text(date, 20, yPos) + yPos += 15 + + // Daily items + pdf.setFontSize(11) + pdf.setFont('helvetica', 'normal') + + const items = [ + { label: 'Penjualan', value: this.formatCurrency(dailyData.revenue) }, + { label: 'HPP (Biaya Pokok)', value: this.formatCurrency(dailyData.cost) }, + { label: 'Laba Kotor', value: this.formatCurrency(dailyData.gross_profit) }, + { label: 'Pajak', value: this.formatCurrency(dailyData.tax) }, + { label: 'Diskon', value: this.formatCurrency(dailyData.discount) }, + { label: 'Jumlah Order', value: dailyData.orders.toString() + ' transaksi' }, + { label: 'Laba Bersih', value: this.formatCurrency(dailyData.net_profit), isTotal: true } + ] + + items.forEach((item, index) => { + if (index > 0) yPos += 10 + + if (item.isTotal) { + pdf.setFont('helvetica', 'bold') + pdf.setFillColor(248, 248, 248) + pdf.rect(20, yPos - 4, pageWidth - 40, 10, 'F') + } else { + pdf.setFont('helvetica', 'normal') + } + + pdf.text(item.label, 25, yPos) + pdf.text(item.value, pageWidth - 25, yPos, { align: 'right' }) + + if (index < items.length - 1) { + pdf.setDrawColor(245, 245, 245) + pdf.setLineWidth(0.2) + pdf.line(25, yPos + 2, pageWidth - 25, yPos + 2) + } + }) + + return yPos + 8 + } + + /** + * Alternative HTML to PDF method (if needed) + */ + static async exportToHTMLPDF(profitData: ProfitLossReport, filename?: string) { + try { + const htmlContent = this.generateSimpleHTML(profitData) + + // Create a temporary element and trigger print + const printWindow = window.open('', '_blank') + if (printWindow) { + printWindow.document.write(htmlContent) + printWindow.document.close() + printWindow.focus() + printWindow.print() + printWindow.close() + } + + return { success: true, filename: filename || 'Laba_Rugi.pdf' } + } catch (error) { + return { success: false, error: `HTML PDF export failed: ${(error as Error).message}` } + } + } + + /** + * Generate simple HTML for printing + */ + private static generateSimpleHTML(profitData: ProfitLossReport): string { + const dateColumns = profitData.data.map(daily => { + const date = new Date(daily.date) + return date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }) + }) + + return ` + + + + Laporan Laba Rugi + + + +

LAPORAN LABA RUGI

+

+ Periode: ${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]} +

+ +

RINGKASAN PERIODE

+
+
Total Revenue: ${this.formatCurrency(profitData.summary.total_revenue)}
+
Total Cost: ${this.formatCurrency(profitData.summary.total_cost)}
+
Gross Profit: ${this.formatCurrency(profitData.summary.gross_profit)}
+
Net Profit: ${this.formatCurrency(profitData.summary.net_profit)}
+
Total Orders: ${profitData.summary.total_orders}
+
+ +
+

RINCIAN HARIAN

+ + + + + + + ${dateColumns.map(date => ``).join('')} + + + + + + + + ${profitData.data.map(daily => ``).join('')} + + + + + + ${profitData.data.map(daily => ``).join('')} + + + + + + ${profitData.data.map(daily => ``).join('')} + + + + + + ${profitData.data.map(daily => ``).join('')} + + + + + + ${profitData.data.map(daily => ``).join('')} + + +
NOKETERANGAN${date}
1TOTAL PENJ:${this.formatCurrency(daily.revenue)}
2HPP:${this.formatCurrency(daily.cost)}
3Laba Kotor:${this.formatCurrency(daily.gross_profit)}
4Biaya lain:${this.formatCurrency(daily.tax + daily.discount)}
5Laba/Rugi:${this.formatCurrency(daily.net_profit)}
+ + + ` + } +} diff --git a/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx b/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx index 907205d..3411a01 100644 --- a/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx +++ b/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx @@ -3,8 +3,11 @@ import DateRangePicker from '@/components/RangeDatePicker' import { ReportItem, ReportItemFooter, ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem' import { ExcelExportProfitLossService } from '@/services/export/excel/ExcelExportProfitLossService' +import { PDFExportProfitLossService } from '@/services/export/pdf/PDFExportProfitLossService' import { ProfitLossReport } from '@/types/services/analytic' -import { Button, Card, CardContent, Box } from '@mui/material' +import { Button, Card, CardContent, Box, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material' + +import { useState } from 'react' interface ReportProfitLossContentProps { profitData: ProfitLossReport | undefined @@ -31,23 +34,52 @@ const ReportProfitLossContent = ({ onStartDateChange, onEndDateChange }: ReportProfitLossContentProps) => { - const handleExport = async () => { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + const handleExportExcel = async () => { if (!profitData) return + handleClose() try { const result = await ExcelExportProfitLossService.exportProfitLossToExcel(profitData) if (result.success) { - // Optional: Show success notification - console.log('Export successful:', result.filename) - // You can add toast notification here + console.log('Excel export successful:', result.filename) } else { - console.error('Export failed:', result.error) - alert('Export gagal. Silakan coba lagi.') + console.error('Excel export failed:', result.error) + alert('Export Excel gagal. Silakan coba lagi.') } } catch (error) { - console.error('Export error:', error) - alert('Terjadi kesalahan saat export.') + console.error('Excel export error:', error) + alert('Terjadi kesalahan saat export Excel.') + } + } + + const handleExportPDF = async () => { + if (!profitData) return + handleClose() + + try { + const result = await PDFExportProfitLossService.exportProfitLossToPDF(profitData) + + if (result.success) { + console.log('PDF export successful:', result.filename) + // Optional: Show success notification + } else { + console.error('PDF export failed:', result.error) + alert('Export PDF gagal. Silakan coba lagi.') + } + } catch (error) { + console.error('PDF export error:', error) + alert('Terjadi kesalahan saat export PDF.') } } @@ -58,13 +90,43 @@ const ReportProfitLossContent = ({ + + + + + + + Export to Excel + + + + + + + Export to PDF + +