profit loss pdf

This commit is contained in:
efrilm 2025-09-25 19:35:22 +07:00
parent d317e8d06f
commit e6bcf287ea
4 changed files with 663 additions and 12 deletions

11
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 `
<!DOCTYPE html>
<html>
<head>
<title>Laporan Laba Rugi</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #1f4e79; text-align: center; }
h2 { color: #333; border-bottom: 2px solid #ccc; }
.summary { margin-bottom: 30px; }
.summary-item { margin: 5px 0; padding: 5px; background: #f9f9f9; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #d35400; color: white; text-align: center; }
.number { text-align: right; }
.center { text-align: center; }
@media print {
body { margin: 10px; }
.page-break { page-break-before: always; }
}
</style>
</head>
<body>
<h1>LAPORAN LABA RUGI</h1>
<p style="text-align: center;">
Periode: ${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}
</p>
<h2>RINGKASAN PERIODE</h2>
<div class="summary">
<div class="summary-item">Total Revenue: <strong>${this.formatCurrency(profitData.summary.total_revenue)}</strong></div>
<div class="summary-item">Total Cost: <strong>${this.formatCurrency(profitData.summary.total_cost)}</strong></div>
<div class="summary-item">Gross Profit: <strong>${this.formatCurrency(profitData.summary.gross_profit)}</strong></div>
<div class="summary-item">Net Profit: <strong>${this.formatCurrency(profitData.summary.net_profit)}</strong></div>
<div class="summary-item">Total Orders: <strong>${profitData.summary.total_orders}</strong></div>
</div>
<div class="page-break"></div>
<h2>RINCIAN HARIAN</h2>
<table>
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th></th>
${dateColumns.map(date => `<th>${date}</th>`).join('')}
</tr>
</thead>
<tbody>
<tr>
<td class="center">1</td>
<td><strong>TOTAL PENJ</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.revenue)}</td>`).join('')}
</tr>
<tr>
<td class="center">2</td>
<td><strong>HPP</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.cost)}</td>`).join('')}
</tr>
<tr>
<td class="center">3</td>
<td><strong>Laba Kotor</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.gross_profit)}</td>`).join('')}
</tr>
<tr>
<td class="center">4</td>
<td><strong>Biaya lain</strong></td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.tax + daily.discount)}</td>`).join('')}
</tr>
<tr style="background-color: #154360; color: white; font-weight: bold;">
<td class="center">5</td>
<td>Laba/Rugi</td>
<td class="center">:</td>
${profitData.data.map(daily => `<td class="number">${this.formatCurrency(daily.net_profit)}</td>`).join('')}
</tr>
</tbody>
</table>
</body>
</html>
`
}
}

View File

@ -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 | HTMLElement>(null)
const open = Boolean(anchorEl)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
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 = ({
<Button
color='secondary'
variant='tonal'
startIcon={<i className='tabler-upload' />}
startIcon={<i className='tabler-download' />}
endIcon={<i className='tabler-chevron-down' />}
className='max-sm:is-full'
onClick={handleExport}
onClick={handleClick}
disabled={!profitData}
aria-controls={open ? 'export-menu' : undefined}
aria-haspopup='true'
aria-expanded={open ? 'true' : undefined}
>
Ekspor
Export
</Button>
<Menu
id='export-menu'
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'export-button'
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={handleExportExcel}>
<ListItemIcon>
<i className='tabler-file-type-xls text-green-600' />
</ListItemIcon>
<ListItemText>Export to Excel</ListItemText>
</MenuItem>
<MenuItem onClick={handleExportPDF}>
<ListItemIcon>
<i className='tabler-file-type-pdf text-red-600' />
</ListItemIcon>
<ListItemText>Export to PDF</ListItemText>
</MenuItem>
</Menu>
<DateRangePicker
startDate={startDate}
endDate={endDate}