diff --git a/.changeset/huge-waves-sneeze.md b/.changeset/huge-waves-sneeze.md new file mode 100644 index 0000000..3147d6b --- /dev/null +++ b/.changeset/huge-waves-sneeze.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": minor +--- + +feat: add money in/out sheets diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 959f8df..8f4c263 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: + js-yaml@<3.14.2: '>=3.14.2' + tar@<=7.5.2: '>=7.5.3' + tar@<=7.5.3: '>=7.5.4' vite@>=7.1.0 <=7.1.10: '>=7.1.11' importers: @@ -1178,8 +1181,8 @@ packages: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} engines: {node: '>= 10'} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} @@ -1361,11 +1364,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - exceljs@4.4.0: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} @@ -1506,8 +1504,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsesc@3.1.0: @@ -1929,9 +1927,6 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -1960,8 +1955,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@7.4.4: - resolution: {integrity: sha512-O1z7ajPkjTgEgmTGz0v9X4eqeEXTDREPTO77pVC1Nbs86feBU1Zhdg+edzavPmYW1olxkwsqA2v4uOw6E8LeDg==} + tar@7.5.6: + resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} engines: {node: '>=18'} tauri-plugin-pldownloader-api@1.0.1: @@ -2369,7 +2364,7 @@ snapshots: '@changesets/parse@0.4.1': dependencies: '@changesets/types': 6.1.0 - js-yaml: 3.14.1 + js-yaml: 4.1.1 '@changesets/pre@2.0.2': dependencies: @@ -2985,7 +2980,7 @@ snapshots: '@tailwindcss/oxide@4.1.13': dependencies: detect-libc: 2.1.0 - tar: 7.4.4 + tar: 7.5.6 optionalDependencies: '@tailwindcss/oxide-android-arm64': 4.1.13 '@tailwindcss/oxide-darwin-arm64': 4.1.13 @@ -3175,9 +3170,7 @@ snapshots: tar-stream: 2.2.0 zip-stream: 4.1.1 - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: @@ -3361,8 +3354,6 @@ snapshots: escalade@3.2.0: {} - esprima@4.0.1: {} - exceljs@4.4.0: dependencies: archiver: 5.3.2 @@ -3502,10 +3493,9 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.1: + js-yaml@4.1.1: dependencies: - argparse: 1.0.10 - esprima: 4.0.1 + argparse: 2.0.1 jsesc@3.1.0: {} @@ -3761,7 +3751,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.1 + js-yaml: 4.1.1 pify: 4.0.1 strip-bom: 3.0.0 @@ -3860,8 +3850,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - sprintf-js@1.0.3: {} - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -3890,7 +3878,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@7.4.4: + tar@7.5.6: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b78c0e..f618fbb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,5 @@ overrides: + js-yaml@<3.14.2: '>=3.14.2' + tar@<=7.5.2: '>=7.5.3' + tar@<=7.5.3: '>=7.5.4' vite@>=7.1.0 <=7.1.10: '>=7.1.11' diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 25c1b72..ee9e04c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "mpesa2csv" -version = "0.9.0" +version = "0.10.2" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 54ad820..24ded26 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": 640, - "height": 730, + "width": 750, + "height": 850, "minWidth": 500, "minHeight": 400, "resizable": true @@ -68,8 +68,8 @@ "y": 170 }, "windowSize": { - "width": 660, - "height": 730 + "width": 750, + "height": 850 } } }, diff --git a/src/App.tsx b/src/App.tsx index 5c64efa..9fa4669 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -493,38 +493,39 @@ function App() { /> ) : status === FileStatus.PROCESSING ? ( -
- Processing Your Statements -
-- File {currentFileIndex + 1} of {files.length} -
- - {/* Progress bar */} -+ File {currentFileIndex + 1} of {files.length} +
+ + {/* Progress bar */} ++
{files[currentFileIndex].name}
)} diff --git a/src/components/export-options.tsx b/src/components/export-options.tsx index 330622b..78fa1f1 100644 --- a/src/components/export-options.tsx +++ b/src/components/export-options.tsx @@ -78,6 +78,17 @@ const SHEET_OPTIONS = [ description: "Top 20 people/entities you send money to and receive money from, with totals and transaction counts. Excludes charges and fees.", }, + { + key: "includeMoneyInSheet" as keyof ExportOptionsType, + name: "Money In", + description: "Separate sheet with all transactions where money was received", + }, + { + key: "includeMoneyOutSheet" as keyof ExportOptionsType, + name: "Money Out", + description: "Separate sheet with all transactions where money was spent", + }, + ]; export default function ExportOptions({ diff --git a/src/index.css b/src/index.css index a52726f..d3fac18 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,20 @@ @custom-variant dark (&:is(.dark *)); +/* Custom animations */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +.animate-shimmer { + animation: shimmer 2s infinite; +} + /* Dark mode support */ @media (prefers-color-scheme: dark) { :root { diff --git a/src/services/exports/index.ts b/src/services/exports/index.ts index 4605d44..e848e77 100644 --- a/src/services/exports/index.ts +++ b/src/services/exports/index.ts @@ -4,3 +4,5 @@ export { addMonthlyWeeklyBreakdownSheet } from "./monthlyWeeklyBreakdownSheet"; export { addDailyBalanceTrackerSheet } from "./dailyBalanceTrackerSheet"; export { addTransactionAmountDistributionSheet } from "./transactionAmountDistributionSheet"; export { addTopContactsSheet } from "./topContactsSheet"; +export { addMoneyInSheet } from "./moneyInSheet"; +export { addMoneyOutSheet } from "./moneyOutSheet"; diff --git a/src/services/exports/moneyInSheet.ts b/src/services/exports/moneyInSheet.ts new file mode 100644 index 0000000..0b5f706 --- /dev/null +++ b/src/services/exports/moneyInSheet.ts @@ -0,0 +1,140 @@ +import { MPesaStatement, } from "../../types"; +import * as ExcelJS from "exceljs"; + +/** + * Adds a "Money In" sheet to the workbook + * This sheet contains all transactions where money was received (paidIn > 0) + */ +export function addMoneyInSheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement +): void { + if (statement.transactions.length === 0) return; + + // Filter transactions where money came in + const moneyInTransactions = statement.transactions.filter( + (t) => t.paidIn !== null && t.paidIn > 0 + ); + + if (moneyInTransactions.length === 0) return; + + // Sort by completion time (most recent first) + const sortedTransactions = [...moneyInTransactions].sort( + (a, b) => + new Date(b.completionTime).getTime() - + new Date(a.completionTime).getTime() + ); + + // Create worksheet + const worksheet = workbook.addWorksheet("Money In"); + + // Check if this is a paybill statement + const isPaybillStatement = sortedTransactions.some( + (t) => t.transactionType !== undefined || t.otherParty !== undefined + ); + + // Define columns + const columns: any[] = [ + { header: "Receipt No", key: "receiptNo", width: 12 }, + { header: "Date & Time", key: "completionTime", width: 20 }, + { header: "Details", key: "details", width: 50 }, + { header: "Amount Received", key: "paidIn", width: 15 }, + { header: "Balance After", key: "balance", width: 15 }, + { header: "Status", key: "transactionStatus", width: 18 }, + ]; + + // Add paybill-specific columns if needed + if (isPaybillStatement) { + columns.push( + { header: "Transaction Type", key: "transactionType", width: 18 }, + { header: "From", key: "otherParty", width: 30 } + ); + } + + worksheet.columns = columns; + + // Style the header row + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF2E7D32" }, // Green color for money in + }; + headerRow.alignment = { horizontal: "center", vertical: "middle" }; + headerRow.height = 20; + + // Add data rows + sortedTransactions.forEach((transaction) => { + const rowData: any = { + receiptNo: transaction.receiptNo, + completionTime: transaction.completionTime, + details: transaction.details, + paidIn: transaction.paidIn, + balance: transaction.balance, + transactionStatus: transaction.transactionStatus, + }; + + // Add paybill-specific fields if present + if (isPaybillStatement) { + rowData.transactionType = transaction.transactionType || ""; + rowData.otherParty = transaction.otherParty || ""; + } + + const row = worksheet.addRow(rowData); + + // Format the amount column with currency style and light green background + const amountCell = row.getCell("paidIn"); + amountCell.numFmt = '#,##0.00'; + amountCell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE8F5E9" }, // Light green + }; + amountCell.font = { bold: true, color: { argb: "FF2E7D32" } }; + + // Format the balance column + const balanceCell = row.getCell("balance"); + balanceCell.numFmt = '#,##0.00'; + }); + + // Add summary row at the top (after headers) + const totalReceived = moneyInTransactions.reduce( + (sum, t) => sum + (t.paidIn || 0), + 0 + ); + + worksheet.insertRow(2, {}); + const summaryRow = worksheet.getRow(2); + summaryRow.getCell(1).value = "SUMMARY"; + summaryRow.getCell(1).font = { bold: true, size: 12 }; + summaryRow.getCell(3).value = `Total Received:`; + summaryRow.getCell(3).font = { bold: true }; + summaryRow.getCell(3).alignment = { horizontal: "right" }; + summaryRow.getCell(4).value = totalReceived; + summaryRow.getCell(4).numFmt = '#,##0.00'; + summaryRow.getCell(4).font = { bold: true, color: { argb: "FF2E7D32" }, size: 12 }; + summaryRow.getCell(4).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFC8E6C9" }, + }; + summaryRow.getCell(5).value = `Transaction Count: ${moneyInTransactions.length}`; + summaryRow.getCell(5).font = { bold: true }; + summaryRow.height = 25; + + // Add borders to all cells + worksheet.eachRow((row) => { + row.eachCell((cell) => { + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + }); + + // Freeze the header rows + worksheet.views = [{ state: "frozen", xSplit: 0, ySplit: 2 }]; +} diff --git a/src/services/exports/moneyOutSheet.ts b/src/services/exports/moneyOutSheet.ts new file mode 100644 index 0000000..9a33269 --- /dev/null +++ b/src/services/exports/moneyOutSheet.ts @@ -0,0 +1,145 @@ +import { MPesaStatement } from "../../types"; +import * as ExcelJS from "exceljs"; + +/** + * Adds a "Money Out" sheet to the workbook + * This sheet contains all transactions where money was spent/withdrawn (withdrawn > 0) + */ +export function addMoneyOutSheet( + workbook: ExcelJS.Workbook, + statement: MPesaStatement, +): void { + if (statement.transactions.length === 0) return; + + // Filter transactions where money went out + const moneyOutTransactions = statement.transactions.filter( + (t) => t.withdrawn !== null && t.withdrawn > 0, + ); + + if (moneyOutTransactions.length === 0) return; + + // Sort by completion time (most recent first) + const sortedTransactions = [...moneyOutTransactions].sort( + (a, b) => + new Date(b.completionTime).getTime() - + new Date(a.completionTime).getTime(), + ); + + // Create worksheet + const worksheet = workbook.addWorksheet("Money Out"); + + // Check if this is a paybill statement + const isPaybillStatement = sortedTransactions.some( + (t) => t.transactionType !== undefined || t.otherParty !== undefined, + ); + + // Define columns + const columns: any[] = [ + { header: "Receipt No", key: "receiptNo", width: 12 }, + { header: "Date & Time", key: "completionTime", width: 20 }, + { header: "Details", key: "details", width: 50 }, + { header: "Amount Spent", key: "withdrawn", width: 15 }, + { header: "Balance After", key: "balance", width: 15 }, + { header: "Status", key: "transactionStatus", width: 18 }, + ]; + + // Add paybill-specific columns if needed + if (isPaybillStatement) { + columns.push( + { header: "Transaction Type", key: "transactionType", width: 18 }, + { header: "To", key: "otherParty", width: 30 }, + ); + } + + worksheet.columns = columns; + + // Style the header row + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFC62828" }, // Red color for money out + }; + headerRow.alignment = { horizontal: "center", vertical: "middle" }; + headerRow.height = 20; + + // Add data rows + sortedTransactions.forEach((transaction) => { + const rowData: any = { + receiptNo: transaction.receiptNo, + completionTime: transaction.completionTime, + details: transaction.details, + withdrawn: transaction.withdrawn, + balance: transaction.balance, + transactionStatus: transaction.transactionStatus, + }; + + // Add paybill-specific fields if present + if (isPaybillStatement) { + rowData.transactionType = transaction.transactionType || ""; + rowData.otherParty = transaction.otherParty || ""; + } + + const row = worksheet.addRow(rowData); + + // Format the amount column with currency style and light red background + const amountCell = row.getCell("withdrawn"); + amountCell.numFmt = "#,##0.00"; + amountCell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFEBEE" }, // Light red + }; + amountCell.font = { bold: true, color: { argb: "FFC62828" } }; + + // Format the balance column + const balanceCell = row.getCell("balance"); + balanceCell.numFmt = "#,##0.00"; + }); + + // Add summary row at the top (after headers) + const totalSpent = moneyOutTransactions.reduce( + (sum, t) => sum + (t.withdrawn || 0), + 0, + ); + + worksheet.insertRow(2, {}); + const summaryRow = worksheet.getRow(2); + summaryRow.getCell(1).value = "SUMMARY"; + summaryRow.getCell(1).font = { bold: true, size: 12 }; + summaryRow.getCell(3).value = `Total Spent:`; + summaryRow.getCell(3).font = { bold: true }; + summaryRow.getCell(3).alignment = { horizontal: "right" }; + summaryRow.getCell(4).value = totalSpent; + summaryRow.getCell(4).numFmt = "#,##0.00"; + summaryRow.getCell(4).font = { + bold: true, + color: { argb: "FFC62828" }, + size: 12, + }; + summaryRow.getCell(4).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCDD2" }, + }; + summaryRow.getCell(5).value = + `Transaction Count: ${moneyOutTransactions.length}`; + summaryRow.getCell(5).font = { bold: true }; + summaryRow.height = 25; + + // Add borders to all cells + worksheet.eachRow((row) => { + row.eachCell((cell) => { + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + }); + + // Freeze the header rows + worksheet.views = [{ state: "frozen", xSplit: 0, ySplit: 2 }]; +} diff --git a/src/services/xlsxService.ts b/src/services/xlsxService.ts index 72b0994..1489f9f 100644 --- a/src/services/xlsxService.ts +++ b/src/services/xlsxService.ts @@ -7,6 +7,8 @@ import { addDailyBalanceTrackerSheet, addTransactionAmountDistributionSheet, addTopContactsSheet, + addMoneyInSheet, + addMoneyOutSheet, } from "./exports"; import { applyTransactionFilters } from "./transactionFilters"; import { formatDate } from "../utils/dateFormatter"; @@ -138,6 +140,16 @@ export class XlsxService { addTopContactsSheet(workbook, statement); } + // Add Money In sheet if requested + if (options?.includeMoneyInSheet) { + addMoneyInSheet(workbook, statement); + } + + // Add Money Out sheet if requested + if (options?.includeMoneyOutSheet) { + addMoneyOutSheet(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 3762f55..6c02523 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -52,6 +52,8 @@ export interface ExportOptions { includeDailyBalanceSheet?: boolean; includeAmountDistributionSheet?: boolean; includeTopContactsSheet?: boolean; + includeMoneyInSheet?: boolean; + includeMoneyOutSheet?: boolean; // Filter options filterOutCharges?: boolean; sortOrder?: SortOrder;