diff --git a/src/services/export/excel/ExcelExportSalesProductCategoryService.ts b/src/services/export/excel/ExcelExportSalesProductCategoryService.ts new file mode 100644 index 0000000..9a3174d --- /dev/null +++ b/src/services/export/excel/ExcelExportSalesProductCategoryService.ts @@ -0,0 +1,325 @@ +// services/excelExportCategoryService.ts +import type { CategoryReport } from '@/types/services/analytic' + +export class ExcelExportSalesProductCategoryService { + /** + * Export Category Sales Report to Excel + */ + static async exportCategorySalesToExcel(categoryData: CategoryReport, 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 KATEGORI']) // Row 0 - Main title + worksheetData.push([`Periode: ${categoryData.date_from.split('T')[0]} - ${categoryData.date_to.split('T')[0]}`]) // Row 1 - Period + worksheetData.push([]) // Empty row + + // Calculate summary + const categorySummary = { + totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0, + orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0, + productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0, + totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0, + totalCategories: categoryData.data?.length || 0 + } + + // Add Summary Section + worksheetData.push(['RINGKASAN PERIODE']) // Section header + worksheetData.push([]) // Empty row + + const summaryData = [ + ['Total Kategori:', categorySummary.totalCategories.toString()], + ['Total Produk:', categorySummary.productCount.toString()], + ['Total Quantity:', categorySummary.totalQuantity.toString()], + ['Total Orders:', categorySummary.orderCount.toString()], + ['Total Revenue:', `Rp ${categorySummary.totalRevenue.toLocaleString('id-ID')}`] + ] + + summaryData.forEach(row => { + worksheetData.push([row[0], row[1]]) // Only 2 columns needed + }) + + worksheetData.push([]) // Empty row + worksheetData.push([]) // Empty row + + // Category Details Section Header + worksheetData.push(['RINCIAN KATEGORI']) // Section header + worksheetData.push([]) // Empty row + + // Header row untuk tabel category data + const headerRow = ['No', 'Nama', 'Total Produk', 'Qty', 'Total Orders', 'Pendapatan'] + worksheetData.push(headerRow) + + // Add category data rows + categoryData.data?.forEach((category, index) => { + const rowData = [ + index + 1, // No + category.category_name, + category.product_count, + category.total_quantity, + category.order_count, + category.total_revenue // Store as number for Excel formatting + ] + worksheetData.push(rowData) + }) + + // Add total row + const totalRow = [ + 'TOTAL', + '', + categorySummary.productCount, + categorySummary.totalQuantity, + categorySummary.orderCount, + categorySummary.totalRevenue + ] + worksheetData.push(totalRow) + + // 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 Kategori') + + // Generate filename + const exportFilename = filename || this.generateFilename('Penjualan_Kategori') + + // 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: 8 }, // No + { wch: 30 }, // Nama + { wch: 15 }, // Total Produk + { wch: 12 }, // Qty + { wch: 15 }, // Total Orders + { wch: 20 } // Pendapatan + ] + 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 category 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 KATEGORI') { + 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 === 'No') { + dataStartRow = i + 1 + break + } + } + + if (dataStartRow === -1) return + + // Count actual data rows (excluding total row) + const dataRowsCount = totalRows - dataStartRow - 1 // -1 for total row + + // Apply currency formatting to Pendapatan column (column 5 - index 5) + for (let row = dataStartRow; row <= dataStartRow + dataRowsCount; row++) { + // Include total row + const cellAddress = XLSX.utils.encode_cell({ r: row, c: 5 }) // Pendapatan column + const cell = worksheet[cellAddress] + + if (cell && typeof cell.v === 'number') { + // Apply Indonesian currency format + cell.z = '#,##0' + cell.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 total row + const totalRowIndex = dataStartRow + dataRowsCount + for (let col = 0; col < 6; col++) { + const cellAddress = XLSX.utils.encode_cell({ r: totalRowIndex, c: col }) + const cell = worksheet[cellAddress] + + if (cell) { + // Apply bold formatting for total row + cell.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 Category Sales data with custom configuration + */ + static async exportCustomCategoryData( + categoryData: CategoryReport, + options?: { + includeSummary?: boolean + includeOrderCount?: boolean + customFilename?: string + sheetName?: string + } + ) { + try { + const XLSX = await import('xlsx') + const worksheetData: any[][] = [] + + // Always include title and period + worksheetData.push(['LAPORAN PENJUALAN KATEGORI']) + worksheetData.push([`Periode: ${categoryData.date_from.split('T')[0]} - ${categoryData.date_to.split('T')[0]}`]) + worksheetData.push([]) + + // Optional summary + if (options?.includeSummary !== false) { + const categorySummary = { + totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0, + orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0, + productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0, + totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0, + totalCategories: categoryData.data?.length || 0 + } + + worksheetData.push(['RINGKASAN PERIODE']) + worksheetData.push([]) + const summaryData = [ + ['Total Kategori:', categorySummary.totalCategories.toString()], + ['Total Produk:', categorySummary.productCount.toString()], + ['Total Quantity:', categorySummary.totalQuantity.toString()], + ['Total Revenue:', `Rp ${categorySummary.totalRevenue.toLocaleString('id-ID')}`] + ] + summaryData.forEach(row => worksheetData.push([row[0], row[1]])) + worksheetData.push([]) + worksheetData.push([]) + } + + worksheetData.push(['RINCIAN KATEGORI']) + worksheetData.push([]) + + // Header row based on options + const headerRow = + options?.includeOrderCount !== false + ? ['No', 'Nama', 'Total Produk', 'Qty', 'Total Orders', 'Pendapatan'] + : ['No', 'Nama', 'Total Produk', 'Qty', 'Pendapatan'] + worksheetData.push(headerRow) + + // Add category data based on options + categoryData.data?.forEach((category, index) => { + const rowData = + options?.includeOrderCount !== false + ? [ + index + 1, + category.category_name, + category.product_count, + category.total_quantity, + category.order_count, + category.total_revenue + ] + : [ + index + 1, + category.category_name, + category.product_count, + category.total_quantity, + category.total_revenue + ] + worksheetData.push(rowData) + }) + + 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 Kategori' + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName) + + const exportFilename = options?.customFilename || this.generateFilename('Custom_Category_Sales') + XLSX.writeFile(workbook, exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting custom category data to Excel:', error) + return { success: false, error: 'Failed to export Excel file' } + } + } +} diff --git a/src/services/export/pdf/PDFExportSalesProductCategoryService.ts b/src/services/export/pdf/PDFExportSalesProductCategoryService.ts new file mode 100644 index 0000000..fc42836 --- /dev/null +++ b/src/services/export/pdf/PDFExportSalesProductCategoryService.ts @@ -0,0 +1,556 @@ +// services/pdfExportCategoryService.ts +import { CategoryReport } from '@/types/services/analytic' + +export class PDFExportSalesProductCategoryService { + /** + * Export Category Sales Report to PDF + */ + static async exportCategorySalesToPDF(categoryData: CategoryReport, 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.addCategoryReportContent(pdf, categoryData) + + // Generate filename + const exportFilename = filename || this.generateFilename('Laporan_Penjualan_Kategori', 'pdf') + + // Save PDF + pdf.save(exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting category report to PDF:', error) + return { success: false, error: `PDF export failed: ${(error as Error).message}` } + } + } + + /** + * Add category report content to PDF + */ + private static async addCategoryReportContent(pdf: any, categoryData: CategoryReport) { + 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, categoryData, yPos, pageWidth, marginLeft, marginRight) + + // Section 1: Ringkasan + checkPageBreak(50) + yPos = this.addRingkasanSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + + // Section 2: Category Details + checkPageBreak(80) + yPos = this.addCategoryDetailsSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + } + + /** + * Add report title + */ + private static addReportTitle( + pdf: any, + categoryData: CategoryReport, + 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 Kategori', pageWidth / 2, yPos, { align: 'center' }) + yPos += 10 + + // Period + pdf.setFontSize(12) + pdf.setFont('helvetica', 'normal') + const periodText = `${categoryData.date_from.split('T')[0]} - ${categoryData.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, + categoryData: CategoryReport, + 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 categorySummary = { + totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0, + orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0, + productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0, + totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0, + totalCategories: categoryData.data?.length || 0, + averageRevenuePerCategory: 0 + } + categorySummary.averageRevenuePerCategory = + categorySummary.totalCategories > 0 ? categorySummary.totalRevenue / categorySummary.totalCategories : 0 + + const ringkasanItems = [ + { label: 'Total Kategori', value: categorySummary.totalCategories.toString(), bold: false }, + { label: 'Total Produk', value: categorySummary.productCount.toString(), bold: false }, + { label: 'Total Quantity', value: categorySummary.totalQuantity.toString(), bold: false }, + { label: 'Total Orders', value: categorySummary.orderCount.toString(), bold: false }, + { label: 'Total Revenue', value: this.formatCurrency(categorySummary.totalRevenue), bold: true }, + { + label: 'Rata-rata Revenue per Kategori', + value: this.formatCurrency(categorySummary.averageRevenuePerCategory), + 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 Category Details section + */ + private static addCategoryDetailsSection( + pdf: any, + categoryData: CategoryReport, + 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 Kategori', marginLeft, yPos) + yPos += 12 + + // 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, tableWidth, 10, 'F') + + pdf.setFont('helvetica', 'bold') + pdf.setFontSize(9) + + const headers = ['Nama', 'Total Produk', 'Qty', 'Pendapatan'] + 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 + + // Calculate summary for footer + const categorySummary = { + totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0, + productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0, + totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0 + } + + // Table rows + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(9) + + categoryData.data?.forEach((category, index) => { + if (checkPageBreak(10)) yPos = 20 + + currentX = marginLeft + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(9) + pdf.setTextColor(0, 0, 0) + + // Category name + pdf.text(category.category_name, currentX + 2, yPos + 5) + currentX += colWidths[0] + + // Product count + pdf.text(category.product_count.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' }) + currentX += colWidths[1] + + // Quantity + pdf.text(category.total_quantity.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' }) + currentX += colWidths[2] + + // Revenue + pdf.text(this.formatCurrency(category.total_revenue), currentX + colWidths[3] - 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 + }) + + // Table footer (Total) + 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] + + pdf.text(categorySummary.productCount.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[1] + + pdf.text(categorySummary.totalQuantity.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[2] + + pdf.text(this.formatCurrency(categorySummary.totalRevenue), currentX + colWidths[3] - 2, yPos + 6, { + 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}` + } + + /** + * Export Category Sales data with custom configuration + */ + static async exportCustomCategoryToPDF( + categoryData: CategoryReport, + options?: { + title?: string + includeSummary?: boolean + customFilename?: string + includeOrderCount?: 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, categoryData, yPos, pageWidth, marginLeft, marginRight) + } + + // Optional summary section + if (options?.includeSummary !== false) { + checkPageBreak(50) + yPos = this.addRingkasanSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + } + + // Category details + checkPageBreak(80) + yPos = this.addCategoryDetailsSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + + const exportFilename = options?.customFilename || this.generateFilename('Custom_Category_Sales', 'pdf') + pdf.save(exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting custom category report to PDF:', error) + return { success: false, error: `PDF export failed: ${(error as Error).message}` } + } + } + + /** + * Export Category Sales with extended table including order count + */ + static async exportExtendedCategoryToPDF(categoryData: CategoryReport, filename?: string) { + 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 + } + + // Title section + yPos = this.addReportTitle(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight) + + // Summary section + checkPageBreak(50) + yPos = this.addRingkasanSection(pdf, categoryData, yPos, pageWidth, marginLeft, marginRight, checkPageBreak) + + // Extended table with order count + checkPageBreak(80) + yPos = this.addExtendedCategoryDetailsSection( + pdf, + categoryData, + yPos, + pageWidth, + marginLeft, + marginRight, + checkPageBreak + ) + + const exportFilename = filename || this.generateFilename('Laporan_Kategori_Extended', 'pdf') + pdf.save(exportFilename) + + return { success: true, filename: exportFilename } + } catch (error) { + console.error('Error exporting extended category report to PDF:', error) + return { success: false, error: `PDF export failed: ${(error as Error).message}` } + } + } + + /** + * Add Extended Category Details section with order count + */ + private static addExtendedCategoryDetailsSection( + pdf: any, + categoryData: CategoryReport, + 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 Kategori (Extended)', marginLeft, yPos) + yPos += 12 + + // Reset formatting + pdf.setTextColor(0, 0, 0) + + // Table setup - wider for 5 columns + const tableWidth = pageWidth - marginLeft - marginRight + const colWidths = [40, 25, 20, 20, 35] // Name, Products, Qty, Orders, Revenue + 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 = ['Nama', 'Total Produk', 'Qty', 'Orders', 'Pendapatan'] + 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 + + // Calculate summary for footer + const categorySummary = { + totalRevenue: categoryData.data?.reduce((sum, item) => sum + (item?.total_revenue || 0), 0) || 0, + productCount: categoryData.data?.reduce((sum, item) => sum + (item?.product_count || 0), 0) || 0, + totalQuantity: categoryData.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0, + orderCount: categoryData.data?.reduce((sum, item) => sum + (item?.order_count || 0), 0) || 0 + } + + // Table rows + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(9) + + categoryData.data?.forEach((category, index) => { + if (checkPageBreak(10)) yPos = 20 + + currentX = marginLeft + pdf.setFont('helvetica', 'normal') + pdf.setFontSize(9) + pdf.setTextColor(0, 0, 0) + + // Category name + const categoryName = + category.category_name.length > 30 ? category.category_name.substring(0, 27) + '...' : category.category_name + pdf.text(categoryName, currentX + 2, yPos + 5) + currentX += colWidths[0] + + // Product count + pdf.text(category.product_count.toString(), currentX + colWidths[1] / 2, yPos + 5, { align: 'center' }) + currentX += colWidths[1] + + // Quantity + pdf.text(category.total_quantity.toString(), currentX + colWidths[2] / 2, yPos + 5, { align: 'center' }) + currentX += colWidths[2] + + // Order count + pdf.text(category.order_count.toString(), currentX + colWidths[3] / 2, yPos + 5, { align: 'center' }) + currentX += colWidths[3] + + // Revenue + pdf.text(this.formatCurrency(category.total_revenue), 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 + }) + + // Table footer (Total) + 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] + + pdf.text(categorySummary.productCount.toString(), currentX + colWidths[1] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[1] + + pdf.text(categorySummary.totalQuantity.toString(), currentX + colWidths[2] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[2] + + pdf.text(categorySummary.orderCount.toString(), currentX + colWidths[3] / 2, yPos + 6, { align: 'center' }) + currentX += colWidths[3] + + pdf.text(this.formatCurrency(categorySummary.totalRevenue), currentX + colWidths[4] - 2, yPos + 6, { + align: 'right' + }) + + return yPos + 20 + } +} diff --git a/src/views/apps/report/sales/sales-product-category/ReportSalesProductCategoryReport.tsx b/src/views/apps/report/sales/sales-product-category/ReportSalesProductCategoryReport.tsx index 4fe9f43..a0a91a5 100644 --- a/src/views/apps/report/sales/sales-product-category/ReportSalesProductCategoryReport.tsx +++ b/src/views/apps/report/sales/sales-product-category/ReportSalesProductCategoryReport.tsx @@ -2,14 +2,17 @@ import DateRangePicker from '@/components/RangeDatePicker' import { ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem' +import { ExcelExportSalesProductCategoryService } from '@/services/export/excel/ExcelExportSalesProductCategoryService' +import { PDFExportSalesProductCategoryService } from '@/services/export/pdf/PDFExportSalesProductCategoryService' import { useCategoryAnalytics } 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 ReportSalesProductCategoryContent = () => { const [startDate, setStartDate] = useState(new Date()) const [endDate, setEndDate] = useState(new Date()) + const [anchorEl, setAnchorEl] = useState(null) const { data: category } = useCategoryAnalytics({ date_from: formatDateDDMMYYYY(startDate!), @@ -23,6 +26,58 @@ const ReportSalesProductCategoryContent = () => { totalQuantity: category?.data?.reduce((sum, item) => sum + (item?.total_quantity || 0), 0) || 0 } + const handleExportClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleExportClose = () => { + setAnchorEl(null) + } + + const handleExportExcel = async () => { + if (!category) { + console.warn('No data available for export') + return + } + + try { + const result = await ExcelExportSalesProductCategoryService.exportCategorySalesToExcel( + category, + `Penjualan_Kategori_${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 (!category) { + console.warn('No data available for export') + return + } + + try { + const result = await PDFExportSalesProductCategoryService.exportCategorySalesToPDF( + category, + `Laporan_Penjualan_Kategori_${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 (
@@ -31,11 +86,30 @@ const ReportSalesProductCategoryContent = () => { color='secondary' variant='tonal' startIcon={} + endIcon={} className='max-sm:is-full' - // onClick={handleExportPDF} + onClick={handleExportClick} > Ekspor + + { + handleExportExcel() + handleExportClose() + }} + > + Export Excel + + { + handleExportPDF() + handleExportClose() + }} + > + Export PDF + +