From 191937e647833bc7d952e696407eb0807bf97509 Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 26 Sep 2025 13:11:26 +0700 Subject: [PATCH] Export Excel Sales --- .../export/excel/ExcelExportSalesService.ts | 469 ++++++++++++++++++ .../ReportPaymentMethodContent.tsx | 1 - .../sales/sales-report/ReportSalesContent.tsx | 57 ++- 3 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 src/services/export/excel/ExcelExportSalesService.ts diff --git a/src/services/export/excel/ExcelExportSalesService.ts b/src/services/export/excel/ExcelExportSalesService.ts new file mode 100644 index 0000000..698b3bb --- /dev/null +++ b/src/services/export/excel/ExcelExportSalesService.ts @@ -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 + ) || {} + + // 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' } + } + } +} diff --git a/src/views/apps/report/financial/payment-method-report/ReportPaymentMethodContent.tsx b/src/views/apps/report/financial/payment-method-report/ReportPaymentMethodContent.tsx index 0516149..3f250ca 100644 --- a/src/views/apps/report/financial/payment-method-report/ReportPaymentMethodContent.tsx +++ b/src/views/apps/report/financial/payment-method-report/ReportPaymentMethodContent.tsx @@ -101,7 +101,6 @@ const ReportPaymentMethodContent = () => { handleExportClose() }} > - Export PDF diff --git a/src/views/apps/report/sales/sales-report/ReportSalesContent.tsx b/src/views/apps/report/sales/sales-report/ReportSalesContent.tsx index 6441362..76d4447 100644 --- a/src/views/apps/report/sales/sales-report/ReportSalesContent.tsx +++ b/src/views/apps/report/sales/sales-report/ReportSalesContent.tsx @@ -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(new Date()) const [endDate, setEndDate] = useState(new Date()) + const [anchorEl, setAnchorEl] = useState(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) => { + setAnchorEl(event.currentTarget) + } + + const handleExportClose = () => { + setAnchorEl(null) + } + return (
@@ -82,11 +116,30 @@ const ReportSalesContent = () => { color='secondary' variant='tonal' startIcon={} + endIcon={} className='max-sm:is-full' - onClick={handleExportPDF} + onClick={handleExportClick} > Ekspor + + { + handleExportExcel() + handleExportClose() + }} + > + Export Excel + + { + handleExportPDF() + handleExportClose() + }} + > + Export PDF + +