Export Excel Sales

This commit is contained in:
efrilm 2025-09-26 13:11:26 +07:00
parent ce9120e7e6
commit 191937e647
3 changed files with 524 additions and 3 deletions

View File

@ -0,0 +1,469 @@
// services/excelExportSalesService.ts
import type { CategoryReport, PaymentReport, ProductSalesReport, ProfitLossReport } from '@/types/services/analytic'
export interface SalesReportData {
profitLoss: ProfitLossReport
paymentAnalytics: PaymentReport
categoryAnalytics: CategoryReport
productAnalytics: ProductSalesReport
}
export class ExcelExportSalesService {
/**
* Export Sales Report to Excel
*/
static async exportSalesReportToExcel(salesData: SalesReportData, filename?: string) {
try {
// Dynamic import untuk xlsx library
const XLSX = await import('xlsx')
// Prepare data untuk Excel
const worksheetData: any[][] = []
// Header dengan report info (baris 1-2)
worksheetData.push(['LAPORAN TRANSAKSI']) // Row 0 - Main title
worksheetData.push([
`Periode: ${salesData.profitLoss.date_from.split('T')[0]} - ${salesData.profitLoss.date_to.split('T')[0]}`
]) // Row 1 - Period
worksheetData.push([]) // Empty row
// Add Summary Section (Ringkasan)
worksheetData.push(['RINGKASAN PERIODE']) // Section header
worksheetData.push([]) // Empty row
const ringkasanData = [
['Total Penjualan:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`],
['Total Diskon:', `Rp ${salesData.profitLoss.summary.total_discount.toLocaleString('id-ID')}`],
['Total Pajak:', `Rp ${salesData.profitLoss.summary.total_tax.toLocaleString('id-ID')}`],
['Total:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`]
]
ringkasanData.forEach(row => {
worksheetData.push([row[0], row[1]])
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Invoice Section
worksheetData.push(['INVOICE']) // Section header
worksheetData.push([]) // Empty row
const invoiceData = [
['Total Invoice:', salesData.profitLoss.summary.total_orders.toString()],
['Rata-rata Tagihan Per Invoice:', `Rp ${salesData.profitLoss.summary.average_profit.toLocaleString('id-ID')}`]
]
invoiceData.forEach(row => {
worksheetData.push([row[0], row[1]])
})
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Payment Methods Section
worksheetData.push(['RINGKASAN METODE PEMBAYARAN']) // Section header
worksheetData.push([]) // Empty row
// Payment methods table header
const paymentHeaderRow = ['No', 'Metode Pembayaran', 'Tipe', 'Jumlah Order', 'Total Amount', 'Persentase']
worksheetData.push(paymentHeaderRow)
// Payment methods data
salesData.paymentAnalytics.data?.forEach((payment, index) => {
const rowData = [
index + 1,
payment.payment_method_name,
payment.payment_method_type.toUpperCase(),
payment.order_count,
payment.total_amount,
`${(payment.percentage ?? 0).toFixed(1)}%`
]
worksheetData.push(rowData)
})
// Payment methods total row
const paymentTotalRow = [
'TOTAL',
'',
'',
salesData.paymentAnalytics.summary?.total_orders ?? 0,
salesData.paymentAnalytics.summary?.total_amount ?? 0,
'100.0%'
]
worksheetData.push(paymentTotalRow)
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Category Section
worksheetData.push(['RINGKASAN KATEGORI']) // Section header
worksheetData.push([]) // Empty row
// Category table header
const categoryHeaderRow = ['No', 'Nama', 'Total Produk', 'Qty', 'Pendapatan']
worksheetData.push(categoryHeaderRow)
// Calculate category summaries
const categorySummary = {
totalRevenue: salesData.categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0,
productCount: salesData.categoryAnalytics.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0,
totalQuantity:
salesData.categoryAnalytics.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0
}
// Category data
salesData.categoryAnalytics.data?.forEach((category, index) => {
const rowData = [
index + 1,
category.category_name,
category.product_count,
category.total_quantity,
category.total_revenue
]
worksheetData.push(rowData)
})
// Category total row
const categoryTotalRow = [
'TOTAL',
'',
categorySummary.productCount,
categorySummary.totalQuantity,
categorySummary.totalRevenue
]
worksheetData.push(categoryTotalRow)
worksheetData.push([]) // Empty row
worksheetData.push([]) // Empty row
// Add Product Section
worksheetData.push(['RINGKASAN ITEM']) // Section header
worksheetData.push([]) // Empty row
// Group products by category
const groupedProducts =
salesData.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:
salesData.productAnalytics.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0,
totalRevenue: salesData.productAnalytics.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0,
totalOrders: salesData.productAnalytics.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0
}
// Product table header
const productHeaderRow = ['Kategori', 'Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata']
worksheetData.push(productHeaderRow)
// Add grouped products data
Object.keys(groupedProducts)
.sort()
.forEach(categoryName => {
const categoryProducts = groupedProducts[categoryName]
// Category header row
worksheetData.push([categoryName.toUpperCase(), '', '', '', '', ''])
// Category products
categoryProducts.forEach(item => {
const rowData = [
'',
item.product_name,
item.quantity_sold,
item.order_count || 0,
item.revenue,
item.average_price
]
worksheetData.push(rowData)
})
// Category subtotal
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)
const categoryAverage = categoryTotalQty > 0 ? categoryTotalRevenue / categoryTotalQty : 0
const categorySubtotalRow = [
`Subtotal ${categoryName}`,
'',
categoryTotalQty,
categoryTotalOrders,
categoryTotalRevenue,
categoryAverage
]
worksheetData.push(categorySubtotalRow)
worksheetData.push([]) // Empty row between categories
})
// Grand total
const grandTotalAverage =
productSummary.totalQuantitySold > 0 ? productSummary.totalRevenue / productSummary.totalQuantitySold : 0
const grandTotalRow = [
'TOTAL KESELURUHAN',
'',
productSummary.totalQuantitySold,
productSummary.totalOrders,
productSummary.totalRevenue,
grandTotalAverage
]
worksheetData.push(grandTotalRow)
// Create workbook dan worksheet
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
// Apply basic formatting
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
// Add worksheet ke workbook
XLSX.utils.book_append_sheet(workbook, worksheet, 'Laporan Transaksi')
// Generate filename
const exportFilename = filename || this.generateFilename('Laporan_Transaksi')
// Download file
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting sales report to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
/**
* Apply basic formatting (SheetJS compatible)
*/
private static applyBasicFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Set column widths
const colWidths = [
{ wch: 25 }, // First column (category/label)
{ wch: 30 }, // Second column (description/name)
{ wch: 15 }, // Third column (numbers)
{ wch: 15 }, // Fourth column (numbers)
{ wch: 20 }, // Fifth column (amounts)
{ wch: 15 } // Sixth column (percentages/averages)
]
worksheet['!cols'] = colWidths
// Set row heights for better spacing
worksheet['!rows'] = [
{ hpt: 30 }, // Title row
{ hpt: 25 }, // Period row
{ hpt: 15 }, // Empty row
{ hpt: 25 }, // Section headers
{ hpt: 15 } // Empty row
]
// Merge cells untuk main headers
const merges = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 5 } }, // Main title
{ s: { r: 1, c: 0 }, e: { r: 1, c: 5 } } // Period
]
// Find and add merges for section headers
const sectionHeaders = [
'RINGKASAN PERIODE',
'INVOICE',
'RINGKASAN METODE PEMBAYARAN',
'RINGKASAN KATEGORI',
'RINGKASAN ITEM'
]
for (let i = 0; i < totalRows; i++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })]
if (cell && sectionHeaders.includes(cell.v)) {
merges.push({ s: { r: i, c: 0 }, e: { r: i, c: 5 } })
}
}
worksheet['!merges'] = merges
// Apply number formatting untuk currency cells
this.applyNumberFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply number formatting for currency
*/
private static applyNumberFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Apply currency formatting to amount columns
for (let row = 0; row < totalRows; row++) {
// Check columns that might contain currency values (columns 1, 4, 5)
;[1, 4, 5].forEach(col => {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const cell = worksheet[cellAddress]
if (cell && typeof cell.v === 'number' && cell.v > 1000) {
// Apply Indonesian currency format for large numbers
cell.z = '#,##0'
cell.t = 'n'
}
})
}
// Apply formatting to specific sections
this.applySectionFormatting(worksheet, totalRows, XLSX)
}
/**
* Apply specific formatting to sections
*/
private static applySectionFormatting(worksheet: any, totalRows: number, XLSX: any) {
// Find and format table headers and total rows
const headerKeywords = ['No', 'Metode Pembayaran', 'Nama', 'Kategori', 'Produk']
const totalKeywords = ['TOTAL', 'Subtotal']
for (let row = 0; row < totalRows; row++) {
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: 0 })]
if (cell) {
// Format table headers
if (headerKeywords.some(keyword => cell.v === keyword)) {
for (let col = 0; col < 6; col++) {
const headerCellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const headerCell = worksheet[headerCellAddress]
if (headerCell) {
headerCell.s = {
font: { bold: true },
fill: { fgColor: { rgb: 'F3F4F6' } },
border: {
bottom: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
// Format total rows
if (totalKeywords.some(keyword => cell.v?.toString().startsWith(keyword))) {
for (let col = 0; col < 6; col++) {
const totalCellAddress = XLSX.utils.encode_cell({ r: row, c: col })
const totalCell = worksheet[totalCellAddress]
if (totalCell) {
totalCell.s = {
font: { bold: true },
border: {
top: { style: 'medium', color: { rgb: '000000' } }
}
}
}
}
}
// Format section headers
const sectionHeaders = [
'RINGKASAN PERIODE',
'INVOICE',
'RINGKASAN METODE PEMBAYARAN',
'RINGKASAN KATEGORI',
'RINGKASAN ITEM'
]
if (sectionHeaders.includes(cell.v)) {
cell.s = {
font: { bold: true, color: { rgb: '662D91' } },
fill: { fgColor: { rgb: 'F8F9FA' } }
}
}
}
}
}
/**
* Generate filename with timestamp
*/
private static generateFilename(prefix: 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}.xlsx`
}
/**
* Export custom sales data to Excel with configuration
*/
static async exportCustomSalesData(
salesData: SalesReportData,
options?: {
includeSections?: {
ringkasan?: boolean
invoice?: boolean
paymentMethods?: boolean
categories?: boolean
products?: boolean
}
customFilename?: string
sheetName?: string
}
) {
try {
const XLSX = await import('xlsx')
const worksheetData: any[][] = []
// Always include title and period
worksheetData.push(['LAPORAN TRANSAKSI'])
worksheetData.push([
`Periode: ${salesData.profitLoss.date_from.split('T')[0]} - ${salesData.profitLoss.date_to.split('T')[0]}`
])
worksheetData.push([])
const sections = options?.includeSections || {
ringkasan: true,
invoice: true,
paymentMethods: true,
categories: true,
products: true
}
// Conditionally add sections based on options
if (sections.ringkasan) {
worksheetData.push(['RINGKASAN PERIODE'])
worksheetData.push([])
// Add ringkasan data...
const ringkasanData = [
['Total Penjualan:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`],
['Total Diskon:', `Rp ${salesData.profitLoss.summary.total_discount.toLocaleString('id-ID')}`],
['Total Pajak:', `Rp ${salesData.profitLoss.summary.total_tax.toLocaleString('id-ID')}`],
['Total:', `Rp ${salesData.profitLoss.summary.total_revenue.toLocaleString('id-ID')}`]
]
ringkasanData.forEach(row => worksheetData.push([row[0], row[1]]))
worksheetData.push([])
worksheetData.push([])
}
// Add other sections similarly based on options...
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.aoa_to_sheet(worksheetData)
this.applyBasicFormatting(worksheet, worksheetData.length, XLSX)
const sheetName = options?.sheetName || 'Laporan Transaksi'
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
const exportFilename = options?.customFilename || this.generateFilename('Custom_Sales_Report')
XLSX.writeFile(workbook, exportFilename)
return { success: true, filename: exportFilename }
} catch (error) {
console.error('Error exporting custom sales report to Excel:', error)
return { success: false, error: 'Failed to export Excel file' }
}
}
}

