add excel pdf

This commit is contained in:
efrilm 2025-12-23 14:46:16 +07:00
parent de32d86e94
commit 67ebb2ebd7
6 changed files with 2119 additions and 552 deletions

1076
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -59,6 +59,7 @@
"cmdk": "1.0.4", "cmdk": "1.0.4",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"emoji-mart": "5.6.0", "emoji-mart": "5.6.0",
"exceljs": "^4.4.0",
"fs-extra": "11.2.0", "fs-extra": "11.2.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"input-otp": "1.4.1", "input-otp": "1.4.1",
@ -84,7 +85,8 @@
"react-use": "17.6.0", "react-use": "17.6.0",
"recharts": "2.15.0", "recharts": "2.15.0",
"use-debounce": "^10.0.5", "use-debounce": "^10.0.5",
"valibot": "0.42.1" "valibot": "0.42.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@iconify/json": "2.2.286", "@iconify/json": "2.2.286",

View File

@ -8,6 +8,8 @@ import {
useCategoryAnalytics useCategoryAnalytics
} from '@/services/queries/analytics' } from '@/services/queries/analytics'
import { useOutletById } from '@/services/queries/outlets' import { useOutletById } from '@/services/queries/outlets'
import { generateExcel } from '@/utils/excelGenerator'
import { generatePDF } from '@/utils/pdfGenerator'
import { formatCurrency, formatDate, formatDateDDMMYYYY, formatDatetime } from '@/utils/transform' import { formatCurrency, formatDate, formatDateDDMMYYYY, formatDatetime } from '@/utils/transform'
import ReportGeneratorComponent from '@/views/dashboards/daily-report/report-generator' import ReportGeneratorComponent from '@/views/dashboards/daily-report/report-generator'
import ReportHeader from '@/views/dashboards/daily-report/report-header' import ReportHeader from '@/views/dashboards/daily-report/report-header'
@ -16,6 +18,8 @@ import React, { useEffect, useRef, useState } from 'react'
const DailyPOSReport = () => { const DailyPOSReport = () => {
const reportRef = useRef<HTMLDivElement | null>(null) const reportRef = useRef<HTMLDivElement | null>(null)
const [isGeneratingExcel, setIsGeneratingExcel] = useState(false)
const [now, setNow] = useState(new Date()) const [now, setNow] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date()) const [selectedDate, setSelectedDate] = useState(new Date())
const [dateRange, setDateRange] = useState({ const [dateRange, setDateRange] = useState({
@ -112,536 +116,22 @@ const DailyPOSReport = () => {
} }
const handleGeneratePDF = async () => { const handleGeneratePDF = async () => {
const reportElement = reportRef.current
if (!reportElement) {
alert('Report element tidak ditemukan')
return
}
setIsGeneratingPDF(true) setIsGeneratingPDF(true)
try { try {
const jsPDF = (await import('jspdf')).default await generatePDF({
const html2canvas = (await import('html2canvas')).default reportRef,
const autoTable = (await import('jspdf-autotable')).default outlet,
profitLoss,
const pdf = new jsPDF({ products,
orientation: 'portrait', paymentAnalytics,
unit: 'mm', category,
format: 'a4', productSummary,
compress: true categorySummary,
filterType,
selectedDate,
dateRange,
now
}) })
// 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 headerImgWidth = 190
const headerImgHeight = (headerCanvas.height * headerImgWidth) / headerCanvas.width
const headerImgData = headerCanvas.toDataURL('image/jpeg', 0.95)
pdf.addImage(headerImgData, 'JPEG', 10, 10, headerImgWidth, headerImgHeight)
}
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 - TETAP PLAIN (TANPA BORDER)
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: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.subheading,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0]
},
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 - TETAP PLAIN (TANPA BORDER)
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: 'grid',
styles: {
lineColor: [0, 0, 0],
fontSize: PDF_FONT_SIZES.subheading,
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
// === TREN PENJUALAN (berdasarkan data sales analytics) ===
// if (profitLoss7Days?.data && profitLoss7Days.data.length > 0) {
// pdf.setFontSize(PDF_FONT_SIZES.heading)
// pdf.text('Tren Penjualan 7 Hari Terakhir', 14, currentY)
// currentY += 10
// // Create canvas for chart
// const canvas = document.createElement('canvas')
// canvas.width = 800
// canvas.height = 400
// const ctx = canvas.getContext('2d')
// if (ctx) {
// const { Chart, registerables } = await import('chart.js')
// Chart.register(...registerables)
// // Prepare chart data
// const chartLabels = profitLoss7Days.data.map(day =>
// new Date(day.date).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' })
// )
// const revenueData = profitLoss7Days.data.map(day => day.revenue)
// const ordersData = profitLoss7Days.data.map(day => day.orders)
// // Create chart
// new Chart(ctx, {
// type: 'bar',
// data: {
// labels: chartLabels,
// datasets: [
// {
// label: 'Total Penjualan (Rp)',
// data: revenueData,
// backgroundColor: 'rgba(54, 23, 94, 0.8)',
// borderColor: 'rgba(54, 23, 94, 1)',
// borderWidth: 1,
// yAxisID: 'y'
// },
// {
// label: 'Jumlah Invoice',
// data: ordersData,
// type: 'line',
// borderColor: 'rgba(59, 130, 246, 1)',
// backgroundColor: 'rgba(59, 130, 246, 0.1)',
// borderWidth: 2,
// tension: 0.4,
// yAxisID: 'y1',
// pointRadius: 4,
// pointBackgroundColor: 'rgba(59, 130, 246, 1)'
// }
// ]
// },
// options: {
// responsive: false,
// animation: false,
// plugins: {
// legend: {
// display: true,
// position: 'top',
// labels: {
// font: { size: 12 },
// padding: 15
// }
// },
// title: {
// display: false
// }
// },
// scales: {
// y: {
// type: 'linear',
// position: 'left',
// beginAtZero: true,
// ticks: {
// callback: function (value) {
// if (typeof value !== 'number') return ''
// return 'Rp ' + (value / 1000).toFixed(0) + 'k'
// }
// },
// title: {
// display: true,
// text: 'Total Penjualan'
// }
// },
// y1: {
// type: 'linear',
// position: 'right',
// beginAtZero: true,
// grid: {
// drawOnChartArea: false
// },
// title: {
// display: true,
// text: 'Jumlah Invoice'
// }
// },
// x: {
// grid: {
// display: false
// }
// }
// }
// }
// })
// // Wait for chart to render
// await new Promise(resolve => setTimeout(resolve, 500))
// // Convert chart to image
// const chartImage = canvas.toDataURL('image/png', 1.0)
// const chartWidth = 180
// const chartHeight = 90
// pdf.addImage(chartImage, 'PNG', 15, currentY, chartWidth, chartHeight)
// currentY += chartHeight + 10
// // Add summary table below chart
// autoTable(pdf, {
// startY: currentY,
// head: [['Total Invoice', 'Total Penjualan']],
// body: [
// [String(profitLoss7Days.summary.total_orders), formatCurrency(profitLoss7Days.summary.total_revenue)]
// ],
// theme: 'grid',
// styles: {
// fontSize: PDF_FONT_SIZES.tableContent,
// cellPadding: PDF_SPACING.cellPadding,
// lineColor: [0, 0, 0],
// lineWidth: 0.1,
// halign: 'center'
// },
// headStyles: {
// fillColor: [54, 23, 94],
// textColor: 255,
// fontStyle: 'bold',
// fontSize: PDF_FONT_SIZES.tableHeader
// },
// margin: { left: 14, right: 14 }
// })
// currentY = (pdf as any).lastAutoTable.finalY + 20
// }
// }
// Payment Method Summary - DENGAN BORDER HITAM
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,
String(payment.order_count),
formatCurrency(payment.total_amount),
`${(payment.percentage ?? 0).toFixed(1)}%`
]) || []
autoTable(pdf, {
startY: currentY,
head: [['Metode', 'Jumlah', 'Total', 'Persentase']],
body: paymentBody,
foot: [
[
'TOTAL',
String(paymentAnalytics?.summary.total_orders ?? 0),
formatCurrency(paymentAnalytics?.summary.total_amount ?? 0),
''
]
],
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.tableContent,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableHeader,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [220, 220, 220],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableFooter,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center'
},
columnStyles: {
1: { halign: 'center' },
2: { halign: 'right' },
3: { halign: 'right' }
},
didParseCell: data => {
if (data.section === 'foot') {
if (data.column.index === 0) {
data.cell.styles.halign = 'left'
} else if (data.column.index === 1) {
data.cell.styles.halign = 'center'
} else if (data.column.index === 2) {
data.cell.styles.halign = 'right'
}
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 20
// Category Summary - DENGAN BORDER HITAM
// Category Summary - DENGAN BORDER HITAM
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: 'grid',
// TAMBAHKAN INI: paksa semua row di satu page, jangan split
showFoot: 'lastPage', // footer cuma muncul di halaman terakhir
tableWidth: 'auto',
styles: {
fontSize: PDF_FONT_SIZES.tableContent,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableHeader,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [220, 220, 220],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableFooter,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center'
},
columnStyles: {
1: { halign: 'center' },
2: { halign: 'right' }
},
didParseCell: data => {
if (data.section === 'foot') {
if (data.column.index === 0) {
data.cell.styles.halign = 'left'
} else if (data.column.index === 1) {
data.cell.styles.halign = 'center'
} else if (data.column.index === 2) {
data.cell.styles.halign = 'right'
}
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 20
// 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<string, any[]>
) || {}
// 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 += 10
// 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, index) => {
const categoryProducts = groupedProducts[categoryName].sort((a, b) => {
const skuA = a.product_sku || ''
const skuB = b.product_sku || ''
return skuA.localeCompare(skuB)
})
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)
])
const estimatedHeight = (productBody.length + 3) * 12
if (currentY + estimatedHeight > 270) {
pdf.addPage()
currentY = 20
}
pdf.setFontSize(PDF_FONT_SIZES.subheading)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(54, 23, 94)
pdf.text(`${index + 1}. ${categoryName.toUpperCase()}`, 16, currentY)
currentY += 15
autoTable(pdf, {
startY: currentY,
head: [['Produk', 'Qty', 'Pendapatan']],
body: productBody,
foot: [[`Subtotal ${categoryName}`, String(categoryTotalQty), formatCurrency(categoryTotalRevenue)]],
showFoot: 'lastPage', // ← TAMBAHKAN INI: subtotal cuma muncul di akhir kategori
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.tableContent,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableHeader,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [200, 200, 200],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableFooter,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center'
},
columnStyles: {
0: { cellWidth: 90 },
1: { halign: 'center', cellWidth: 40 },
2: { halign: 'right', cellWidth: 52 }
},
didParseCell: data => {
if (data.section === 'foot') {
if (data.column.index === 0) {
data.cell.styles.halign = 'left'
} else if (data.column.index === 1) {
data.cell.styles.halign = 'center'
} else if (data.column.index === 2) {
data.cell.styles.halign = 'right'
}
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 15
})
// Grand Total - DENGAN BORDER HITAM
if (currentY > 250) {
pdf.addPage()
currentY = 20
}
autoTable(pdf, {
startY: currentY,
head: [],
body: [
['TOTAL KESELURUHAN', String(productSummary.totalQuantitySold), formatCurrency(productSummary.totalRevenue)]
],
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.grandTotal,
cellPadding: 6,
fontStyle: 'bold',
textColor: [54, 23, 94],
lineColor: [0, 0, 0],
lineWidth: 0.2
},
columnStyles: {
0: { cellWidth: 90 },
1: { halign: 'center', cellWidth: 40 },
2: { halign: 'right', cellWidth: 52 }
},
margin: { left: 14, right: 14 }
})
// 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`
: `laporan-transaksi-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.pdf`
pdf.save(fileName)
} catch (error) { } catch (error) {
console.error('Error generating PDF:', error) console.error('Error generating PDF:', error)
alert(`Terjadi kesalahan saat membuat PDF: ${error}`) alert(`Terjadi kesalahan saat membuat PDF: ${error}`)
@ -650,6 +140,29 @@ const DailyPOSReport = () => {
} }
} }
const handleGenerateExcel = async () => {
setIsGeneratingExcel(true)
try {
await generateExcel({
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange
})
} catch (error) {
console.error('Error generating Excel:', error)
alert(`Terjadi kesalahan saat membuat Excel: ${error}`)
} finally {
setIsGeneratingExcel(false)
}
}
const LoadingOverlay = ({ isVisible, message = 'Generating PDF...' }: { isVisible: boolean; message?: string }) => { const LoadingOverlay = ({ isVisible, message = 'Generating PDF...' }: { isVisible: boolean; message?: string }) => {
if (!isVisible) return null if (!isVisible) return null
@ -707,6 +220,8 @@ const DailyPOSReport = () => {
onSingleDateChange={setSelectedDate} onSingleDateChange={setSelectedDate}
onDateRangeChange={setDateRange} onDateRangeChange={setDateRange}
onGeneratePDF={handleGeneratePDF} onGeneratePDF={handleGeneratePDF}
onGenerateExcel={handleGenerateExcel}
isGeneratingExcel={isGeneratingExcel}
/> />
<div ref={reportRef} className='max-w-4xl mx-auto bg-white min-h-[297mm]' style={{ width: '210mm' }}> <div ref={reportRef} className='max-w-4xl mx-auto bg-white min-h-[297mm]' style={{ width: '210mm' }}>

525
src/utils/excelGenerator.ts Normal file
View File

@ -0,0 +1,525 @@
// excelGenerator.ts
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import type ExcelJS from 'exceljs'
interface ExcelGeneratorParams {
outlet: any
profitLoss: any
products: any
paymentAnalytics: any
category: any
productSummary: {
totalQuantitySold: number
totalRevenue: number
totalOrders: number
}
categorySummary: {
totalRevenue: number
orderCount: number
productCount: number
totalQuantity: number
}
filterType: 'single' | 'range'
selectedDate: Date
dateRange: { startDate: Date; endDate: Date }
}
const formatDateForInput = (date: Date) => {
return date.toISOString().split('T')[0]
}
const getReportPeriodText = (params: ExcelGeneratorParams) => {
if (params.filterType === 'single') {
return `${formatDateDDMMYYYY(params.selectedDate)} - ${formatDateDDMMYYYY(params.selectedDate)}`
}
return `${formatDateDDMMYYYY(params.dateRange.startDate)} - ${formatDateDDMMYYYY(params.dateRange.endDate)}`
}
// ========== EXCEL STYLES (IMPROVED) ==========
const headerStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 12, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern,
alignment: { vertical: 'middle', horizontal: 'center' },
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
const titleStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 18 },
alignment: { vertical: 'middle', horizontal: 'left' }
}
const subtitleStyle: Partial<ExcelJS.Style> = {
font: { size: 11, color: { argb: 'FF6B7280' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
const labelStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 11 },
alignment: { vertical: 'middle', horizontal: 'left' }
}
const totalRowStyle: Partial<ExcelJS.Style> = {
font: { bold: true, size: 11 },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F4F6' } } as ExcelJS.FillPattern,
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
const dataStyle: Partial<ExcelJS.Style> = {
border: {
top: { style: 'thin', color: { argb: 'FFF3F4F6' } },
left: { style: 'thin', color: { argb: 'FFF3F4F6' } },
bottom: { style: 'thin', color: { argb: 'FFF3F4F6' } },
right: { style: 'thin', color: { argb: 'FFF3F4F6' } }
}
}
// Zebra striping untuk data rows
const dataStyleAlt: Partial<ExcelJS.Style> = {
...dataStyle,
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFAFAFA' } } as ExcelJS.FillPattern
}
const currencyFormat = '#,##0'
const percentageFormat = '0.0"%"'
export const generateExcel = async (params: ExcelGeneratorParams) => {
const {
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange
} = params
const ExcelJS = await import('exceljs')
const workbook = new ExcelJS.Workbook()
// Metadata
workbook.creator = outlet?.name || 'POS System'
workbook.created = new Date()
// ============ SHEET 1: RINGKASAN ============
const ws1 = workbook.addWorksheet('Ringkasan', {
views: [{ showGridLines: false }]
})
// Title
ws1.mergeCells('A1:B1')
const titleCell = ws1.getCell('A1')
titleCell.value = 'LAPORAN TRANSAKSI'
titleCell.style = {
font: { bold: true, size: 20, color: { argb: 'FF1F2937' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
ws1.getRow(1).height = 30
// Outlet Info
ws1.getCell('A2').value = outlet?.name || ''
ws1.getCell('A2').style = { ...subtitleStyle, font: { size: 12, color: { argb: 'FF4B5563' } } }
ws1.getCell('A3').value = outlet?.address || ''
ws1.getCell('A3').style = subtitleStyle
ws1.getCell('A4').value = outlet?.phone || ''
ws1.getCell('A4').style = subtitleStyle
ws1.getCell('A5').value = 'Periode:'
ws1.getCell('B5').value = getReportPeriodText(params)
ws1.getCell('A5').style = labelStyle
ws1.getCell('B5').style = { font: { size: 11 } }
// Section: Ringkasan
ws1.getCell('A7').value = 'RINGKASAN'
ws1.getCell('A7').style = {
font: { bold: true, size: 14, color: { argb: 'FF36175E' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
// Header row
ws1.getCell('A8').value = 'Keterangan'
ws1.getCell('B8').value = 'Jumlah (IDR)'
ws1.getCell('A8').style = headerStyle
ws1.getCell('B8').style = headerStyle
ws1.getRow(8).height = 25
// Data rows
const summaryData = [
['Total Penjualan', profitLoss?.summary.total_revenue || 0],
['Total Diskon', profitLoss?.summary.total_discount || 0],
['Total Pajak', profitLoss?.summary.total_tax || 0],
['PB1', 0],
['Service Charge', 0]
]
summaryData.forEach((row, idx) => {
const rowNum = idx + 9
ws1.getCell(`A${rowNum}`).value = row[0]
ws1.getCell(`B${rowNum}`).value = row[1]
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
ws1.getCell(`A${rowNum}`).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws1.getCell(`B${rowNum}`).style = {
...baseStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat
}
ws1.getRow(rowNum).height = 22
})
// Total row
const totalRow = 9 + summaryData.length
ws1.getCell(`A${totalRow}`).value = 'Total'
ws1.getCell(`B${totalRow}`).value = profitLoss?.summary.total_revenue || 0
ws1.getCell(`A${totalRow}`).style = { ...totalRowStyle, alignment: { horizontal: 'left' } }
ws1.getCell(`B${totalRow}`).style = {
...totalRowStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat
}
ws1.getRow(totalRow).height = 25
// Invoice section (pisah dari tabel ringkasan)
const invoiceRow = totalRow + 2
ws1.getCell(`A${invoiceRow}`).value = 'INVOICE'
ws1.getCell(`A${invoiceRow}`).style = {
font: { bold: true, size: 14, color: { argb: 'FF36175E' } },
alignment: { vertical: 'middle', horizontal: 'left' }
}
ws1.getCell(`A${invoiceRow + 1}`).value = 'Total Invoice'
ws1.getCell(`B${invoiceRow + 1}`).value = profitLoss?.summary.total_orders || 0
ws1.getCell(`A${invoiceRow + 1}`).style = { ...dataStyle, font: { bold: true } }
ws1.getCell(`B${invoiceRow + 1}`).style = { ...dataStyle, alignment: { horizontal: 'right' }, font: { bold: true } }
// Column widths
ws1.getColumn(1).width = 28
ws1.getColumn(2).width = 22
// ============ SHEET 2: METODE PEMBAYARAN ============
const ws2 = workbook.addWorksheet('Metode Pembayaran', {
views: [{ showGridLines: false }]
})
// Title
ws2.mergeCells('A1:D1')
const paymentTitle = ws2.getCell('A1')
paymentTitle.value = 'RINGKASAN METODE PEMBAYARAN'
paymentTitle.style = titleStyle
ws2.getRow(1).height = 30
// Headers
const paymentHeaders = ['Metode', 'Jumlah Order', 'Total Amount (IDR)', 'Persentase']
paymentHeaders.forEach((header, idx) => {
const cell = ws2.getCell(3, idx + 1)
cell.value = header
cell.style = headerStyle
})
ws2.getRow(3).height = 25
// Data
let paymentRow = 4
paymentAnalytics?.data?.forEach((payment: any, idx: number) => {
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
ws2.getCell(paymentRow, 1).value = payment.payment_method_name
ws2.getCell(paymentRow, 2).value = payment.order_count
ws2.getCell(paymentRow, 3).value = payment.total_amount
ws2.getCell(paymentRow, 4).value = (payment.percentage || 0) / 100
ws2.getCell(paymentRow, 1).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws2.getCell(paymentRow, 2).style = { ...baseStyle, alignment: { horizontal: 'center' } }
ws2.getCell(paymentRow, 3).style = {
...baseStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat
}
ws2.getCell(paymentRow, 4).style = {
...baseStyle,
alignment: { horizontal: 'center' },
numFmt: percentageFormat
}
ws2.getRow(paymentRow).height = 22
paymentRow++
})
// Total row
ws2.getCell(paymentRow, 1).value = 'TOTAL'
ws2.getCell(paymentRow, 2).value = paymentAnalytics?.summary.total_orders || 0
ws2.getCell(paymentRow, 3).value = paymentAnalytics?.summary.total_amount || 0
ws2.getCell(paymentRow, 4).value = ''
ws2.getCell(paymentRow, 1).style = { ...totalRowStyle, alignment: { horizontal: 'left' } }
ws2.getCell(paymentRow, 2).style = { ...totalRowStyle, alignment: { horizontal: 'center' } }
ws2.getCell(paymentRow, 3).style = {
...totalRowStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat
}
ws2.getCell(paymentRow, 4).style = totalRowStyle
ws2.getRow(paymentRow).height = 25
// Column widths
ws2.getColumn(1).width = 28
ws2.getColumn(2).width = 16
ws2.getColumn(3).width = 22
ws2.getColumn(4).width = 16
// ============ SHEET 3: KATEGORI ============
const ws3 = workbook.addWorksheet('Kategori', {
views: [{ showGridLines: false }]
})
// Title
ws3.mergeCells('A1:C1')
const categoryTitle = ws3.getCell('A1')
categoryTitle.value = 'RINGKASAN KATEGORI'
categoryTitle.style = titleStyle
ws3.getRow(1).height = 30
// Headers
const categoryHeaders = ['Nama', 'Qty', 'Pendapatan (IDR)']
categoryHeaders.forEach((header, idx) => {
const cell = ws3.getCell(3, idx + 1)
cell.value = header
cell.style = headerStyle
})
ws3.getRow(3).height = 25
// Data
let categoryRow = 4
category?.data?.forEach((cat: any, idx: number) => {
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
ws3.getCell(categoryRow, 1).value = cat.category_name
ws3.getCell(categoryRow, 2).value = cat.total_quantity
ws3.getCell(categoryRow, 3).value = cat.total_revenue
ws3.getCell(categoryRow, 1).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws3.getCell(categoryRow, 2).style = { ...baseStyle, alignment: { horizontal: 'center' } }
ws3.getCell(categoryRow, 3).style = {
...baseStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat
}
ws3.getRow(categoryRow).height = 22
categoryRow++
})
// Total row
ws3.getCell(categoryRow, 1).value = 'TOTAL'
ws3.getCell(categoryRow, 2).value = categorySummary.totalQuantity
ws3.getCell(categoryRow, 3).value = categorySummary.totalRevenue
ws3.getCell(categoryRow, 1).style = { ...totalRowStyle, alignment: { horizontal: 'left' } }
ws3.getCell(categoryRow, 2).style = { ...totalRowStyle, alignment: { horizontal: 'center' } }
ws3.getCell(categoryRow, 3).style = {
...totalRowStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat
}
ws3.getRow(categoryRow).height = 25
// Column widths
ws3.getColumn(1).width = 35
ws3.getColumn(2).width = 12
ws3.getColumn(3).width = 22
// ============ SHEET 4: DETAIL PRODUK ============
const ws4 = workbook.addWorksheet('Detail Produk', {
views: [{ showGridLines: false }]
})
// Title
ws4.mergeCells('A1:C1')
const productTitle = ws4.getCell('A1')
productTitle.value = 'RINGKASAN ITEM PER KATEGORI'
productTitle.style = titleStyle
ws4.getRow(1).height = 30
// Group products by category
const groupedProducts =
products?.data?.reduce(
(acc: any, item: any) => {
const categoryName = item.category_name || 'Tidak Berkategori'
if (!acc[categoryName]) {
acc[categoryName] = []
}
acc[categoryName].push(item)
return acc
},
{} as Record<string, any[]>
) || {}
let currentRow = 3
// 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, index) => {
// Category header
ws4.mergeCells(`A${currentRow}:C${currentRow}`)
const catHeader = ws4.getCell(`A${currentRow}`)
catHeader.value = `${index + 1}. ${categoryName.toUpperCase()}`
catHeader.style = {
font: { bold: true, size: 13, color: { argb: 'FF36175E' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } } as ExcelJS.FillPattern,
alignment: { vertical: 'middle', horizontal: 'left' },
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
ws4.getRow(currentRow).height = 28
currentRow++
// Column headers
const prodHeaders = ['Produk', 'Qty', 'Pendapatan (IDR)']
prodHeaders.forEach((header, idx) => {
const cell = ws4.getCell(currentRow, idx + 1)
cell.value = header
cell.style = {
font: { bold: true, size: 11, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern,
alignment: { vertical: 'middle', horizontal: 'center' },
border: {
top: { style: 'thin', color: { argb: 'FFD1D5DB' } },
left: { style: 'thin', color: { argb: 'FFD1D5DB' } },
bottom: { style: 'thin', color: { argb: 'FFD1D5DB' } },
right: { style: 'thin', color: { argb: 'FFD1D5DB' } }
}
}
})
ws4.getRow(currentRow).height = 24
currentRow++
// Sort products
const categoryProducts = groupedProducts[categoryName].sort((a: any, b: any) => {
const skuA = a.product_sku || ''
const skuB = b.product_sku || ''
return skuA.localeCompare(skuB)
})
// Add products with zebra striping
categoryProducts.forEach((product: any, idx: number) => {
const isAlt = idx % 2 === 1
const baseStyle = isAlt ? dataStyleAlt : dataStyle
ws4.getCell(currentRow, 1).value = product.product_name
ws4.getCell(currentRow, 2).value = product.quantity_sold
ws4.getCell(currentRow, 3).value = product.revenue
ws4.getCell(currentRow, 1).style = { ...baseStyle, alignment: { horizontal: 'left' } }
ws4.getCell(currentRow, 2).style = { ...baseStyle, alignment: { horizontal: 'center' } }
ws4.getCell(currentRow, 3).style = {
...baseStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat
}
ws4.getRow(currentRow).height = 20
currentRow++
})
// Subtotal
const subQty = categoryProducts.reduce((sum: number, p: any) => sum + p.quantity_sold, 0)
const subRevenue = categoryProducts.reduce((sum: number, p: any) => sum + p.revenue, 0)
ws4.getCell(currentRow, 1).value = `Subtotal ${categoryName}`
ws4.getCell(currentRow, 2).value = subQty
ws4.getCell(currentRow, 3).value = subRevenue
ws4.getCell(currentRow, 1).style = {
...totalRowStyle,
alignment: { horizontal: 'left' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F4F6' } } as ExcelJS.FillPattern
}
ws4.getCell(currentRow, 2).style = {
...totalRowStyle,
alignment: { horizontal: 'center' },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F4F6' } } as ExcelJS.FillPattern
}
ws4.getCell(currentRow, 3).style = {
...totalRowStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat,
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF3F4F6' } } as ExcelJS.FillPattern
}
ws4.getRow(currentRow).height = 24
currentRow += 3 // Spacing lebih lega
})
// Grand Total
ws4.getCell(currentRow, 1).value = 'TOTAL KESELURUHAN'
ws4.getCell(currentRow, 2).value = productSummary.totalQuantitySold
ws4.getCell(currentRow, 3).value = productSummary.totalRevenue
ws4.getCell(currentRow, 1).style = {
...totalRowStyle,
alignment: { horizontal: 'left' },
font: { bold: true, size: 13, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern
}
ws4.getCell(currentRow, 2).style = {
...totalRowStyle,
alignment: { horizontal: 'center' },
font: { bold: true, size: 13, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern
}
ws4.getCell(currentRow, 3).style = {
...totalRowStyle,
alignment: { horizontal: 'right' },
numFmt: currencyFormat,
font: { bold: true, size: 13, color: { argb: 'FFFFFFFF' } },
fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF36175E' } } as ExcelJS.FillPattern
}
ws4.getRow(currentRow).height = 28
// Column widths
ws4.getColumn(1).width = 45
ws4.getColumn(2).width = 12
ws4.getColumn(3).width = 22
// ============ GENERATE & DOWNLOAD FILE ============
const fileName =
filterType === 'single'
? `laporan-transaksi-${formatDateForInput(selectedDate)}.xlsx`
: `laporan-transaksi-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.xlsx`
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
window.URL.revokeObjectURL(url)
}

443
src/utils/pdfGenerator.ts Normal file
View File

@ -0,0 +1,443 @@
// pdfGenerator.ts
import { formatCurrency } from '@/utils/transform'
// PDF Configuration
const PDF_FONT_SIZES = {
heading: 18,
subheading: 16,
tableContent: 12,
tableHeader: 12,
tableFooter: 12,
grandTotal: 14,
footer: 11
}
const PDF_SPACING = {
cellPadding: 5,
cellPaddingLarge: 6,
sectionGap: 20,
headerGap: 15
}
interface PDFGeneratorParams {
reportRef: React.RefObject<HTMLDivElement>
outlet: any
profitLoss: any
products: any
paymentAnalytics: any
category: any
productSummary: {
totalQuantitySold: number
totalRevenue: number
totalOrders: number
}
categorySummary: {
totalRevenue: number
orderCount: number
productCount: number
totalQuantity: number
}
filterType: 'single' | 'range'
selectedDate: Date
dateRange: { startDate: Date; endDate: Date }
now: Date
}
const formatDateForInput = (date: Date) => {
return date.toISOString().split('T')[0]
}
export const generatePDF = async (params: PDFGeneratorParams) => {
const {
reportRef,
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
categorySummary,
filterType,
selectedDate,
dateRange,
now
} = params
const reportElement = reportRef.current
if (!reportElement) {
throw new Error('Report element tidak ditemukan')
}
// Dynamic imports
const jsPDF = (await import('jspdf')).default
const html2canvas = (await import('html2canvas')).default
const autoTable = (await import('jspdf-autotable')).default
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
compress: true
})
// Capture header section
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 headerImgWidth = 190
const headerImgHeight = (headerCanvas.height * headerImgWidth) / headerCanvas.width
const headerImgData = headerCanvas.toDataURL('image/jpeg', 0.95)
pdf.addImage(headerImgData, 'JPEG', 10, 10, headerImgWidth, headerImgHeight)
}
let currentY = 80
// ========== RINGKASAN SECTION ==========
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.setTextColor(54, 23, 94)
pdf.setFont('helvetica', 'bold')
pdf.text('Ringkasan', 14, currentY)
currentY += 15
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: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.subheading,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0]
},
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: 'grid',
styles: {
lineColor: [0, 0, 0],
fontSize: PDF_FONT_SIZES.subheading,
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 SECTION ==========
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.text('Ringkasan Metode Pembayaran', 14, currentY)
currentY += 15
const paymentBody =
paymentAnalytics?.data?.map((payment: any) => [
payment.payment_method_name,
String(payment.order_count),
formatCurrency(payment.total_amount),
`${(payment.percentage ?? 0).toFixed(1)}%`
]) || []
autoTable(pdf, {
startY: currentY,
head: [['Metode', 'Jumlah', 'Total', 'Persentase']],
body: paymentBody,
foot: [
[
'TOTAL',
String(paymentAnalytics?.summary.total_orders ?? 0),
formatCurrency(paymentAnalytics?.summary.total_amount ?? 0),
''
]
],
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.tableContent,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableHeader,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [220, 220, 220],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableFooter,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center'
},
columnStyles: {
1: { halign: 'center' },
2: { halign: 'right' },
3: { halign: 'right' }
},
didParseCell: (data: any) => {
if (data.section === 'foot') {
if (data.column.index === 0) {
data.cell.styles.halign = 'left'
} else if (data.column.index === 1) {
data.cell.styles.halign = 'center'
} else if (data.column.index === 2) {
data.cell.styles.halign = 'right'
}
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 20
// ========== CATEGORY SECTION ==========
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.text('Ringkasan Kategori', 14, currentY)
currentY += 15
const categoryBody =
category?.data?.map((c: any) => [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: 'grid',
showFoot: 'lastPage',
tableWidth: 'auto',
styles: {
fontSize: PDF_FONT_SIZES.tableContent,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableHeader,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [220, 220, 220],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableFooter,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center'
},
columnStyles: {
1: { halign: 'center' },
2: { halign: 'right' }
},
didParseCell: (data: any) => {
if (data.section === 'foot') {
if (data.column.index === 0) {
data.cell.styles.halign = 'left'
} else if (data.column.index === 1) {
data.cell.styles.halign = 'center'
} else if (data.column.index === 2) {
data.cell.styles.halign = 'right'
}
}
},
margin: { left: 14, right: 14 }
})
currentY = (pdf as any).lastAutoTable.finalY + 20
// ========== PRODUCT DETAILS BY CATEGORY ==========
const groupedProducts =
products?.data?.reduce(
(acc: any, item: any) => {
const categoryName = item.category_name || 'Tidak Berkategori'
if (!acc[categoryName]) {
acc[categoryName] = []
}
acc[categoryName].push(item)
return acc
},
{} as Record<string, any[]>
) || {}
pdf.addPage()
currentY = 20
pdf.setFontSize(PDF_FONT_SIZES.heading)
pdf.text('Ringkasan Item Per Kategori', 14, currentY)
currentY += 10
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, index) => {
const categoryProducts = groupedProducts[categoryName].sort((a: any, b: any) => {
const skuA = a.product_sku || ''
const skuB = b.product_sku || ''
return skuA.localeCompare(skuB)
})
const categoryTotalQty = categoryProducts.reduce((sum: number, item: any) => sum + (item.quantity_sold || 0), 0)
const categoryTotalRevenue = categoryProducts.reduce((sum: number, item: any) => sum + (item.revenue || 0), 0)
const productBody = categoryProducts.map((item: any) => [
item.product_name,
String(item.quantity_sold),
formatCurrency(item.revenue)
])
const estimatedHeight = (productBody.length + 3) * 12
if (currentY + estimatedHeight > 270) {
pdf.addPage()
currentY = 20
}
pdf.setFontSize(PDF_FONT_SIZES.subheading)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(54, 23, 94)
pdf.text(`${index + 1}. ${categoryName.toUpperCase()}`, 16, currentY)
currentY += 15
autoTable(pdf, {
startY: currentY,
head: [['Produk', 'Qty', 'Pendapatan']],
body: productBody,
foot: [[`Subtotal ${categoryName}`, String(categoryTotalQty), formatCurrency(categoryTotalRevenue)]],
showFoot: 'lastPage',
theme: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.tableContent,
cellPadding: PDF_SPACING.cellPadding,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
headStyles: {
fillColor: [54, 23, 94],
textColor: 255,
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableHeader,
lineColor: [0, 0, 0],
lineWidth: 0.1
},
footStyles: {
fillColor: [200, 200, 200],
textColor: [60, 60, 60],
fontStyle: 'bold',
fontSize: PDF_FONT_SIZES.tableFooter,
lineColor: [0, 0, 0],
lineWidth: 0.1,
halign: 'center'
},
columnStyles: {
0: { cellWidth: 90 },
1: { halign: 'center', cellWidth: 40 },
2: { halign: 'right', cellWidth: 52 }
},
didParseCell: (data: any) => {
if (data.section === 'foot') {
if (data.column.index === 0) {
data.cell.styles.halign = 'left'
} else if (data.column.index === 1) {
data.cell.styles.halign = 'center'
} else if (data.column.index === 2) {
data.cell.styles.halign = 'right'
}
}
},
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: 'grid',
styles: {
fontSize: PDF_FONT_SIZES.grandTotal,
cellPadding: 6,
fontStyle: 'bold',
textColor: [54, 23, 94],
lineColor: [0, 0, 0],
lineWidth: 0.2
},
columnStyles: {
0: { cellWidth: 90 },
1: { halign: 'center', cellWidth: 40 },
2: { halign: 'right', cellWidth: 52 }
},
margin: { left: 14, right: 14 }
})
// ========== 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' })
}
// ========== SAVE PDF ==========
const fileName =
filterType === 'single'
? `laporan-transaksi-${formatDateForInput(selectedDate)}.pdf`
: `laporan-transaksi-${formatDateForInput(dateRange.startDate)}-to-${formatDateForInput(dateRange.endDate)}.pdf`
pdf.save(fileName)
}

View File

@ -59,6 +59,7 @@ interface ReportGeneratorProps {
onSingleDateChange: (date: Date) => void onSingleDateChange: (date: Date) => void
onDateRangeChange: (dateRange: DateRange) => void onDateRangeChange: (dateRange: DateRange) => void
onGeneratePDF: () => void onGeneratePDF: () => void
onGenerateExcel: () => void
// Optional props dengan default values // Optional props dengan default values
maxWidth?: string maxWidth?: string
@ -66,8 +67,10 @@ interface ReportGeneratorProps {
customQuickActions?: CustomQuickActions | null customQuickActions?: CustomQuickActions | null
periodFormat?: string periodFormat?: string
downloadButtonText?: string downloadButtonText?: string
downloadExcelButtonText?: string
cardShadow?: string cardShadow?: string
primaryColor?: string primaryColor?: string
excelButtonColor?: string
// Optional helper functions // Optional helper functions
formatDateForInput?: ((date: Date) => string) | null formatDateForInput?: ((date: Date) => string) | null
@ -80,6 +83,7 @@ interface ReportGeneratorProps {
// Loading state // Loading state
isGenerating?: boolean isGenerating?: boolean
isGeneratingExcel?: boolean
// Custom labels // Custom labels
labels?: Labels labels?: Labels
@ -110,6 +114,22 @@ const PurpleButton = styled(Button)(({ theme }) => ({
} }
})) }))
const GreenButton = styled(Button)(({ theme }) => ({
backgroundColor: '#16a34a',
color: 'white',
textTransform: 'none',
fontWeight: 500,
padding: '8px 24px',
'&:hover': {
backgroundColor: '#15803d',
opacity: 0.9
},
'&:disabled': {
backgroundColor: theme.palette.mode === 'dark' ? '#444' : '#ccc',
color: theme.palette.mode === 'dark' ? '#888' : '#666'
}
}))
const QuickActionButton = styled(Button)(({ theme }) => ({ const QuickActionButton = styled(Button)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.08)' : '#f8f7fa', backgroundColor: theme.palette.mode === 'dark' ? 'rgba(231, 227, 252, 0.08)' : '#f8f7fa',
color: theme.palette.mode === 'dark' ? theme.palette.text.secondary : '#6f6b7d', color: theme.palette.mode === 'dark' ? theme.palette.text.secondary : '#6f6b7d',
@ -140,6 +160,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
onSingleDateChange, onSingleDateChange,
onDateRangeChange, onDateRangeChange,
onGeneratePDF, onGeneratePDF,
onGenerateExcel,
// Optional props dengan default values // Optional props dengan default values
maxWidth = '1024px', maxWidth = '1024px',
@ -147,8 +168,10 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
customQuickActions = null, customQuickActions = null,
periodFormat = 'id-ID', periodFormat = 'id-ID',
downloadButtonText = 'Download PDF', downloadButtonText = 'Download PDF',
downloadExcelButtonText = 'Download Excel',
cardShadow, cardShadow,
primaryColor = '#36175e', primaryColor = '#36175e',
excelButtonColor = '#16a34a',
// Optional helper functions // Optional helper functions
formatDateForInput = null, formatDateForInput = null,
@ -161,6 +184,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
// Loading state // Loading state
isGenerating = false, isGenerating = false,
isGeneratingExcel = false,
// Custom labels // Custom labels
labels = { labels = {
@ -175,7 +199,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
last7DaysLabel: '7 Hari Terakhir', last7DaysLabel: '7 Hari Terakhir',
last30DaysLabel: '30 Hari Terakhir', last30DaysLabel: '30 Hari Terakhir',
periodLabel: 'Periode:', periodLabel: 'Periode:',
exportHelpText: 'Klik tombol download untuk mengeksport laporan ke PDF' exportHelpText: 'Klik tombol download untuk mengeksport laporan ke PDF atau Excel'
} }
}) => { }) => {
const theme = useTheme() const theme = useTheme()
@ -271,14 +295,24 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
</Typography> </Typography>
} }
action={ action={
<PurpleButton <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
onClick={onGeneratePDF} <PurpleButton
variant='contained' onClick={onGeneratePDF}
disabled={isGenerating} variant='contained'
sx={{ backgroundColor: primaryColor }} disabled={isGenerating}
> sx={{ backgroundColor: primaryColor }}
{isGenerating ? 'Generating...' : downloadButtonText} >
</PurpleButton> {isGenerating ? 'Generating...' : downloadButtonText}
</PurpleButton>
<GreenButton
onClick={onGenerateExcel}
variant='contained'
disabled={isGeneratingExcel}
sx={{ backgroundColor: excelButtonColor }}
>
{isGeneratingExcel ? 'Generating...' : downloadExcelButtonText}
</GreenButton>
</Box>
} }
sx={{ pb: 2 }} sx={{ pb: 2 }}
/> />
@ -505,7 +539,7 @@ const ReportGeneratorComponent: React.FC<ReportGeneratorProps> = ({
mt: 1 mt: 1
}} }}
> >
{labels.exportHelpText || 'Klik tombol download untuk mengeksport laporan ke PDF'} {labels.exportHelpText || 'Klik tombol download untuk mengeksport laporan ke PDF atau Excel'}
</Typography> </Typography>
</InfoBox> </InfoBox>