Report Profit Loss

This commit is contained in:
efrilm 2025-09-25 15:45:49 +07:00
parent 79cd4f9dcb
commit dc32c8553b
4 changed files with 404 additions and 5 deletions

106
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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' }
}
}
}

View File

@ -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 (