From 3ae00d95dec27945428f1a9e2078d34a74e049cc Mon Sep 17 00:00:00 2001 From: David Amunga Date: Sat, 27 Sep 2025 18:27:56 +0300 Subject: [PATCH] feat: add transaction amount distribution sheet and update export options --- .changeset/rich-trains-hang.md | 5 + .changeset/tricky-wasps-stare.md | 5 + package.json | 2 + pnpm-lock.yaml | 61 ++++ src-tauri/tauri.conf.json | 6 +- src/App.tsx | 268 ++++++------------ src/components/export-options.tsx | 127 +++++++++ src/components/password-prompt.tsx | 32 +++ src/components/ui/label.tsx | 22 ++ src/components/ui/tooltip.tsx | 59 ++++ src/services/exports/index.ts | 1 + .../transactionAmountDistributionSheet.ts | 267 +++++++++++++++++ src/services/xlsxService.ts | 6 + src/types/index.ts | 1 + 14 files changed, 672 insertions(+), 190 deletions(-) create mode 100644 .changeset/rich-trains-hang.md create mode 100644 .changeset/tricky-wasps-stare.md create mode 100644 src/components/export-options.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/services/exports/transactionAmountDistributionSheet.ts diff --git a/.changeset/rich-trains-hang.md b/.changeset/rich-trains-hang.md new file mode 100644 index 0000000..cddde2f --- /dev/null +++ b/.changeset/rich-trains-hang.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +fix: reset/skip options after upload diff --git a/.changeset/tricky-wasps-stare.md b/.changeset/tricky-wasps-stare.md new file mode 100644 index 0000000..3fb9eae --- /dev/null +++ b/.changeset/tricky-wasps-stare.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": minor +--- + +feat: Add Transaction Amount Distribution Sheet diff --git a/package.json b/package.json index 788cf40..c260404 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.3", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9520015..4a0f889 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,18 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tailwindcss/vite': specifier: ^4.1.3 version: 4.1.13(vite@7.1.7(@types/node@24.5.2)(jiti@2.6.0)(lightningcss@1.30.1)) @@ -651,6 +657,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -725,6 +744,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -2665,6 +2697,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -2748,6 +2789,26 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.13)(react@19.1.1)': dependencies: react: 19.1.1 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 62b5663..7b2cca3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,8 +13,8 @@ "windows": [ { "title": "mpesa2csv - Convert M-PESA Statements to CSV/Excel", - "width": 600, - "height": 630, + "width": 640, + "height": 600, "minWidth": 500, "minHeight": 400, "resizable": true @@ -64,7 +64,7 @@ }, "windowSize": { "width": 660, - "height": 630 + "height": 600 } } }, diff --git a/src/App.tsx b/src/App.tsx index 352d248..052a813 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,25 +3,18 @@ import { MPesaStatement, FileStatus, ExportFormat, - ExportOptions, + ExportOptions as ExportOptionsType, } from "./types"; import { PdfService } from "./services/pdfService"; import { ExportService } from "./services/exportService"; import FileUploader from "./components/file-uploader"; import PasswordPrompt from "./components/password-prompt"; +import ExportOptions from "./components/export-options"; import { UpdateChecker } from "./components/update-checker"; import { Download, RotateCcw } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import dayjs from "dayjs"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "./components/ui/select"; import { Button } from "./components/ui/button"; -import { Checkbox } from "./components/ui/checkbox"; function App() { const [files, setFiles] = useState([]); @@ -33,7 +26,7 @@ function App() { const [exportFormat, setExportFormat] = useState( ExportFormat.XLSX ); - const [exportOptions, setExportOptions] = useState({ + const [exportOptions, setExportOptions] = useState({ includeChargesSheet: false, includeSummarySheet: false, includeBreakdownSheet: false, @@ -47,6 +40,29 @@ function App() { return dayjs().format("YYYY-MM-DD_HH-mm-ss"); }; + const handleFormatChange = (format: ExportFormat) => { + setExportFormat(format); + const combinedStatement = statements[0]; + const fileName = ExportService.getFileName( + combinedStatement, + format, + formatDateForFilename() + ); + + ExportService.createDownloadLink(combinedStatement, format, exportOptions) + .then(setExportLink) + .catch(() => setExportLink("")); + setExportFileName(fileName); + }; + + const handleOptionsChange = (options: ExportOptionsType) => { + setExportOptions(options); + const combinedStatement = statements[0]; + ExportService.createDownloadLink(combinedStatement, exportFormat, options) + .then(setExportLink) + .catch(() => setExportLink("")); + }; + // Get app version on component mount useEffect(() => { const getVersion = async () => { @@ -231,6 +247,52 @@ function App() { } }; + const handleSkipFile = async () => { + if (files.length === 0) return; + + setError(undefined); + const nextIndex = currentFileIndex + 1; + + if (nextIndex < files.length) { + // Continue processing remaining files + const result = await processFiles(files, nextIndex, statements); + if (result?.error) { + setStatus(FileStatus.ERROR); + setError(result.error); + } + } else { + // No more files to process + if (statements.length > 0) { + // We have some processed statements, show success + const combinedStatement = combineStatements(statements); + const fileName = ExportService.getFileName( + combinedStatement, + exportFormat, + formatDateForFilename() + ); + + ExportService.createDownloadLink( + combinedStatement, + exportFormat, + exportOptions + ) + .then(setExportLink) + .catch(() => setExportLink("")); + setExportFileName(fileName); + setStatus(FileStatus.SUCCESS); + } else { + // No statements processed, reset to idle + setStatus(FileStatus.IDLE); + setFiles([]); + setCurrentFileIndex(0); + } + } + }; + + const handleResetProcess = () => { + handleReset(); + }; + const handleReset = () => { setFiles([]); setStatus(FileStatus.IDLE); @@ -317,6 +379,8 @@ function App() {
- - +
- {exportFormat === ExportFormat.XLSX && ( -
- -
-
- -

- Creates a separate sheet with all transaction charges - and fees -

-
- -
- -

- Creates a comprehensive financial analysis with cash - flow, spending patterns, and insights -

-
- -
- -

- Creates a pivot-like table with monthly and weekly - aggregations showing inflows, outflows, net change, - and average transaction size -

-
- -
- -

- Creates a day-by-day balance tracker showing your - highest and lowest balances with spending pattern - insights -

-
-
-
- )} -
diff --git a/src/components/export-options.tsx b/src/components/export-options.tsx new file mode 100644 index 0000000..237e77d --- /dev/null +++ b/src/components/export-options.tsx @@ -0,0 +1,127 @@ +import { ExportFormat, ExportOptions as ExportOptionsType } from "../types"; +import { ExportService } from "../services/exportService"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { Checkbox } from "./ui/checkbox"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { Label } from "./ui/label"; +import { Info } from "lucide-react"; + +interface ExportOptionsProps { + exportFormat: ExportFormat; + exportOptions: ExportOptionsType; + onFormatChange: (format: ExportFormat) => void; + onOptionsChange: (options: ExportOptionsType) => void; +} + +// Sheet option configuration with short names and descriptions +const SHEET_OPTIONS = [ + { + key: "includeChargesSheet" as keyof ExportOptionsType, + name: "Charges & Fees", + description: "Separate sheet with all transaction charges and fees", + }, + { + key: "includeSummarySheet" as keyof ExportOptionsType, + name: "Financial Summary", + description: + "Comprehensive financial analysis with cash flow, spending patterns, and insights", + }, + { + key: "includeBreakdownSheet" as keyof ExportOptionsType, + name: "Monthly & Weekly Breakdown", + description: + "Pivot-like table with monthly and weekly aggregations showing inflows, outflows, net change, and average transaction size", + }, + { + key: "includeDailyBalanceSheet" as keyof ExportOptionsType, + name: "Daily Balance Tracker", + description: + "Day-by-day balance tracker showing highest and lowest balances with spending pattern insights", + }, + { + key: "includeAmountDistributionSheet" as keyof ExportOptionsType, + name: "Transaction Amount Distribution", + description: + "Groups transactions by amount ranges (e.g., <100 KES, 100-500 KES, >500 KES), showing counts, totals, and percentages for inflows and outflows separately. Excludes charges and fees.", + }, +]; + +export default function ExportOptions({ + exportFormat, + exportOptions, + onFormatChange, + onOptionsChange, +}: ExportOptionsProps) { + const handleFormatChange = (value: ExportFormat) => { + onFormatChange(value); + }; + + const handleOptionChange = ( + optionKey: keyof ExportOptionsType, + value: boolean + ) => { + const newOptions = { + ...exportOptions, + [optionKey]: value, + }; + onOptionsChange(newOptions); + }; + + return ( +
+
+ + +
+ + {exportFormat === ExportFormat.XLSX && ( +
+ +
+ {SHEET_OPTIONS.map((option) => ( +
+ + handleOptionChange(option.key, Boolean(value)) + } + className="rounded border-gray-300 text-primary focus:ring-primary" + /> + + + + + + +

{option.description}

+
+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/password-prompt.tsx b/src/components/password-prompt.tsx index 2150519..1df272c 100644 --- a/src/components/password-prompt.tsx +++ b/src/components/password-prompt.tsx @@ -7,6 +7,8 @@ import { Button } from "./ui/button"; interface PasswordPromptProps { onPasswordSubmit: (password: string) => void; + onSkip?: () => void; + onReset?: () => void; status: FileStatus; error?: string; currentFileName?: string; @@ -16,6 +18,8 @@ interface PasswordPromptProps { const PasswordPrompt: React.FC = ({ onPasswordSubmit, + onSkip, + onReset, status, error, currentFileName, @@ -87,6 +91,34 @@ const PasswordPrompt: React.FC = ({ "Unlock PDF" )} + + {/* Skip and Reset buttons */} +
+ {onSkip && ( + + )} + {onReset && ( + + )} +
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..ef7133a --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..715bf76 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/services/exports/index.ts b/src/services/exports/index.ts index 29880c5..8413dac 100644 --- a/src/services/exports/index.ts +++ b/src/services/exports/index.ts @@ -2,3 +2,4 @@ export { addChargesSheet } from "./chargesSheet"; export { addFinancialSummarySheet } from "./financialSummarySheet"; export { addMonthlyWeeklyBreakdownSheet } from "./monthlyWeeklyBreakdownSheet"; export { addDailyBalanceTrackerSheet } from "./dailyBalanceTrackerSheet"; +export { addTransactionAmountDistributionSheet } from "./transactionAmountDistributionSheet"; diff --git a/src/services/exports/transactionAmountDistributionSheet.ts b/src/services/exports/transactionAmountDistributionSheet.ts new file mode 100644 index 0000000..ecd6625 --- /dev/null +++ b/src/services/exports/transactionAmountDistributionSheet.ts @@ -0,0 +1,267 @@ +import { MPesaStatement } from "../../types"; +import * as ExcelJS from "exceljs"; + +interface SizeDistributionBucket { + range: string; + minAmount: number; + maxAmount: number | null; + inflowCount: number; + inflowTotal: number; + inflowPercentage: number; + outflowCount: number; + outflowTotal: number; + outflowPercentage: number; +} + +interface SizeDistributionSummary { + totalInflows: number; + totalOutflows: number; + totalInflowTransactions: number; + totalOutflowTransactions: number; + buckets: SizeDistributionBucket[]; +} + +export function addTransactionAmountDistributionSheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + // Filter out charge transactions for this analysis + const nonChargeTransactions = statement.transactions.filter( + (transaction) => !transaction.details.toLowerCase().includes("charge") + ); + + if (nonChargeTransactions.length === 0) { + return; // No non-charge transactions found, don't create the sheet + } + + // Calculate size distribution + const sizeDistribution = calculateSizeDistribution(nonChargeTransactions); + + // Create the worksheet + const worksheet = workbook.addWorksheet("Transaction Amount Distribution"); + + // Add title + worksheet.mergeCells("A1:I1"); + const titleCell = worksheet.getCell("A1"); + titleCell.value = "Transaction Amount Distribution Analysis"; + titleCell.font = { bold: true, size: 16 }; + titleCell.alignment = { horizontal: "center" }; + titleCell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF4472C4" }, + }; + + // Add subtitle + worksheet.mergeCells("A2:I2"); + const subtitleCell = worksheet.getCell("A2"); + subtitleCell.value = "Excludes transaction charges and fees"; + subtitleCell.font = { italic: true, size: 12 }; + subtitleCell.alignment = { horizontal: "center" }; + + // Set up headers starting from row 4 + const headerRow = 4; + const headers = [ + "Amount Range", + "Inflow Count", + "Inflow Total (KES)", + "Inflow %", + "Outflow Count", + "Outflow Total (KES)", + "Outflow %", + "Total Count", + "Total Amount (KES)", + ]; + + headers.forEach((header, index) => { + const cell = worksheet.getCell(headerRow, index + 1); + cell.value = header; + cell.font = { bold: true }; + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE0E0E0" }, + }; + cell.alignment = { horizontal: "center" }; + }); + + // Add data rows + let currentRow = headerRow + 1; + sizeDistribution.buckets.forEach((bucket) => { + const totalCount = bucket.inflowCount + bucket.outflowCount; + const totalAmount = bucket.inflowTotal + bucket.outflowTotal; + + worksheet.getCell(currentRow, 1).value = bucket.range; + worksheet.getCell(currentRow, 2).value = bucket.inflowCount; + worksheet.getCell(currentRow, 3).value = bucket.inflowTotal; + worksheet.getCell(currentRow, 4).value = `${bucket.inflowPercentage.toFixed( + 1 + )}%`; + worksheet.getCell(currentRow, 5).value = bucket.outflowCount; + worksheet.getCell(currentRow, 6).value = bucket.outflowTotal; + worksheet.getCell( + currentRow, + 7 + ).value = `${bucket.outflowPercentage.toFixed(1)}%`; + worksheet.getCell(currentRow, 8).value = totalCount; + worksheet.getCell(currentRow, 9).value = totalAmount; + + // Format currency cells + worksheet.getCell(currentRow, 3).numFmt = "#,##0.00"; + worksheet.getCell(currentRow, 6).numFmt = "#,##0.00"; + worksheet.getCell(currentRow, 9).numFmt = "#,##0.00"; + + currentRow++; + }); + + // Add summary section + currentRow += 2; + worksheet.mergeCells(`A${currentRow}:I${currentRow}`); + const summaryTitleCell = worksheet.getCell(`A${currentRow}`); + summaryTitleCell.value = "Summary"; + summaryTitleCell.font = { bold: true, size: 14 }; + summaryTitleCell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFD700" }, + }; + + currentRow++; + worksheet.getCell(currentRow, 1).value = "Total Inflow Transactions:"; + worksheet.getCell(currentRow, 2).value = + sizeDistribution.totalInflowTransactions; + worksheet.getCell(currentRow, 1).font = { bold: true }; + + currentRow++; + worksheet.getCell(currentRow, 1).value = "Total Inflow Amount:"; + worksheet.getCell(currentRow, 2).value = sizeDistribution.totalInflows; + worksheet.getCell(currentRow, 2).numFmt = "#,##0.00"; + worksheet.getCell(currentRow, 1).font = { bold: true }; + + currentRow++; + worksheet.getCell(currentRow, 1).value = "Total Outflow Transactions:"; + worksheet.getCell(currentRow, 2).value = + sizeDistribution.totalOutflowTransactions; + worksheet.getCell(currentRow, 1).font = { bold: true }; + + currentRow++; + worksheet.getCell(currentRow, 1).value = "Total Outflow Amount:"; + worksheet.getCell(currentRow, 2).value = sizeDistribution.totalOutflows; + worksheet.getCell(currentRow, 2).numFmt = "#,##0.00"; + worksheet.getCell(currentRow, 1).font = { bold: true }; + + // Set column widths + worksheet.columns = [ + { width: 20 }, // Amount Range + { width: 12 }, // Inflow Count + { width: 18 }, // Inflow Total + { width: 10 }, // Inflow % + { width: 12 }, // Outflow Count + { width: 18 }, // Outflow Total + { width: 10 }, // Outflow % + { width: 12 }, // Total Count + { width: 18 }, // Total Amount + ]; + + // Add borders to all data cells + const dataRange = worksheet.getRows(headerRow, currentRow); + if (dataRange) { + dataRange.forEach((row) => { + if (row) { + row.eachCell((cell) => { + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + } + }); + } +} + +function calculateSizeDistribution( + transactions: any[] +): SizeDistributionSummary { + // Define amount ranges (in KES) + const ranges = [ + { range: "< 100 KES", minAmount: 0, maxAmount: 99.99 }, + { range: "100 - 500 KES", minAmount: 100, maxAmount: 500 }, + { range: "501 - 1,000 KES", minAmount: 501, maxAmount: 1000 }, + { range: "1,001 - 5,000 KES", minAmount: 1001, maxAmount: 5000 }, + { range: "5,001 - 10,000 KES", minAmount: 5001, maxAmount: 10000 }, + { range: "10,001 - 50,000 KES", minAmount: 10001, maxAmount: 50000 }, + { range: "> 50,000 KES", minAmount: 50001, maxAmount: null }, + ]; + + // Initialize buckets + const buckets: SizeDistributionBucket[] = ranges.map((range) => ({ + range: range.range, + minAmount: range.minAmount, + maxAmount: range.maxAmount, + inflowCount: 0, + inflowTotal: 0, + inflowPercentage: 0, + outflowCount: 0, + outflowTotal: 0, + outflowPercentage: 0, + })); + + let totalInflows = 0; + let totalOutflows = 0; + let totalInflowTransactions = 0; + let totalOutflowTransactions = 0; + + // Process each transaction + transactions.forEach((transaction) => { + const amount = Math.abs(transaction.paidIn || transaction.withdrawn || 0); + const isInflow = transaction.paidIn !== null && transaction.paidIn > 0; + const isOutflow = + transaction.withdrawn !== null && transaction.withdrawn > 0; + + if (!isInflow && !isOutflow) return; + + // Find the appropriate bucket + const bucket = buckets.find((b) => { + if (b.maxAmount === null) { + return amount >= b.minAmount; + } + return amount >= b.minAmount && amount <= b.maxAmount; + }); + + if (bucket) { + if (isInflow) { + bucket.inflowCount++; + bucket.inflowTotal += amount; + totalInflows += amount; + totalInflowTransactions++; + } else if (isOutflow) { + bucket.outflowCount++; + bucket.outflowTotal += amount; + totalOutflows += amount; + totalOutflowTransactions++; + } + } + }); + + // Calculate percentages + buckets.forEach((bucket) => { + bucket.inflowPercentage = + totalInflowTransactions > 0 + ? (bucket.inflowCount / totalInflowTransactions) * 100 + : 0; + bucket.outflowPercentage = + totalOutflowTransactions > 0 + ? (bucket.outflowCount / totalOutflowTransactions) * 100 + : 0; + }); + + return { + totalInflows, + totalOutflows, + totalInflowTransactions, + totalOutflowTransactions, + buckets, + }; +} diff --git a/src/services/xlsxService.ts b/src/services/xlsxService.ts index f635f56..bc1e773 100644 --- a/src/services/xlsxService.ts +++ b/src/services/xlsxService.ts @@ -5,6 +5,7 @@ import { addFinancialSummarySheet, addMonthlyWeeklyBreakdownSheet, addDailyBalanceTrackerSheet, + addTransactionAmountDistributionSheet, } from "./exports"; export class XlsxService { @@ -94,6 +95,11 @@ export class XlsxService { addDailyBalanceTrackerSheet(workbook, statement); } + // Add Transaction Amount Distribution sheet if requested + if (options?.includeAmountDistributionSheet) { + addTransactionAmountDistributionSheet(workbook, statement); + } + const buffer = await workbook.xlsx.writeBuffer(); return buffer as ArrayBuffer; } diff --git a/src/types/index.ts b/src/types/index.ts index a930b83..6528c41 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -33,4 +33,5 @@ export interface ExportOptions { includeSummarySheet?: boolean; includeBreakdownSheet?: boolean; includeDailyBalanceSheet?: boolean; + includeAmountDistributionSheet?: boolean; }