Sales Report PDF

This commit is contained in:
efrilm 2025-09-25 22:52:09 +07:00
parent e6bcf287ea
commit 07c0bdb3af
4 changed files with 1098 additions and 0 deletions

View File

@ -0,0 +1,18 @@
import ReportTitle from '@/components/report/ReportTitle'
import ReportSalesContent from '@/views/apps/report/sales/sales-report/ReportSalesContent'
import Grid from '@mui/material/Grid2'
const SalesReportPage = () => {
return (
<Grid container spacing={6}>
<Grid size={{ xs: 12 }}>
<ReportTitle title='Laporan Penjualan' />
</Grid>
<Grid size={{ xs: 12 }}>
<ReportSalesContent />
</Grid>
</Grid>
)
}
export default SalesReportPage

View File

@ -0,0 +1,686 @@
import { CategoryReport, PaymentReport, ProductSalesReport, ProfitLossReport } from '@/types/services/analytic'
export interface SalesReportData {
profitLoss: ProfitLossReport
paymentAnalytics: PaymentReport
categoryAnalytics: CategoryReport
productAnalytics: ProductSalesReport
}
export class PDFExportSalesService {
/**
* Export Sales Report to PDF
*/
static async exportSalesReportToPDF(salesData: SalesReportData, filename?: string) {
try {
// Dynamic import untuk jsPDF
const jsPDFModule = await import('jspdf')
const jsPDF = jsPDFModule.default
// Create new PDF document - PORTRAIT A4
const pdf = new jsPDF('p', 'mm', 'a4')
// Add content
await this.addSalesReportContent(pdf, salesData)
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Transaksi', 'pdf')
// Save PDF
pdf.save(exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting sales report to PDF:', error)
return { success: false, error: `PDF export failed: ${(error as Error).message}` }
}
}
/**
* Add sales report content to PDF
*/
private static async addSalesReportContent(pdf: any, salesData: SalesReportData) {
let yPos = 20
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const marginLeft = 20
const marginRight = 20
const marginBottom = 15
// Helper function to check page break
const checkPageBreak = (neededSpace: number) => {
if (yPos + neededSpace > pageHeight - marginBottom) {
pdf.addPage()
yPos = 20
return true
}
return false
}
// Title
yPos = this.addReportTitle(pdf, salesData.profitLoss, yPos, pageWidth, marginLeft, marginRight)
// Section 1: Ringkasan
checkPageBreak(60)
yPos = this.addRingkasanSection(pdf, salesData.profitLoss, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 2: Invoice Summary
checkPageBreak(50)
yPos = this.addInvoiceSection(pdf, salesData.profitLoss, yPos, pageWidth, marginLeft, marginRight, checkPageBreak)
// Section 3: Payment Methods
checkPageBreak(120)
yPos = this.addPaymentMethodsSection(
pdf,
salesData.paymentAnalytics,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
// Section 4: Category Summary
checkPageBreak(120)
yPos = this.addCategorySection(
pdf,
salesData.categoryAnalytics,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
// Section 5: Product Summary
checkPageBreak(150)
yPos = this.addProductSection(
pdf,
salesData.productAnalytics,
yPos,
pageWidth,
marginLeft,
marginRight,
checkPageBreak
)
}
/**
* Add report title
*/
private static addReportTitle(
pdf: any,
profitLoss: ProfitLossReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number
): number {
let yPos = startY
// Title
pdf.setFontSize(20)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(0, 0, 0)
pdf.text('Laporan Transaksi', pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Period
pdf.setFontSize(12)
pdf.setFont('helvetica', 'normal')
const periodText = `${profitLoss.date_from.split('T')[0]} - ${profitLoss.date_to.split('T')[0]}`
pdf.text(periodText, pageWidth / 2, yPos, { align: 'center' })
yPos += 10
// Purple line separator
pdf.setDrawColor(102, 45, 145)
pdf.setLineWidth(2)
pdf.line(marginLeft, yPos, pageWidth - marginRight, yPos)
yPos += 15
return yPos
}
/**
* Add Ringkasan section
*/
private static addRingkasanSection(
pdf: any,
profitLoss: ProfitLossReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(16)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan', marginLeft, yPos)
yPos += 10
// Reset text color
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
const ringkasanItems = [
{ label: 'Total Penjualan', value: this.formatCurrency(profitLoss.summary.total_revenue), bold: false },
{ label: 'Total Diskon', value: this.formatCurrency(profitLoss.summary.total_discount), bold: false },
{ label: 'Total Pajak', value: this.formatCurrency(profitLoss.summary.total_tax), bold: false },
{ label: 'Total', value: this.formatCurrency(profitLoss.summary.total_revenue), bold: true }
]
ringkasanItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
// Set font weight
pdf.setFont('helvetica', item.bold ? 'bold' : 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line (except for total row)
if (!item.bold) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 10
}
/**
* Add Invoice section
*/
private static addInvoiceSection(
pdf: any,
profitLoss: ProfitLossReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(16)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Invoice', marginLeft, yPos)
yPos += 10
// Reset formatting
pdf.setTextColor(0, 0, 0)
pdf.setFontSize(11)
const invoiceItems = [
{ label: 'Total Invoice', value: profitLoss.summary.total_orders.toString() },
{ label: 'Rata-rata Tagihan Per Invoice', value: this.formatCurrency(profitLoss.summary.average_profit) }
]
invoiceItems.forEach((item, index) => {
if (checkPageBreak(15)) yPos = 20
pdf.setFont('helvetica', 'normal')
pdf.text(item.label, marginLeft, yPos)
pdf.text(item.value, pageWidth - marginRight, yPos, { align: 'right' })
// Light separator line
if (index < invoiceItems.length - 1) {
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.5)
pdf.line(marginLeft, yPos + 2, pageWidth - marginRight, yPos + 2)
}
yPos += 8
})
return yPos + 15
}
/**
* Add Payment Methods section
*/
private static addPaymentMethodsSection(
pdf: any,
paymentAnalytics: PaymentReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan Metode Pembayaran', marginLeft, yPos)
yPos += 12
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [50, 25, 30, 35, 25] // Method, Type, Order, Amount, %
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
const headers = ['Metode Pembayaran', 'Tipe', 'Jumlah Order', 'Total Amount', 'Persentase']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 6)
} else {
pdf.text(header, currentX + colWidths[index] / 2, yPos + 6, { align: 'center' })
}
currentX += colWidths[index]
})
yPos += 12
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
paymentAnalytics.data?.forEach((payment, index) => {
if (checkPageBreak(10)) yPos = 20
currentX = marginLeft
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
pdf.setTextColor(0, 0, 0)
// Method name
pdf.text(payment.payment_method_name, currentX + 2, yPos + 5)
currentX += colWidths[0]
// Type with simple color coding
const typeText = payment.payment_method_type.toUpperCase()
if (payment.payment_method_type === 'cash') {
pdf.setTextColor(0, 120, 0) // Green
} else if (payment.payment_method_type === 'card') {
pdf.setTextColor(0, 80, 200) // Blue
} else {
pdf.setTextColor(200, 100, 0) // Orange
}
pdf.text(typeText, currentX + colWidths[1] / 2, yPos + 5, { align: 'center' })
pdf.setTextColor(0, 0, 0) // Reset color
currentX += colWidths[1]
// Order count
pdf.text(payment.order_count.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' })
currentX += colWidths[2]
// Amount
pdf.text(this.formatCurrency(payment.total_amount), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' })
currentX += colWidths[3]
// Percentage
pdf.text(`${(payment.percentage ?? 0).toFixed(1)}%`, currentX + colWidths[4] / 2, yPos + 5, { align: 'center' })
// Draw bottom border line
pdf.setDrawColor(230, 230, 230)
pdf.setLineWidth(0.3)
pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8)
yPos += 10
})
// Table footer (Total) - directly after last row
pdf.setFillColor(245, 245, 245) // Lighter gray
pdf.rect(marginLeft, yPos, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(9)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 6)
currentX += colWidths[0] + colWidths[1]
pdf.text((paymentAnalytics.summary?.total_orders ?? 0).toString(), currentX + colWidths[2] / 2, yPos + 6, {
align: 'center'
})
currentX += colWidths[2]
pdf.text(this.formatCurrency(paymentAnalytics.summary?.total_amount ?? 0), currentX + colWidths[3] - 2, yPos + 6, {
align: 'right'
})
return yPos + 25
}
/**
* Add Category section
*/
private static addCategorySection(
pdf: any,
categoryAnalytics: CategoryReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(16)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan Kategori', marginLeft, yPos)
yPos += 15
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [50, 30, 25, 35] // Name, Products, Qty, Revenue
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos - 3, tableWidth, 12, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(10)
const headers = ['Nama', 'Total Produk', 'Qty', 'Pendapatan']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 3)
} else {
pdf.text(header, currentX + colWidths[index] - 2, yPos + 3, { align: 'right' })
}
currentX += colWidths[index]
})
yPos += 15
// Calculate summaries
const categorySummary = {
totalRevenue: categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
productCount: categoryAnalytics.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
// Table rows
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(9)
categoryAnalytics.data?.forEach((category, index) => {
if (checkPageBreak(12)) yPos = 20
// Row background (alternating)
if (index % 2 === 1) {
pdf.setFillColor(250, 250, 250)
pdf.rect(marginLeft, yPos - 3, tableWidth, 10, 'F')
}
currentX = marginLeft
// Category name
pdf.text(category.category_name, currentX + 2, yPos + 2)
currentX += colWidths[0]
// Product count
pdf.text(category.product_count.toString(), currentX + colWidths[1] - 2, yPos + 2, { align: 'right' })
currentX += colWidths[1]
// Quantity
pdf.text(category.total_quantity.toString(), currentX + colWidths[2] - 2, yPos + 2, { align: 'right' })
currentX += colWidths[2]
// Revenue
pdf.text(this.formatCurrency(category.total_revenue), currentX + colWidths[3] - 2, yPos + 2, { align: 'right' })
yPos += 10
})
// Table footer (Total)
if (checkPageBreak(12)) yPos = 20
pdf.setFillColor(220, 220, 220)
pdf.rect(marginLeft, yPos - 3, tableWidth, 12, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(10)
currentX = marginLeft
pdf.text('TOTAL', currentX + 2, yPos + 3)
currentX += colWidths[0]
pdf.text(categorySummary.productCount.toString(), currentX + colWidths[1] - 2, yPos + 3, { align: 'right' })
currentX += colWidths[1]
pdf.text(categorySummary.totalQuantity.toString(), currentX + colWidths[2] - 2, yPos + 3, { align: 'right' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(categorySummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 3, {
align: 'right'
})
return yPos + 25
}
/**
* Add Product section with category grouping
*/
private static addProductSection(
pdf: any,
productAnalytics: ProductSalesReport,
startY: number,
pageWidth: number,
marginLeft: number,
marginRight: number,
checkPageBreak: (space: number) => boolean
): number {
let yPos = startY
// Section title
pdf.setFontSize(16)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text('Ringkasan Item', marginLeft, yPos)
yPos += 15
// Reset formatting
pdf.setTextColor(0, 0, 0)
// Table setup
const tableWidth = pageWidth - marginLeft - marginRight
const colWidths = [60, 20, 20, 30, 30] // Product, Qty, Order, Revenue, Average
let currentX = marginLeft
// Table header
pdf.setFillColor(240, 240, 240)
pdf.rect(marginLeft, yPos - 3, tableWidth, 12, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(10)
const headers = ['Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
currentX = marginLeft
headers.forEach((header, index) => {
if (index === 0) {
pdf.text(header, currentX + 2, yPos + 3)
} else {
pdf.text(header, currentX + colWidths[index] - 2, yPos + 3, { align: 'right' })
}
currentX += colWidths[index]
})
yPos += 15
// Group products by category
const groupedProducts =
productAnalytics.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[]>
) || {}
// Calculate product summary
const productSummary = {
totalQuantitySold: productAnalytics.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: productAnalytics.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: productAnalytics.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
pdf.setFontSize(9)
// Render grouped products
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Check page break for category header
if (checkPageBreak(15)) yPos = 20
// Category header
pdf.setFillColor(230, 230, 230)
pdf.rect(marginLeft, yPos - 3, tableWidth, 12, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(102, 45, 145)
pdf.text(categoryName.toUpperCase(), marginLeft + 2, yPos + 3)
pdf.setTextColor(0, 0, 0)
yPos += 12
// Category products
categoryProducts.forEach((item, index) => {
if (checkPageBreak(10)) yPos = 20
// Row background (alternating within category)
if (index % 2 === 1) {
pdf.setFillColor(250, 250, 250)
pdf.rect(marginLeft, yPos - 2, tableWidth, 8, 'F')
}
pdf.setFont('helvetica', 'normal')
currentX = marginLeft
// Product name (indented and truncated if needed)
const productName =
item.product_name.length > 45 ? item.product_name.substring(0, 42) + '...' : item.product_name
pdf.text(productName, currentX + 5, yPos + 2) // Indented for products
currentX += colWidths[0]
// Quantity
pdf.text(item.quantity_sold.toString(), currentX + colWidths[1] - 2, yPos + 2, { align: 'right' })
currentX += colWidths[1]
// Order count
pdf.text((item.order_count || 0).toString(), currentX + colWidths[2] - 2, yPos + 2, { align: 'right' })
currentX += colWidths[2]
// Revenue
pdf.text(this.formatCurrency(item.revenue), currentX + colWidths[3] - 2, yPos + 2, { align: 'right' })
currentX += colWidths[3]
// Average price
pdf.text(this.formatCurrency(item.average_price), currentX + colWidths[4] - 2, yPos + 2, { align: 'right' })
yPos += 8
})
// Category subtotal
if (checkPageBreak(10)) yPos = 20
const categoryTotalQty = categoryProducts.reduce((sum, item) => sum + (item.quantity_sold || 0), 0)
const categoryTotalOrders = categoryProducts.reduce((sum, item) => sum + (item.order_count || 0), 0)
const categoryTotalRevenue = categoryProducts.reduce((sum, item) => sum + (item.revenue || 0), 0)
pdf.setFillColor(200, 200, 200)
pdf.rect(marginLeft, yPos - 2, tableWidth, 10, 'F')
pdf.setFont('helvetica', 'bold')
currentX = marginLeft
pdf.text(`Subtotal ${categoryName}`, currentX + 5, yPos + 3)
currentX += colWidths[0]
pdf.text(categoryTotalQty.toString(), currentX + colWidths[1] - 2, yPos + 3, { align: 'right' })
currentX += colWidths[1]
pdf.text(categoryTotalOrders.toString(), currentX + colWidths[2] - 2, yPos + 3, { align: 'right' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(categoryTotalRevenue), currentX + colWidths[3] - 2, yPos + 3, { align: 'right' })
yPos += 15
})
// Grand total
if (checkPageBreak(12)) yPos = 20
pdf.setFillColor(180, 180, 180)
pdf.rect(marginLeft, yPos - 3, tableWidth, 12, 'F')
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(10)
currentX = marginLeft
pdf.text('TOTAL KESELURUHAN', currentX + 2, yPos + 3)
currentX += colWidths[0]
pdf.text(productSummary.totalQuantitySold.toString(), currentX + colWidths[1] - 2, yPos + 3, { align: 'right' })
currentX += colWidths[1]
pdf.text(productSummary.totalOrders.toString(), currentX + colWidths[2] - 2, yPos + 3, { align: 'right' })
currentX += colWidths[2]
pdf.text(this.formatCurrency(productSummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 3, {
align: 'right'
})
return yPos + 20
}
/**
* Format currency for display
*/
private static formatCurrency(amount: number): string {
return `Rp ${amount.toLocaleString('id-ID')}`
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: string, extension: string): string {
const now = new Date()
const year = now.getFullYear()
const month = (now.getMonth() + 1).toString().padStart(2, '0')
const day = now.getDate().toString().padStart(2, '0')
const hour = now.getHours().toString().padStart(2, '0')
const minute = now.getMinutes().toString().padStart(2, '0')
return `${prefix}_${year}_${month}_${day}_${hour}${minute}.${extension}`
}
}

View File

@ -11,6 +11,11 @@ const ReportSalesList: React.FC = () => {
const { lang: locale } = useParams() const { lang: locale } = useParams()
const salesReports = [ const salesReports = [
{
title: 'Penjualan',
iconClass: 'tabler-receipt-2',
link: getLocalizedUrl(`/apps/report/sales/sales-report`, locale as Locale)
},
{ {
title: 'Detail Penjualan', title: 'Detail Penjualan',
iconClass: 'tabler-receipt-2', iconClass: 'tabler-receipt-2',

View File

@ -0,0 +1,389 @@
'use client'
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItem, ReportItemFooter, ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { PDFExportSalesService } from '@/services/export/pdf/PDFExportSalesService'
import {
useCategoryAnalytics,
usePaymentAnalytics,
useProductSalesAnalytics,
useProfitLossAnalytics
} from '@/services/queries/analytics'
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent, Paper } from '@mui/material'
import { useState } from 'react'
const ReportSalesContent = () => {
const [startDate, setStartDate] = useState<Date | null>(new Date())
const [endDate, setEndDate] = useState<Date | null>(new Date())
const { data: profitLoss } = useProfitLossAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const { data: paymentAnalytics } = usePaymentAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const { data: category } = useCategoryAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const { data: products } = useProductSalesAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
date_to: formatDateDDMMYYYY(endDate!)
})
const categorySummary = {
totalRevenue: category?.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
orderCount: category?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0,
productCount: category?.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity: category?.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
const productSummary = {
totalQuantitySold: products?.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: products?.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: products?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
const handleExportPDF = async () => {
try {
const salesData = {
profitLoss: profitLoss!,
paymentAnalytics: paymentAnalytics!,
categoryAnalytics: category!,
productAnalytics: products!
}
const result = await PDFExportSalesService.exportSalesReportToPDF(salesData)
if (result.success) {
console.log('PDF export successful:', result.filename)
// Optional: Show success notification
} else {
console.error('PDF export failed:', result.error)
alert('Export PDF gagal. Silakan coba lagi.')
}
} catch (error) {
console.error('PDF export error:', error)
alert('Terjadi kesalahan saat export PDF.')
}
}
return (
<Card>
<div className='p-6 border-be'>
<div className='flex items-center justify-end gap-2'>
<Button
color='secondary'
variant='tonal'
startIcon={<i className='tabler-upload' />}
className='max-sm:is-full'
onClick={handleExportPDF}
>
Ekspor
</Button>
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
</div>
<CardContent>
<ReportItemHeader
title='Ringkasan'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<ReportItem
accountCode=''
accountName='Total Penjualan'
amount={profitLoss?.summary.total_revenue ?? 0}
onClick={() => {}}
/>
<ReportItem
accountCode=''
accountName='Total Diskon'
amount={profitLoss?.summary.total_discount ?? 0}
onClick={() => {}}
/>
<ReportItem
accountCode=''
accountName='Total Pajak'
amount={profitLoss?.summary.total_tax ?? 0}
onClick={() => {}}
/>
<ReportItemFooter title='Total' amount={profitLoss?.summary.total_revenue ?? 0} />
<ReportItemSubheader title='' />
<ReportItemHeader
title='Invoice'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<ReportItem
accountCode=''
accountName='Total Invoice'
amount={profitLoss?.summary.total_orders ?? 0}
onClick={() => {}}
/>
<ReportItem
accountCode=''
accountName='Rata-rata Tagihan per Invoice'
amount={profitLoss?.summary.average_profit ?? 0}
onClick={() => {}}
/>
<ReportItemSubheader title='' />
<ReportItemHeader
title='Ringkasan Metode Pembayaran'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Metode Pembayaran</th>
<th className='text-center p-3 font-semibold'>Tipe</th>
<th className='text-center p-3 font-semibold'>Jumlah Order</th>
<th className='text-right p-3 font-semibold'>Total Amount</th>
<th className='text-center p-3 font-semibold'>Persentase</th>
</tr>
</thead>
<tbody>
{paymentAnalytics?.data?.map((payment, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{payment.payment_method_name}</td>
<td className='p-3 text-center'>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
payment.payment_method_type === 'cash'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{payment.payment_method_type.toUpperCase()}
</span>
</td>
<td className='p-3 text-center text-gray-700'>{payment.order_count}</td>
<td className='p-3 text-right font-semibold text-gray-800'>{formatCurrency(payment.total_amount)}</td>
<td className='p-3 text-center font-medium' style={{ color: '#36175e' }}>
{(payment.percentage ?? 0).toFixed(1)}%
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3'></td>
<td className='p-3 text-center font-bold'>{paymentAnalytics?.summary.total_orders ?? 0}</td>
<td className='p-3 text-right font-bold'>
{formatCurrency(paymentAnalytics?.summary.total_amount ?? 0)}
</td>
<td className='p-3 text-center font-bold'></td>
</tr>
</tfoot>
</table>
</div>
<ReportItemSubheader title='' />
<ReportItemHeader
title='Ringkasan Kategori'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold'>Nama</th>
<th className='text-center p-3 font-semibold'>Total Produk</th>
<th className='text-center p-3 font-semibold'>Qty</th>
<th className='text-right p-3 font-semibold'>Pendapatan</th>
</tr>
</thead>
<tbody>
{category?.data?.map((c, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className='p-3 font-medium text-gray-800'>{c.category_name}</td>
<td className='p-3 text-center text-gray-700'>{c.product_count}</td>
<td className='p-3 text-center text-gray-700'>{c.total_quantity}</td>
<td className='p-3 text-right font-semibold' style={{ color: '#36175e' }}>
{formatCurrency(c.total_revenue)}
</td>
</tr>
)) || []}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300'>
<td className='p-3 font-bold'>TOTAL</td>
<td className='p-3 text-center font-bold'>{categorySummary?.productCount ?? 0}</td>
<td className='p-3 text-center font-bold'>{categorySummary?.totalQuantity ?? 0}</td>
<td className='p-3 text-right font-bold'>{formatCurrency(categorySummary?.totalRevenue ?? 0)}</td>
</tr>
</tfoot>
</table>
</div>
<ReportItemSubheader title='' />
<ReportItemHeader
title='Ringkasan Item'
date={`${profitLoss?.date_from.split('T')[0]} - ${profitLoss?.date_to.split('T')[0]}`}
/>
<div className='bg-gray-50 border border-gray-200 overflow-visible'>
<div className='overflow-x-auto'>
<table className='w-full table-fixed' style={{ minWidth: '100%' }}>
<colgroup>
<col style={{ width: '40%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr className='text-gray-800 border-b-2 border-gray-300'>
<th className='text-left p-3 font-semibold border-r border-gray-300'>Produk</th>
<th className='text-center p-3 font-semibold border-r border-gray-300'>Qty</th>
<th className='text-center p-3 font-semibold border-r border-gray-300'>Order</th>
<th className='text-right p-3 font-semibold border-r border-gray-300'>Pendapatan</th>
<th className='text-right p-3 font-semibold'>Rata Rata</th>
</tr>
</thead>
<tbody>
{(() => {
// 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[]>
) || {}
const rows: JSX.Element[] = []
let globalIndex = 0
// Sort categories alphabetically
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Category header row
rows.push(
<tr
key={`category-${categoryName}`}
className='bg-gray-100 border-b border-gray-300'
style={{ pageBreakInside: 'avoid' }}
>
<td
className='p-3 font-bold text-gray-900 border-r border-gray-300'
style={{ color: '#36175e' }}
>
{categoryName.toUpperCase()}
</td>
<td className='p-3 border-r border-gray-300'></td>
<td className='p-3 border-r border-gray-300'></td>
<td className='p-3 border-r border-gray-300'></td>
<td className='p-3'></td>
</tr>
)
// Product rows for this category
categoryProducts.forEach((item, index) => {
globalIndex++
rows.push(
<tr
key={`product-${item.product_name}-${index}`}
className={`${globalIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'} border-b border-gray-200`}
style={{ pageBreakInside: 'avoid' }}
>
<td
className='p-3 pl-6 font-medium text-gray-800 border-r border-gray-200'
style={{ wordWrap: 'break-word' }}
>
{item.product_name}
</td>
<td className='p-3 text-center text-gray-700 border-r border-gray-200'>
{item.quantity_sold}
</td>
<td className='p-3 text-center text-gray-700 border-r border-gray-200'>
{item.order_count ?? 0}
</td>
<td className='p-3 text-right font-semibold text-gray-800 border-r border-gray-200'>
{formatCurrency(item.revenue)}
</td>
<td className='p-3 text-right font-medium text-gray-800'>
{formatCurrency(item.average_price)}
</td>
</tr>
)
})
// Category subtotal row
const categoryTotalQty = categoryProducts.reduce(
(sum, item) => sum + (item.quantity_sold || 0),
0
)
const categoryTotalOrders = categoryProducts.reduce(
(sum, item) => sum + (item.order_count || 0),
0
)
const categoryTotalRevenue = categoryProducts.reduce((sum, item) => sum + (item.revenue || 0), 0)
rows.push(
<tr
key={`subtotal-${categoryName}`}
className='bg-gray-200 border-b-2 border-gray-400'
style={{ pageBreakInside: 'avoid' }}
>
<td className='p-3 pl-6 font-semibold text-gray-800 border-r border-gray-400'>
Subtotal {categoryName}
</td>
<td className='p-3 text-center font-semibold text-gray-800 border-r border-gray-400'>
{categoryTotalQty}
</td>
<td className='p-3 text-center font-semibold text-gray-800 border-r border-gray-400'>
{categoryTotalOrders}
</td>
<td className='p-3 text-right font-semibold text-gray-800 border-r border-gray-400'>
{formatCurrency(categoryTotalRevenue)}
</td>
<td className='p-3'></td>
</tr>
)
})
return rows
})()}
</tbody>
<tfoot>
<tr className='text-gray-800 border-t-2 border-gray-300' style={{ pageBreakInside: 'avoid' }}>
<td className='p-3 font-bold border-r border-gray-300'>TOTAL KESELURUHAN</td>
<td className='p-3 text-center font-bold border-r border-gray-300'>
{productSummary.totalQuantitySold ?? 0}
</td>
<td className='p-3 text-center font-bold border-r border-gray-300'>
{productSummary.totalOrders ?? 0}
</td>
<td className='p-3 text-right font-bold border-r border-gray-300'>
{formatCurrency(productSummary.totalRevenue ?? 0)}
</td>
<td className='p-3'></td>
</tr>
</tfoot>
</table>
</div>
</div>
<ReportItemSubheader title='' />
</CardContent>
</Card>
)
}
export default ReportSalesContent