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
@@ -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>
`
}
}