View File

@ -101,7 +101,6 @@ const ReportPaymentMethodContent = () => {
handleExportClose()
}}
>
<i className='tabler-file-pdf mr-2' />
Export PDF
</MenuItem>
</Menu>

View File

@ -2,6 +2,7 @@
import DateRangePicker from '@/components/RangeDatePicker'
import { ReportItem, ReportItemFooter, ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem'
import { ExcelExportSalesService } from '@/services/export/excel/ExcelExportSalesService'
import { PDFExportSalesService } from '@/services/export/pdf/PDFExportSalesService'
import {
useCategoryAnalytics,
@ -10,12 +11,13 @@ import {
useProfitLossAnalytics
} from '@/services/queries/analytics'
import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform'
import { Button, Card, CardContent, Paper } from '@mui/material'
import { Button, Card, CardContent, Menu, MenuItem, 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 [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { data: profitLoss } = useProfitLossAnalytics({
date_from: formatDateDDMMYYYY(startDate!),
@ -74,6 +76,38 @@ const ReportSalesContent = () => {
}
}
const handleExportExcel = async () => {
try {
const salesData = {
profitLoss: profitLoss!,
paymentAnalytics: paymentAnalytics!,
categoryAnalytics: category!,
productAnalytics: products!
}
const result = await ExcelExportSalesService.exportSalesReportToExcel(salesData)
if (result.success) {
console.log('Excel export successful:', result.filename)
// Optional: Show success notification
} else {
console.error('Excel export failed:', result.error)
alert('Export Excel gagal. Silakan coba lagi.')
}
} catch (error) {
console.error('Excel export error:', error)
alert('Terjadi kesalahan saat export Excel.')
}
}
const handleExportClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleExportClose = () => {
setAnchorEl(null)
}
return (
<Card>
<div className='p-6 border-be'>
@ -82,11 +116,30 @@ const ReportSalesContent = () => {
color='secondary'
variant='tonal'
startIcon={<i className='tabler-upload' />}
endIcon={<i className='tabler-chevron-down' />}
className='max-sm:is-full'
onClick={handleExportPDF}
onClick={handleExportClick}
>
Ekspor
</Button>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleExportClose}>
<MenuItem
onClick={() => {
handleExportExcel()
handleExportClose()
}}
>
Export Excel
</MenuItem>
<MenuItem
onClick={() => {
handleExportPDF()
handleExportClose()
}}
>
Export PDF
</MenuItem>
</Menu>
<DateRangePicker
startDate={startDate}
endDate={endDate}