add excel pdf
This commit is contained in:
parent
de32d86e94
commit
67ebb2ebd7
1076
package-lock.json
generated
1076
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
@ -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
525
src/utils/excelGenerator.ts
Normal 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
443
src/utils/pdfGenerator.ts
Normal 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)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user