From dc32c8553b188817a203affae51c3b85dfc3c63d Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 25 Sep 2025 15:45:49 +0700 Subject: [PATCH] Report Profit Loss --- package-lock.json | 106 ++++++- package.json | 3 +- .../excel/ExcelExportProfitLossService.ts | 278 ++++++++++++++++++ .../profit-loss/ReportProfitLossContent.tsx | 22 +- 4 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 src/services/export/excel/ExcelExportProfitLossService.ts diff --git a/package-lock.json b/package-lock.json index 693fcc2..8a79567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,8 @@ "react-use": "17.6.0", "recharts": "2.15.0", "use-debounce": "^10.0.5", - "valibot": "0.42.1" + "valibot": "0.42.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@iconify/json": "2.2.286", @@ -3936,6 +3937,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4660,6 +4670,19 @@ "node": ">=10.0.0" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4812,6 +4835,15 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -4953,6 +4985,18 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -6877,6 +6921,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -11093,6 +11146,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.4.tgz", @@ -13045,6 +13110,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -13177,6 +13260,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index ab00d67..315044a 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "react-use": "17.6.0", "recharts": "2.15.0", "use-debounce": "^10.0.5", - "valibot": "0.42.1" + "valibot": "0.42.1", + "xlsx": "^0.18.5" }, "devDependencies": { "@iconify/json": "2.2.286", diff --git a/src/services/export/excel/ExcelExportProfitLossService.ts b/src/services/export/excel/ExcelExportProfitLossService.ts new file mode 100644 index 0000000..6335b54 --- /dev/null +++ b/src/services/export/excel/ExcelExportProfitLossService.ts @@ -0,0 +1,278 @@ +// services/excelExportService.ts +import type { ProfitLossReport } from '@/types/services/analytic' + +export class ExcelExportProfitLossService { + /** + * Export Profit Loss Report to Excel + */ + static async exportProfitLossToExcel(profitData: ProfitLossReport, filename?: string) { + try { + // Dynamic import untuk xlsx library + const XLSX = await import('xlsx') + + // Prepare data untuk Excel + const worksheetData: any[][] = [] + + // Header dengan company info (baris 1-2) - update data + worksheetData.push(['LABA RUGI']) // Row 0 - Main title + worksheetData.push([`Periode: ${profitData.date_from.split('T')[0]} - ${profitData.date_to.split('T')[0]}`]) // Row 1 - Period + worksheetData.push([]) // Empty row + + // Add Summary Section + worksheetData.push(['RINGKASAN PERIODE']) // Section header + worksheetData.push([]) // Empty row + + const summaryData = [ + ['Total Revenue:', `Rp ${profitData.summary.total_revenue.toLocaleString('id-ID')}`], + ['Total Cost:', `Rp ${profitData.summary.total_cost.toLocaleString('id-ID')}`], + ['Gross Profit:', `Rp ${profitData.summary.gross_profit.toLocaleString('id-ID')}`], + ['Gross Profit Margin:', `${profitData.summary.gross_profit_margin.toFixed(1)}%`], + ['Total Tax:', `Rp ${profitData.summary.total_tax.toLocaleString('id-ID')}`], + ['Total Discount:', `Rp ${profitData.summary.total_discount.toLocaleString('id-ID')}`], + ['Net Profit:', `Rp ${profitData.summary.net_profit.toLocaleString('id-ID')}`], + ['Net Profit Margin:', `${profitData.summary.net_profit_margin.toFixed(1)}%`], + ['Total Orders:', profitData.summary.total_orders.toString()], + ['Average Profit:', `Rp ${profitData.summary.average_profit.toLocaleString('id-ID')}`], + ['Profitability Ratio:', `${profitData.summary.profitability_ratio.toFixed(1)}%`] + ] + + summaryData.forEach(row => { + worksheetData.push([row[0], row[1]]) // Only 2 columns needed + }) + + worksheetData.push([]) // Empty row + worksheetData.push([]) // Empty row + + // Daily Data Section Header + worksheetData.push(['RINCIAN HARIAN']) // Section header + worksheetData.push([]) // Empty row + + // Prepare date columns - ambil dari daily data + const dateColumns = profitData.data.map(daily => { + const date = new Date(daily.date) + return date.toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short' + }) + }) + + // Header row untuk tabel daily data (hanya tanggal) + const headerRow = ['NO', 'KET', '', ...dateColumns] + worksheetData.push(headerRow) + + // Prepare data rows + const rows = this.prepareProfitLossRows(profitData) + + // Add data rows ke worksheet (hanya nilai per tanggal) + rows.forEach(row => { + const rowData = [ + row.no, + row.label, + ':', + ...row.values.map(val => val.today) // Store as numbers for better Excel handling + ] + worksheetData.push(rowData) + }) + + // Create workbook dan worksheet + const workbook = XLSX.utils.book_new() + const worksheet = XLSX.utils.aoa_to_sheet(worksheetData) + + // Apply basic formatting + this.applyBasicFormatting(worksheet, dateColumns.length, worksheetData.length, XLSX) + + // Add worksheet ke workbook + XLSX.utils.book_append_sheet(workbook, worksheet, 'Laba Rugi') + + // Generate filename + const exportFilename = filename || this.generateFilename('Laba_Rugi') + + // 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' } + } + } + + /** + * Prepare profit loss data rows (tanpa percentage) + */ + private static prepareProfitLossRows(profitData: ProfitLossReport) { + return [ + { + no: 1, + label: 'TOTAL PENJ', + values: profitData.data.map(daily => ({ + today: daily.revenue, + mtd: daily.revenue // TODO: Replace with actual MTD data from API + })) + }, + { + no: 2, + label: 'HPP', + values: profitData.data.map(daily => ({ + today: daily.cost, + mtd: daily.cost + })) + }, + { + no: 3, + label: 'Laba Kotor (1-2)', + values: profitData.data.map(daily => ({ + today: daily.gross_profit, + mtd: daily.gross_profit + })) + }, + { + no: 4, + label: 'Biaya lain', + values: profitData.data.map(daily => { + const totalCosts = daily.tax + daily.discount + return { + today: totalCosts, + mtd: totalCosts + } + }) + }, + { + no: 5, + label: 'Laba/Rugi (3-4)', + values: profitData.data.map(daily => ({ + today: daily.net_profit, + mtd: daily.net_profit + })) + } + ] + } + + /** + * Apply basic formatting (SheetJS compatible) + */ + private static applyBasicFormatting(worksheet: any, dateColumnsCount: number, totalRows: number, XLSX: any) { + // Set column widths + const colWidths = [ + { wch: 5 }, // NO + { wch: 25 }, // KET + { wch: 3 }, // : + ...Array(dateColumnsCount).fill({ wch: 18 }) + ] + 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: dateColumnsCount + 2 } }, // Title + { s: { r: 1, c: 0 }, e: { r: 1, c: dateColumnsCount + 2 } }, // Period + { s: { r: 3, c: 0 }, e: { r: 3, c: dateColumnsCount + 2 } } // Summary header + ] + + // Find and add merge for daily data header + for (let i = 0; i < totalRows; i++) { + const cell = worksheet[XLSX.utils.encode_cell({ r: i, c: 0 })] + if (cell && cell.v === 'RINCIAN HARIAN') { + merges.push({ s: { r: i, c: 0 }, e: { r: i, c: dateColumnsCount + 2 } }) + break + } + } + + worksheet['!merges'] = merges + + // Apply number formatting untuk currency cells + this.applyNumberFormatting(worksheet, totalRows, dateColumnsCount + 3, XLSX) + } + + /** + * Apply number formatting for currency + */ + private static applyNumberFormatting(worksheet: any, totalRows: number, totalCols: number, XLSX: any) { + // Find table data start (after "NO" header) + 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 + + // Apply currency formatting to data cells (starting from column 3 - after NO, KET, :) + for (let row = dataStartRow; row < dataStartRow + 5; row++) { + // 5 data rows + for (let col = 3; col < totalCols; col++) { + const cellAddress = XLSX.utils.encode_cell({ r: row, c: col }) + const cell = worksheet[cellAddress] + + if (cell && typeof cell.v === 'number') { + // Apply Indonesian currency format + cell.z = '#,##0' + cell.t = 'n' + } + } + } + } + + /** + * 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 any data to Excel with custom configuration + */ + static async exportToExcel( + data: any[][], + sheetName: string = 'Sheet1', + filename?: string, + options?: { + colWidths?: { wch: number }[] + merges?: { s: { r: number; c: number }; e: { r: number; c: number } }[] + } + ) { + try { + const XLSX = await import('xlsx') + + const workbook = XLSX.utils.book_new() + const worksheet = XLSX.utils.aoa_to_sheet(data) + + // Apply options + if (options?.colWidths) { + worksheet['!cols'] = options.colWidths + } + if (options?.merges) { + worksheet['!merges'] = options.merges + } + + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName) + + const exportFilename = filename || this.generateFilename('Export') + 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' } + } + } +} diff --git a/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx b/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx index 8d84ee6..907205d 100644 --- a/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx +++ b/src/views/apps/report/profit-loss/ReportProfitLossContent.tsx @@ -2,6 +2,7 @@ import DateRangePicker from '@/components/RangeDatePicker' import { ReportItem, ReportItemFooter, ReportItemHeader, ReportItemSubheader } from '@/components/report/ReportItem' +import { ExcelExportProfitLossService } from '@/services/export/excel/ExcelExportProfitLossService' import { ProfitLossReport } from '@/types/services/analytic' import { Button, Card, CardContent, Box } from '@mui/material' @@ -30,9 +31,24 @@ const ReportProfitLossContent = ({ onStartDateChange, onEndDateChange }: ReportProfitLossContentProps) => { - const handleExport = () => { - // TODO: Implement export functionality - console.log('Export data:', profitData) + const handleExport = async () => { + if (!profitData) return + + try { + const result = await ExcelExportProfitLossService.exportProfitLossToExcel(profitData) + + if (result.success) { + // Optional: Show success notification + console.log('Export successful:', result.filename) + // You can add toast notification here + } else { + console.error('Export failed:', result.error) + alert('Export gagal. Silakan coba lagi.') + } + } catch (error) { + console.error('Export error:', error) + alert('Terjadi kesalahan saat export.') + } } return (