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
@@ -8,6 +8,8 @@ import {
useCategoryAnalytics
} from '@/services/queries/analytics'
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 ReportGeneratorComponent from '@/views/dashboards/daily-report/report-generator'
import ReportHeader from '@/views/dashboards/daily-report/report-header'
@@ -16,6 +18,8 @@ import React, { useEffect, useRef, useState } from 'react'
const DailyPOSReport = () => {
const reportRef = useRef<HTMLDivElement | null>(null)
const [isGeneratingExcel, setIsGeneratingExcel] = useState(false)
const [now, setNow] = useState(new Date())
const [selectedDate, setSelectedDate] = useState(new Date())
const [dateRange, setDateRange] = useState({
@@ -112,536 +116,22 @@ const DailyPOSReport = () => {
}
const handleGeneratePDF = async () => {
const reportElement = reportRef.current
if (!reportElement) {
alert('Report element tidak ditemukan')
return
}
setIsGeneratingPDF(true)
try {
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
await generatePDF({
reportRef,
outlet,
profitLoss,
products,
paymentAnalytics,
category,
productSummary,
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) {
console.error('Error generating 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 }) => {
if (!isVisible) return null
@@ -707,6 +220,8 @@ const DailyPOSReport = () => {
onSingleDateChange={setSelectedDate}
onDateRangeChange={setDateRange}
onGeneratePDF={handleGeneratePDF}
onGenerateExcel={handleGenerateExcel}
isGeneratingExcel={isGeneratingExcel}
/>
<div ref={reportRef} className='max-w-4xl mx-auto bg-white min-h-[297mm]' style={{ width: '210mm' }}>