diff --git a/src/services/export/excel/ExcelExportSalesProductService.ts b/src/services/export/excel/ExcelExportSalesProductService.ts new file mode 100644 index 0000000..38aed2e --- /dev/null +++ b/src/services/export/excel/ExcelExportSalesProductService.ts @@ -0,0 +1,414 @@ +// services/excelExportProductService.ts +import type { ProductSalesReport } from '@/types/services/analytic' + +export class ExcelExportSalesProductService { + /** + * Export Product Sales Report to Excel + */ + static async exportProductSalesToExcel(productData: ProductSalesReport, 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 PENJUALAN PRODUK']) // Row 0 - Main title + worksheetData.push([`Periode: ${productData.date_from.split('T')[0]} - ${productData.date_to.split('T')[0]}`]) // Row 1 - Period + worksheetData.push([]) // Empty row + + // Calculate summary + const productSummary = { + totalQuantitySold: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0, + totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0, + totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0, + averageRevenue: 0 + } + productSummary.averageRevenue = + productSummary.totalQuantitySold > 0 ? productSummary.totalRevenue / productSummary.totalQuantitySold : 0 + + // Add Summary Section + worksheetData.push(['RINGKASAN PERIODE']) // Section header + worksheetData.push([]) // Empty row + + const summaryData = [ + ['Total Quantity Sold:', productSummary.totalQuantitySold.toString()], + ['Total Orders:', productSummary.totalOrders.toString()], + ['Total Revenue:', `Rp ${productSummary.totalRevenue.toLocaleString('id-ID')}`], + ['Average Revenue per Item:', `Rp ${productSummary.averageRevenue.toLocaleString('id-ID')}`] + ] + + summaryData.forEach(row => { + worksheetData.push([row[0], row[1]]) // Only 2 columns needed + }) + + worksheetData.push([]) // Empty row + worksheetData.push([]) // Empty row + + // Product Details Section Header + worksheetData.push(['RINCIAN PRODUK']) // Section header + worksheetData.push([]) // Empty row + + // Group products by category + const groupedProducts = + productData.data?.reduce( + (acc, item) => { + const categoryName = item.category_name || 'Tidak Berkategori' + if (!acc[categoryName]) { + acc[categoryName] = [] + } + acc[categoryName].push(item) + return acc + }, + {} as Record + ) || {} + + // Header row untuk tabel product data + const headerRow = ['Kategori', 'Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata'] + worksheetData.push(headerRow) + + // 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 = [ + '', // Empty for category column (indented effect) + item.product_name, + item.quantity_sold, + item.order_count || 0, + item.revenue, // Store as number for Excel formatting + item.average_price // Store as number for Excel formatting + ] + 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 categorySubtotalRow = [ + `Subtotal ${categoryName}`, + '', + categoryTotalQty, + categoryTotalOrders, + categoryTotalRevenue, + '' + ] + worksheetData.push(categorySubtotalRow) + worksheetData.push([]) // Empty row between categories + }) + + // Grand total + const grandTotalRow = [ + 'TOTAL KESELURUHAN', + '', + productSummary.totalQuantitySold, + productSummary.totalOrders, + productSummary.totalRevenue, + '' + ] + 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, 'Penjualan Produk') + + // Generate filename + const exportFilename = filename || this.generateFilename('Penjualan_Produk') + + // Download file + XLSX.writeFile(workbook, exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting 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 }, // Kategori + { wch: 40 }, // Produk + { wch: 12 }, // Qty + { wch: 12 }, // Order + { wch: 20 }, // Pendapatan + { wch: 18 } // Rata Rata + ] + 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 }, // Summary header + { hpt: 15 } // Empty row + ] + + // Merge cells untuk headers + const merges = [ + { s: { r: 0, c: 0 }, e: { r: 0, c: 5 } }, // Title (span across all columns) + { s: { r: 1, c: 0 }, e: { r: 1, c: 5 } }, // Period (span across all columns) + { s: { r: 3, c: 0 }, e: { r: 3, c: 5 } } // Summary header (span across all columns) + ] + + // Find and add merge for product details header + for (let i = 0; i < totalRows; i++) { + const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })] + if (cell && cell.v === 'RINCIAN PRODUK') { + merges.push({ s: { r: i, c: 0 }, e: { r: i, c: 5 } }) // Span across all columns + break + } + } + + worksheet['!merges'] = merges + + // Apply number formatting untuk currency cells + this.applyNumberFormatting(worksheet, totalRows, XLSX) + } + + /** + * Apply number formatting for currency and styling + */ + private static applyNumberFormatting(worksheet: any, totalRows: number, XLSX: any) { + // Find table data start (after header row) + let dataStartRow = -1 + for (let i = 0; i < totalRows; i++) { + const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })] + if (cell && cell.v === 'Kategori') { + dataStartRow = i + 1 + break + } + } + + if (dataStartRow === -1) return + + // Apply currency formatting to Pendapatan and Rata Rata columns (columns 4 and 5) + for (let row = dataStartRow; row < totalRows; row++) { + // Pendapatan column (index 4) + const revenueCell = worksheet[XLSX.utils.encode_cell({ r: row, c: 4 })] + if (revenueCell && typeof revenueCell.v === 'number') { + revenueCell.z = '#,##0' + revenueCell.t = 'n' + } + + // Rata Rata column (index 5) + const avgCell = worksheet[XLSX.utils.encode_cell({ r: row, c: 5 })] + if (avgCell && typeof avgCell.v === 'number') { + avgCell.z = '#,##0' + avgCell.t = 'n' + } + } + + // Apply styling to header row + const headerRowIndex = dataStartRow - 1 + for (let col = 0; col < 6; col++) { + const cellAddress = XLSX.utils.encode_cell({ r: headerRowIndex, c: col }) + const cell = worksheet[cellAddress] + + if (cell) { + // Apply bold formatting (basic approach for SheetJS) + cell.s = { + font: { bold: true }, + fill: { fgColor: { rgb: 'F3F4F6' } }, // Light gray background + border: { + bottom: { style: 'medium', color: { rgb: '000000' } } + } + } + } + } + + // Apply styling to category headers and totals + for (let row = dataStartRow; row < totalRows; row++) { + const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: 0 })] + + if (cell && cell.v) { + const cellValue = cell.v.toString() + + // Style category headers (uppercase text without "Subtotal" or "TOTAL") + if ( + cellValue === cellValue.toUpperCase() && + !cellValue.includes('Subtotal') && + !cellValue.includes('TOTAL') && + cellValue.length > 0 + ) { + for (let col = 0; col < 6; col++) { + const categoryCellAddress = XLSX.utils.encode_cell({ r: row, c: col }) + const categoryCell = worksheet[categoryCellAddress] + if (categoryCell) { + categoryCell.s = { + font: { bold: true, color: { rgb: '36175E' } }, + fill: { fgColor: { rgb: 'F8F8F8' } } + } + } + } + } + + // Style subtotal and total rows + if (cellValue.startsWith('Subtotal') || cellValue.startsWith('TOTAL')) { + 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' } } + } + } + } + } + } + } + } + } + + /** + * 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 Product Sales data with custom configuration + */ + static async exportCustomProductData( + productData: ProductSalesReport, + options?: { + includeSummary?: boolean + customFilename?: string + sheetName?: string + groupByCategory?: boolean + } + ) { + try { + const XLSX = await import('xlsx') + const worksheetData: any[][] = [] + + // Always include title and period + worksheetData.push(['LAPORAN PENJUALAN PRODUK']) + worksheetData.push([`Periode: ${productData.date_from.split('T')[0]} - ${productData.date_to.split('T')[0]}`]) + worksheetData.push([]) + + // Optional summary + if (options?.includeSummary !== false) { + const productSummary = { + totalQuantitySold: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0, + totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0, + totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0 + } + + worksheetData.push(['RINGKASAN PERIODE']) + worksheetData.push([]) + const summaryData = [ + ['Total Quantity Sold:', productSummary.totalQuantitySold.toString()], + ['Total Orders:', productSummary.totalOrders.toString()], + ['Total Revenue:', `Rp ${productSummary.totalRevenue.toLocaleString('id-ID')}`] + ] + summaryData.forEach(row => worksheetData.push([row[0], row[1]])) + worksheetData.push([]) + worksheetData.push([]) + } + + worksheetData.push(['RINCIAN PRODUK']) + worksheetData.push([]) + + // Header row + const headerRow = + options?.groupByCategory !== false + ? ['Kategori', 'Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata'] + : ['Produk', 'Kategori', 'Qty', 'Order', 'Pendapatan', 'Rata Rata'] + worksheetData.push(headerRow) + + // Add product data based on grouping option + if (options?.groupByCategory !== false) { + // Group by category (default) + const groupedProducts = + productData.data?.reduce( + (acc, item) => { + const categoryName = item.category_name || 'Tidak Berkategori' + if (!acc[categoryName]) { + acc[categoryName] = [] + } + acc[categoryName].push(item) + return acc + }, + {} as Record + ) || {} + + Object.keys(groupedProducts) + .sort() + .forEach(categoryName => { + const categoryProducts = groupedProducts[categoryName] + worksheetData.push([categoryName.toUpperCase(), '', '', '', '', '']) + categoryProducts.forEach(item => { + worksheetData.push([ + '', + item.product_name, + item.quantity_sold, + item.order_count || 0, + item.revenue, + item.average_price + ]) + }) + }) + } else { + // Flat list without grouping + productData.data?.forEach(item => { + worksheetData.push([ + item.product_name, + item.category_name, + item.quantity_sold, + item.order_count || 0, + item.revenue, + item.average_price + ]) + }) + } + + const workbook = XLSX.utils.book_new() + const worksheet = XLSX.utils.aoa_to_sheet(worksheetData) + + this.applyBasicFormatting(worksheet, worksheetData.length, XLSX) + + const sheetName = options?.sheetName || 'Penjualan Produk' + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName) + + const exportFilename = options?.customFilename || this.generateFilename('Custom_Product_Sales') + XLSX.writeFile(workbook, exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting custom product data to Excel:', error) + return { success: false, error: 'Failed to export Excel file' } + } + } +} diff --git a/src/services/export/pdf/PdfExportSalesProductSevice.ts b/src/services/export/pdf/PdfExportSalesProductSevice.ts new file mode 100644 index 0000000..e0c9422 --- /dev/null +++ b/src/services/export/pdf/PdfExportSalesProductSevice.ts @@ -0,0 +1,439 @@ +// services/pdfExportProductService.ts +import { ProductSalesReport } from '@/types/services/analytic' + +export class PDFExportSalesProductService { + /** + * Export Product Sales Report to PDF + */ + static async exportProductSalesToPDF(productData: ProductSalesReport, 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.addProductReportContent(pdf, productData) + + // Generate filename + const exportFilename = filename || this.generateFilename('Laporan_Penjualan_Produk', 'pdf') + + // Save PDF + pdf.save(exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting product report to PDF:', error) + return { success: false, error: `PDF export failed: ${(error as Error).message}` } + } + } + + /** + * Add product report content to PDF + */ + private static async addProductReportContent(pdf: any, productData: ProductSalesReport) { + 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, productData, yPos, pageWidth, marginLeft, marginRight) + + // Section 1: Ringkasan + checkPageBreak(50) + yPos = this.addRingkasanSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + + // Section 2: Product Details + checkPageBreak(100) + yPos = this.addProductDetailsSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + } + + /** + * Add report title + */ + private static addReportTitle( + pdf: any, + productData: ProductSalesReport, + 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 Penjualan Produk', pageWidth / 2, yPos, { align: 'center' }) + yPos += 10 + + // Period + pdf.setFontSize(12) + pdf.setFont('helvetica', 'normal') + const periodText = `${productData.date_from.split('T')[0]} - ${productData.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, + productData: ProductSalesReport, + 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', marginLeft, yPos) + yPos += 12 + + // Reset text color + pdf.setTextColor(0, 0, 0) + pdf.setFontSize(11) + + // Calculate summary + const productSummary = { + totalQuantitySold: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0, + totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0, + totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0, + averageRevenue: 0, + totalProducts: productData.data?.length || 0 + } + productSummary.averageRevenue = + productSummary.totalQuantitySold > 0 ? productSummary.totalRevenue / productSummary.totalQuantitySold : 0 + + const ringkasanItems = [ + { label: 'Total Produk', value: productSummary.totalProducts.toString(), bold: false }, + { label: 'Total Quantity Sold', value: productSummary.totalQuantitySold.toString(), bold: false }, + { label: 'Total Orders', value: productSummary.totalOrders.toString(), bold: false }, + { label: 'Total Revenue', value: this.formatCurrency(productSummary.totalRevenue), bold: true }, + { label: 'Average Revenue per Item', value: this.formatCurrency(productSummary.averageRevenue), bold: false } + ] + + 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 bold 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 + 20 + } + + /** + * Add Product Details section - SAMA STYLE SEPERTI SALES REPORT + */ + private static addProductDetailsSection( + pdf: any, + productData: ProductSalesReport, + 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('Rincian Produk', marginLeft, yPos) + yPos += 12 + + // 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, tableWidth, 10, 'F') + + pdf.setFont('helvetica', 'bold') + pdf.setFontSize(9) + + const headers = ['Produk', 'Qty', 'Order', 'Pendapatan', 'Rata Rata'] + 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 + + // Group products by category + const groupedProducts = + productData.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: productData.data?.reduce((sum, item) => sum + (item?.quantity_sold || 0), 0) || 0, + totalRevenue: productData.data?.reduce((sum, item) => sum + (item?.revenue || 0), 0) || 0, + totalOrders: productData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0 + } + + // Table rows + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(9) + + // Render grouped products + Object.keys(groupedProducts) + .sort() + .forEach(categoryName => { + const categoryProducts = groupedProducts[categoryName] + + // Check page break for category header + if (checkPageBreak(10)) yPos = 20 + + // Category header + pdf.setFillColor(248, 248, 248) // Soft background + pdf.rect(marginLeft, yPos, tableWidth, 10, 'F') + pdf.setFont('helvetica', 'bold') + pdf.setFontSize(9) + pdf.setTextColor(102, 45, 145) + pdf.text(categoryName.toUpperCase(), marginLeft + 2, yPos + 6) + pdf.setTextColor(0, 0, 0) + yPos += 10 + + // Category products + categoryProducts.forEach((item, index) => { + if (checkPageBreak(10)) yPos = 20 + + currentX = marginLeft + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(9) + pdf.setTextColor(0, 0, 0) + + // 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 + 2, yPos + 5) // Indented for products + currentX += colWidths[0] + + // Quantity + pdf.text(item.quantity_sold.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' }) + currentX += colWidths[1] + + // Order count + pdf.text((item.order_count || 0).toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' }) + currentX += colWidths[2] + + // Revenue + pdf.text(this.formatCurrency(item.revenue), currentX + colWidths[3] - 2, yPos + 5, { align: 'right' }) + currentX += colWidths[3] + + // Average price + pdf.text(this.formatCurrency(item.average_price), currentX + colWidths[4] - 2, yPos + 5, { align: 'right' }) + + // Draw bottom border line + pdf.setDrawColor(230, 230, 230) + pdf.setLineWidth(0.3) + pdf.line(marginLeft, yPos + 8, marginLeft + tableWidth, yPos + 8) + + yPos += 10 + }) + + // 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(240, 240, 240) // Sama dengan table header + pdf.rect(marginLeft, yPos, tableWidth, 10, 'F') + pdf.setFont('helvetica', 'bold') + pdf.setFontSize(9) + + currentX = marginLeft + pdf.text(`Subtotal ${categoryName}`, currentX + 2, yPos + 6) + currentX += colWidths[0] + + pdf.text(categoryTotalQty.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[1] + + pdf.text(categoryTotalOrders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[2] + + pdf.text(this.formatCurrency(categoryTotalRevenue), currentX + colWidths[3] - 2, yPos + 6, { align: 'right' }) + + yPos += 10 + }) + + // Grand total + if (checkPageBreak(10)) yPos = 20 + + 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 KESELURUHAN', currentX + 2, yPos + 6) + currentX += colWidths[0] + + pdf.text(productSummary.totalQuantitySold.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[1] + + pdf.text(productSummary.totalOrders.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[2] + + pdf.text(this.formatCurrency(productSummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 6, { + align: 'right' + }) + + return yPos + 25 + } + + /** + * 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}` + } + + /** + * Export Product Sales data with custom configuration + */ + static async exportCustomProductToPDF( + productData: ProductSalesReport, + options?: { + title?: string + includeSummary?: boolean + customFilename?: string + groupByCategory?: boolean + } + ) { + try { + const jsPDFModule = await import('jspdf') + const jsPDF = jsPDFModule.default + + const pdf = new jsPDF('p', 'mm', 'a4') + + let yPos = 20 + const pageWidth = pdf.internal.pageSize.getWidth() + const pageHeight = pdf.internal.pageSize.getHeight() + const marginLeft = 20 + const marginRight = 20 + const marginBottom = 15 + + const checkPageBreak = (neededSpace: number) => { + if (yPos + neededSpace > pageHeight - marginBottom) { + pdf.addPage() + yPos = 20 + return true + } + return false + } + + // Custom title if provided + if (options?.title) { + pdf.setFontSize(20) + pdf.setFont('helvetica', 'bold') + pdf.setTextColor(0, 0, 0) + pdf.text(options.title, pageWidth / 2, yPos, { align: 'center' }) + yPos += 15 + } else { + yPos = this.addReportTitle(pdf, productData, yPos, pageWidth, marginLeft, marginRight) + } + + // Optional summary section + if (options?.includeSummary !== false) { + checkPageBreak(50) + yPos = this.addRingkasanSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + } + + // Product details + checkPageBreak(100) + yPos = this.addProductDetailsSection(pdf, productData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + + const exportFilename = options?.customFilename || this.generateFilename('Custom_Product_Sales', 'pdf') + pdf.save(exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting custom product report to PDF:', error) + return { success: false, error: `PDF export failed: ${(error as Error).message}` } + } + } +} diff --git a/src/views/apps/report/sales/sales-per-product/ReportSalesPerProductContent.tsx b/src/views/apps/report/sales/sales-per-product/ReportSalesPerProductContent.tsx index e3c3328..cb29d67 100644 --- a/src/views/apps/report/sales/sales-per-product/ReportSalesPerProductContent.tsx +++ b/src/views/apps/report/sales/sales-per-product/ReportSalesPerProductContent.tsx @@ -2,14 +2,17 @@ import DateRangePicker from '@/components/RangeDatePicker' import { ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem' +import { ExcelExportSalesProductService } from '@/services/export/excel/ExcelExportSalesProductService' +import { PDFExportSalesProductService } from '@/services/export/pdf/PdfExportSalesProductSevice' import { useProductSalesAnalytics } from '@/services/queries/analytics' import { formatCurrency, formatDateDDMMYYYY } from '@/utils/transform' -import { Button, Card, CardContent } from '@mui/material' +import { Button, Card, CardContent, Menu, MenuItem } from '@mui/material' import { useState } from 'react' const ReportSalesPerProductContent = () => { const [startDate, setStartDate] = useState(new Date()) const [endDate, setEndDate] = useState(new Date()) + const [anchorEl, setAnchorEl] = useState(null) const { data: products } = useProductSalesAnalytics({ date_from: formatDateDDMMYYYY(startDate!), @@ -22,6 +25,58 @@ const ReportSalesPerProductContent = () => { totalOrders: products?.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0 } + const handleExportClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleExportClose = () => { + setAnchorEl(null) + } + + const handleExportExcel = async () => { + if (!products) { + console.warn('No data available for export') + return + } + + try { + const result = await ExcelExportSalesProductService.exportProductSalesToExcel( + products, + `Penjualan_Produk_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.xlsx` + ) + + if (result.success) { + console.log(`Excel exported successfully: ${result.filename}`) + } else { + console.error('Export failed:', result.error) + } + } catch (error) { + console.error('Export error:', error) + } + } + + const handleExportPDF = async () => { + if (!products) { + console.warn('No data available for export') + return + } + + try { + const result = await PDFExportSalesProductService.exportProductSalesToPDF( + products, + `Laporan_Penjualan_Produk_${formatDateDDMMYYYY(startDate!)}_${formatDateDDMMYYYY(endDate!)}.pdf` + ) + + if (result.success) { + console.log(`PDF exported successfully: ${result.filename}`) + } else { + console.error('Export failed:', result.error) + } + } catch (error) { + console.error('Export error:', error) + } + } + return (
@@ -30,11 +85,30 @@ const ReportSalesPerProductContent = () => { color='secondary' variant='tonal' startIcon={} + endIcon={} className='max-sm:is-full' - // onClick={handleExportPDF} + onClick={handleExportClick} > Ekspor + + { + handleExportExcel() + handleExportClose() + }} + > + Export Excel + + { + handleExportPDF() + handleExportClose() + }} + > + Export PDF + +