diff --git a/CHANGELOG.md b/CHANGELOG.md index 64fe61a..9b937d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Admin Manage Users page: notification preference (Email/WhatsApp/Both) can now be viewed and edited directly from the user list, instead of only being self-editable from the user's own profile. +- Admin Registrations dashboard: added aggregate stat tiles (counts per status, total revenue, total outstanding) and per-registration paid/outstanding amounts, plus a "Payments" detail block per registration showing each payment's amount, method, date, and who recorded it. +- Reports: registration status breakdown now has a "Count by" toggle to switch between counting one per registration and counting by ticket quantity (so a registration with 3 tickets counts as 3). +- Reports: donations breakdown now shows Used/Unused amounts per event, reflecting the new donation-leg tracking below. +- Payment accountability: payments now record who recorded them (`recordedBy`), separate from who they're for. Self-service/webhook payments record the payer as the recorder. Surfaced across the payments report, supervisor payments page, and the registrations dashboard. +- Cashup: new "Payment accountability by staff member" section breaking down recorded payments per staff member for an event, by method (Cash/Card/EFT/Other) — cash also shows a live actual-vs-expected variance once staff enter physical denomination counts per person. +- Cashup: staff can now enter each other's actual cash denomination counts at any time (not required to close the event); the event's cash "actual" figure is the live sum of these per-person counts instead of one manual event-wide entry. +- Cashup: new "Report" tab presenting a clean read-only summary of the cashup, which opens automatically once an event is closed. +- Reports: complete redesign — a sidebar of universal filters (events, date range with presets, include past/inactive/closed events) that apply across whichever report is open, a searchable/categorized report card grid, and a popup viewer with report-specific filters and Print/Email/Excel/WhatsApp export actions. Includes a new in-app Reporting Guide. +- Reports: WhatsApp added as an export channel alongside Print, Email, and Excel — sends the report PDF to the current user's own WhatsApp number. +- Reports: visual pass on the report popup — colored stat tiles for key totals (Order Total, Paid, Paid via donations, Outstanding, Unassigned donations, etc.) and small bar charts (revenue by method, registration status, ticket usage, donations used/unused, registration types, income by method, net profit by event) added to most reports, plus search boxes on the Master Orders' Orders/Donations tables. The Reports page also hides the dashboard sidebar since it's a full-width workspace of its own. +- Reports: Master Orders Breakdown moved from "Orders" into the "Registration" category. +- Reports: the Reporting Guide's non-Overview tabs now use the same icon-card layout as Overview instead of plain bullet lists; "Need more help?" now points to the site administrator's email (admin@crosscode.co.za). +- Email/WhatsApp Attendees: new `{{payment.link}}` placeholder that generates a live, per-recipient Yoco payment link for their outstanding balance at send time. +- Reports: PDF exports (download, Print, Email, WhatsApp) now share a branded design matching the web report — a colored title band, the same colored stat tiles and bar chart shown on screen (where applicable), an explanatory note box, and a highlighted totals row in tables — instead of a plain black-and-white dump. Since Email and WhatsApp already reuse the same PDF renderer, both now send this styled PDF automatically. +- Reports: the "Excel" export now downloads a styled `.xlsx` workbook (colored header, stat rows, a chart rendered with a native data-bar, a bold colored table header, a highlighted totals row, and frozen header/auto-sized columns) instead of a plain CSV. + +### Changed + +- Donations are no longer mutated when assigned to a registration — assignment now creates an immutable "leg" record referencing the original donation, so a partially-used donation keeps its original amount and remains assignable for its remaining balance instead of losing its history. +- Email Attendees / WhatsApp Attendees now respond immediately after queuing recipients and send in the background, instead of blocking the page until every message has been sent. + +### Fixed + +- Reports/Cashup: fixed several places where money was double-counted once a donation was partially or fully assigned to a registration (e.g. a R250 donation with R50 assigned was showing as R300 received). Payments between dates, Revenue summary, Cashup reconciliation, Finance report, and Profit report now count each real inflow exactly once. +- Reports: Revenue Detailed and Master Orders Breakdown no longer attribute a donation-funded portion of an order to the registrant as if they'd paid it themselves — "Paid" now reflects only what the person actually paid directly, with the donation-covered amount broken out separately and attributed to the donor. +- Reports: Finance report was double-counting a registration's ticket value once under "what was sold" and again under a separate "Donations" line when the order was funded (even partially) by a donation. +- Reports: Master Orders Breakdown's "Donations made" table (donor, amount, used/unused) is now included in the PDF, Excel, Email, and WhatsApp exports — previously only the Orders table was exported and the donations breakdown was visible on screen only. Also fixed a PDF rendering bug where a table title following another table (e.g. "Donations made" below the Orders table) could render at the page's right edge instead of the left margin. +- Reports: "Email" export failed with `connect ECONNREFUSED 127.0.0.1:587` — it built its own mail transporter directly from `EMAIL_HOST`/etc. env vars instead of using the shared, DB-configurable SMTP settings (Admin → Site Settings) that the rest of the app already sends through, so it never picked up a working mail server. Now reuses the same shared mailer as tickets and account emails, with matching branded HTML styling. +- Reports: "WhatsApp" export surfaced an unhelpful `Request failed with status code 500` on failure. WhatsApp send errors now report the actual reason from the WhatsApp API, and a disconnected WhatsApp session is now detected and auto-recovered the same way it already is for other WhatsApp actions (previously only ticket/text sends had this handling — PDF sends did not). + ## [1.3.2] - 2026-08-03 ### Added diff --git a/backend/package-lock.json b/backend/package-lock.json index 4a17698..beed5c1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "event-management-backend", - "version": "1.2", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "event-management-backend", - "version": "1.2", + "version": "1.3.2", "hasInstallScript": true, "dependencies": { "@prisma/client": "^5.4.2", @@ -14,6 +14,7 @@ "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", + "exceljs": "^4.4.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", @@ -29,6 +30,35 @@ "prisma": "^5.4.2" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, "node_modules/@prisma/client": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", @@ -106,6 +136,12 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -163,12 +199,87 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -190,7 +301,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -219,6 +329,28 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -232,6 +364,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -275,7 +424,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -304,6 +452,39 @@ "base64-js": "^1.1.2" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -316,6 +497,23 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -374,6 +572,18 @@ "node": ">=6" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -449,11 +659,25 @@ "node": ">= 0.8" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -507,6 +731,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -520,12 +750,43 @@ "node": ">= 0.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/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -610,6 +871,45 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -640,6 +940,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -700,6 +1009,36 @@ "node": ">= 0.6" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -746,6 +1085,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -867,6 +1219,18 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -882,6 +1246,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -937,6 +1317,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -962,6 +1363,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -1039,6 +1446,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1046,6 +1473,23 @@ "dev": true, "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1116,6 +1560,12 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jpeg-exif": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", @@ -1150,6 +1600,54 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -1171,6 +1669,57 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/linebreak": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", @@ -1190,6 +1739,12 @@ "node": ">= 0.4" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1202,6 +1757,36 @@ "node": ">=8" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1214,12 +1799,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -1238,12 +1842,30 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1317,7 +1939,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -1467,7 +2088,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1506,6 +2126,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -1566,6 +2195,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -1632,6 +2270,12 @@ "fsevents": "2.3.3" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1740,6 +2384,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1774,6 +2448,19 @@ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", "license": "MIT" }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1800,6 +2487,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -1872,6 +2571,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2028,12 +2733,37 @@ "node": ">=4" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2072,6 +2802,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2133,6 +2872,54 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2206,6 +2993,18 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2255,6 +3054,41 @@ "engines": { "node": ">=6" } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } } } } diff --git a/backend/package.json b/backend/package.json index bd50500..25a8636 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", + "exceljs": "^4.4.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", diff --git a/backend/prisma/migrations/20260728060433_add_yoco_fee_fields_to_payment/migration.sql b/backend/prisma/migrations/20260728060433_add_yoco_fee_fields_to_payment/migration.sql new file mode 100644 index 0000000..9d95348 --- /dev/null +++ b/backend/prisma/migrations/20260728060433_add_yoco_fee_fields_to_payment/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION, +ADD COLUMN "feeChannel" TEXT, +ADD COLUMN "feePayer" TEXT, +ADD COLUMN "feeRate" DOUBLE PRECISION; diff --git a/backend/prisma/migrations/20260728065200_add_event_fee_payer_override/migration.sql b/backend/prisma/migrations/20260728065200_add_event_fee_payer_override/migration.sql new file mode 100644 index 0000000..4d29411 --- /dev/null +++ b/backend/prisma/migrations/20260728065200_add_event_fee_payer_override/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT; diff --git a/backend/prisma/migrations/20260728070306_remove_fee_payer_fields/migration.sql b/backend/prisma/migrations/20260728070306_remove_fee_payer_fields/migration.sql new file mode 100644 index 0000000..6bb74db --- /dev/null +++ b/backend/prisma/migrations/20260728070306_remove_fee_payer_fields/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost. + - You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Event" DROP COLUMN "feePayerOnline"; + +-- AlterTable +ALTER TABLE "Payment" DROP COLUMN "feePayer"; diff --git a/backend/prisma/migrations/20260728085349_add_fee_amount_to_cashup_line/migration.sql b/backend/prisma/migrations/20260728085349_add_fee_amount_to_cashup_line/migration.sql new file mode 100644 index 0000000..bef60fa --- /dev/null +++ b/backend/prisma/migrations/20260728085349_add_fee_amount_to_cashup_line/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION; diff --git a/backend/prisma/migrations/20260803132738_add_payment_recorded_by_and_indexes/migration.sql b/backend/prisma/migrations/20260803132738_add_payment_recorded_by_and_indexes/migration.sql new file mode 100644 index 0000000..3b30e86 --- /dev/null +++ b/backend/prisma/migrations/20260803132738_add_payment_recorded_by_and_indexes/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Payment" ADD COLUMN "recordedById" TEXT; + +-- CreateIndex +CREATE INDEX "Payment_recordedById_idx" ON "Payment"("recordedById"); + +-- CreateIndex +CREATE INDEX "Payment_originalPaymentId_idx" ON "Payment"("originalPaymentId"); + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260804093754_add_person_cash_counts/migration.sql b/backend/prisma/migrations/20260804093754_add_person_cash_counts/migration.sql new file mode 100644 index 0000000..0b38208 --- /dev/null +++ b/backend/prisma/migrations/20260804093754_add_person_cash_counts/migration.sql @@ -0,0 +1,43 @@ +-- CreateTable +CREATE TABLE "EventCashupPersonCount" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "enteredById" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EventCashupPersonCount_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventCashupPersonCountDenomination" ( + "id" TEXT NOT NULL, + "countId" TEXT NOT NULL, + "value" DOUBLE PRECISION NOT NULL, + "count" INTEGER NOT NULL, + + CONSTRAINT "EventCashupPersonCountDenomination_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "EventCashupPersonCount_eventId_idx" ON "EventCashupPersonCount"("eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "EventCashupPersonCount_eventId_userId_key" ON "EventCashupPersonCount"("eventId", "userId"); + +-- CreateIndex +CREATE INDEX "EventCashupPersonCountDenomination_countId_idx" ON "EventCashupPersonCountDenomination"("countId"); + +-- AddForeignKey +ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_enteredById_fkey" FOREIGN KEY ("enteredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventCashupPersonCountDenomination" ADD CONSTRAINT "EventCashupPersonCountDenomination_countId_fkey" FOREIGN KEY ("countId") REFERENCES "EventCashupPersonCount"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index daab2b6..dbc1db2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -63,6 +63,7 @@ model User { notificationPreference NotificationPreference @default(email) registrations Registration[] payments Payment[] + paymentsRecorded Payment[] @relation("PaymentRecordedBy") tickets Ticket[] ticketScans TicketUsage[] passwordResets PasswordReset[] @@ -74,6 +75,9 @@ model User { eventsReopened Event[] @relation("EventReopenedBy") cashupsPerformed EventCashup[] notifyForEvents Event[] @relation("EventNotifyRecipients") + + personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor") + personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy") } model Event { @@ -112,6 +116,7 @@ model Event { reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull) costs EventCost[] cashups EventCashup[] + personCashCounts EventCashupPersonCount[] // Users who should receive registration/payment/daily-summary notifications for this // event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js). @@ -215,6 +220,7 @@ model Payment { amount Float method String userId String + recordedById String? registrationId String? eventId String? isDonation Boolean @default(false) @@ -223,6 +229,7 @@ model Payment { originalPaymentId String? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Restrict) + recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull) registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull) event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull) originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull) @@ -230,9 +237,11 @@ model Payment { YocoTransaction YocoTransaction[] @@index([userId]) + @@index([recordedById]) @@index([registrationId]) @@index([eventId]) @@index([createdAt]) + @@index([originalPaymentId]) } model Ticket { @@ -479,6 +488,36 @@ model EventCashupDenomination { @@index([lineId]) } +// Actual physical cash counted for one staff member's recorded cash payments, entered any +// time (not part of the close flow) purely for accountability — compared against the +// system-expected amount (computeCashAccountabilityByUser) to show a per-person variance. +model EventCashupPersonCount { + id String @id @default(uuid()) + eventId String + userId String + enteredById String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade) + enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull) + denominations EventCashupPersonCountDenomination[] + + @@unique([eventId, userId]) + @@index([eventId]) +} + +model EventCashupPersonCountDenomination { + id String @id @default(uuid()) + countId String + value Float + count Int + personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade) + + @@index([countId]) +} + // ─── Church Website Models (disabled — kept for reference, not active Prisma models) ── // // model Ministry { diff --git a/backend/src/controllers/broadcastController.js b/backend/src/controllers/broadcastController.js index c01f621..f2e02e1 100644 --- a/backend/src/controllers/broadcastController.js +++ b/backend/src/controllers/broadcastController.js @@ -29,14 +29,7 @@ function buildEventContext(event) { return { eventTitle, eventStart, eventLink, eventLinkHtml }; } -function replacePlaceholders(str, ctx) { - if (!str) return str; - return String(str) - .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') - .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') - .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') - .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || '')); -} +const { replacePlaceholders } = require('../utils/placeholders'); function parseFreeformEmails(lines) { // Supports formats: @@ -166,9 +159,9 @@ const sendBroadcast = async (req, res) => { eventLink: escapeHtml(eventCtx.eventLink), eventLinkHtml: eventCtx.eventLinkHtml, } : ctxBase; - const finalSubject = replacePlaceholders(subject, ctxBase); - const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined; - const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined); + const finalSubject = await replacePlaceholders(subject, ctxBase); + const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined; + const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined); await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText }); })); diff --git a/backend/src/controllers/cashupController.js b/backend/src/controllers/cashupController.js index 48d8641..8080b41 100644 --- a/backend/src/controllers/cashupController.js +++ b/backend/src/controllers/cashupController.js @@ -1,7 +1,7 @@ const prisma = require('../config/db'); const { v4: uuidv4 } = require('uuid'); const { safeErrorMessage } = require('../utils/errorUtils'); -const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils'); +const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils'); // @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history // @route GET /api/cashups/event/:eventId @@ -15,6 +15,45 @@ const getEventCashup = async (req, res) => { } }; +// @desc Payments recorded for an event, broken down by staff member and method (cash/card/eft/ +// other) — cash also includes the live actual-vs-expected from per-person counts. +// @route GET /api/cashups/event/:eventId/cash-by-user +// @access Private/Supervisor +const getCashByRecordedUser = async (req, res) => { + try { + const [rows, cash] = await Promise.all([ + computeAccountabilityByUser(req.params.eventId), + computeEventCashActualFromPersonCounts(req.params.eventId) + ]); + res.json({ rows, cashActualTotal: cash.actual, cashDenominations: cash.denominations }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Enter (or update) one staff member's actual physical cash count for an event — +// optional, can be done any time, purely for per-person accountability. Never blocks +// or is required for closing the event. +// @route PUT /api/cashups/event/:eventId/person-cash/:userId +// @access Private/Supervisor +const savePersonCash = async (req, res) => { + try { + const { eventId, userId } = req.params; + const { denominations, notes } = req.body; + + const targetUser = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); + if (!targetUser) { + res.status(404); + throw new Error('User not found'); + } + + const record = await savePersonCashCount(eventId, userId, { denominations, notes, enteredById: req.user.id }); + res.json(record); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + // @desc Save in-progress reconciliation entries without closing the event // @route PUT /api/cashups/event/:eventId/draft // @access Private/Admin @@ -49,30 +88,41 @@ const closeEvent = async (req, res) => { const financials = await computeEventFinancials(eventId); const isFullCashup = Array.isArray(lines) && lines.length > 0; - const cashupLines = isFullCashup - ? lines - .filter(l => l && ALL_METHODS.includes(l.method)) + let cashupLines = []; + if (isFullCashup) { + const nonCashLines = lines + .filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash') .map(l => { const expected = financials.expectedCashByMethod[l.method] || 0; - const denominations = l.method === 'cash' && Array.isArray(l.denominations) - ? l.denominations - .map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 })) - .filter(d => d.value > 0 && d.count > 0) - : []; - const actual = denominations.length > 0 - ? denominations.reduce((sum, d) => sum + d.value * d.count, 0) - : (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null); + const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null; return { id: uuidv4(), method: l.method, expectedAmount: expected, actualAmount: actual, variance: actual !== null ? actual - expected : null, - notes: l.notes || null, - denominations: denominations.length > 0 ? { create: denominations } : undefined + notes: l.notes || null }; - }) - : []; + }); + + // Cash is never entered as a single event-wide figure — it's always the live sum of every + // staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's + // always sourced here rather than from whatever (if anything) the frontend sent for it. + const cashLineInput = lines.find(l => l && l.method === 'cash'); + const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId); + const cashExpected = financials.expectedCashByMethod.cash || 0; + const cashLine = { + id: uuidv4(), + method: 'cash', + expectedAmount: cashExpected, + actualAmount: cashActual, + variance: cashActual !== null ? cashActual - cashExpected : null, + notes: cashLineInput?.notes || null, + denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined + }; + + cashupLines = [cashLine, ...nonCashLines]; + } const totalActualRevenue = isFullCashup ? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0) @@ -179,6 +229,8 @@ const getCashupAudit = async (req, res) => { module.exports = { getEventCashup, + getCashByRecordedUser, + savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index 42eff26..f4137ae 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -1226,6 +1226,7 @@ const emailEventAttendees = async (req, res) => { const { sendMail } = require('../utils/email'); const { computeRegistrationTotalDue } = require('../utils/pricing'); + const { replacePlaceholders } = require('../utils/placeholders'); function fmtAmount(amt) { const n = Number(amt || 0); @@ -1234,17 +1235,6 @@ const emailEventAttendees = async (req, res) => { function fmtDate(d) { try { return new Date(d).toLocaleString(); } catch { return String(d); } } - function replacePlaceholders(str, ctx) { - if (!str) return str; - return String(str) - .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') - .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') - .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') - .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || '')) - .replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '') - .replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || '')) - .replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || ''); - } // Build per-recipient registration aggregates for this event const regsByEmail = new Map(); @@ -1255,42 +1245,66 @@ const emailEventAttendees = async (req, res) => { regsByEmail.get(em).push(r); } - let sent = 0; + const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); - // Special handling for tickets template: trigger ticket emails containing attachments + // Generates a live Yoco checkout link for the recipient's first registration with an + // outstanding balance — only called when the template actually uses {{payment.link}}, to + // avoid an unnecessary Yoco API call per recipient otherwise. + function makePaymentLinkResolver(regs) { + return async () => { + const reg = (regs || []).find(r => { + const due = computeRegistrationTotalDue(r, new Date()); + const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + return due - paid > 0.01; + }); + if (!reg) return ''; + const { createRegistrationCheckoutInternal } = require('./paymentController'); + const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, { + successUrl: `${baseUrl}/payment/success`, + cancelUrl: `${baseUrl}/payment/cancel`, + failureUrl: `${baseUrl}/payment/failure`, + }); + return result.redirectUrl; + }; + } + + // Fire-and-forget: respond immediately with a queued count, then send in the background. + // Large recipient lists used to block the request until every email was sent — now the + // caller gets an instant response and failures are just logged server-side. if (template === 'tickets') { const { emailTickets } = require('./ticketController'); - for (const rcpt of recipients) { - try { + res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' }); + (async () => { + const results = await Promise.allSettled(recipients.map(async rcpt => { const regs = regsByEmail.get(rcpt.email) || []; - // Send tickets for each registration that belongs to this recipient for this event for (const reg of regs) { - // Use the controller helper as in other parts of the code const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } }; const mockRes = { status: () => mockRes, json: () => {} }; await emailTickets(mockReq, mockRes); } - sent++; - } catch (e) { - try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {} - } - } - return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' }); + })); + results.forEach((r, i) => { + if (r.status === 'rejected') { + try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {} + } + }); + })(); + return; } const eventTitle = event?.title || 'the event'; const eventStart = event?.startDate ? fmtDate(event.startDate) : ''; // Build event and promo links for placeholders - const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`; const eventLinkHtml = `${eventLink}`; const promoTitle = promoEvent?.title || ''; const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : ''; const promoLinkHtml = promoLink ? `${promoLink}` : ''; - // Send individually with personalization - for (const rcpt of recipients) { - try { + res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' }); + + (async () => { + const results = await Promise.allSettled(recipients.map(async rcpt => { const regs = regsByEmail.get(rcpt.email) || []; // Sum outstanding balance across this user's registrations for the event let totalDue = 0; let totalPaid = 0; @@ -1300,6 +1314,7 @@ const emailEventAttendees = async (req, res) => { totalDue += due; totalPaid += paid; } const balance = Math.max(totalDue - totalPaid, 0); + const paymentLinkResolver = makePaymentLinkResolver(regs); // Build context for placeholder replacement const ctxBase = { name: rcpt.name || '', @@ -1310,6 +1325,7 @@ const emailEventAttendees = async (req, res) => { promoLink, balance, balanceFmt: fmtAmount(balance), + paymentLinkResolver, }; const ctxHtml = { ...ctxBase, @@ -1347,18 +1363,18 @@ const emailEventAttendees = async (req, res) => { } // Always perform placeholder replacement on whatever we have - const finalSubject = replacePlaceholders(subj || '', ctxBase); - const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined; - const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined); + const finalSubject = await replacePlaceholders(subj || '', ctxBase); + const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined; + const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined); await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText }); - sent++; - } catch (e) { - try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {} - } - } - - return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' }); + })); + results.forEach((r, i) => { + if (r.status === 'rejected') { + try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {} + } + }); + })(); } catch (error) { return res.status(400).json({ message: error.message }); } @@ -1445,6 +1461,7 @@ const whatsappEventAttendees = async (req, res) => { const { sendText } = require('../utils/whatsapp'); const { computeRegistrationTotalDue } = require('../utils/pricing'); + const { replacePlaceholders } = require('../utils/placeholders'); function fmtAmount(amt) { const n = Number(amt || 0); @@ -1453,15 +1470,6 @@ const whatsappEventAttendees = async (req, res) => { function fmtDate(d) { try { return new Date(d).toLocaleString(); } catch { return String(d); } } - function replacePlaceholders(str, ctx) { - if (!str) return str; - return String(str) - .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') - .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') - .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') - .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '') - .replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || ''); - } const regsByPhone = new Map(); for (const r of registrations) { @@ -1476,29 +1484,52 @@ const whatsappEventAttendees = async (req, res) => { const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''); const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`; - let sent = 0; + // Generates a live Yoco checkout link for the recipient's first registration with an + // outstanding balance — only called when the template actually uses {{payment.link}}. + function makePaymentLinkResolver(regs) { + return async () => { + const reg = (regs || []).find(r => { + const due = computeRegistrationTotalDue(r, new Date()); + const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0); + return due - paid > 0.01; + }); + if (!reg) return ''; + const { createRegistrationCheckoutInternal } = require('./paymentController'); + const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, { + successUrl: `${baseUrl}/payment/success`, + cancelUrl: `${baseUrl}/payment/cancel`, + failureUrl: `${baseUrl}/payment/failure`, + }); + return result.redirectUrl; + }; + } - // Special handling for tickets template: send ticket PDFs via WhatsApp + // Fire-and-forget: respond immediately with a queued count, then send in the background. if (template === 'tickets') { const { emailTickets } = require('./ticketController'); - for (const rcpt of recipients) { - try { + res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' }); + (async () => { + const results = await Promise.allSettled(recipients.map(async rcpt => { const regs = regsByPhone.get(rcpt.phone) || []; for (const reg of regs) { const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } }; const mockRes = { status: () => mockRes, json: () => {} }; await emailTickets(mockReq, mockRes); } - sent++; - } catch (e) { - try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {} - } - } - return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' }); + })); + results.forEach((r, i) => { + if (r.status === 'rejected') { + try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {} + } + }); + })(); + return; } - for (const rcpt of recipients) { - try { + res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' }); + + (async () => { + const results = await Promise.allSettled(recipients.map(async rcpt => { const regs = regsByPhone.get(rcpt.phone) || []; let totalDue = 0; let totalPaid = 0; for (const r of regs) { @@ -1514,6 +1545,7 @@ const whatsappEventAttendees = async (req, res) => { eventLink, balance, balanceFmt: fmtAmount(balance), + paymentLinkResolver: makePaymentLinkResolver(regs), }; let msg = message; @@ -1523,15 +1555,15 @@ const whatsappEventAttendees = async (req, res) => { msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`; } - const finalMessage = replacePlaceholders(msg || '', ctx); + const finalMessage = await replacePlaceholders(msg || '', ctx); await sendText(rcpt.phone, finalMessage); - sent++; - } catch (e) { - try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {} - } - } - - return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' }); + })); + results.forEach((r, i) => { + if (r.status === 'rejected') { + try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {} + } + }); + })(); } catch (error) { return res.status(400).json({ message: error.message }); } diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index da273e4..750d9bf 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -157,6 +157,7 @@ const createPayment = async (req, res) => { amount: requestedAmount, method, userId, + recordedById: req.user.id, registrationId: null, eventId: registration.eventId, isDonation: true, @@ -164,6 +165,7 @@ const createPayment = async (req, res) => { }, include: { user: { select: { id: true, name: true, email: true } }, + recordedBy: { select: { id: true, name: true, email: true } }, event: true } }); @@ -175,6 +177,7 @@ const createPayment = async (req, res) => { amount: applyAmount, method, userId, + recordedById: req.user.id, registrationId, eventId: registrationEventId || eventId || null, isDonation: false, @@ -182,6 +185,7 @@ const createPayment = async (req, res) => { }, include: { user: { select: { id: true, name: true, email: true } }, + recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { event: true } }, event: (registrationEventId || eventId) ? true : undefined } @@ -195,6 +199,7 @@ const createPayment = async (req, res) => { amount: excess, method, userId, + recordedById: req.user.id, registrationId: null, eventId: registration.eventId, isDonation: true, @@ -212,6 +217,7 @@ const createPayment = async (req, res) => { amount: parseFloat(amount), method, userId, + recordedById: req.user.id, registrationId: registrationId || null, eventId: registrationEventId || eventId || null, isDonation: isDonation || false, @@ -219,6 +225,7 @@ const createPayment = async (req, res) => { }, include: { user: { select: { id: true, name: true, email: true } }, + recordedBy: { select: { id: true, name: true, email: true } }, registration: registrationId ? { include: { event: true } } : undefined, event: (registrationEventId || eventId) ? true : undefined } @@ -329,6 +336,7 @@ const getPayments = async (req, res) => { const include = { user: { select: { id: true, name: true, email: true } }, + recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { event: true, @@ -452,6 +460,13 @@ const getPaymentById = async (req, res) => { email: true } }, + recordedBy: { + select: { + id: true, + name: true, + email: true + } + }, registration: { include: { event: true @@ -507,6 +522,13 @@ const getPaymentsByRegistration = async (req, res) => { name: true, email: true } + }, + recordedBy: { + select: { + id: true, + name: true, + email: true + } } } }); @@ -533,6 +555,7 @@ const getPaymentsByEvent = async (req, res) => { }, include: { user: { select: { id: true, name: true, email: true } }, + recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { user: { select: { id: true, name: true, email: true } } } } @@ -576,10 +599,18 @@ const assignDonationToRegistration = async (req, res) => { throw new Error('Only donations can be assigned to registrations'); } - // Check if payment is already assigned to a registration - if (payment.registrationId) { + // Donations are never mutated once created — their remaining balance is the original + // amount minus every leg (a Payment row with isDonation:false and originalPaymentId + // pointing back at this donation) already allocated from it. + const existingLegs = await prisma.payment.findMany({ + where: { originalPaymentId: payment.id, isDonation: false } + }); + const alreadyUsed = existingLegs.reduce((sum, leg) => sum + leg.amount, 0); + const remainingDonation = payment.amount - alreadyUsed; + + if (remainingDonation <= 0.000001) { res.status(400); - throw new Error('This payment is already assigned to a registration'); + throw new Error('This donation has already been fully allocated'); } if (payment.eventId) { @@ -628,16 +659,16 @@ const assignDonationToRegistration = async (req, res) => { } // How much of the donation to apply — defaults to today's behaviour (as much as the - // donation covers, capped at what's owed) but staff can specify a smaller amount and - // deliberately leave the registrant owing a balance. - let allocateAmount = amount != null ? Number(amount) : Math.min(payment.amount, remainingAmount); + // donation's remaining balance covers, capped at what's owed) but staff can specify a + // smaller amount and deliberately leave the registrant owing a balance. + let allocateAmount = amount != null ? Number(amount) : Math.min(remainingDonation, remainingAmount); if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) { res.status(400); throw new Error('Allocation amount must be greater than zero'); } - if (allocateAmount > payment.amount) { + if (allocateAmount > remainingDonation) { res.status(400); - throw new Error('Cannot allocate more than the donation amount'); + throw new Error(`Cannot allocate more than the donation's remaining balance of R${remainingDonation.toFixed(2)}`); } if (allocateAmount > remainingAmount) { res.status(400); @@ -647,39 +678,24 @@ const assignDonationToRegistration = async (req, res) => { let updatedRegistration; let generatedTickets = []; let originalPaymentId = payment.id; - let splitPayment = null; - // Update the payment to be associated with the registration and adjust amount - await prisma.payment.update({ - where: { id: payment.id }, + // Create an immutable leg referencing the donation — the donation row itself is never + // touched, so its original amount and history stay intact and it can be assigned again + // later if this leg doesn't use it up. + const leg = await prisma.payment.create({ data: { - registrationId, + id: uuidv4(), amount: allocateAmount, - isDonation: false + method: payment.method, + userId: payment.userId, + recordedById: req.user.id, + registrationId, + eventId: registration.eventId, + isDonation: false, + originalPaymentId: payment.id, } }); - // If less than the full donation was allocated, the remainder stays as an unassigned - // donation (same donor, no notification — it's a bookkeeping split, not a new gift). - if (allocateAmount < payment.amount) { - const leftoverAmount = payment.amount - allocateAmount; - - splitPayment = await prisma.payment.create({ - data: { - id: uuidv4(), - amount: leftoverAmount, - method: payment.method, - userId: payment.userId, - eventId: payment.eventId, - isDonation: true, - externalId: payment.externalId ? `${payment.externalId}-split` : null, - status: payment.status, - originalPaymentId: payment.id, - createdAt: payment.createdAt - } - }); - } - if (allocateAmount >= remainingAmount) { // Fully covers what's owed updatedRegistration = await prisma.registration.update({ @@ -707,9 +723,9 @@ const assignDonationToRegistration = async (req, res) => { } // Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails), - // then tickets (guarantees order). The split/leftover payment is never notified. + // then tickets (guarantees order). const { sendDonationAssignmentEmails } = require('../utils/notifications'); - const _adPaymentId = payment?.id; + const _adPaymentId = leg?.id; const _adShouldEmailTickets = generatedTickets.length > 0; const _adUserId = registration?.userId; const _adRegId = registrationId; @@ -730,10 +746,8 @@ const assignDonationToRegistration = async (req, res) => { originalPaymentId, updatedRegistration, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined, - splitPayment: splitPayment ? { - ...splitPayment, - originalPaymentId: payment.id - } : null + leg, + donationRemaining: remainingDonation - allocateAmount }; res.status(200).json(result); @@ -1151,6 +1165,7 @@ const createRefund = async (req, res) => { amount: -Math.abs(amt), method: method || 'refund', userId, + recordedById: req.user.id, registrationId: linkRegistrationId, eventId: linkEventId, isDonation: false, @@ -1159,6 +1174,7 @@ const createRefund = async (req, res) => { }, include: { user: { select: { id: true, name: true, email: true } }, + recordedBy: { select: { id: true, name: true, email: true } }, registration: { include: { event: true } }, event: true } @@ -1210,6 +1226,12 @@ const getPaymentStats = async (req, res) => { const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000); const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000); + // Exclude donation-application legs — a leg re-labels part of an already-counted donation + // as applied to a registration, it isn't new money. Summing both would double-count it. + const excludeDonationLegs = { + NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } + }; + const [totalToday, totalWeek, totalMonth] = await Promise.all([ prisma.payment.aggregate({ _sum: { @@ -1218,7 +1240,8 @@ const getPaymentStats = async (req, res) => { where: { createdAt: { gte: startOfDay - } + }, + ...excludeDonationLegs } }), prisma.payment.aggregate({ @@ -1228,7 +1251,8 @@ const getPaymentStats = async (req, res) => { where: { createdAt: { gte: lastWeek - } + }, + ...excludeDonationLegs } }), prisma.payment.aggregate({ @@ -1238,7 +1262,8 @@ const getPaymentStats = async (req, res) => { where: { createdAt: { gte: lastMonth - } + }, + ...excludeDonationLegs } }) ]); diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index f1d9741..ed783a2 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -380,7 +380,7 @@ const getRegistrations = async (req, res) => { try { const registrations = await prisma.registration.findMany({ include: { - payments: true, + payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, @@ -472,7 +472,7 @@ const getRegistrationById = async (req, res) => { phoneNumber: true } }, - payments: true, + payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, formResponses: { include: { answers: true } } } }); @@ -653,7 +653,7 @@ const getRegistrationsByEvent = async (req, res) => { tickets: true, } }, - payments: true, + payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } } }, orderBy: { createdAt: 'asc' } diff --git a/backend/src/controllers/reportController.js b/backend/src/controllers/reportController.js index bd166e7..d796b8c 100644 --- a/backend/src/controllers/reportController.js +++ b/backend/src/controllers/reportController.js @@ -1,269 +1,464 @@ const PDFDocument = require('pdfkit'); +const ExcelJS = require('exceljs'); const fs = require('fs'); const path = require('path'); -const nodemailer = require('nodemailer'); - -// Utility: draw a table -function drawTable(doc, startX, startY, colWidths, rows, header) { - let y = startY; - doc.font('Helvetica-Bold'); - if (header && header.length) { - let x = startX; - header.forEach((h, i) => { - const w = colWidths[i] || 80; - doc.rect(x, y, w, 20).stroke(); - doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 }); - x += w; - }); - y += 20; - } - doc.font('Helvetica'); - rows.forEach((row) => { - let x = startX; - row.forEach((cell, i) => { - const w = colWidths[i] || 80; - const h = 18; - doc.rect(x, y, w, h).stroke(); - doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 }); - x += w; - }); - y += 18; - // New page if overflow - if (y > doc.page.height - 40) { - doc.addPage(); - y = 20; - } - }); -} +const { sendMail, emailWrapper } = require('../utils/email'); function a4Doc(orientation = 'portrait') { return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' }); } +// Brand palette — mirrors the web reports' indigo theme and the dataviz-skill categorical +// palette used by the on-screen HorizontalBarChart, so exported PDFs/Excel look like the +// same report instead of a plain data dump. +const BRAND = '#4f46e5'; +const BRAND_DARK = '#3730a3'; +const BRAND_LIGHT = '#eef2ff'; +const TEXT_DARK = '#111827'; +const TEXT_MUTED = '#6b7280'; +const BORDER = '#e5e7eb'; +const TONE_COLORS = { + green: { bg: '#ecfdf5', accent: '#059669' }, + blue: { bg: '#eff6ff', accent: '#2563eb' }, + violet: { bg: '#f5f3ff', accent: '#7c3aed' }, + amber: { bg: '#fffbeb', accent: '#d97706' }, + rose: { bg: '#fff1f2', accent: '#e11d48' }, + gray: { bg: '#f3f4f6', accent: '#4b5563' }, +}; +const CATEGORICAL_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#4a3aa7', '#e34948']; + +function isEmphasisRow(firstCell) { + const s = String(firstCell ?? '').trim(); + return /^total$/i.test(s) || /net profit/i.test(s) || /unassigned donations/i.test(s) || /revenue per ticket/i.test(s); +} + +function drawHeader(doc, title, subtitle) { + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const x = doc.page.margins.left; + const y = doc.page.margins.top; + const h = subtitle ? 46 : 32; + doc.save(); + doc.roundedRect(x, y, pageWidth, h, 6).fill(BRAND); + doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(16).text(title || 'Report', x + 14, y + 9, { width: pageWidth - 28 }); + if (subtitle) { + doc.font('Helvetica').fontSize(9).fillColor('#e0e7ff').text(subtitle, x + 14, y + 30, { width: pageWidth - 28 }); + } + doc.restore(); + doc.y = y + h + 14; +} + +function drawStats(doc, stats) { + if (!Array.isArray(stats) || stats.length === 0) return; + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const perRow = Math.max(1, Math.min(5, Math.floor(pageWidth / 110))); + const gap = 8; + const boxW = (pageWidth - gap * (perRow - 1)) / perRow; + const boxH = 34; + let rowY = doc.y; + stats.forEach((s, i) => { + const col = i % perRow; + if (col === 0 && i !== 0) rowY += boxH + gap; + const bx = doc.page.margins.left + col * (boxW + gap); + const tone = TONE_COLORS[s.tone] || TONE_COLORS.gray; + doc.save(); + doc.roundedRect(bx, rowY, boxW, boxH, 5).fill(tone.bg); + doc.fillColor(TEXT_MUTED).font('Helvetica').fontSize(7.5).text(String(s.label || ''), bx + 8, rowY + 6, { width: boxW - 16 }); + doc.fillColor(tone.accent).font('Helvetica-Bold').fontSize(11).text(String(s.value || ''), bx + 8, rowY + 17, { width: boxW - 16 }); + doc.restore(); + }); + doc.y = rowY + boxH + 16; + if (doc.y > doc.page.height - 60) doc.addPage(); +} + +function drawChart(doc, chart) { + if (!chart || !Array.isArray(chart.data) || chart.data.length === 0) return; + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const x = doc.page.margins.left; + if (chart.title) { + doc.fillColor(TEXT_MUTED).font('Helvetica-Bold').fontSize(9).text(chart.title, x, doc.y); + doc.moveDown(0.4); + } + const labelW = 110; + const valueW = 80; + const barAreaW = pageWidth - labelW - valueW - 16; + const max = Math.max(1, ...chart.data.map(d => Math.abs(d.value || 0))); + const rowH = 16; + chart.data.forEach((d, i) => { + const y = doc.y; + doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(String(d.label || ''), x, y + 3, { width: labelW - 8 }); + const trackX = x + labelW; + doc.roundedRect(trackX, y + 2, barAreaW, 8, 4).fill('#f3f4f6'); + const w = Math.max(4, (Math.abs(d.value || 0) / max) * barAreaW); + doc.roundedRect(trackX, y + 2, w, 8, 4).fill(CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length]); + doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(d.displayValue != null ? String(d.displayValue) : String(d.value), trackX + barAreaW + 8, y + 3, { width: valueW - 8, align: 'right' }); + doc.y = y + rowH; + if (doc.y > doc.page.height - 60) doc.addPage(); + }); + doc.moveDown(0.8); +} + +function drawNote(doc, note) { + if (!note) return; + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const x = doc.page.margins.left; + const y = doc.y; + doc.font('Helvetica-Oblique').fontSize(8.5); + const h = doc.heightOfString(note, { width: pageWidth - 20 }) + 14; + doc.save(); + doc.roundedRect(x, y, pageWidth, h, 5).fill('#f9fafb'); + doc.fillColor(TEXT_MUTED).text(note, x + 10, y + 7, { width: pageWidth - 20 }); + doc.restore(); + doc.y = y + h + 12; + if (doc.y > doc.page.height - 60) doc.addPage(); +} + +// Draws a single branded table (header band, zebra rows, highlighted total rows) at the +// document's current y — shared by the main table body and any extraTables sections below it. +function drawTable(doc, table) { + const columns = Array.isArray(table.columns) ? table.columns : []; + const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1); + const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; + const baseWidth = Math.floor(pageWidth / Math.max(1, colCount)); + const colWidths = new Array(colCount).fill(baseWidth); + if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2); + + // Header band + if (columns.length) { + let x = doc.page.margins.left; + const y = doc.y; + doc.save(); + doc.rect(x, y, pageWidth, 22).fill(BRAND_LIGHT); + doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(10.5); + columns.forEach((h, i) => { + const w = colWidths[i] || baseWidth; + doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 }); + x += w; + }); + doc.restore(); + doc.moveDown(1.6); + } + + // Rows — zebra striped, with a highlighted tint+bold for total/summary rows + const rows = table.rows; + rows.forEach((row) => { + const emphasis = isEmphasisRow(row[0]); + const rowY = doc.y; + const rowH = 18; + const bg = emphasis ? BRAND_LIGHT : (rows.indexOf(row) % 2 === 0 ? '#ffffff' : '#f9fafb'); + doc.save(); + doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore(); + let x = doc.page.margins.left; + row.forEach((cell, i) => { + const w = colWidths[i] || baseWidth; + doc.fillColor(emphasis ? BRAND_DARK : TEXT_DARK).font(emphasis ? 'Helvetica-Bold' : 'Helvetica').fontSize(9.5).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 }); + doc.strokeColor(BORDER).lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke(); + x += w; + }); + doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke(); + doc.moveDown(1.1); + if (doc.y > doc.page.height - 40) { + doc.addPage(); + } + }); + doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke(); + doc.moveDown(1); +} + +// Shared drawing logic for all PDF-producing endpoints (download, email, WhatsApp) — draws a +// branded header, optional stat tiles / bar chart / note, then a 'table' or 'layered' body +// (plus any extraTables sections below it) onto whatever PDFDocument the caller gives it +// (streamed straight to the HTTP response for download, or to a temp file for email/WhatsApp). +function drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }) { + drawHeader(doc, title, subtitle); + drawStats(doc, stats); + drawChart(doc, chart); + drawNote(doc, note); + + // Path-drawing ops (rect/moveTo/lineTo, used throughout drawTable's borders) leave PDFKit's + // implicit text cursor (doc.x) at the last point drawn rather than the left margin, so every + // text() call below passes an explicit x — relying on the implicit cursor after a table has + // rendered puts the next label at the table's right edge instead of the margin. + const leftX = doc.page.margins.left; + + if (kind === 'table' && table && Array.isArray(table.rows)) { + drawTable(doc, table); + } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { + if (layered.header) { + doc.font('Helvetica-Bold').fontSize(13).fillColor(TEXT_DARK).text(layered.header, leftX, doc.y); + doc.moveDown(0.3); + } + doc.font('Helvetica').fontSize(11); + for (const section of layered.sections) { + doc.fillColor(BRAND_DARK).font('Helvetica-Bold').text(String(section.title || ''), leftX, doc.y, { continued: false }); + doc.moveDown(0.15); + doc.font('Helvetica').fontSize(10); + if (Array.isArray(section.items) && section.items.length) { + for (const item of section.items) { + doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill(BRAND).stroke(); + doc.fillColor(TEXT_DARK); + doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 }); + doc.moveDown(0.2); + } + } else { + doc.fillColor(TEXT_MUTED).text('No items', leftX, doc.y); + } + doc.moveDown(0.5); + if (doc.y > doc.page.height - 60) doc.addPage(); + } + } else { + doc.fillColor(TEXT_MUTED).font('Helvetica').text('No content', leftX, doc.y); + } + + if (Array.isArray(extraTables)) { + extraTables.forEach(t => { + if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return; + if (doc.y > doc.page.height - 100) doc.addPage(); + doc.moveDown(0.6); + if (t.title) { + doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(11).text(t.title, leftX, doc.y); + doc.moveDown(0.4); + } + drawTable(doc, t); + }); + } +} + +// Renders payload to a temp PDF file and returns its path — shared by emailPdf and whatsappPdf. +async function renderReportPdfToFile(payload) { + const tempDir = path.join(__dirname, '..', '..', 'temp'); + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); + const filePath = path.join(tempDir, `${(payload.title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_')); + + await new Promise((resolve, reject) => { + const doc = a4Doc(payload.orientation === 'landscape' ? 'landscape' : 'portrait'); + const ws = fs.createWriteStream(filePath); + doc.pipe(ws); + drawReportPdf(doc, payload); + doc.end(); + ws.on('finish', resolve); + ws.on('error', reject); + }); + + return filePath; +} + +// Builds a styled .xlsx workbook mirroring the same payload shape used for PDF — branded +// title band, stat label/value rows, a chart rendered as a mini-table with a native Excel +// data-bar conditional format (the closest free/no-extra-viz-dependency equivalent of the +// web's bar chart), then the main table with a bold colored header row and highlighted +// total rows. +async function buildReportWorkbook(payload) { + const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {}; + const wb = new ExcelJS.Workbook(); + wb.creator = 'Hope Family Church Events'; + wb.created = new Date(); + const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report'; + const ws = wb.addWorksheet(sheetName); + + const colCount = Math.max(4, (table?.columns?.length || 0)); + let r = 1; + + ws.mergeCells(r, 1, r, colCount); + const titleCell = ws.getCell(r, 1); + titleCell.value = title || 'Report'; + titleCell.font = { bold: true, size: 16, color: { argb: 'FFFFFFFF' } }; + titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F46E5' } }; + titleCell.alignment = { vertical: 'middle' }; + ws.getRow(r).height = 26; + r++; + + if (subtitle) { + ws.mergeCells(r, 1, r, colCount); + const subCell = ws.getCell(r, 1); + subCell.value = subtitle; + subCell.font = { italic: true, size: 10, color: { argb: 'FF6B7280' } }; + r++; + } + r++; + + if (Array.isArray(stats) && stats.length) { + stats.forEach(s => { + ws.getCell(r, 1).value = s.label; + ws.getCell(r, 1).font = { color: { argb: 'FF6B7280' }, size: 10 }; + ws.getCell(r, 2).value = s.value; + ws.getCell(r, 2).font = { bold: true, size: 11 }; + r++; + }); + r++; + } + + if (chart && Array.isArray(chart.data) && chart.data.length) { + if (chart.title) { + ws.getCell(r, 1).value = chart.title; + ws.getCell(r, 1).font = { bold: true, size: 10 }; + r++; + } + const chartStartRow = r; + chart.data.forEach(d => { + ws.getCell(r, 1).value = d.label; + ws.getCell(r, 2).value = typeof d.value === 'number' ? d.value : Number(d.value) || 0; + r++; + }); + ws.addConditionalFormatting({ + ref: `B${chartStartRow}:B${r - 1}`, + rules: [{ type: 'dataBar', cfvo: [{ type: 'min' }, { type: 'max' }], color: { argb: 'FF2A78D6' } }] + }); + r++; + } + + if (note) { + ws.mergeCells(r, 1, r, colCount); + ws.getCell(r, 1).value = note; + ws.getCell(r, 1).font = { italic: true, size: 9, color: { argb: 'FF6B7280' } }; + ws.getCell(r, 1).alignment = { wrapText: true }; + r += 2; + } + + if (kind === 'table' && table && Array.isArray(table.rows)) { + const columns = table.columns || []; + const { nextRow, dataStartRow } = writeTableRows(ws, r, columns, table.rows); + ws.views = [{ state: 'frozen', ySplit: dataStartRow - 1 }]; + r = nextRow; + } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { + if (layered.header) { + ws.getCell(r, 1).value = layered.header; + ws.getCell(r, 1).font = { bold: true, size: 12 }; + r += 2; + } + layered.sections.forEach(section => { + ws.getCell(r, 1).value = section.title; + ws.getCell(r, 1).font = { bold: true, color: { argb: 'FF3730A3' } }; + r++; + (section.items || []).forEach(item => { + ws.getCell(r, 1).value = `• ${item}`; + r++; + }); + r++; + }); + ws.getColumn(1).width = 90; + } + + if (Array.isArray(extraTables)) { + extraTables.forEach(t => { + if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return; + r++; + if (t.title) { + ws.getCell(r, 1).value = t.title; + ws.getCell(r, 1).font = { bold: true, size: 12, color: { argb: 'FF3730A3' } }; + r++; + } + const { nextRow } = writeTableRows(ws, r, t.columns || [], t.rows); + r = nextRow; + }); + } + + return wb; +} + +// Writes a header row (bold, colored fill) + zebra/emphasis-highlighted data rows at the given +// sheet row — shared by the main table and any extraTables sections. Returns the next free row +// and the data's start row (for optional freeze-pane use by the caller). +function writeTableRows(ws, startRow, columns, rows) { + let r = startRow; + const headerRow = ws.getRow(r); + columns.forEach((c, i) => { + const cell = headerRow.getCell(i + 1); + cell.value = c; + cell.font = { bold: true, color: { argb: 'FF3730A3' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } }; + cell.border = { bottom: { style: 'thin', color: { argb: 'FFE5E7EB' } } }; + }); + headerRow.commit(); + r++; + const dataStartRow = r; + rows.forEach((row, idx) => { + const excelRow = ws.getRow(r); + const emphasis = isEmphasisRow(row[0]); + row.forEach((val, i) => { + const cell = excelRow.getCell(i + 1); + cell.value = val === '' ? null : val; + if (emphasis) { + cell.font = { bold: true, color: { argb: 'FF3730A3' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } }; + } else if (idx % 2 === 1) { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } }; + } + }); + excelRow.commit(); + r++; + }); + columns.forEach((c, i) => { + let maxLen = String(c || '').length; + rows.forEach(row => { const v = row[i]; if (v != null && v !== '') maxLen = Math.max(maxLen, String(v).length); }); + const col = ws.getColumn(i + 1); + col.width = Math.max(col.width || 0, Math.min(40, Math.max(10, maxLen + 2))); + }); + return { nextRow: r, dataStartRow }; +} + // POST /api/reports/pdf -// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } } +// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? } const generatePdf = async (req, res) => { try { - const { title, kind, table, layered, orientation } = req.body || {}; + const { title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables } = req.body || {}; res.setHeader('Content-Type', 'application/pdf'); const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait'); doc.pipe(res); - - // Title - doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' }); - doc.moveDown(0.5); - - if (kind === 'table' && table && Array.isArray(table.rows)) { - const columns = Array.isArray(table.columns) ? table.columns : []; - const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1); - const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; - // Slightly wider first column to mimic site tables - const baseWidth = Math.floor(pageWidth / Math.max(1, colCount)); - const colWidths = new Array(colCount).fill(baseWidth); - if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2); - - // Draw header band - if (columns.length) { - let x = doc.page.margins.left; - const y = doc.y; - doc.save(); - doc.rect(x, y, pageWidth, 22).fill('#f3f4f6'); - doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11); - columns.forEach((h, i) => { - const w = colWidths[i] || baseWidth; - doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 }); - x += w; - }); - doc.restore(); - doc.moveDown(1.6); - } - - // Zebra rows - const rows = table.rows; - rows.forEach((row, idx) => { - const rowY = doc.y; - const rowH = 18; - const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb'; - doc.save(); - doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore(); - let x = doc.page.margins.left; - row.forEach((cell, i) => { - const w = colWidths[i] || baseWidth; - // Cell text - doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 }); - // Vertical separators similar to table borders - doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke(); - x += w; - }); - // Right border - doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke(); - doc.moveDown(1.1); - if (doc.y > doc.page.height - 40) { - doc.addPage(); - } - }); - // Bottom border - doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke(); - - } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { - if (layered.header) { - doc.font('Helvetica-Bold').fontSize(13).text(layered.header); - doc.moveDown(0.3); - } - doc.font('Helvetica').fontSize(11); - for (const section of layered.sections) { - doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false }); - doc.moveDown(0.15); - doc.font('Helvetica').fontSize(10); - if (Array.isArray(section.items) && section.items.length) { - for (const item of section.items) { - // Bullet dot - doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke(); - doc.fillColor('#111827'); - doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 }); - doc.moveDown(0.2); - } - } else { - doc.text('No items'); - } - doc.moveDown(0.5); - if (doc.y > doc.page.height - 60) doc.addPage(); - } - } else { - doc.font('Helvetica').text('No content'); - } - + drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }); doc.end(); } catch (e) { res.status(400).json({ message: e.message }); } }; +// POST /api/reports/excel +// body: same payload shape as /pdf — used to produce a styled .xlsx mirroring the PDF/web report. +const generateExcel = async (req, res) => { + try { + const payload = req.body || {}; + const wb = await buildReportWorkbook(payload); + const filename = `${(payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.xlsx`; + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await wb.xlsx.write(res); + res.end(); + } catch (e) { + res.status(400).json({ message: e.message }); + } +}; + // POST /api/reports/email -// body: { title, kind, table?, layered?, subject?, body? } +// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? } const emailPdf = async (req, res) => { try { - const { title, kind, table, layered, subject, body, orientation } = req.body || {}; + const { title, subtitle, kind, table, layered, subject, body, orientation, stats, chart, note, extraTables } = req.body || {}; const user = req.user; if (!user || !user.email) { res.status(400); throw new Error('User email not available'); } - // Ensure temp dir - const tempDir = path.join(__dirname, '..', '..', 'temp'); - if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }); - const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_')); + const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables }); - // Build PDF to file - await new Promise((resolve, reject) => { - const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait'); - const ws = fs.createWriteStream(filePath); - doc.pipe(ws); - - doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report'); - doc.moveDown(0.5); - - if (kind === 'table' && table && Array.isArray(table.rows)) { - const columns = Array.isArray(table.columns) ? table.columns : []; - const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1); - const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; - // Slightly wider first column - const baseWidth = Math.floor(pageWidth / Math.max(1, colCount)); - const colWidths = new Array(colCount).fill(baseWidth); - if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2); - - // Header band - if (columns.length) { - let x = doc.page.margins.left; - const y = doc.y; - doc.save(); - doc.rect(x, y, pageWidth, 22).fill('#f3f4f6'); - doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11); - columns.forEach((h, i) => { - const w = colWidths[i] || baseWidth; - doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 }); - x += w; - }); - doc.restore(); - doc.moveDown(1.6); - } - - // Rows zebra - const rows = table.rows; - rows.forEach((row, idx) => { - const rowY = doc.y; - const rowH = 18; - const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb'; - doc.save(); - doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore(); - let x = doc.page.margins.left; - row.forEach((cell, i) => { - const w = colWidths[i] || baseWidth; - doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 }); - doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke(); - x += w; - }); - doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke(); - doc.moveDown(1.1); - if (doc.y > doc.page.height - 40) { - doc.addPage(); - } - }); - doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke(); - - } else if (kind === 'layered' && layered && Array.isArray(layered.sections)) { - if (layered.header) { - doc.font('Helvetica-Bold').fontSize(13).text(layered.header); - doc.moveDown(0.3); - } - doc.font('Helvetica').fontSize(11); - for (const section of layered.sections) { - doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false }); - doc.moveDown(0.15); - doc.font('Helvetica').fontSize(10); - if (Array.isArray(section.items) && section.items.length) { - for (const item of section.items) { - doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke(); - doc.fillColor('#111827'); - doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 }); - doc.moveDown(0.2); - } - } else { - doc.text('No items'); - } - doc.moveDown(0.5); - if (doc.y > doc.page.height - 60) doc.addPage(); - } - } else { - doc.font('Helvetica').text('No content'); - } - - doc.end(); - ws.on('finish', resolve); - ws.on('error', reject); - }); - - // Send email using nodemailer (same config as tickets) - const transporter = nodemailer.createTransport({ - host: process.env.EMAIL_HOST, - port: process.env.EMAIL_PORT, - secure: process.env.EMAIL_PORT === '465', - auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS } - }); - - await transporter.sendMail({ - from: process.env.EMAIL_FROM, + // Use the shared mail utility (DB-configured SMTP via Admin -> Site Settings, with env + // fallback) instead of a one-off transporter — a bare `process.env.EMAIL_HOST` transporter + // ignores that configuration entirely and fails wherever SMTP is only set up via the DB. + const bodyText = body || 'Please find your report attached.'; + await sendMail({ to: user.email, subject: subject || (title ? `${title} PDF` : 'Report PDF'), - text: body || 'Please find your report attached.', + text: bodyText, + html: emailWrapper( + `

${bodyText}

` + + `

Your report ${title || 'Report'} is attached as a PDF.

`, + { preheader: title || 'Report PDF' } + ), attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }] }); - // Clean try { fs.unlinkSync(filePath); } catch {} res.json({ message: `Report emailed to ${user.email}` }); @@ -272,4 +467,33 @@ const emailPdf = async (req, res) => { } }; -module.exports = { generatePdf, emailPdf }; +// POST /api/reports/whatsapp +// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, caption? } +// Sends to the current user's own WhatsApp number (same self-service pattern as emailPdf). +const whatsappPdf = async (req, res) => { + let filePath = null; + try { + const { title, subtitle, kind, table, layered, caption, orientation, stats, chart, note, extraTables } = req.body || {}; + const user = req.user; + + const { isValidZAPhone } = require('../utils/whatsapp'); + if (!user || !isValidZAPhone(user.phoneNumber)) { + res.status(400); + throw new Error('No valid WhatsApp number on your account. Add one in your profile to use this.'); + } + + filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables }); + + const { sendPdf } = require('../utils/whatsapp'); + const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; + await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report'); + + res.json({ message: `Report sent to your WhatsApp` }); + } catch (e) { + res.status(400).json({ message: e.message }); + } finally { + if (filePath) { try { fs.unlinkSync(filePath); } catch {} } + } +}; + +module.exports = { generatePdf, generateExcel, emailPdf, whatsappPdf }; diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index e046eb0..a741920 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -354,21 +354,15 @@ const updateUserProfile = async (req, res) => { } // Normalize phone - const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp'); + const { normalizeZAPhone } = require('../utils/whatsapp'); let newPhone = user.phoneNumber; if (phoneNumber !== undefined) { newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null; } // Validate notification preference — WhatsApp requires a valid SA phone number - const allowedPrefs = ['email', 'whatsapp', 'both']; - let newPref = user.notificationPreference; - if (notificationPreference !== undefined) { - newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference; - if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) { - newPref = 'email'; - } - } + const { resolveNotificationPreference } = require('../utils/notificationPreference'); + const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference); // Update user data const updatedUser = await prisma.user.update({ @@ -554,7 +548,11 @@ const updateUser = async (req, res) => { throw new Error('User not found'); } - const { name, email, role, isActive, phoneNumber, password } = req.body; + const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body; + + const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber; + const { resolveNotificationPreference } = require('../utils/notificationPreference'); + const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference); // Prepare data update, allow admin to set a new password const data = { @@ -562,7 +560,8 @@ const updateUser = async (req, res) => { email: email || user.email, role: role || user.role, isActive: isActive !== undefined ? isActive : user.isActive, - phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber, + phoneNumber: newPhone, + notificationPreference: newPref, updatedAt: new Date() }; @@ -579,6 +578,7 @@ const updateUser = async (req, res) => { email: true, role: true, phoneNumber: true, + notificationPreference: true, createdAt: true, updatedAt: true, isActive: true diff --git a/backend/src/controllers/webhookController.js b/backend/src/controllers/webhookController.js index 83ae861..0f8ff0e 100644 --- a/backend/src/controllers/webhookController.js +++ b/backend/src/controllers/webhookController.js @@ -323,6 +323,7 @@ const handlePaymentSucceeded = async (webhookData) => { externalId: yocoPaymentId, registrationId: registration?.id || null, userId: resolvedUserId, + recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder eventId: registration?.eventId || metadata?.eventId || null, isDonation: !registration?.id }, @@ -334,6 +335,13 @@ const handlePaymentSucceeded = async (webhookData) => { email: true } }, + recordedBy: { + select: { + id: true, + name: true, + email: true + } + }, registration: registration ? { include: { event: true diff --git a/backend/src/controllers/yocoTransactionsController.js b/backend/src/controllers/yocoTransactionsController.js index d1efcec..7fcb03e 100644 --- a/backend/src/controllers/yocoTransactionsController.js +++ b/backend/src/controllers/yocoTransactionsController.js @@ -138,6 +138,7 @@ const reconcileYocoTransaction = async (req, res) => { amount: amountFloat, method: ytx.methodType || 'card', userId: resolvedUserId, + recordedById: req.user?.id || null, // staff who performed the reconciliation registrationId: registration?.id || null, eventId: registration?.eventId || eventId || null, isDonation: !registration?.id, diff --git a/backend/src/routes/cashupRoutes.js b/backend/src/routes/cashupRoutes.js index 961dbfd..51dbd87 100644 --- a/backend/src/routes/cashupRoutes.js +++ b/backend/src/routes/cashupRoutes.js @@ -1,10 +1,12 @@ const express = require('express'); const router = express.Router(); -const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController'); +const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController'); const { protect, supervisor, admin } = require('../middleware/authMiddleware'); router.get('/audit', protect, supervisor, getCashupAudit); router.get('/event/:eventId', protect, supervisor, getEventCashup); +router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser); +router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash); router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft); router.post('/event/:eventId/close', protect, admin, closeEvent); router.post('/event/:eventId/reopen', protect, admin, reopenEvent); diff --git a/backend/src/routes/reportRoutes.js b/backend/src/routes/reportRoutes.js index bfc29e2..5c76bfc 100644 --- a/backend/src/routes/reportRoutes.js +++ b/backend/src/routes/reportRoutes.js @@ -1,12 +1,18 @@ const express = require('express'); const router = express.Router(); -const { generatePdf, emailPdf } = require('../controllers/reportController'); +const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController'); const { protect } = require('../middleware/authMiddleware'); // Generate and download PDF router.post('/pdf', protect, generatePdf); +// Generate and download styled Excel (.xlsx) +router.post('/excel', protect, generateExcel); + // Email PDF to current user router.post('/email', protect, emailPdf); +// Send PDF to current user's own WhatsApp +router.post('/whatsapp', protect, whatsappPdf); + module.exports = router; diff --git a/backend/src/utils/cashupUtils.js b/backend/src/utils/cashupUtils.js index 33259fb..ab48702 100644 --- a/backend/src/utils/cashupUtils.js +++ b/backend/src/utils/cashupUtils.js @@ -21,6 +21,16 @@ function bucketForMethod(method) { return 'other'; } +// A donation is never mutated once created — assigning it to a registration creates a separate +// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead. +// That leg is not new money: it just re-labels part of an already-counted donation as applied +// to a registration. Revenue/cash totals must count each real inflow exactly once, so legs are +// excluded everywhere money is summed — the money was already counted via the donation itself. +// (Refunds also set originalPaymentId, but always with a negative amount, so they're unaffected.) +function isDonationLeg(p) { + return !p.isDonation && !!p.originalPaymentId && p.amount > 0; +} + // Throws if the event is closed. Callers wrap this in their existing try/catch // (res.statusCode is set before throwing, matching the rest of the controllers). async function assertEventOpen(eventId, res) { @@ -93,16 +103,30 @@ async function computeEventFinancials(eventId) { quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity; } - const nonRefundPayments = payments.filter(p => p.amount > 0); - const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId); - const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0); - const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0); + // Real inflows only — excludes donation-application legs, which would otherwise double-count + // money already counted once via the source donation (e.g. a R250 donation with R50 assigned + // to a registration must total R250 received, not R300). + const nonRefundPayments = payments.filter(p => p.amount > 0 && !isDonationLeg(p)); + // Donations are never mutated once assigned — assignment creates a separate "leg" Payment + // row (isDonation:false, originalPaymentId -> the donation), so a donation's registrationId + // stays null forever. Its actual unallocated amount is its original amount minus every leg + // that already references it, not simply "every donation with no registrationId". + const legsByDonationId = new Map(); + for (const p of payments) { + if (p.originalPaymentId && !p.isDonation) { + legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + p.amount); + } + } + const donationPayments = payments.filter(p => p.isDonation); + const unallocatedDonations = donationPayments.filter(p => (p.amount - (legsByDonationId.get(p.id) || 0)) > 0.000001); + const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + Math.max(p.amount - (legsByDonationId.get(p.id) || 0), 0), 0); + const totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0); const paymentsByMethod = emptyByMethod(); for (const p of nonRefundPayments) { paymentsByMethod[bucketForMethod(p.method)] += p.amount; } - const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0); + const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0); // Costs, with computed totals and attribution to a payment method's float (if tagged) const costBreakdown = costs.map(c => { @@ -195,12 +219,143 @@ async function computeEventFinancials(eventId) { }; } +// Payment accountability, per staff member who recorded the payment, broken down by every +// method (not just cash) — lets a cashup reconcile not just the total float but who is +// responsible for which portion of it. Cash also folds in any actual physical count entered for +// that person (EventCashupPersonCount, entered any time, independent of the event-wide close) to +// show an actual-vs-expected variance per person — the event's cash actual is the sum of these +// per-person counts (see computeEventCashActualFromPersonCounts), not a separate manual entry. +// Card/EFT/Other have no physical "count" concept, so they're just recorded amounts. +async function computeAccountabilityByUser(eventId) { + const [payments, personCounts] = await Promise.all([ + prisma.payment.findMany({ + where: { OR: [{ eventId }, { registration: { eventId } }] }, + include: { recordedBy: { select: { id: true, name: true, email: true } } } + }), + prisma.eventCashupPersonCount.findMany({ + where: { eventId }, + include: { + user: { select: { id: true, name: true, email: true } }, + enteredBy: { select: { id: true, name: true } }, + denominations: true + } + }) + ]); + + const emptyMethodTotals = () => ({ total: 0, count: 0 }); + const emptyEntry = (userId, name, email) => ({ + userId: userId || null, + name: name || 'Unknown / legacy', + email: email || null, + cash: { ...emptyMethodTotals(), actual: null, variance: null, denominations: [], enteredBy: null, countUpdatedAt: null, notes: null }, + card: emptyMethodTotals(), + eft: emptyMethodTotals(), + other: emptyMethodTotals() + }); + + const byUser = new Map(); + for (const p of payments) { + // A donation-application leg isn't new money — it's the same money already recorded once, + // as the donation. Counting it again here would double-attribute it to whoever did the + // assignment, on top of whoever originally recorded the donation. + if (isDonationLeg(p)) continue; + const method = bucketForMethod(p.method); + const key = p.recordedById || 'unknown'; + const entry = byUser.get(key) || emptyEntry(p.recordedById, p.recordedBy?.name, p.recordedBy?.email); + entry[method].total += p.amount; + entry[method].count += 1; + byUser.set(key, entry); + } + + for (const pc of personCounts) { + const key = pc.userId; + const entry = byUser.get(key) || emptyEntry(pc.userId, pc.user?.name, pc.user?.email); + const actual = pc.denominations.reduce((s, d) => s + d.value * d.count, 0); + entry.cash.actual = actual; + entry.cash.variance = actual - entry.cash.total; + entry.cash.denominations = pc.denominations.map(d => ({ value: d.value, count: d.count })); + entry.cash.enteredBy = pc.enteredBy ? { id: pc.enteredBy.id, name: pc.enteredBy.name } : null; + entry.cash.countUpdatedAt = pc.updatedAt; + entry.cash.notes = pc.notes || null; + byUser.set(key, entry); + } + + return Array.from(byUser.values()).sort((a, b) => { + const totalA = a.cash.total + a.card.total + a.eft.total + a.other.total; + const totalB = b.cash.total + b.card.total + b.eft.total + b.other.total; + return totalB - totalA; + }); +} + +// The event's cash "actual" is the live sum of every staff member's entered physical count — +// there is no separate event-wide entry any more. Used both to display a live figure before +// close and to source the closed cashup's permanent Cash line. +async function computeEventCashActualFromPersonCounts(eventId) { + const personCounts = await prisma.eventCashupPersonCount.findMany({ + where: { eventId }, + include: { denominations: true } + }); + if (personCounts.length === 0) return { actual: null, denominations: [] }; + + const byValue = new Map(); + let actual = 0; + for (const pc of personCounts) { + for (const d of pc.denominations) { + actual += d.value * d.count; + byValue.set(d.value, (byValue.get(d.value) || 0) + d.count); + } + } + const denominations = Array.from(byValue.entries()) + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.value - a.value); + return { actual, denominations }; +} + +// Upsert one staff member's actual physical cash count for an event — optional, can be entered +// any time (not required to close the event), purely for per-person accountability. +async function savePersonCashCount(eventId, userId, { denominations, notes, enteredById }) { + const cleanDenoms = (Array.isArray(denominations) ? denominations : []) + .map(d => ({ value: Number(d.value), count: parseInt(d.count, 10) || 0 })) + .filter(d => d.value > 0 && d.count > 0); + + const existing = await prisma.eventCashupPersonCount.findUnique({ + where: { eventId_userId: { eventId, userId } } + }); + + const record = existing + ? await prisma.eventCashupPersonCount.update({ + where: { id: existing.id }, + data: { + notes: notes || null, + enteredById: enteredById || null, + denominations: { deleteMany: {}, create: cleanDenoms } + }, + include: { denominations: true } + }) + : await prisma.eventCashupPersonCount.create({ + data: { + eventId, + userId, + notes: notes || null, + enteredById: enteredById || null, + denominations: { create: cleanDenoms } + }, + include: { denominations: true } + }); + + return record; +} + module.exports = { METHOD_BUCKETS, ALL_METHODS, ZAR_DENOMINATIONS, bucketForMethod, + isDonationLeg, assertEventOpen, assertRegistrationEventOpen, - computeEventFinancials + computeEventFinancials, + computeAccountabilityByUser, + computeEventCashActualFromPersonCounts, + savePersonCashCount }; diff --git a/backend/src/utils/notificationPreference.js b/backend/src/utils/notificationPreference.js new file mode 100644 index 0000000..88047c4 --- /dev/null +++ b/backend/src/utils/notificationPreference.js @@ -0,0 +1,16 @@ +const { isValidZAPhone } = require('./whatsapp'); + +const ALLOWED_NOTIFICATION_PREFERENCES = ['email', 'whatsapp', 'both']; + +// WhatsApp/Both requires a valid SA phone number — silently falls back to email otherwise, +// since a user without a usable phone number can never receive WhatsApp notifications anyway. +function resolveNotificationPreference(requested, phoneNumber, fallback) { + if (requested === undefined) return fallback; + let pref = ALLOWED_NOTIFICATION_PREFERENCES.includes(requested) ? requested : fallback; + if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) { + pref = 'email'; + } + return pref; +} + +module.exports = { ALLOWED_NOTIFICATION_PREFERENCES, resolveNotificationPreference }; diff --git a/backend/src/utils/placeholders.js b/backend/src/utils/placeholders.js new file mode 100644 index 0000000..40fcca7 --- /dev/null +++ b/backend/src/utils/placeholders.js @@ -0,0 +1,30 @@ +// Shared template placeholder substitution for bulk emails/WhatsApp messages and broadcasts. +// Async because {{payment.link}} needs to create a live Yoco checkout session per recipient — +// every other placeholder is a plain synchronous string replace. +async function replacePlaceholders(str, ctx = {}) { + if (!str) return str; + let out = String(str) + .replace(/\{\{\s*name\s*\}\}/g, ctx.name || '') + .replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '') + .replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '') + .replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || '')) + .replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '') + .replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || '')) + .replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || ''); + + if (/\{\{\s*payment\.link\s*\}\}/.test(out) && typeof ctx.paymentLinkResolver === 'function') { + let link = ''; + try { + link = (await ctx.paymentLinkResolver()) || ''; + } catch (e) { + try { console.warn('[placeholders] payment.link resolution failed:', e?.message || e); } catch {} + } + out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, link); + } else { + out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, ''); + } + + return out; +} + +module.exports = { replacePlaceholders }; diff --git a/backend/src/utils/whatsapp.js b/backend/src/utils/whatsapp.js index 16237e6..f9e7d92 100644 --- a/backend/src/utils/whatsapp.js +++ b/backend/src/utils/whatsapp.js @@ -105,6 +105,12 @@ function isSessionNotFound(e) { return msg.includes('session not found') || msg.includes('instance not found'); } +/** Extracts the WAWP API's own error reason when present, instead of axios's generic + * "Request failed with status code NNN" (which carries no information about what went wrong). */ +function wawpErrorMessage(e) { + return e?.response?.data?.message || e?.message || 'Unknown WhatsApp API error'; +} + /** * If the WAWP API reports "Session not found", clear the stale instance ID * from the DB so the admin UI drops back to the Session Instance setup step. @@ -221,12 +227,20 @@ async function sendText(toPhone, message) { const chatId = toChatId(toPhone); if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; } const { token, instanceId } = await getConfig(); - await axios.post(`${BASE}/send/text`, { - access_token: token, - instance_id: instanceId, - chatId, - message, - }); + try { + await axios.post(`${BASE}/send/text`, { + access_token: token, + instance_id: instanceId, + chatId, + message, + }); + } catch (e) { + if (isSessionNotFound(e)) { + try { await handleSessionNotFound(e); } catch {} + throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.'); + } + throw new Error(`WhatsApp text send failed: ${wawpErrorMessage(e)}`); + } } /** @@ -258,17 +272,26 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) { const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`; const { token, instanceId } = await getConfig(); - await axios.post(`${BASE}/send/pdf`, { - access_token: token, - instance_id: instanceId, - chatId, - file: { - url: pdfUrl, - filename: filename || 'tickets.pdf', - mimetype: 'application/pdf', - }, - caption: caption || '', - }); + try { + await axios.post(`${BASE}/send/pdf`, { + access_token: token, + instance_id: instanceId, + chatId, + file: { + url: pdfUrl, + filename: filename || 'tickets.pdf', + mimetype: 'application/pdf', + }, + caption: caption || '', + }); + } catch (e) { + try { fs.unlinkSync(tempPath); } catch {} + if (isSessionNotFound(e)) { + try { await handleSessionNotFound(e); } catch {} + throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.'); + } + throw new Error(`WhatsApp PDF send failed: ${wawpErrorMessage(e)}`); + } // Clean up after 5 minutes — WAWP will have fetched the file by then setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000); diff --git a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx index e0a465a..314aac3 100644 --- a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx @@ -26,7 +26,7 @@ export default function EventCashupPage() { const router = useRouter(); const { token } = useAuth(); - const [tab, setTab] = useState<"costs" | "reconciliation">("costs"); + const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs"); const [data, setData] = useState(null); const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]); const [loading, setLoading] = useState(false); @@ -72,6 +72,7 @@ export default function EventCashupPage() {
+
{loading &&
Loading…
} @@ -89,8 +90,13 @@ export default function EventCashupPage() { setBusy={setBusy} setError={setError} onChanged={load} + onClosed={() => setTab("report")} /> )} + + {!loading && data && tab === "report" && ( + + )} ); } @@ -266,9 +272,349 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }: // ─── Reconciliation tab ───────────────────────────────────────────────────── -function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged }: { +type PersonDenom = { value: number; count: number }; +type MethodTotals = { total: number; count: number }; +type CashMethodTotals = MethodTotals & { + actual: number | null; variance: number | null; denominations: PersonDenom[]; + enteredBy: { id: string; name: string } | null; countUpdatedAt: string | null; notes: string | null; +}; +type AccountabilityRow = { + userId: string | null; name: string; email: string | null; + cash: CashMethodTotals; card: MethodTotals; eft: MethodTotals; other: MethodTotals; +}; +type AccountabilityResponse = { rows: AccountabilityRow[]; cashActualTotal: number | null; cashDenominations: PersonDenom[] }; + +// Payment accountability, per staff member who recorded the payment, broken down by method — +// separate from the overall cash reconciliation above, which only totals the float without +// saying who's responsible for it. For cash, staff can optionally enter what was physically +// counted for each person, any time (not required to close the event); the event's cash actual +// is the live sum of these per-person counts, shown here and used by the reconciliation table. +function CashByUserSection({ eventId, token, onCashSummaryChange }: { + eventId: string; token: string; + onCashSummaryChange?: (summary: { actualTotal: number | null; denominations: PersonDenom[] }) => void; +}) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(true); + const [editingUserId, setEditingUserId] = useState(null); + + const load = () => { + if (!token || !eventId) return; + setLoading(true); + apiFetch(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token }) + .then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); }) + .catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] })) + .finally(() => setLoading(false)); + }; + + useEffect(load, [eventId, token]); + + const rows = data?.rows || []; + const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") => + rows.reduce((s, r) => s + (r[m][key] || 0), 0); + const totalCash = sumOf("cash", "total"); + const totalCashActual = rows.reduce((s, r) => s + (r.cash.actual || 0), 0); + const grandTotal = rows.reduce((s, r) => s + r.cash.total + r.card.total + r.eft.total + r.other.total, 0); + + return ( +
+ + {open && ( + loading ? ( +
Loading…
+ ) : rows.length === 0 ? ( +
No payments recorded for this event.
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + + + + + {rows.map(r => ( + + + + + + + + + + + + + {r.userId && editingUserId === r.userId && ( + + + + )} + + ))} + + + + + + + + + + + + +
Staff memberCashCardEFTOtherTotal
ExpectedActualVariance
+
{r.name}
+ {r.email &&
{r.email}
} +
{money(r.cash.total)}{r.cash.actual != null ? money(r.cash.actual) : } + {r.cash.variance != null ? ( + Math.abs(r.cash.variance) > 0.01 ? ( + + {money(r.cash.variance)} + + ) : Matches + ) : } + {r.card.total > 0 ? money(r.card.total) : }{r.eft.total > 0 ? money(r.eft.total) : }{r.other.total > 0 ? money(r.other.total) : }{money(r.cash.total + r.card.total + r.eft.total + r.other.total)} + {r.userId && ( + + )} +
+ { setEditingUserId(null); load(); }} + onCancel={() => setEditingUserId(null)} + /> +
Total{money(totalCash)}{money(totalCashActual)}{money(totalCashActual - totalCash)}{money(sumOf("card", "total"))}{money(sumOf("eft", "total"))}{money(sumOf("other", "total"))}{money(grandTotal)}
+
+ ) + )} +
+ ); +} + +// Denomination entry for one person's actual cash count — pick a denomination from the dropdown, +// enter how many, add it to the list. Repeatable, editable, removable before saving. +function PersonCashCountEditor({ eventId, token, userId, initialDenominations, initialNotes, enteredBy, countUpdatedAt, onSaved, onCancel }: { + eventId: string; token: string; userId: string; + initialDenominations: PersonDenom[]; initialNotes: string | null; + enteredBy: { id: string; name: string } | null; countUpdatedAt: string | null; + onSaved: () => void; onCancel: () => void; +}) { + const initialCounts: Record = {}; + for (const d of initialDenominations) initialCounts[d.value] = String(d.count); + const [counts, setCounts] = useState>(initialCounts); + const [notes, setNotes] = useState(initialNotes || ""); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + + const setCount = (value: number, v: string) => setCounts(prev => ({ ...prev, [value]: v })); + + const lines: PersonDenom[] = ZAR_DENOMINATIONS + .map(value => ({ value, count: parseInt(counts[value] || "0", 10) || 0 })) + .filter(d => d.count > 0); + + const runningTotal = lines.reduce((s, l) => s + l.value * l.count, 0); + + const save = async () => { + setSaving(true); setErr(null); + try { + await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, { + method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null } + }); + onSaved(); + } catch (e: any) { + setErr(e?.message || "Failed to save count"); + } finally { + setSaving(false); + } + }; + + return ( +
+ {err &&
{err}
} + {enteredBy && countUpdatedAt && ( +
Last entered by {enteredBy.name} on {new Date(countUpdatedAt).toLocaleString()}
+ )} + +
+ {ZAR_DENOMINATIONS.map(v => ( +
+ {denomLabel(v)} + × + setCount(v, e.target.value)} + /> +
+ ))} +
+ +
Total: {money(runningTotal)}
+ +
+ + setNotes(e.target.value)} /> +
+ +
+ + +
+
+ ); +} + +// ─── Report tab ────────────────────────────────────────────────────────── +// A clean, read-only summary of the cashup — opened automatically once the event is closed, so +// staff land straight on "here's what happened" instead of the editable Reconciliation tab. +function ReportTab({ eventId, token, data, isClosed }: { + eventId: string; token: string; data: EventFinancials; isClosed: boolean; +}) { + const reconciled = data.reconciled; + const latestClose = data.history.find(h => h.action === "closed" || h.action === "quick_closed"); + + return ( +
+
+
+
Cashup report
+ + {isClosed ? "Closed" : "Open (live preview)"} + +
+ {latestClose ? ( +
+ {latestClose.action === "closed" ? "Closed (full cashup)" : "Quick closed"} by {latestClose.performedBy?.name || "Unknown"} on {new Date(latestClose.createdAt).toLocaleString()} +
+ ) : ( +
This event hasn't been closed yet — figures below are a live preview and will change as more payments come in.
+ )} +
+ +
+
Revenue by method
+ + + + + + + + + + + + {METHODS.map(m => { + const r = reconciled?.byMethod?.[m]; + return ( + + + + + + + + ); + })} + +
MethodIncomeExpected cashActualVariance
{METHOD_LABELS[m]}{money(data.paymentsByMethod[m])}{money(data.expectedCashByMethod[m])}{r?.actual != null ? money(r.actual) : "—"}{r?.variance != null ? money(r.variance) : "—"}
+
+ + {reconciled?.byMethod?.cash?.denominations?.length ? ( +
+
Cash denomination count
+ + + {reconciled.byMethod.cash.denominations.map((d: any) => ( + + ))} + +
{denomLabel(d.value)}× {d.count}{money(d.value * d.count)}
+
+ ) : null} + + + + {data.costs.length > 0 && ( +
+
Costs
+ + + + + + + + + + {data.costs.map((c: any) => ( + + + + + + ))} + +
LabelPaid fromTotal
{c.label}{c.paidFromMethod || "—"}{money(c.total ?? c.amount)}
+
+ )} + +
+
+
Donations counted as profit
+
{money(data.unallocatedDonationsTotal)}
+
+
+
Total costs
+
{money(data.totalCosts)}
+
+
+
Net profit
+
{money(data.netProfit)}
+
+
+
+ ); +} + +function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged, onClosed }: { eventId: string; token: string; data: EventFinancials; busy: boolean; - setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; + setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void; }) { const isClosed = data.event.cashupStatus === "closed"; const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined; @@ -281,32 +627,23 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh return base; }, [draftLines]); - const initialDenomCounts: Record = useMemo(() => { - const cashLine = (draftLines || []).find(l => l.method === "cash"); - const out: Record = {}; - for (const d of cashLine?.denominations || []) out[d.value] = String(d.count); - return out; - }, [draftLines]); - const [lines, setLines] = useState>(initialLines); - const [denomCounts, setDenomCounts] = useState>(initialDenomCounts); const [closeNotes, setCloseNotes] = useState(""); const [reopenNotes, setReopenNotes] = useState(""); + // Cash no longer has its own manual entry — it's the live sum of every staff member's + // per-person count, reported up from CashByUserSection below. + const [cashSummary, setCashSummary] = useState<{ actualTotal: number | null; denominations: PersonDenom[] }>({ actualTotal: null, denominations: [] }); - useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]); + useEffect(() => { setLines(initialLines); }, [initialLines]); const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => { setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } })); }; - const cashDenominationsPayload = () => ZAR_DENOMINATIONS - .map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 })) - .filter(d => d.count > 0); - - const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0); - + // Cash is included with notes only — its actual/denominations are always sourced server-side + // from per-person counts (see cashupController.closeEvent), never from this payload. const buildLinesPayload = () => METHODS.map(m => m === "cash" - ? { method: "cash", denominations: cashDenominationsPayload(), notes: lines.cash.notes || null } + ? { method: "cash", notes: lines.cash.notes || null } : { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null }); const saveDraft = async () => { @@ -327,6 +664,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh try { await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } }); onChanged(); + onClosed(); } catch (e: any) { setError(e?.message || "Failed to close event"); } finally { @@ -340,6 +678,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh try { await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } }); onChanged(); + onClosed(); } catch (e: any) { setError(e?.message || "Failed to close event"); } finally { @@ -391,7 +730,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh {isClosed ? ( money(r?.actual ?? null) ) : m === "cash" ? ( - money(cashActualFromDenoms) + cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : not counted ) : ( setLine(m, "actualAmount", e.target.value)} /> )} @@ -400,7 +739,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh {isClosed ? (r?.variance != null ? money(r.variance) : not reconciled) : (m === "cash" - ? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—") + ? (cashSummary.actualTotal != null ? money(cashSummary.actualTotal - data.expectedCashByMethod[m]) : "—") : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))} @@ -413,38 +752,31 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh })} + {!isClosed && ( +
Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.
+ )} + +
Cash denomination count
- {isClosed ? ( - reconciled?.byMethod?.cash?.denominations?.length ? ( + {(() => { + const denoms = isClosed ? (reconciled?.byMethod?.cash?.denominations || []) : cashSummary.denominations; + return denoms.length > 0 ? ( - {reconciled.byMethod.cash.denominations.map(d => ( - + {denoms.map((d: any) => ( + ))}
{denomLabel(d.value)}× {d.count}{money(d.value * d.count)}
{denomLabel(d.value)}× {d.count}{money(d.value * d.count)}
- ) :
No denomination breakdown recorded for this cashup.
- ) : ( -
- {ZAR_DENOMINATIONS.map(v => ( -
- {denomLabel(v)} - × - setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))} - /> -
- ))} -
- )} + ) : ( +
+ {isClosed ? "No denomination breakdown recorded for this cashup." : "No per-staff-member cash counts entered yet — see “Payment accountability by staff member” above."} +
+ ); + })()}
diff --git a/frontend/src/app/dashboard/admin/registrations/page.tsx b/frontend/src/app/dashboard/admin/registrations/page.tsx index f9abc0c..118d938 100644 --- a/frontend/src/app/dashboard/admin/registrations/page.tsx +++ b/frontend/src/app/dashboard/admin/registrations/page.tsx @@ -157,6 +157,32 @@ export default function AdminRegistrationsPage() { return "text-gray-700 bg-gray-50"; }; + const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => { + const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) + ? Number(opt.priceSnapshot) + : (opt.eventOption?.price || 0); + return sum + unit * (opt.quantity || 0); + }, 0); + const totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0); + + // Aggregate stats across the currently filtered registrations — counts by status, plus + // revenue/outstanding totals (cancelled registrations are excluded from the money totals + // since they're not expected to be paid). + const stats = useMemo(() => { + const counts: Record = {}; + let totalRevenue = 0; + let totalOutstanding = 0; + filtered.forEach((r: any) => { + counts[r.status] = (counts[r.status] || 0) + 1; + if (r.status === "cancelled") return; + const due = totalDueFor(r); + const paid = totalPaidFor(r); + totalRevenue += paid; + totalOutstanding += Math.max(due - paid, 0); + }); + return { counts, totalRevenue, totalOutstanding }; + }, [filtered]); + return (
@@ -173,6 +199,24 @@ export default function AdminRegistrationsPage() { {error &&
{error}
} {info &&
{info}
} + {/* Aggregate stats */} +
+ {STATUS_OPTIONS.map(s => ( +
+
{s.replace("_", " ")}
+
{stats.counts[s] || 0}
+
+ ))} +
+
Total revenue
+
R {stats.totalRevenue.toFixed(2)}
+
+
+
Total outstanding
+
R {stats.totalOutstanding.toFixed(2)}
+
+
+ {/* Filters */}
@@ -221,12 +265,9 @@ export default function AdminRegistrationsPage() {
    {filtered.map((r: any) => { - const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => { - const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) - ? Number(opt.priceSnapshot) - : (opt.eventOption?.price || 0); - return sum + unit * (opt.quantity || 0); - }, 0); + const totalDue = totalDueFor(r); + const totalPaid = totalPaidFor(r); + const outstanding = Math.max(totalDue - totalPaid, 0); const isExpanded = expanded.has(r.id); const responses = formResponses[r.id]; const loadingResponse = loadingForms.has(r.id); @@ -245,7 +286,9 @@ export default function AdminRegistrationsPage() {
    {r.user?.email && {r.user.email}} {r.user?.phoneNumber && {r.user.phoneNumber}} - R {totalDue.toFixed(2)} + R {totalPaid.toFixed(2)} paid + {outstanding > 0.000001 && R {outstanding.toFixed(2)} owing} + (R {totalDue.toFixed(2)} total) #{String(r.id).slice(0, 8)}
@@ -306,6 +349,31 @@ export default function AdminRegistrationsPage() {
)} + {/* Payments */} +
+
Payments
+ {(r.payments || []).length === 0 ? ( +
No payments recorded.
+ ) : ( +
+ {r.payments.map((p: any) => ( +
+
+ {p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'} +
+
{new Date(p.createdAt).toLocaleString()}
+ {p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && ( +
Recorded by: {p.recordedBy.name}
+ )} +
+ ))} +
+ )} +
+ Paid: R {totalPaid.toFixed(2)}{outstanding > 0.000001 && · Owing: R {outstanding.toFixed(2)}} +
+
+ {/* Form responses */}
Form responses
diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx index 2744a0c..06873a3 100644 --- a/frontend/src/app/dashboard/admin/users/page.tsx +++ b/frontend/src/app/dashboard/admin/users/page.tsx @@ -12,6 +12,7 @@ interface UserItem { email: string; role: string; phoneNumber?: string | null; + notificationPreference?: string; isActive: boolean; createdAt: string; updatedAt: string; @@ -20,6 +21,8 @@ interface UserItem { const roleOptions = ["user", "staff", "supervisor", "admin"] as const; type Role = typeof roleOptions[number]; +const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const; + // Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order function fuzzyMatch(query: string, target: string): boolean { const q = query.toLowerCase(); @@ -71,10 +74,11 @@ export default function AdminUsersPage() { const [cRole, setCRole] = useState("user"); const [creating, setCreating] = useState(false); - // Inline edit state + // Edit modal state const [editingId, setEditingId] = useState(null); const [editData, setEditData] = useState & { password?: string }>({}); const [saving, setSaving] = useState(false); + const editingUser = useMemo(() => users.find(u => u.id === editingId) || null, [users, editingId]); const buildQuery = useCallback((p: number, ps = pageSize) => { const qs = new URLSearchParams({ page: String(p), limit: String(ps) }); @@ -152,6 +156,7 @@ export default function AdminUsersPage() { email: editData.email, role: editData.role, phoneNumber: editData.phoneNumber || null, + notificationPreference: editData.notificationPreference, isActive: editData.isActive, }; if (editData.password && editData.password.trim().length > 0) { @@ -305,8 +310,8 @@ export default function AdminUsersPage() { Email Role Phone + Notify Active - Password Actions @@ -314,63 +319,30 @@ export default function AdminUsersPage() { {users.map(u => ( - {editingId === u.id ? ( - setEditData(d => ({ ...d, name: e.target.value }))} /> - ) : ( - {u.name} - )} + {u.name} - {editingId === u.id ? ( - setEditData(d => ({ ...d, email: e.target.value }))} /> - ) : ( - {u.email} - )} + {u.email} - {editingId === u.id ? ( - - ) : ( - {u.role} - )} + {u.role} - {editingId === u.id ? ( - setEditData(d => ({ ...d, phoneNumber: e.target.value }))} /> - ) : ( - {u.phoneNumber || ""} - )} + {u.phoneNumber || ""} - {editingId === u.id ? ( - setEditData(d => ({ ...d, isActive: e.target.checked }))} /> - ) : ( - {u.isActive ? "Yes" : "No"} - )} + {u.notificationPreference || "email"} - {editingId === u.id ? ( - setEditData(d => ({ ...d, password: e.target.value }))} /> - ) : ( - - )} + {u.isActive ? "Yes" : "No"} - {editingId === u.id ? ( -
- - -
- ) : ( -
- - - - -
- )} +
+ + + + +
))} @@ -431,6 +403,70 @@ export default function AdminUsersPage() {
)}
+ + {editingUser && ( +
+
!saving && cancelEdit()} /> +
+
+
+

Edit user

+ +
+
+
+ + setEditData(d => ({ ...d, name: e.target.value }))} /> +
+
+ + setEditData(d => ({ ...d, email: e.target.value }))} /> +
+
+
+ + +
+
+ + setEditData(d => ({ ...d, phoneNumber: e.target.value }))} /> +
+
+
+
+ + +
+
+ +
+
+
+ + setEditData(d => ({ ...d, password: e.target.value }))} /> +
+
+
+ + +
+
+
+
+ )}
); } \ No newline at end of file diff --git a/frontend/src/app/dashboard/layout.tsx b/frontend/src/app/dashboard/layout.tsx index bd60444..ba18e65 100644 --- a/frontend/src/app/dashboard/layout.tsx +++ b/frontend/src/app/dashboard/layout.tsx @@ -63,16 +63,25 @@ export default function DashboardLayout({ children }: { children: React.ReactNod ); } + // The Reports page is a full-width, self-contained workspace (its own header, filters, and + // navigation) — the dashboard sidebar's section links (My Events, Profile, Admin, etc.) would + // just crowd it, so it's hidden there specifically, not app-wide. + const hideSidebar = pathname === "/dashboard/supervisor/reports"; + return (
- {/* Mobile dropdown navigation */} - - {/* Desktop sidebar */} -
- -
+ {!hideSidebar && ( + <> + {/* Mobile dropdown navigation */} + + {/* Desktop sidebar */} +
+ +
+ + )}
{children}
diff --git a/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx index defc9e7..e092cf5 100644 --- a/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx +++ b/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx @@ -466,8 +466,8 @@ function EmailAttendeesPageInner() { if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n'); } const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload }); - const sent = res?.sent ?? 0; const matched = res?.matched ?? 0; - setInfo(`Sent ${sent} out of ${matched} recipient(s).`); + const queued = res?.queued ?? res?.matched ?? 0; + setInfo(`Queued ${queued} recipient(s) for sending.`); // Reset form to default state resetAttendeesForm(); } catch (e: any) { @@ -552,7 +552,7 @@ function EmailAttendeesPageInner() {
-
Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}
+
Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}, {'{{payment.link}}'}
@@ -572,9 +572,10 @@ function EmailAttendeesPageInner() {
  • {'{{event.title}}'} — the event title.
  • {'{{event.start}}'} — the event start date/time (local).
  • {'{{balance}}'} — outstanding amount across the attendee’s registrations for the selected event.
  • +
  • {'{{payment.link}}'} — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).
  • -

    Example: Hi {'{{name}}'}, your balance is {'{{balance}}'}.

    -

    To add new placeholders, extend the replacement logic in eventController.emailEventAttendees (replacePlaceholders function) and update this help.

    +

    Example: Hi {'{{name}}'}, your balance is {'{{balance}}'}. Pay here: {'{{payment.link}}'}

    +

    To add new placeholders, extend backend/src/utils/placeholders.js and update this help.

    diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx index 32963ee..4e82bcb 100644 --- a/frontend/src/app/dashboard/supervisor/payments/page.tsx +++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx @@ -7,6 +7,15 @@ import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import { scoreUser } from "@/lib/fuzzyMatch"; +// A donation is never mutated once created — assigning it to a registration creates a separate +// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead. +// That leg is not new money: it just re-labels part of an already-counted donation as applied +// to a registration. Money stats/lists must count each real inflow exactly once, so legs are +// excluded — the money was already counted via the original donation row. +function isDonationLeg(p: any): boolean { + return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0; +} + function RegistrationOptions({ regs, regOutstanding }: { regs: any[]; regOutstanding: Record; @@ -439,7 +448,8 @@ function PaymentsContent() { // Stats const todayTotals = useMemo(() => { const start = new Date(); start.setHours(0,0,0,0); - const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime()); + // Exclude donation-application legs — that money was already counted once, as the donation. + const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p)); const revenue = today.reduce((s,p)=> s + (p.amount||0), 0); const donations = today.filter(p => p.isDonation).length; return { revenue, donations, count: today.length }; @@ -805,7 +815,7 @@ function PaymentsContent() { {loadingList && Loading…}
      - {payments.slice(0, 25).map(p => { + {payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => { const amt = p.amount || 0; const isRefund = amt < 0; return ( @@ -818,6 +828,9 @@ function PaymentsContent() { {(p.registration?.user?.name || p.user?.name) &&
      Name: {p.registration?.user?.name || p.user?.name}
      } {p.registrationId &&
      Registration: #{String(p.registrationId).slice(0,8)}
      } {p.eventId &&
      Event: {p.event?.title || p.eventId}
      } + {p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && ( +
      Recorded by: {p.recordedBy.name}
      + )} ); })} @@ -1025,27 +1038,50 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi [registrations, registrationId] ); - // Donations "relevant to that event" — unassigned donations logged against the same event - // as the chosen registration. + // A donation is never mutated once assigned — each assignment creates a separate "leg" + // Payment row (isDonation:false, originalPaymentId -> the donation). A donation's remaining + // balance is its original amount minus every leg that references it, so it stays offerable + // (and its registrationId stays null forever) until fully used up. + const legsById = useMemo(() => { + const m = new Map(); + payments.forEach((p: any) => { + if (p.originalPaymentId && !p.isDonation) { + m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + (p.amount || 0)); + } + }); + return m; + }, [payments]); + + // Donations "relevant to that event" — donations logged against the same event as the + // chosen registration that still have a remaining, unused balance. const donationsForEvent = useMemo(() => { if (!selectedRegistration) return [] as any[]; const eventId = selectedRegistration.eventId || selectedRegistration.event?.id; - return payments.filter((p: any) => p.isDonation && !p.registrationId && String(p.eventId) === String(eventId)); - }, [payments, selectedRegistration]); + return payments.filter((p: any) => { + if (!p.isDonation || p.registrationId) return false; + if (String(p.eventId) !== String(eventId)) return false; + const remaining = (p.amount || 0) - (legsById.get(p.id) || 0); + return remaining > 0.000001; + }); + }, [payments, selectedRegistration, legsById]); const selectedDonation = useMemo( () => payments.find((p: any) => String(p.id) === String(paymentId)), [payments, paymentId] ); + const donationRemaining = selectedDonation + ? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0) + : 0; + const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 0) : 0; - // The most that can be allocated: never more than the donation itself, never more than - // what's actually owed. Staff can type a smaller amount to leave a balance outstanding. + // The most that can be allocated: never more than the donation's remaining balance, never + // more than what's actually owed. Staff can type a smaller amount to leave a balance owing. const maxAllocatable = useMemo(() => { if (!selectedDonation) return 0; - return Math.min(selectedDonation.amount || 0, outstanding); - }, [selectedDonation, outstanding]); + return Math.min(donationRemaining, outstanding); + }, [selectedDonation, donationRemaining, outstanding]); // Default to "apply as much as needed" whenever a new donation is picked — the common // case needs no typing, but the field stays editable for a deliberate partial allocation. @@ -1055,7 +1091,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi }, [paymentId]); const amountNum = parseFloat(amountStr || "0"); - const leftover = selectedDonation ? Math.max(0, (selectedDonation.amount || 0) - amountNum) : 0; + const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0; const assign = async () => { if (!token) return; @@ -1110,12 +1146,13 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi > {donationsForEvent.map((p: any) => { - const label = `R ${(p.amount || 0).toFixed(2)} — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`; + const remaining = (p.amount || 0) - (legsById.get(p.id) || 0); + const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`; return ; })} {registrationId && donationsForEvent.length === 0 && ( -
      No unassigned donations for this event.
      +
      No donations with a remaining balance for this event.
      )} {selectedDonation && ( @@ -1131,8 +1168,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi onChange={e => setAmountStr(e.target.value)} />
      - Donation is R {(selectedDonation.amount || 0).toFixed(2)}; outstanding balance is R {outstanding.toFixed(2)}. - {leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay unassigned as a donation.} + Donation has R {donationRemaining.toFixed(2)} remaining (of R {(selectedDonation.amount || 0).toFixed(2)} total); outstanding balance is R {outstanding.toFixed(2)}. + {leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.}
      )} diff --git a/frontend/src/app/dashboard/supervisor/reports/page.tsx b/frontend/src/app/dashboard/supervisor/reports/page.tsx index b4d5082..0984015 100644 --- a/frontend/src/app/dashboard/supervisor/reports/page.tsx +++ b/frontend/src/app/dashboard/supervisor/reports/page.tsx @@ -7,13 +7,8 @@ import ReportsV2 from "@/components/reports/ReportsV2"; export default function SupervisorReportsPage() { const router = useRouter(); return ( -
      -
      -

      Reports

      - -
      -

      View, export, or email operational reports for events.

      - +
      + router.push('/dashboard')} />
      ); } diff --git a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx index ef02d1d..cbf0890 100644 --- a/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx +++ b/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx @@ -476,7 +476,7 @@ function WhatsAppAttendeesPageInner() { if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; } setSending(true); const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() }); - setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`); + setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`); resetAttendeesForm(); } catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); } }; @@ -655,7 +655,7 @@ function WhatsAppAttendeesPageInner() {
      - Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"} + Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}, {"{{payment.link}}"}
      @@ -676,6 +676,7 @@ function WhatsAppAttendeesPageInner() {
    • {"{{event.title}}"} — the event title.
    • {"{{event.start}}"} — the event start date/time.
    • {"{{balance}}"} — outstanding balance for the event.
    • +
    • {"{{payment.link}}"} — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).

    Preference indicators: WA = WhatsApp only,{" "} diff --git a/frontend/src/components/reports/EventsDropdown.tsx b/frontend/src/components/reports/EventsDropdown.tsx new file mode 100644 index 0000000..971445e --- /dev/null +++ b/frontend/src/components/reports/EventsDropdown.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +export default function EventsDropdown({ options, value, onChange }: { + options: { value: string; label: string }[]; + value: string[]; + onChange: (v: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, []); + + const toggle = (v: string) => { + onChange(value.includes(v) ? value.filter(x => x !== v) : [...value, v]); + }; + + const summary = value.length === 0 + ? "No events selected" + : options.length > 0 && value.length === options.length + ? "All events" + : value.length === 1 + ? (options.find(o => o.value === value[0])?.label || "1 event selected") + : `${value.length} events selected`; + + return ( +

    + + {open && ( +
    +
    + + +
    + {options.map(opt => ( + + ))} + {options.length === 0 &&
    No events available.
    } +
    + )} +
    + ); +} diff --git a/frontend/src/components/reports/ReportCatalog.ts b/frontend/src/components/reports/ReportCatalog.ts new file mode 100644 index 0000000..5db6e31 --- /dev/null +++ b/frontend/src/components/reports/ReportCatalog.ts @@ -0,0 +1,45 @@ +import type { LucideIcon } from "lucide-react"; +import { + Calendar, + Users, + PieChart, + Ticket, + TrendingUp, + FileText, + Box, + ListChecks, + Heart, + Camera, + FileBarChart, + BarChart3, + ClipboardList, +} from "lucide-react"; + +export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other"; + +export const REPORT_CATEGORIES: ReportCategory[] = ["Financial", "Registration", "Ticketing", "Donations", "Cashup", "Other"]; + +export const REPORTS = [ + { key: "payments", label: "Payments between dates", description: "View payments within a date range", category: "Financial", icon: Calendar, fields: 4 }, + { key: "attendees", label: "Attendees per event (grouped by option)", description: "Grouped by option", category: "Registration", icon: Users, fields: 3 }, + { key: "regTypes", label: "Registration type counts", description: "Count by registration type", category: "Registration", icon: PieChart, fields: 2 }, + { key: "usage", label: "Ticket usage summary", description: "Summary of ticket usage", category: "Ticketing", icon: Ticket, fields: 3 }, + { key: "revenue", label: "Revenue summary (by method)", description: "Summary of revenue by payment method", category: "Financial", icon: TrendingUp, fields: 5 }, + { key: "revenueDetailed", label: "Revenue detailed", description: "Detailed revenue breakdown", category: "Financial", icon: FileText, fields: 6 }, + { key: "masterOrders", label: "Master orders breakdown", description: "Overview of orders, payments, and donations for the selected filters", category: "Registration", icon: Box, fields: 4 }, + { key: "regStatus", label: "Registration status breakdown", description: "Registration status overview", category: "Registration", icon: ListChecks, fields: 3 }, + { key: "donations", label: "Donations breakdown", description: "Breakdown of donations", category: "Donations", icon: Heart, fields: 3 }, + { key: "cashup", label: "Cashup reconciliation", description: "Reconcile cashup totals", category: "Cashup", icon: Camera, fields: 4 }, + { key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 }, + { key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 }, + { key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 }, +] as const satisfies ReadonlyArray<{ + key: string; + label: string; + description: string; + category: ReportCategory; + icon: LucideIcon; + fields: number; +}>; + +export type ReportKey = typeof REPORTS[number]["key"]; diff --git a/frontend/src/components/reports/ReportViewerModal.tsx b/frontend/src/components/reports/ReportViewerModal.tsx new file mode 100644 index 0000000..3c548e7 --- /dev/null +++ b/frontend/src/components/reports/ReportViewerModal.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React from "react"; +import { X, Info, RefreshCw, type LucideIcon } from "lucide-react"; + +export default function ReportViewerModal({ + title, description, icon: Icon, onClose, actions, filters, onRefresh, busy, children, +}: { + title: string; + description?: string; + icon?: LucideIcon; + onClose: () => void; + actions?: React.ReactNode; + filters?: React.ReactNode; + onRefresh?: () => void; + busy?: boolean; + children: React.ReactNode; +}) { + return ( + // Above the site header (Navbar is `sticky top-0 z-50`) so the popup never sits behind it. +
    +
    +
    +
    e.stopPropagation()}> +
    +
    + {Icon && ( +
    + +
    + )} +
    +

    {title}

    + {description &&

    {description}

    } +
    +
    +
    + {actions} + +
    +
    + +
    +
    + + {filters || This report has no extra filters beyond Events and Date range in the sidebar.} +
    + {onRefresh && ( + + )} +
    + +
    + {children} +
    +
    +
    +
    + ); +} + +export function ReportActionButton({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) { + return ( + + ); +} diff --git a/frontend/src/components/reports/ReportingGuideModal.tsx b/frontend/src/components/reports/ReportingGuideModal.tsx new file mode 100644 index 0000000..58f1b00 --- /dev/null +++ b/frontend/src/components/reports/ReportingGuideModal.tsx @@ -0,0 +1,221 @@ +"use client"; + +import React, { useState } from "react"; +import { + X, Home, Filter, ListFilter, Download, BarChart2, MessageCircleQuestion, + Calendar, CalendarClock, EyeOff, Printer, Mail, FileSpreadsheet, MessageCircle, + CreditCard, Gift, Clock, HandHeart, RefreshCw, type LucideIcon, +} from "lucide-react"; + +type GuideTab = "overview" | "universal" | "specific" | "exporting" | "fields" | "help"; + +const TABS: { key: GuideTab; label: string; icon: LucideIcon }[] = [ + { key: "overview", label: "Overview", icon: Home }, + { key: "universal", label: "Universal filters", icon: Filter }, + { key: "specific", label: "Report-specific filters", icon: ListFilter }, + { key: "exporting", label: "Exporting reports", icon: Download }, + { key: "fields", label: "Fields & metrics", icon: BarChart2 }, + { key: "help", label: "Need more help?", icon: MessageCircleQuestion }, +]; + +const TONES = { + indigo: { bg: "bg-indigo-50", icon: "text-indigo-600" }, + emerald: { bg: "bg-emerald-50", icon: "text-emerald-600" }, + amber: { bg: "bg-amber-50", icon: "text-amber-600" }, + blue: { bg: "bg-blue-50", icon: "text-blue-600" }, + violet: { bg: "bg-violet-50", icon: "text-violet-600" }, + rose: { bg: "bg-rose-50", icon: "text-rose-600" }, + gray: { bg: "bg-gray-100", icon: "text-gray-600" }, +} as const; +type Tone = keyof typeof TONES; + +function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) { + const t = TONES[tone] || TONES.gray; + return ( +
    +
    + +
    +
    +
    {title}
    +
    {children}
    +
    +
    + ); +} + +export const GUIDE_DISMISSED_KEY = "hope_events_reports_guide_dismissed"; +const ADMIN_EMAIL = "admin@crosscode.co.za"; + +export default function ReportingGuideModal({ onClose }: { onClose: (dontShowAgain: boolean) => void }) { + const [tab, setTab] = useState("overview"); + const [dontShowAgain, setDontShowAgain] = useState(false); + + return ( + // Above the site header (Navbar is `sticky top-0 z-50`) and above the report popup + // (z-[60]), since the guide can be opened while a report is showing. +
    +
    onClose(dontShowAgain)} /> +
    +
    e.stopPropagation()}> +
    +
    +
    + +
    +
    +

    Reporting guide

    +

    This guide explains how reports work and how to use the available filters.

    +
    +
    + +
    + +
    + + +
    + {tab === "overview" && ( +
    +

    Reports help you view key data about your events. You can filter the data, preview it on screen, and export or email it.

    +
    + + Apply universal filters (like events and date range) that affect all reports, and report-specific filters for more detailed results. + + + Preview your report, adjust filters, and choose how you want the data to appear. + + + Export your report to Excel, PDF, or send it by email or WhatsApp. + +
    +
    + )} + + {tab === "universal" && ( +
    +

    Universal filters live in the sidebar on the left and apply to whichever report you open — you only set them once, not per report.

    +
    + + Pick one or more events. Every report loads data for exactly these events. + + + Controls which events even appear in the Events list to pick from. + + + A preset (This month, Last month, This year) or a custom range. Only applies to reports that are inherently date-based (e.g. Payments between dates, Revenue reports, Cashup audit trail) — reports like Attendees or Ticket usage show a live snapshot and ignore the date range. + +
    +
    + )} + + {tab === "specific" && ( +
    +

    Some reports have extra options that only make sense for that report — these appear at the top of the report popup once it's open, separate from the universal filters.

    +
    + + Which single event to show (defaults to the first selected event) and whether to include cancelled registrations. + + + Whether to include cancelled registrations in the counts, and whether to count by number of registrations or by ticket quantity (so someone with 3 tickets counts as 3). + + + Adjust a report-specific filter, then use the "Refresh" button inside the popup to re-run the report without closing it. + +
    +
    + )} + + {tab === "exporting" && ( +
    +

    Every report can be exported straight from its popup:

    +
    + + Opens a print-ready PDF in a new tab; use your browser's print button from there. + + + Sends the PDF to your own account email. + + + Downloads a styled .xlsx workbook — colored header, key totals, and a chart section where available — matching the on-screen report. + + + Sends the PDF to your own account's WhatsApp number (needs a valid phone number on file). + +
    +
    + )} + + {tab === "fields" && ( +
    +

    A few terms come up across several financial reports and are easy to misread — here's what each one actually means:

    +
    + + Money the person paid themselves directly (cash/card/eft/online). Never includes money that reached their order via someone else's donation. + + + The portion of an order that was covered by an assigned donation. This is part of what's "settled" on the order, but it's the donor's money, not the registrant's — so it's broken out separately and attributed to the donor elsewhere in the report. + + + What's still owed on an order, after direct payments and any donation cover. + + + Real money already received as a donation that hasn't been applied to any order yet. + + + How much of a given donation has been assigned to orders (Used) versus what's still available to assign (Unused). A donation is never overwritten when assigned — the original donation record always keeps its full original amount. + +
    +
    + )} + + {tab === "help" && ( +
    +

    Still stuck? Reach out to the site administrator — they can check the underlying data with you or flag anything that looks wrong.

    +
    +
    + +
    +
    +
    Site administrator
    + {ADMIN_EMAIL} +
    +
    +

    Financial figures matter — if a number in a report doesn't look right, it's always worth asking rather than assuming.

    +
    + )} +
    +
    + +
    + + +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/reports/ReportsShell.tsx b/frontend/src/components/reports/ReportsShell.tsx new file mode 100644 index 0000000..36aab9c --- /dev/null +++ b/frontend/src/components/reports/ReportsShell.tsx @@ -0,0 +1,205 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { ArrowLeft, HelpCircle, Search } from "lucide-react"; +import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog"; +import EventsDropdown from "./EventsDropdown"; + +type EventLite = { id: string; title: string }; +type DatePreset = "all_time" | "this_month" | "last_month" | "this_year" | "custom"; + +export default function ReportsShell({ + events, isAdmin, + showPastEvents, setShowPastEvents, + showInactiveEvents, setShowInactiveEvents, + showClosedEvents, setShowClosedEvents, + selectedEventIds, setSelectedEventIds, + dateFrom, setDateFrom, dateTo, setDateTo, + report, setReport, + onViewReport, busy, + onOpenGuide, + onBack, +}: { + events: EventLite[]; isAdmin: boolean; + showPastEvents: boolean; setShowPastEvents: (v: boolean) => void; + showInactiveEvents: boolean; setShowInactiveEvents: (v: boolean) => void; + showClosedEvents: boolean; setShowClosedEvents: (v: boolean) => void; + selectedEventIds: string[]; setSelectedEventIds: (v: string[]) => void; + dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void; + report: ReportKey; setReport: (r: ReportKey) => void; + onViewReport: () => void; busy: boolean; + onOpenGuide: () => void; + onBack?: () => void; +}) { + const [search, setSearch] = useState(""); + const [category, setCategory] = useState<"All" | ReportCategory>("All"); + const [datePreset, setDatePreset] = useState("all_time"); + + const iso = (d: Date) => d.toISOString().slice(0, 10); + const applyPreset = (preset: DatePreset) => { + setDatePreset(preset); + const now = new Date(); + if (preset === "this_month") { + setDateFrom(iso(new Date(now.getFullYear(), now.getMonth(), 1))); + setDateTo(iso(new Date(now.getFullYear(), now.getMonth() + 1, 0))); + } else if (preset === "last_month") { + setDateFrom(iso(new Date(now.getFullYear(), now.getMonth() - 1, 1))); + setDateTo(iso(new Date(now.getFullYear(), now.getMonth(), 0))); + } else if (preset === "this_year") { + setDateFrom(iso(new Date(now.getFullYear(), 0, 1))); + setDateTo(iso(new Date(now.getFullYear(), 11, 31))); + } else if (preset === "all_time") { + setDateFrom(""); setDateTo(""); + } + // 'custom' leaves dateFrom/dateTo as whatever's typed in the fields below + }; + + const filteredReports = useMemo(() => { + return REPORTS.filter(r => { + if (category !== "All" && r.category !== category) return false; + if (search.trim()) { + const q = search.trim().toLowerCase(); + if (!r.label.toLowerCase().includes(q) && !r.description.toLowerCase().includes(q)) return false; + } + return true; + }); + }, [search, category]); + + return ( +
    +
    +
    +

    Reports

    +

    View, export, or email operational reports for events.

    +
    +
    +
    + + setSearch(e.target.value)} + /> +
    + {onBack && ( + + )} +
    +
    + +
    + {/* Sidebar: universal filters */} + + + {/* Main: categories, report grid */} +
    +
    + {(["All", ...REPORT_CATEGORIES] as const).map(c => ( + + ))} +
    + +
    + {filteredReports.map(r => { + const Icon = r.icon; + const selected = report === r.key; + return ( + + ); + })} + {filteredReports.length === 0 && ( +
    No reports match your search.
    + )} +
    + +
    +
    Filters will be applied to the selected report where relevant.
    + +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/reports/ReportsV2.tsx b/frontend/src/components/reports/ReportsV2.tsx index 3973c04..7ea9374 100644 --- a/frontend/src/components/reports/ReportsV2.tsx +++ b/frontend/src/components/reports/ReportsV2.tsx @@ -3,26 +3,14 @@ import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch, API_BASE } from "@/lib/api"; -import { downloadCsv, emailReportPdf, downloadReportPdf } from "@/lib/export"; - -// Types for report selection -const REPORTS = [ - { key: "payments", label: "Payments between dates" }, - { key: "attendees", label: "Attendees per event (grouped by option)" }, - { key: "regTypes", label: "Registration type counts" }, - { key: "usage", label: "Ticket usage summary" }, - { key: "revenue", label: "Revenue summary (by method)" }, - { key: "revenueDetailed", label: "Revenue detailed" }, - { key: "masterOrders", label: "Master orders breakdown" }, - { key: "regStatus", label: "Registration status breakdown" }, - { key: "donations", label: "Donations breakdown" }, - { key: "cashup", label: "Cashup reconciliation" }, - { key: "financeReport", label: "Finance report (revenue & costs)" }, - { key: "profitReport", label: "Profit report" }, - { key: "cashupAudit", label: "Cashup audit trail" }, -] as const; - -type ReportKey = typeof REPORTS[number]["key"]; +import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export"; +import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp } from "lucide-react"; +import { REPORTS, type ReportKey } from "./ReportCatalog"; +import ReportsShell from "./ReportsShell"; +import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal"; +import ReportingGuideModal, { GUIDE_DISMISSED_KEY } from "./ReportingGuideModal"; +import { StatTile, StatTileRow } from "./StatTile"; +import { HorizontalBarChart } from "./charts/HorizontalBarChart"; const METHOD_KEYS = ["cash", "card", "eft", "other"] as const; const METHOD_LABEL: Record = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; @@ -32,6 +20,15 @@ function denomLabel(v: number): string { function money2(n: number | null | undefined): string { return `R${Number(n || 0).toFixed(2)}`; } +// A donation is never mutated once created — assigning it to a registration creates a separate +// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead. +// That leg is not new money: it just re-labels part of an already-counted donation as applied +// to a registration. Aggregate revenue/received totals must count each real inflow exactly +// once, so legs are excluded — the money was already counted via the original donation row. +// (Refunds also set originalPaymentId, but always with a negative amount, so they're unaffected.) +function isDonationLeg(p: any): boolean { + return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0; +} function actionLabel(action: string): string { return action === "closed" ? "Closed (full cashup)" : action === "quick_closed" ? "Quick closed" : "Reopened"; } @@ -90,25 +87,7 @@ function optionUnitPrice(ro: any, referenceTime: any, atTime: Date): number { } // Simple MultiSelect control used in filters -function MultiSelect({ options, value, onChange }: { options: { value: string; label: string }[]; value: string[]; onChange: (v: string[]) => void }) { - const toggle = (v: string) => { - const set = new Set(value); - if (set.has(v)) set.delete(v); else set.add(v); - onChange(Array.from(set)); - }; - return ( -
    - {options.map(opt => ( - - ))} -
    - ); -} - -export default function ReportsV2() { +export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) { const { token, user } = useAuth(); const role = user?.role || "user"; const canView = role === "admin" || role === "supervisor"; @@ -155,47 +134,54 @@ export default function ReportsV2() { const [report, setReport] = useState("payments"); const [ready, setReady] = useState(false); // toggled by View button const [busy, setBusy] = useState(false); + const [popupOpen, setPopupOpen] = useState(false); + const [guideOpen, setGuideOpen] = useState(false); - // Filters per report - // Payments - const [payFrom, setPayFrom] = useState(""); - const [payTo, setPayTo] = useState(""); - const [payEvents, setPayEvents] = useState([]); + // Show the reporting guide automatically on first visit, unless the user dismissed it for good. + useEffect(() => { + try { + if (typeof window !== "undefined" && window.localStorage.getItem(GUIDE_DISMISSED_KEY) !== "1") { + setGuideOpen(true); + } + } catch {} + }, []); - // Attendees + const closeGuide = (dontShowAgain: boolean) => { + setGuideOpen(false); + if (dontShowAgain) { + try { window.localStorage.setItem(GUIDE_DISMISSED_KEY, "1"); } catch {} + } + }; + + // Universal filters — apply to every report's onView loader (per-report filters below stay + // report-specific). A single events selection replaces what used to be ~10 separate + // per-report "Events" multi-selects, and a single date range replaces payFrom/payTo and + // auditFrom/auditTo — both now live in the sidebar (ReportsShell) instead of being re-picked + // per report. + const [selectedEventIds, setSelectedEventIds] = useState([]); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + // Back-compat aliases so the many existing date-filtered useMemo/CSV/PDF/email blocks below + // (written against payFrom/payTo/auditFrom/auditTo) don't all need renaming. + const payFrom = dateFrom, payTo = dateTo, auditFrom = dateFrom, auditTo = dateTo; + + // Attendees (single-event report — defaults to the first universally-selected event, with + // its own picker in the popup if more than one event is selected) const [attEventId, setAttEventId] = useState(""); const [attIncludeCancelled, setAttIncludeCancelled] = useState(false); - // Registration type counts - const [regTypeEvents, setRegTypeEvents] = useState([]); - - // Ticket usage summary - const [usageEvents, setUsageEvents] = useState([]); - - // Revenue summary - const [revEvents, setRevEvents] = useState([]); - // Revenue detailed - const [revDetEvents, setRevDetEvents] = useState([]); - - //Master Events - const [masterEvents, setMasterEvents] = useState([]); + // Master orders breakdown — in-popup search boxes for the Orders / Donations made tables + const [masterOrderSearch, setMasterOrderSearch] = useState(""); + const [masterDonationSearch, setMasterDonationSearch] = useState(""); // Registration status breakdown - const [statusEvents, setStatusEvents] = useState([]); const [statusIncludeCancelled, setStatusIncludeCancelled] = useState(true); - - // Donations breakdown - const [donationEvents, setDonationEvents] = useState([]); - - // Cashup / Finance / Profit (all driven by the same per-event financials endpoint) - const [cashupEvents, setCashupEvents] = useState([]); - const [financeEvents, setFinanceEvents] = useState([]); - const [profitEvents, setProfitEvents] = useState([]); - - // Cashup audit trail - const [auditEvents, setAuditEvents] = useState([]); - const [auditFrom, setAuditFrom] = useState(""); - const [auditTo, setAuditTo] = useState(""); + const [statusCountMode, setStatusCountMode] = useState<'registrations' | 'tickets'>('registrations'); + // 'registrations' mode: one count per registration row. 'tickets' mode: sum of ticket + // quantity across the registration's options, so someone with 3 tickets counts as 3. + const countForStatus = (r: any) => statusCountMode === 'tickets' + ? (r.registrationOptions || []).reduce((s: number, ro: any) => s + (ro.quantity || 0), 0) + : 1; // Data stores const [paymentsByEvent, setPaymentsByEvent] = useState>({}); @@ -204,24 +190,20 @@ export default function ReportsV2() { const [financialsByEvent, setFinancialsByEvent] = useState>({}); const [auditRows, setAuditRows] = useState([]); - // Initialize default selections when events load + // Initialize default selection when events load useEffect(() => { if (filteredEvents.length === 0) return; - const first1 = filteredEvents.slice(0, 1).map((e: any) => e.id); - if (payEvents.length === 0) setPayEvents(first1); - if (regTypeEvents.length === 0) setRegTypeEvents(first1); - if (usageEvents.length === 0) setUsageEvents(first1); - if (revEvents.length === 0) setRevEvents(first1); - if (revDetEvents.length === 0) setRevDetEvents(first1); - if (statusEvents.length === 0) setStatusEvents(first1); + if (selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id)); if (!attEventId) setAttEventId(filteredEvents[0].id); - if (masterEvents.length === 0) setMasterEvents(first1); - if (donationEvents.length === 0) setDonationEvents(first1); - if (cashupEvents.length === 0) setCashupEvents(first1); - if (financeEvents.length === 0) setFinanceEvents(first1); - if (profitEvents.length === 0) setProfitEvents(first1); }, [filteredEvents]); + // Attendees' single-event picker defaults to (and stays within) the universal selection. + useEffect(() => { + if (selectedEventIds.length > 0 && !selectedEventIds.includes(attEventId)) { + setAttEventId(selectedEventIds[0]); + } + }, [selectedEventIds]); + // Loaders const loadPayments = async (eventIds: string[]) => { if (!token || !eventIds.length) return {} as Record; @@ -300,52 +282,52 @@ export default function ReportsV2() { setReady(false); try { if (report === "payments") { - const byEv = await loadPayments(payEvents); + const byEv = await loadPayments(selectedEventIds); setPaymentsByEvent(byEv); } else if (report === "attendees") { - const byEv = await loadRegistrations([attEventId]); + const byEv = await loadRegistrations([attEventId || selectedEventIds[0]].filter(Boolean)); setRegistrationsByEvent(byEv); } else if (report === "regTypes") { - const byEv = await loadRegistrations(regTypeEvents); + const byEv = await loadRegistrations(selectedEventIds); setRegistrationsByEvent(byEv); } else if (report === "usage") { - const usage = await loadTicketUsage(usageEvents); + const usage = await loadTicketUsage(selectedEventIds); setTicketUsage(usage); } else if (report === "revenue") { - const byEv = await loadPayments(revEvents); + const byEv = await loadPayments(selectedEventIds); setPaymentsByEvent(byEv); } else if (report === "revenueDetailed") { const [byEvP, byEvR] = await Promise.all([ - loadPayments(revDetEvents), - loadRegistrations(revDetEvents) + loadPayments(selectedEventIds), + loadRegistrations(selectedEventIds) ]); setPaymentsByEvent(byEvP); setRegistrationsByEvent(byEvR); } else if (report === "regStatus") { - const byEv = await loadRegistrations(statusEvents); + const byEv = await loadRegistrations(selectedEventIds); setRegistrationsByEvent(byEv); } else if (report === "masterOrders") { const [byEvP, byEvR] = await Promise.all([ - loadPayments(masterEvents), - loadRegistrations(masterEvents) + loadPayments(selectedEventIds), + loadRegistrations(selectedEventIds) ]); setPaymentsByEvent(byEvP); setRegistrationsByEvent(byEvR); } else if (report === "donations") { - const byEv = await loadPayments(donationEvents); + const byEv = await loadPayments(selectedEventIds); setPaymentsByEvent(byEv); } else if (report === "cashup") { - const byEv = await loadFinancials(cashupEvents); + const byEv = await loadFinancials(selectedEventIds); setFinancialsByEvent(byEv); } else if (report === "financeReport") { - const byEv = await loadFinancials(financeEvents); + const byEv = await loadFinancials(selectedEventIds); setFinancialsByEvent(byEv); } else if (report === "profitReport") { - const byEv = await loadFinancials(profitEvents); + const byEv = await loadFinancials(selectedEventIds); setFinancialsByEvent(byEv); } else if (report === "cashupAudit") { - const rows = await loadCashupAudit(auditEvents); + const rows = await loadCashupAudit(selectedEventIds); setAuditRows(rows); } setReady(true); @@ -367,15 +349,23 @@ export default function ReportsV2() { if (report !== "payments") return [] as any[]; const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; - const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string }[] = []; + const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string; recordedByName: string }[] = []; for (const evId of Object.keys(paymentsByEvent)) { const ev = filteredEvents.find(e => e.id === evId); const evTitle = ev?.title || evId; for (const p of paymentsByEvent[evId] || []) { + // Exclude donation-application legs — that money was already counted once, as the + // donation itself. Only real inflows (payments and donations) belong here. + if (isDonationLeg(p)) continue; const t = new Date(p.createdAt).getTime(); if ((df && t < df) || (dt && t > dt)) continue; // Reporting rule: if donation, attribute to payer; otherwise attribute to the registration's assigned user const u = p.isDonation ? (p.user || {}) : ((p.registration?.user || p.user) || {}); + // Self-service (webhook) payments record the payer as recordedBy too — only call it + // out when a different staff member actually recorded it, to avoid noise. + const recordedByName = (p.recordedBy && String(p.recordedBy.id) !== String(p.userId)) + ? (p.recordedBy.name || p.recordedBy.email || "") + : ""; rows.push({ eventId: evId, eventTitle: evTitle, @@ -385,6 +375,7 @@ export default function ReportsV2() { method: getPaymentMethod(p), isDonation: p.isDonation, createdAt: p.createdAt, + recordedByName, }); } } @@ -443,23 +434,6 @@ export default function ReportsV2() { return map; }, [paymentsByEvent]); - const donationsByUser = useMemo(() => { - const map = new Map(); - - Object.values(paymentsByEvent).forEach((payments: any) => { - (payments || []).forEach((p: any) => { - if (!p.isDonation) return; - - const userId = p.user?.id || p.userId; - if (!userId) return; - - map.set(userId, (map.get(userId) || 0) + (p.amount || 0)); - }); - }); - - return map; - }, [paymentsByEvent]); - // Build payment totals per registration (all payments, no date filter) const paidByReg = useMemo(() => { const map = new Map(); @@ -473,13 +447,63 @@ export default function ReportsV2() { return map; }, [paymentsByEvent]); + // How much of each registration's Total paid (above) came from an assigned donation leg, + // rather than a direct cash/card/eft/online payment — this is a subset of paidByReg, not + // additional money, and is unambiguous per-registration (unlike "this donor's total + // donations", which isn't specific to this order and shouldn't be repeated per row). + const paidViaDonationByReg = useMemo(() => { + const map = new Map(); + Object.keys(paymentsByEvent).forEach(evId => { + (paymentsByEvent[evId] || []).forEach((p: any) => { + if (p.registrationId && isDonationLeg(p)) { + map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.amount || 0)); + } + }); + }); + return map; + }, [paymentsByEvent]); + + // Donations made (as donor), with used/unused split — independent of any single registration, + // shown as its own breakdown so "who gave what" is never conflated with "what this order cost". + // Shared by Master Orders and Revenue Detailed — both need to attribute the full donation + // amount to the donor, never to whichever registration a portion of it happened to fund. + const donationsMadeRows = useMemo(() => { + const rows: { eventId: string; eventTitle: string; donor: string; email?: string; amount: number; used: number; unused: number; createdAt: string }[] = []; + Object.keys(paymentsByEvent).forEach(evId => { + const ev = filteredEvents.find(e => e.id === evId); + const evTitle = ev?.title || evId; + const evPayments = paymentsByEvent[evId] || []; + evPayments.filter((p: any) => p.isDonation).forEach((donation: any) => { + const used = evPayments + .filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donation.id) + .reduce((s: number, leg: any) => s + (leg.amount || 0), 0); + rows.push({ + eventId: evId, + eventTitle: evTitle, + donor: donation.user?.name || donation.userId || "Donor", + email: donation.user?.email, + amount: donation.amount || 0, + used, + unused: Math.max((donation.amount || 0) - used, 0), + createdAt: donation.createdAt, + }); + }); + }); + rows.sort((a, b) => a.eventTitle.localeCompare(b.eventTitle) || a.donor.localeCompare(b.donor)); + return rows; + }, [paymentsByEvent, filteredEvents]); + const revDetRows = useMemo(() => { if (report !== "revenueDetailed") return [] as any[]; const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number }; const rows: Row[] = []; - // Iterate registrations to include those with zero payments and show each once + // Iterate registrations to include those with zero payments and show each once. "Total paid" + // is what THIS person actually paid directly — money that reached them via an assigned + // donation is not their payment, so it's excluded here and attributed to the donor instead + // (below). Outstanding still reflects the full picture (direct + donation-funded), since + // that's genuinely how much of the order remains unsettled. Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const evTitle = ev?.title || evId; @@ -487,23 +511,43 @@ export default function ReportsV2() { const created = r.createdAt ? new Date(r.createdAt).getTime() : null; if (df && created && created < df) return; if (dt && created && created > dt) return; - const totalPaid = paidByReg.get(r.id) || 0; + const directPaid = Math.max((paidByReg.get(r.id) || 0) - (paidViaDonationByReg.get(r.id) || 0), 0); const outstanding = outstandingByReg.get(r.id) || 0; rows.push({ eventId: evId, eventTitle: evTitle, userName: r.user?.name || r.userId || "User", userEmail: r.user?.email, - totalPaid, + totalPaid: directPaid, status: r.status, registrationId: r.id, outstanding, }); }); }); + + // Every donation is real money received from the donor, whether or not (or how much of) it + // has since been assigned to fund someone else's registration — so it's attributed here in + // full, under the donor, never under whichever registration it happened to fund. + donationsMadeRows.forEach(d => { + const created = d.createdAt ? new Date(d.createdAt).getTime() : null; + if (df && created && created < df) return; + if (dt && created && created > dt) return; + rows.push({ + eventId: d.eventId, + eventTitle: d.eventTitle, + userName: d.donor, + userEmail: d.email, + totalPaid: d.amount, + status: d.used <= 0.000001 ? "donation (unassigned)" : (d.unused > 0.000001 ? "donation (partly assigned)" : "donation (fully assigned)"), + registrationId: "", + outstanding: 0, + }); + }); + rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || ''))); return rows; - }, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg]); + }, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, paidViaDonationByReg, outstandingByReg, donationsMadeRows]); // Derived for attendees (layered) for a single event const attendeesLayer = useMemo(() => { @@ -623,14 +667,19 @@ export default function ReportsV2() { } }); + // "Paid" is what this person actually paid directly — money that reached this + // registration via an assigned donation is the donor's money, not theirs, so it's + // excluded here and shown separately in "Paid via donation" instead. + const paidViaDonation = paidViaDonationByReg.get(r.id) || 0; + const directPaid = Math.max((paidByReg.get(r.id) || 0) - paidViaDonation, 0); const baseRow: any = { eventTitle, name: r.user?.name || "User", email: r.user?.email, orderTotal: 0, - totalPaid: paidByReg.get(r.id) || 0, + totalPaid: directPaid, outstanding: outstandingByReg.get(r.id) || 0, - donations: donationsByUser.get(r.user?.id || r.userId) || 0, + paidViaDonation, __prices: {} as Record // 👈 hidden helper }; @@ -660,7 +709,7 @@ export default function ReportsV2() { return rows; - }, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg]); + }, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg, paidViaDonationByReg]); //Master Report Totals const masterTotals = useMemo(() => { @@ -670,7 +719,7 @@ export default function ReportsV2() { orderTotal: 0, totalPaid: 0, outstanding: 0, - donations: 0, + paidViaDonation: 0, }; // 👇 initialise dynamic option totals @@ -683,6 +732,7 @@ export default function ReportsV2() { totals.orderTotal += row.orderTotal; totals.totalPaid += row.totalPaid; totals.outstanding += row.outstanding; + totals.paidViaDonation += row.paidViaDonation || 0; masterOptions.forEach(opt => { const qty = row[opt.name] || 0; @@ -699,102 +749,145 @@ export default function ReportsV2() { }); }); - totals.donations = Array.from(donationsByUser.values()) - .reduce((s, v) => s + v, 0); + // Donations logged for these events that haven't (fully) been applied to any order yet — + // real money already received, sitting unused, distinct from what orders actually cost. + totals.unassignedDonations = donationsMadeRows.reduce((s, d) => s + d.unused, 0); return totals; - }, [masterRows, masterOptions, report]); + }, [masterRows, masterOptions, report, donationsMadeRows]); // Export/print/email handlers per report - const doExportCsv = () => { + // Builds the export payload for the current report — one shared builder consumed by Print, + // Email, WhatsApp, and Excel, so all four channels always show identical data/styling instead + // of four independently-maintained copies drifting apart. Stats/chart data (when present) + // mirror exactly what the on-screen report shows above its table, computed the same way. + const buildReportPayload = (): ReportPdfPayload | null => { + const subtitle = `Generated ${new Date().toLocaleString()}`; if (report === "payments") { - return downloadCsv(`payments_${new Date().toISOString().slice(0,10)}`, paymentRows.map(r => ({ - Event: r.eventTitle, - User: r.userName, - Email: r.userEmail || "", - Amount: r.amount, - Method: r.method, - Donation: r.isDonation ? "Yes" : "No", - Date: new Date(r.createdAt).toLocaleString(), - }))); + return { + title: 'Payments Report', subtitle, kind: 'table', orientation: 'portrait', + table: { + columns: ["Event","User","Email","Amount","Method","Donation","Recorded by","Date"], + rows: paymentRows.map(r => [r.eventTitle, r.userName, r.userEmail || "", `R ${Number(r.amount).toFixed(2)}`, r.method, r.isDonation ? "Yes" : "No", r.recordedByName || "", new Date(r.createdAt).toLocaleString()]) + } + }; } if (report === "attendees") { - const rows: any[] = []; - attendeesLayer.options.forEach(opt => { - opt.users.forEach((u: any) => rows.push({ Event: attendeesLayer.eventTitle, Option: opt.optionName, Name: u.name, Email: u.email || "", Status: u.status, Qty: u.Qty })); - }); - return downloadCsv(`attendees_${attEventId}_${new Date().toISOString().slice(0,10)}`, rows); + const sections = attendeesLayer.options.map(opt => ({ + title: opt.optionName, + items: opt.users.map((u: any) => `${u.name}${u.email ? ` (${u.email})` : ''} — ${u.status}${typeof u.qty==='number' ? ` • Qty: ${u.qty}` : ''}`) + })); + return { title: 'Attendees Report', subtitle, kind: 'layered', orientation: 'portrait', layered: { header: `Event: ${attendeesLayer.eventTitle}`, sections } }; } if (report === "regTypes") { - return downloadCsv(`registration_types_${new Date().toISOString().slice(0,10)}`, regTypeRows.map(r => ({ Event: r.eventTitle, Type: r.type, Quantity: r.qty }))); + const byType = new Map(); + regTypeRows.forEach(r => byType.set(r.type, (byType.get(r.type) || 0) + r.qty)); + return { + title: 'Registration Types Report', subtitle, kind: 'table', orientation: 'portrait', + chart: { title: 'Across all selected events', data: Array.from(byType.entries()).map(([label, value]) => ({ label, value })) }, + table: { columns: ["Event","Type","Quantity"], rows: regTypeRows.map(r => [r.eventTitle, r.type, String(r.qty)]) } + }; } if (report === "usage") { - const rows: any[] = []; + const rows: (string|number)[][] = []; + let totalUsed = 0, totalUnused = 0; Object.keys(ticketUsage).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); - rows.push({ Event: ev?.title || evId, Used: ticketUsage[evId].used, Unused: ticketUsage[evId].unused }); + const tu = ticketUsage[evId]; + rows.push([ev?.title || evId, tu.used, tu.unused]); + totalUsed += tu.used; totalUnused += tu.unused; }); - return downloadCsv(`ticket_usage_${new Date().toISOString().slice(0,10)}`, rows); + return { + title: 'Ticket Usage Report', subtitle, kind: 'table', orientation: 'portrait', + chart: { title: 'Used vs. unused, across all selected events', data: [{ label: "Used", value: totalUsed }, { label: "Unused", value: totalUnused }] }, + table: { columns: ["Event","Used","Unused"], rows } + }; } if (report === "revenue") { const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; - const rows: any[] = []; + const rows: (string|number)[][] = []; + const byMethodTotal: Record = {}; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const list = (paymentsByEvent[evId] || []).filter((p: any) => { + if (isDonationLeg(p)) return false; // already counted once, as the source donation const t = new Date(p.createdAt).getTime(); return !(df && t < df) && !(dt && t > dt); }); const byMethod: Record = {}; let total = 0; - list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); }); - if (list.length === 0) rows.push({ Event: ev?.title || evId, Method: '-', Amount: 0, Total: 0 }); - Object.keys(byMethod).forEach(m => rows.push({ Event: ev?.title || evId, Method: m, Amount: byMethod[m], Total: total })); + list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); byMethodTotal[m] = (byMethodTotal[m] || 0) + (p.amount || 0); total += (p.amount || 0); }); + if (Object.keys(byMethod).length === 0) rows.push([ev?.title || evId, '-', 0, 0]); + Object.keys(byMethod).forEach(m => rows.push([ev?.title || evId, m, Number(byMethod[m].toFixed(2)), Number(total.toFixed(2))])); }); - return downloadCsv(`revenue_${new Date().toISOString().slice(0,10)}`, rows); + return { + title: 'Revenue Summary', subtitle, kind: 'table', orientation: 'portrait', + chart: { title: 'By method, across all selected events', data: Object.keys(byMethodTotal).sort().map(m => ({ label: m, value: byMethodTotal[m], displayValue: `R ${byMethodTotal[m].toFixed(2)}` })) }, + table: { columns: ["Event","Method","Amount","Event Total"], rows } + }; } if (report === "revenueDetailed") { - return downloadCsv(`revenue_detailed_${new Date().toISOString().slice(0,10)}`, revDetRows.map((r: any) => ({ - Event: r.eventTitle, - Name: r.userName, - Email: r.userEmail || "", - Status: r.status || "", - TotalPaid: r.totalPaid, - Outstanding: r.outstanding, - RegistrationId: r.registrationId - }))); + const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ + r.eventTitle, r.userName, r.userEmail || "", r.status || "", + Number((r.totalPaid || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "", + ]); + return { + title: 'Revenue Detailed', subtitle, kind: 'table', orientation: 'landscape', + table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows } + }; } if (report === "regStatus") { - const rows: any[] = []; + const rows: (string|number)[][] = []; + const agg: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; - const counts: Record = {}; - const statuses = ["pending","partial_paid","paid","cancelled"]; - statuses.forEach(s => counts[s] = 0); - regs.forEach((r: any) => { - if (!statusIncludeCancelled && r.status === 'cancelled') return; - counts[r.status] = (counts[r.status] || 0) + 1; - }); - rows.push({ Event: ev?.title || evId, Pending: counts["pending"] || 0, Partial: counts["partial_paid"] || 0, Paid: counts["paid"] || 0, Cancelled: counts["cancelled"] || 0 }); + const counts: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; + regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + countForStatus(r); agg[r.status] = (agg[r.status] || 0) + countForStatus(r); }); + rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); }); - return downloadCsv(`registration_status_${new Date().toISOString().slice(0,10)}`, rows); + const unit = statusCountMode === 'tickets' ? 'tickets' : 'registrations'; + return { + title: 'Registration Status Breakdown', subtitle, kind: 'table', + chart: { title: `Across all selected events, by ${unit}`, data: [ + { label: "Pending", value: agg.pending || 0 }, { label: "Partial", value: agg.partial_paid || 0 }, + { label: "Paid", value: agg.paid || 0 }, { label: "Cancelled", value: agg.cancelled || 0 }, + ] }, + table: { columns: ["Event", statusCountMode === 'tickets' ? "Pending (tickets)" : "Pending", statusCountMode === 'tickets' ? "Partial (tickets)" : "Partial", statusCountMode === 'tickets' ? "Paid (tickets)" : "Paid", statusCountMode === 'tickets' ? "Cancelled (tickets)" : "Cancelled"], rows } + }; } if (report === "donations") { - const rows: any[] = []; + const usedFor = (evId: string, donationId: string) => + (paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId) + .reduce((s: number, leg: any) => s + (leg.amount || 0), 0); + const rows: (string|number)[][] = []; + let grandTotal = 0, grandUsed = 0; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); - (paymentsByEvent[evId] || []).filter((p: any) => p.isDonation).forEach((p: any) => { - const u = p.user || {}; - rows.push({ Event: ev?.title || evId, Donor: u.name || u.email || p.userId || '', Email: u.email || '', Amount: p.amount, Method: getPaymentMethod(p), Date: new Date(p.createdAt).toLocaleString() }); - }); + const evPayments = paymentsByEvent[evId] || []; + const dons = evPayments.filter((p: any) => p.isDonation); + const total = dons.reduce((s: number, p: any) => s + (p.amount || 0), 0); + const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0); + const unused = Math.max(total - used, 0); + grandTotal += total; grandUsed += used; + rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length]); }); - return downloadCsv(`donations_${new Date().toISOString().slice(0,10)}`, rows); + const grandUnused = Math.max(grandTotal - grandUsed, 0); + return { + title: 'Donations Breakdown', subtitle, kind: 'table', orientation: 'portrait', + stats: [ + { label: 'Total donated', value: `R ${grandTotal.toFixed(2)}`, tone: 'rose' }, + { label: 'Used', value: `R ${grandUsed.toFixed(2)}`, tone: 'violet' }, + { label: 'Unused', value: `R ${grandUnused.toFixed(2)}`, tone: 'amber' }, + ], + chart: { title: 'Used vs. unused, across all selected events', data: [{ label: "Used", value: grandUsed, displayValue: `R ${grandUsed.toFixed(2)}` }, { label: "Unused", value: grandUnused, displayValue: `R ${grandUnused.toFixed(2)}` }] }, + table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows } + }; } if (report === "cashup") { - const cols = ["Event", "Section", "Detail", "Income", "CostsFromMethod", "ExpectedCash", "Actual", "Variance"]; - const rows: any[] = []; + const rows: (string | number)[][] = []; + const incomeByMethod: Record = {}; Object.keys(financialsByEvent).forEach(evId => { const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); @@ -802,368 +895,188 @@ export default function ReportsV2() { const title = ev?.title || evId; METHOD_KEYS.forEach(m => { const r = f.reconciled?.byMethod?.[m]; - rows.push({ Event: title, Section: "Method", Detail: METHOD_LABEL[m], Income: f.paymentsByMethod?.[m] || 0, CostsFromMethod: f.costsByMethod?.[m] || 0, ExpectedCash: f.expectedCashByMethod?.[m] || 0, Actual: r?.actual ?? "", Variance: r?.variance ?? "" }); + incomeByMethod[m] = (incomeByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); + rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); if (m === "cash") { - (r?.denominations || []).forEach((d: any) => rows.push({ Event: title, Section: "Denomination", Detail: denomLabel(d.value) + " × " + d.count, Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: d.value * d.count, Variance: "" })); + (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""])); } }); - if ((f.untaggedCostsTotal || 0) > 0) { - rows.push({ Event: title, Section: "Untagged costs", Detail: "Not tied to a specific method", Income: "", CostsFromMethod: f.untaggedCostsTotal, ExpectedCash: "", Actual: "", Variance: "" }); - } - rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Income: "", CostsFromMethod: "", ExpectedCash: "", Actual: f.unallocatedDonationsTotal || 0, Variance: "" }); + if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]); + rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]); }); - return downloadCsv(`cashup_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); + return { + title: 'Cashup Report', subtitle, kind: 'table', orientation: 'landscape', + chart: { title: 'Income by method, across all selected events', data: METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: incomeByMethod[m] || 0, displayValue: `R ${(incomeByMethod[m] || 0).toFixed(2)}` })) }, + table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows } + }; } if (report === "financeReport") { - const cols = ["Event", "Section", "Detail", "Amount"]; - const rows: any[] = []; + const rows: (string | number)[][] = []; + let sold = 0, paidByAll = 0, donationsProfit = 0, costsTotal = 0, netProfitTotal = 0; + const paidByMethod: Record = {}; Object.keys(financialsByEvent).forEach(evId => { const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); if (!f) return; const title = ev?.title || evId; - (f.salesByOption || []).forEach((s: any) => rows.push({ Event: title, Section: "Sales", Detail: `${s.name} (${s.quantitySold} sold)`, Amount: s.revenue })); - rows.push({ Event: title, Section: "Sales", Detail: "Donations", Amount: f.totalDonations || 0 }); - METHOD_KEYS.forEach(m => rows.push({ Event: title, Section: "Paid by", Detail: METHOD_LABEL[m], Amount: f.paymentsByMethod?.[m] || 0 })); + sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0); + paidByAll += METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0); + METHOD_KEYS.forEach(m => { paidByMethod[m] = (paidByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); }); + donationsProfit += f.unallocatedDonationsTotal || 0; + costsTotal += f.totalCosts || 0; + netProfitTotal += f.netProfit || 0; + // "Sales" is ticket-price revenue for orders that reached 'paid' status — whether that + // order was funded directly or via an assigned donation, its full price is already + // counted here once. A separate "Donations" line for the full donation total would + // double-count the assigned portion; only the still-unassigned remainder is money not + // otherwise reflected in Sales, and that's already shown below under "Donations counted + // as profit" — no need to repeat it here. + rows.push([title, "Sales", "What was sold", ""]); + (f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))])); + rows.push([title, "Paid by", "", ""]); + METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))])); + rows.push([title, "Cashup comparison", "", ""]); METHOD_KEYS.forEach(m => { const r = f.reconciled?.byMethod?.[m]; - rows.push({ Event: title, Section: "Cashup comparison", Detail: `${METHOD_LABEL[m]} — expected`, Amount: f.expectedCashByMethod?.[m] || 0 }); - rows.push({ Event: title, Section: "Cashup comparison", Detail: `${METHOD_LABEL[m]} — actual`, Amount: r?.actual ?? "" }); - rows.push({ Event: title, Section: "Cashup comparison", Detail: `${METHOD_LABEL[m]} — variance`, Amount: r?.variance ?? "" }); + rows.push([title, "", `${METHOD_LABEL[m]} — expected / actual / variance`, `${money2(f.expectedCashByMethod?.[m])} / ${r?.actual != null ? money2(r.actual) : "—"} / ${r?.variance != null ? money2(r.variance) : "—"}`]); }); - (f.costs || []).forEach((c: any) => rows.push({ Event: title, Section: "Costs", Detail: `${c.label}${c.paidFromMethod ? ` (paid via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Amount: -(c.total ?? c.amount) })); - rows.push({ Event: title, Section: "Donations counted as profit", Detail: "Unallocated donations", Amount: f.unallocatedDonationsTotal || 0 }); - rows.push({ Event: title, Section: "Net profit", Detail: "", Amount: f.netProfit || 0 }); + rows.push([title, "Costs", "", ""]); + (f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))])); + rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]); + rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]); }); - return downloadCsv(`finance_report_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); + return { + title: 'Finance Report', subtitle, kind: 'table', orientation: 'landscape', + stats: [ + { label: 'What was sold', value: `R ${sold.toFixed(2)}`, tone: 'green' }, + { label: 'Paid by (all methods)', value: `R ${paidByAll.toFixed(2)}`, tone: 'blue' }, + { label: 'Donations counted as profit', value: `R ${donationsProfit.toFixed(2)}`, tone: 'rose' }, + { label: 'Total costs', value: `R ${costsTotal.toFixed(2)}`, tone: 'amber' }, + { label: 'Net profit', value: `R ${netProfitTotal.toFixed(2)}`, tone: 'violet' }, + ], + chart: { title: 'Paid by, across all selected events', data: METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: paidByMethod[m] || 0, displayValue: `R ${(paidByMethod[m] || 0).toFixed(2)}` })) }, + table: { columns: ["Event", "Section", "Detail", "Amount"], rows } + }; } if (report === "profitReport") { - const rows = Object.keys(financialsByEvent).map(evId => { + const evIds = Object.keys(financialsByEvent); + const totals = evIds.reduce((acc, evId) => { + const f = financialsByEvent[evId]; + acc.revenue += f?.effectiveTotalRevenue || 0; + acc.costs += f?.totalCosts || 0; + acc.profit += f?.netProfit || 0; + return acc; + }, { revenue: 0, costs: 0, profit: 0 }); + const rows: (string | number)[][] = evIds.map(evId => { const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); - return { Event: ev?.title || evId, Revenue: f?.effectiveTotalRevenue || 0, Costs: f?.totalCosts || 0, NetProfit: f?.netProfit || 0 }; + return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))]; }); - return downloadCsv(`profit_report_${new Date().toISOString().slice(0,10)}`, rows); + return { + title: 'Profit Report', subtitle, kind: 'table', orientation: 'portrait', + stats: [ + { label: 'Revenue', value: `R ${totals.revenue.toFixed(2)}`, tone: 'green' }, + { label: 'Costs', value: `R ${totals.costs.toFixed(2)}`, tone: 'amber' }, + { label: 'Net profit', value: `R ${totals.profit.toFixed(2)}`, tone: 'violet' }, + ], + chart: evIds.length > 1 ? { title: 'Net profit by event', data: evIds.map(evId => ({ label: filteredEvents.find(e => e.id === evId)?.title || evId, value: financialsByEvent[evId]?.netProfit || 0, displayValue: `R ${(financialsByEvent[evId]?.netProfit || 0).toFixed(2)}` })) } : undefined, + table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows } + }; } if (report === "cashupAudit") { - const cols = ["Event", "Action", "PerformedBy", "Date", "Method", "Expected", "Actual", "Variance", "DeltaVsPreviousClose", "DonationsCountedAsProfit", "Notes"]; - const rows: any[] = []; + const rows: (string | number)[][] = []; auditRows.forEach((r: any) => { - const base = { Event: r.event?.title || r.eventId, Action: actionLabel(r.action), PerformedBy: r.performedBy?.name || "", Date: new Date(r.createdAt).toLocaleString() }; + const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()]; if (r.action === "reopened") { - rows.push({ ...base, Method: "", Expected: "", Actual: "", Variance: "", DeltaVsPreviousClose: "", DonationsCountedAsProfit: "", Notes: r.notes || "" }); + rows.push([...base, "", "", "", "", "", r.notes || ""]); } else { - (r.lines || []).forEach((l: any) => rows.push({ - ...base, Method: METHOD_LABEL[l.method] || l.method, Expected: l.expectedAmount, Actual: l.actualAmount ?? "", Variance: l.variance ?? "", - DeltaVsPreviousClose: r._deltaVsPrevious?.[l.method] ?? "", DonationsCountedAsProfit: r.unallocatedDonationsTotal || 0, Notes: l.notes || r.notes || "" - })); + (r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""])); + if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]); } }); - return downloadCsv(`cashup_audit_${new Date().toISOString().slice(0,10)}`, rows, cols.map(c => ({ key: c, label: c }))); + return { + title: 'Cashup Audit Trail', subtitle, kind: 'table', orientation: 'landscape', + table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } + }; } if (report === "masterOrders") { - const rows = masterRows.map(r => ({ - Event: r.eventTitle, - Name: r.name, - Email: r.email || "", - - ...masterOptions.reduce((acc, opt) => { - acc[opt.name] = r[opt.name] ?? 0; - return acc; - }, {} as any), - - OrderTotal: r.orderTotal, - Paid: r.totalPaid, - Outstanding: r.outstanding, - Donations: r.donations - })); - - // 👇 TOTAL ROW - rows.push({ - Event: "TOTAL", - Name: "", - Email: "", - - ...masterOptions.reduce((acc, opt) => { - acc[opt.name] = masterTotals?.[opt.name] ?? 0; - return acc; - }, {} as any), - - OrderTotal: masterTotals?.orderTotal ?? 0, - Paid: masterTotals?.totalPaid ?? 0, - Outstanding: masterTotals?.outstanding ?? 0, - Donations: masterTotals?.donations ?? 0, - }); - - // 👇 REVENUE PER TICKET ROW 🔥 - rows.push({ - Event: "Revenue per Ticket", - Name: "", - Email: "", - - ...masterOptions.reduce((acc, opt) => { - acc[opt.name] = masterTotals?.[`${opt.name}_revenue`] ?? 0; - return acc; - }, {} as any), - - OrderTotal: "", - Paid: "", - Outstanding: "", - Donations: "" - }); - - return downloadCsv( - `master_orders_${new Date().toISOString().slice(0,10)}`, - rows - ); + const bodyRows: (string | number)[][] = masterRows.map(r => [ + r.eventTitle, r.name, r.email || "", + ...masterOptions.map(opt => r[opt.name] ?? 0), + Number(r.orderTotal.toFixed(2)), Number(r.totalPaid.toFixed(2)), Number((r.paidViaDonation || 0).toFixed(2)), Number(r.outstanding.toFixed(2)), + ]); + bodyRows.push([ + "TOTAL", "", "", + ...masterOptions.map(opt => masterTotals?.[opt.name] ?? 0), + Number((masterTotals?.orderTotal ?? 0).toFixed(2)), Number((masterTotals?.totalPaid ?? 0).toFixed(2)), Number((masterTotals?.paidViaDonation ?? 0).toFixed(2)), Number((masterTotals?.outstanding ?? 0).toFixed(2)), + ]); + bodyRows.push([ + "Revenue per Ticket", "", "", + ...masterOptions.map(opt => `R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}`), + "", "", "", "", + ]); + bodyRows.push([ + "Unassigned donations", "", "", + ...masterOptions.map(() => ""), + "", "", "", Number((masterTotals?.unassignedDonations ?? 0).toFixed(2)), + ]); + return { + title: "Master Orders Breakdown", subtitle, kind: "table", orientation: "landscape", + stats: [ + { label: 'Order Total', value: `R ${(masterTotals?.orderTotal ?? 0).toFixed(2)}`, tone: 'green' }, + { label: 'Paid', value: `R ${(masterTotals?.totalPaid ?? 0).toFixed(2)}`, tone: 'blue' }, + { label: 'Paid via donations', value: `R ${(masterTotals?.paidViaDonation ?? 0).toFixed(2)}`, tone: 'violet' }, + { label: 'Outstanding', value: `R ${(masterTotals?.outstanding ?? 0).toFixed(2)}`, tone: 'amber' }, + { label: 'Unassigned donations', value: `R ${(masterTotals?.unassignedDonations ?? 0).toFixed(2)}`, tone: 'rose' }, + ], + note: `"Paid" is only what each person actually paid themselves — money that reached their order via an assigned donation shows separately under "Paid via donation" and is attributed to the donor below, not the registrant. "Unassigned donations" is real money already received that hasn't been applied to any order yet.`, + table: { + columns: ["Event", "Name", "Email", ...masterOptions.map(o => o.name), "Order Total", "Paid", "Paid via donation", "Outstanding"], + rows: bodyRows + }, + extraTables: donationsMadeRows.length > 0 ? [{ + title: "Donations made", + columns: ["Event", "Donor", "Email", "Donated", "Used", "Unused"], + rows: donationsMadeRows.map(d => [d.eventTitle, d.donor, d.email || "", Number(d.amount.toFixed(2)), Number(d.used.toFixed(2)), Number(d.unused.toFixed(2))]) + }] : undefined, + }; } + return null; + }; + + const doExportExcel = async () => { + if (!token) { alert('Please login to export Excel'); return; } + try { + const payload = buildReportPayload(); + if (!payload) return; + await downloadReportExcel(API_BASE, token, payload); + } catch (e: any) { + alert(e?.message || 'Failed to generate Excel file'); + } + }; + + const REPORT_LABEL: Record = { + payments: "Payments Report", + attendees: "Attendees Report", + regTypes: "Registration Types Report", + usage: "Ticket Usage Report", + revenue: "Revenue Summary", + revenueDetailed: "Revenue Detailed Report", + regStatus: "Registration Status Breakdown", + masterOrders: "Master Orders Report", + donations: "Donations Breakdown", + cashup: "Cashup Report", + financeReport: "Finance Report", + profitReport: "Profit Report", + cashupAudit: "Cashup Audit Trail", }; const doPrintPdf = async () => { if (!token) { alert('Please login to export PDF'); return; } try { - if (report === "payments") { - await downloadReportPdf(API_BASE, token, { - title: 'Payments Report', - kind: 'table', - orientation: 'portrait', - table: { - columns: ["Event","User","Email","Amount","Method","Donation","Date"], - rows: paymentRows.map(r => [r.eventTitle, r.userName, r.userEmail || "", `R ${Number(r.amount).toFixed(2)}`, r.method, r.isDonation ? "Yes" : "No", new Date(r.createdAt).toLocaleString()]) - } - }); - return; - } - if (report === "attendees") { - // Build layered sections: each option -> list of strings (user line) - const sections = attendeesLayer.options.map(opt => ({ - title: opt.optionName, - items: opt.users.map((u: any) => `${u.name}${u.email ? ` (${u.email})` : ''} — ${u.status}${typeof u.qty==='number' ? ` • Qty: ${u.qty}` : ''}`) - })); - await downloadReportPdf(API_BASE, token, { - title: 'Attendees Report', - kind: 'layered', - orientation: 'portrait', - layered: { header: `Event: ${attendeesLayer.eventTitle}`, sections } - }); - return; - } - if (report === "regTypes") { - await downloadReportPdf(API_BASE, token, { - title: 'Registration Types Report', - kind: 'table', - orientation: 'portrait', - table: { - columns: ["Event","Type","Quantity"], - rows: regTypeRows.map(r => [r.eventTitle, r.type, String(r.qty)]) - } - }); - return; - } - if (report === "usage") { - const rows: (string|number)[][] = []; - Object.keys(ticketUsage).forEach(evId => { - const ev = filteredEvents.find(e => e.id === evId); - const tu = ticketUsage[evId]; - rows.push([ev?.title || evId, tu.used, tu.unused]); - }); - await downloadReportPdf(API_BASE, token, { - title: 'Ticket Usage Report', - kind: 'table', - orientation: 'portrait', - table: { columns: ["Event","Used","Unused"], rows } - }); - return; - } - if (report === "revenue") { - const df = payFrom ? new Date(payFrom).getTime() : null; - const dt = payTo ? new Date(payTo).getTime() : null; - const rows: (string|number)[][] = []; - Object.keys(paymentsByEvent).forEach(evId => { - const ev = filteredEvents.find(e => e.id === evId); - const list = (paymentsByEvent[evId] || []).filter((p: any) => { - const t = new Date(p.createdAt).getTime(); - return !(df && t < df) && !(dt && t > dt); - }); - const byMethod: Record = {}; - let total = 0; - list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); }); - if (Object.keys(byMethod).length === 0) rows.push([ev?.title || evId, '-', 0, 0]); - Object.keys(byMethod).forEach(m => rows.push([ev?.title || evId, m, Number(byMethod[m].toFixed(2)), Number(total.toFixed(2))])); - }); - await downloadReportPdf(API_BASE, token, { - title: 'Revenue Summary', - kind: 'table', - orientation: 'portrait', - table: { columns: ["Event","Method","Amount","Event Total"], rows } - }); - return; - } - if (report === "revenueDetailed") { - const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ - r.eventTitle, - r.userName, - r.userEmail || "", - r.status || "", - Number((r.totalPaid || 0).toFixed(2)), - Number((r.outstanding || 0).toFixed(2)), - r.registrationId || "", - ]); - await downloadReportPdf(API_BASE, token, { - title: 'Revenue Detailed', - kind: 'table', - orientation: 'landscape', - table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows } - }); - return; - } - if (report === "regStatus") { - const rows: (string|number)[][] = []; - Object.keys(registrationsByEvent).forEach(evId => { - const ev = filteredEvents.find(e => e.id === evId); - const regs = registrationsByEvent[evId] || []; - const counts: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; - regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); - rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); - }); - await downloadReportPdf(API_BASE, token, { - title: 'Registration Status Breakdown', - kind: 'table', - table: { columns: ["Event","Pending","Partial","Paid","Cancelled"], rows } - }); - return; - } - if (report === "cashup") { - const rows: (string | number)[][] = []; - Object.keys(financialsByEvent).forEach(evId => { - const f = financialsByEvent[evId]; - const ev = filteredEvents.find(e => e.id === evId); - if (!f) return; - const title = ev?.title || evId; - METHOD_KEYS.forEach(m => { - const r = f.reconciled?.byMethod?.[m]; - rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); - if (m === "cash") { - (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""])); - } - }); - if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]); - rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]); - }); - await downloadReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows } }); - return; - } - if (report === "financeReport") { - const rows: (string | number)[][] = []; - Object.keys(financialsByEvent).forEach(evId => { - const f = financialsByEvent[evId]; - const ev = filteredEvents.find(e => e.id === evId); - if (!f) return; - const title = ev?.title || evId; - rows.push([title, "Sales", "What was sold", ""]); - (f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))])); - rows.push([title, "", "Donations", Number((f.totalDonations || 0).toFixed(2))]); - rows.push([title, "Paid by", "", ""]); - METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))])); - rows.push([title, "Cashup comparison", "", ""]); - METHOD_KEYS.forEach(m => { - const r = f.reconciled?.byMethod?.[m]; - rows.push([title, "", `${METHOD_LABEL[m]} — expected / actual / variance`, `${money2(f.expectedCashByMethod?.[m])} / ${r?.actual != null ? money2(r.actual) : "—"} / ${r?.variance != null ? money2(r.variance) : "—"}`]); - }); - rows.push([title, "Costs", "", ""]); - (f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))])); - rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]); - rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]); - }); - await downloadReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows } }); - return; - } - if (report === "profitReport") { - const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => { - const f = financialsByEvent[evId]; - const ev = filteredEvents.find(e => e.id === evId); - return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))]; - }); - await downloadReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows } }); - return; - } - if (report === "cashupAudit") { - const rows: (string | number)[][] = []; - auditRows.forEach((r: any) => { - const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()]; - if (r.action === "reopened") { - rows.push([...base, "", "", "", "", "", r.notes || ""]); - } else { - (r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""])); - if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]); - } - }); - await downloadReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows } }); - return; - } - if (report === "masterOrders") { - const bodyRows: (string | number)[][] = masterRows.map(r => [ - r.eventTitle, - r.name, - r.email || "", - - ...masterOptions.map(opt => r[opt.name] ?? 0), - - Number(r.orderTotal.toFixed(2)), - Number(r.totalPaid.toFixed(2)), - Number(r.outstanding.toFixed(2)) - ]); - - // 👇 TOTAL ROW - bodyRows.push([ - "TOTAL", - "", - "", - - ...masterOptions.map(opt => masterTotals?.[opt.name] ?? 0), - - Number((masterTotals?.orderTotal ?? 0).toFixed(2)), - Number((masterTotals?.totalPaid ?? 0).toFixed(2)), - Number((masterTotals?.outstanding ?? 0).toFixed(2)) - ]); - - // 👇 REVENUE PER TICKET ROW 🔥 - bodyRows.push([ - "Revenue per Ticket", - "", - "", - - ...masterOptions.map(opt => - `R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}` - ), - - "", - "", - "" - ]); - - await downloadReportPdf(API_BASE, token, { - title: "Master Orders Breakdown", - kind: "table", - orientation: "landscape", - table: { - columns: [ - "Event", - "Name", - "Email", - - ...masterOptions.map(o => o.name), - - "Order Total", - "Paid", - "Outstanding" - ], - rows: bodyRows - } - }); - - return; - } + const payload = buildReportPayload(); + if (!payload) return; + await viewReportPdf(API_BASE, token, payload); } catch (e: any) { alert(e?.message || 'Failed to generate PDF'); } @@ -1171,117 +1084,28 @@ export default function ReportsV2() { const doEmail = async () => { if (!token) { alert('Please login to email PDF'); return; } - const subject = { - payments: "Payments Report", - attendees: "Attendees Report", - regTypes: "Registration Types Report", - usage: "Ticket Usage Report", - revenue: "Revenue Summary", - revenueDetailed: "Revenue Detailed Report", - regStatus: "Registration Status Breakdown", - masterOrders: "Master Orders Report", - donations: "Donations Breakdown", - cashup: "Cashup Report", - financeReport: "Finance Report", - profitReport: "Profit Report", - cashupAudit: "Cashup Audit Trail", - }[report]; - const body = "Report generated on " + new Date().toLocaleString(); try { - if (report === 'payments') { - await emailReportPdf(API_BASE, token, { - title: 'Payments Report', kind: 'table', orientation: 'portrait', - table: { columns: ["Event","User","Email","Amount","Method","Donation","Date"], rows: paymentRows.map(r => [r.eventTitle, r.userName, r.userEmail || "", `R ${Number(r.amount).toFixed(2)}`, r.method, r.isDonation ? "Yes" : "No", new Date(r.createdAt).toLocaleString()]) }, - subject, body - }); - } else if (report === 'attendees') { - const sections = attendeesLayer.options.map(opt => ({ title: opt.optionName, items: opt.users.map((u: any) => `${u.name}${u.email ? ` (${u.email})` : ''} — ${u.status}${typeof u.qty==='number' ? ` • Qty: ${u.qty}` : ''}`) })); - await emailReportPdf(API_BASE, token, { title: 'Attendees Report', kind: 'layered', orientation: 'portrait', layered: { header: `Event: ${attendeesLayer.eventTitle}`, sections }, subject, body }); - } else if (report === 'regTypes') { - await emailReportPdf(API_BASE, token, { title: 'Registration Types Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Type","Quantity"], rows: regTypeRows.map(r => [r.eventTitle, r.type, String(r.qty)]) }, subject, body }); - } else if (report === 'usage') { - const rows: (string|number)[][] = []; - Object.keys(ticketUsage).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const tu = ticketUsage[evId]; rows.push([ev?.title || evId, tu.used, tu.unused]); }); - await emailReportPdf(API_BASE, token, { title: 'Ticket Usage Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Used","Unused"], rows }, subject, body }); - } else if (report === 'revenue') { - const df = payFrom ? new Date(payFrom).getTime() : null; - const dt = payTo ? new Date(payTo).getTime() : null; - const rows: (string|number)[][] = []; - Object.keys(paymentsByEvent).forEach(evId => { - const ev = filteredEvents.find(e => e.id === evId); - const list = (paymentsByEvent[evId] || []).filter((p: any) => { const t = new Date(p.createdAt).getTime(); return !(df && t < df) && !(dt && t > dt); }); - const byMethod: Record = {}; let total = 0; - list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); }); - if (Object.keys(byMethod).length === 0) rows.push([ev?.title || evId, '-', 0, 0]); - Object.keys(byMethod).forEach(m => rows.push([ev?.title || evId, m, Number(byMethod[m].toFixed(2)), Number(total.toFixed(2))])); - }); - await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body }); - } else if (report === 'revenueDetailed') { - const rows: (string|number)[][] = (revDetRows as any[]).map(r => [ r.eventTitle, r.userName, r.userEmail || "", r.status || "", Number((r.totalPaid || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "" ]); - await emailReportPdf(API_BASE, token, { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }, subject, body }); - } else if (report === 'regStatus') { - const rows: (string|number)[][] = []; - Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); }); - await emailReportPdf(API_BASE, token, { title: 'Registration Status Breakdown', kind: 'table', table: { columns: ["Event","Pending","Partial","Paid","Cancelled"], rows }, subject, body }); - } else if (report === 'cashup') { - const rows: (string | number)[][] = []; - Object.keys(financialsByEvent).forEach(evId => { - const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); if (!f) return; - const title = ev?.title || evId; - METHOD_KEYS.forEach(m => { - const r = f.reconciled?.byMethod?.[m]; - rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]); - if (m === "cash") (r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""])); - }); - if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]); - rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]); - }); - await emailReportPdf(API_BASE, token, { title: 'Cashup Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows }, subject, body }); - } else if (report === 'financeReport') { - const rows: (string | number)[][] = []; - Object.keys(financialsByEvent).forEach(evId => { - const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); if (!f) return; - const title = ev?.title || evId; - rows.push([title, "Sales", "What was sold", ""]); - (f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))])); - rows.push([title, "", "Donations", Number((f.totalDonations || 0).toFixed(2))]); - rows.push([title, "Paid by", "", ""]); - METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))])); - rows.push([title, "Cashup comparison", "", ""]); - METHOD_KEYS.forEach(m => { - const r = f.reconciled?.byMethod?.[m]; - rows.push([title, "", `${METHOD_LABEL[m]} — expected / actual / variance`, `${money2(f.expectedCashByMethod?.[m])} / ${r?.actual != null ? money2(r.actual) : "—"} / ${r?.variance != null ? money2(r.variance) : "—"}`]); - }); - rows.push([title, "Costs", "", ""]); - (f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))])); - rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]); - rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]); - }); - await emailReportPdf(API_BASE, token, { title: 'Finance Report', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Section", "Detail", "Amount"], rows }, subject, body }); - } else if (report === 'profitReport') { - const rows: (string | number)[][] = Object.keys(financialsByEvent).map(evId => { - const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); - return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))]; - }); - await emailReportPdf(API_BASE, token, { title: 'Profit Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows }, subject, body }); - } else if (report === 'cashupAudit') { - const rows: (string | number)[][] = []; - auditRows.forEach((r: any) => { - const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()]; - if (r.action === "reopened") { - rows.push([...base, "", "", "", "", "", r.notes || ""]); - } else { - (r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""])); - } - }); - await emailReportPdf(API_BASE, token, { title: 'Cashup Audit Trail', kind: 'table', orientation: 'landscape', table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }, subject, body }); - } + const payload = buildReportPayload(); + if (!payload) return; + await emailReportPdf(API_BASE, token, { ...payload, subject: REPORT_LABEL[report], body: "Report generated on " + new Date().toLocaleString() }); alert('Email sent with PDF attachment'); } catch (e: any) { alert(e?.message || 'Failed to email PDF'); } }; + const doWhatsApp = async () => { + if (!token) { alert('Please login to send via WhatsApp'); return; } + try { + const payload = buildReportPayload(); + if (!payload) return; + await whatsappReportPdf(API_BASE, token, { ...payload, caption: REPORT_LABEL[report] }); + alert('Report sent to your WhatsApp'); + } catch (e: any) { + alert(e?.message || 'Failed to send via WhatsApp'); + } + }; + // Rendering helpers function htmlTable(headers: string[], rows: (string | number)[][]) { const thead = `${headers.map(h => `${escapeHtml(h)}`).join("")}`; @@ -1316,6 +1140,50 @@ export default function ReportsV2() { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c] as string)); } + // Report-specific filters shown inside the popup — whatever each report had beyond the + // universal Events/Date range, which now live in the sidebar (ReportsShell) instead. `null` + // when a report has none, so the modal chrome shows its own fallback note. + const reportSpecificFilters = report === "attendees" ? ( +
    + + +
    + ) : report === "regStatus" ? ( +
    + + +
    + ) : null; + + const reportActions = ready && ( + <> + + + + + + ); + + const activeReportMeta = REPORTS.find(r => r.key === report); + // UI return (
    @@ -1324,185 +1192,37 @@ export default function ReportsV2() { )} {error &&
    {error}
    } - {/* Step 1: select report */} -
    -
    Select a report
    -
    - {REPORTS.map(r => ( - - ))} -
    -
    + { setReport(r); setReady(false); }} + onViewReport={() => { setPopupOpen(true); onView(); }} + busy={busy} + onOpenGuide={() => setGuideOpen(true)} + onBack={onBack} + /> - {/* Step 2: filters per report */} -
    -
    Filters
    -
    - - {isAdmin && ( - - )} - -
    - {report === "payments" && ( -
    -
    - - setPayFrom(e.target.value)} /> -
    -
    - - setPayTo(e.target.value)} /> -
    -
    - - ({ value: ev.id, label: ev.title }))} value={payEvents} onChange={setPayEvents} /> -
    -
    - )} - {report === "attendees" && ( -
    - - -
    - )} - {report === "regTypes" && ( -
    - - ({ value: ev.id, label: ev.title }))} value={regTypeEvents} onChange={setRegTypeEvents} /> -
    - )} - {report === "usage" && ( -
    - - ({ value: ev.id, label: ev.title }))} value={usageEvents} onChange={setUsageEvents} /> -
    - )} - {report === "revenue" && ( -
    -
    - - setPayFrom(e.target.value)} /> -
    -
    - - setPayTo(e.target.value)} /> -
    -
    - - ({ value: ev.id, label: ev.title }))} value={revEvents} onChange={setRevEvents} /> -
    -
    - )} - {report === "revenueDetailed" && ( -
    -
    - - setPayFrom(e.target.value)} /> -
    -
    - - setPayTo(e.target.value)} /> -
    -
    - - ({ value: ev.id, label: ev.title }))} value={revDetEvents} onChange={setRevDetEvents} /> -
    -
    - )} - {report === "regStatus" && ( -
    -
    - - ({ value: ev.id, label: ev.title }))} value={statusEvents} onChange={setStatusEvents} /> -
    - -
    - )} - {report === "masterOrders" && ( -
    - - ({ value: ev.id, label: ev.title }))} value={masterEvents} onChange={setMasterEvents} /> -
    - )} - {report === "donations" && ( -
    - - ({ value: ev.id, label: ev.title }))} value={donationEvents} onChange={setDonationEvents} /> -
    - )} - {report === "cashup" && ( -
    - - ({ value: ev.id, label: ev.title }))} value={cashupEvents} onChange={setCashupEvents} /> -
    - )} - {report === "financeReport" && ( -
    - - ({ value: ev.id, label: ev.title }))} value={financeEvents} onChange={setFinanceEvents} /> -
    - )} - {report === "profitReport" && ( -
    - - ({ value: ev.id, label: ev.title }))} value={profitEvents} onChange={setProfitEvents} /> -
    - )} - {report === "cashupAudit" && ( -
    -
    - - setAuditFrom(e.target.value)} /> -
    -
    - - setAuditTo(e.target.value)} /> -
    -
    - - ({ value: ev.id, label: ev.title }))} value={auditEvents} onChange={setAuditEvents} /> -
    -
    - )} -
    - -
    -
    - - {/* Step 3: results + actions */} - {ready && ( -
    -
    -
    Results
    -
    - - - -
    -
    + {guideOpen && } + {popupOpen && ( + setPopupOpen(false)} + filters={reportSpecificFilters} + actions={reportActions} + onRefresh={onView} + busy={busy} + > + {!ready &&
    Loading…
    } + {ready && ( + <> {report === "payments" && (
    {paymentRows.length === 0 ? ( @@ -1542,7 +1262,7 @@ export default function ReportsV2() {
      {list.map((r: any, i: number) => (
    • - {r.method}{r.isDonation ? ' • Donation' : ''} + {r.method}{r.isDonation ? ' • Donation' : ''}{r.recordedByName ? ` • Recorded by ${r.recordedByName}` : ''} R {Number(r.amount).toFixed(2)} • {new Date(r.createdAt).toLocaleString()}
    • ))} @@ -1587,65 +1307,72 @@ export default function ReportsV2() { )} {report === "regTypes" && ( -
      +
      {regTypeRows.length === 0 ? (
      No data available. Select events and view.
      - ) : ( - - - - - - - - - - {regTypeRows.map((r, idx) => ( - - - - - - ))} - -
      EventRegistration TypeQuantity
      {r.eventTitle}{r.type}{r.qty}
      - )} + ) : (() => { + const byType = new Map(); + regTypeRows.forEach(r => byType.set(r.type, (byType.get(r.type) || 0) + r.qty)); + return ( + <> +
      +
      Across all selected events
      + ({ label, value }))} valueFormatter={v => String(v)} /> +
      +
      + + + + + + + + + + {regTypeRows.map((r, idx) => ( + + + + + + ))} + +
      EventRegistration TypeQuantity
      {r.eventTitle}{r.type}{r.qty}
      +
      + + ); + })()}
      )} {report === "usage" && ( -
      +
      {Object.keys(ticketUsage).length === 0 ? (
      No data available. Select events and view.
      ) : ( - - - - - - - - - - {Object.keys(ticketUsage).map(evId => { - const ev = events.find(e => e.id === evId); - const tu = ticketUsage[evId]; - return ( - - - - - - ); - })} - -
      EventUsedUnused
      {ev?.title || evId}{tu.used}{tu.unused}
      + Object.keys(ticketUsage).map(evId => { + const ev = events.find(e => e.id === evId); + const tu = ticketUsage[evId]; + const total = tu.used + tu.unused; + return ( +
      +
      +
      {ev?.title || evId}
      +
      {total} tickets total
      +
      + String(v)} + /> +
      + ); + }) )}
      )} {report === "revenue" && ( -
      +
      {Object.keys(paymentsByEvent).length === 0 ? (
      No data available. Select events and view.
      ) : ( @@ -1654,26 +1381,24 @@ export default function ReportsV2() { const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; const list = (paymentsByEvent[evId] || []).filter((p: any) => { + if (isDonationLeg(p)) return false; // already counted once, as the source donation const t = new Date(p.createdAt).getTime(); return !(df && t < df) && !(dt && t > dt); }); const byMethod: Record = {}; let total = 0; list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); }); + const chartData = Object.keys(byMethod).sort().map(m => ({ label: m, value: byMethod[m] })); return ( -
      -
      -
      {ev?.title || evId}
      -
      Total: R {total.toFixed(2)}
      +
      +
      +
      {ev?.title || evId}
      +
      R {total.toFixed(2)}
      - {Object.keys(byMethod).length === 0 ? ( + {chartData.length === 0 ? (
      No payments in date range.
      ) : ( -
        - {Object.keys(byMethod).sort().map(m => ( -
      • {m}R {byMethod[m].toFixed(2)}
      • - ))} -
      + `R ${v.toFixed(2)}`} /> )}
      ); @@ -1683,7 +1408,7 @@ export default function ReportsV2() { )} {report === "revenueDetailed" && ( -
      +
      {Object.keys(registrationsByEvent).length === 0 ? (
      No data available. Select events and view.
      ) : ( @@ -1694,35 +1419,35 @@ export default function ReportsV2() { const regIds = rows.map(r => r.registrationId); const outstandingTotal = regIds.reduce((s, id) => s + (outstandingByReg.get(id) || 0), 0); return ( -
      -
      -
      {ev?.title || evId}
      -
      Total: R {total.toFixed(2)}{` • Outstanding: R ${outstandingTotal.toFixed(2)}`}
      +
      +
      +
      {ev?.title || evId}
      +
      Paid R {total.toFixed(2)} · Outstanding R {outstandingTotal.toFixed(2)}
      {rows.length === 0 ? (
      No registrations in date range.
      ) : ( -
      - +
      +
      - - - - - - - + + + + + + + {rows.map((r, idx) => ( - - - - - - - + + + + + + + ))} @@ -1737,78 +1462,138 @@ export default function ReportsV2() { )} {report === "regStatus" && ( -
      +
      {Object.keys(registrationsByEvent).length === 0 ? (
      No data available. Select events and view.
      ) : ( -
      NameEmailStatusTotal paidOutstandingRegistration
      NameEmailStatusTotal paidOutstandingRegistration
      {r.userName}{r.userEmail || ''}{r.status || ''}R {Number(r.totalPaid || 0).toFixed(2)}R {Number(r.outstanding || 0).toFixed(2)}{r.registrationId || ''}
      {r.userName}{r.userEmail || ''}{r.status || ''}R {Number(r.totalPaid || 0).toFixed(2)}R {Number(r.outstanding || 0).toFixed(2)}{r.registrationId || ''}
      - - - - - - - - - - - {Object.keys(registrationsByEvent).map(evId => { - const ev = events.find(e => e.id === evId); - const regs = registrationsByEvent[evId] || []; - const counts: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; - regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); - return ( - - - - - - + <> + {(() => { + const agg: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; + Object.values(registrationsByEvent).forEach((regs: any) => { + (regs || []).forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; agg[r.status] = (agg[r.status] || 0) + countForStatus(r); }); + }); + const unit = statusCountMode === 'tickets' ? 'tickets' : 'registrations'; + return ( +
      +
      Across all selected events, by {unit}
      + String(v)} + /> +
      + ); + })()} +
      +
      EventPendingPartialPaidCancelled
      {ev?.title || evId}{counts.pending || 0}{counts.partial_paid || 0}{counts.paid || 0}{counts.cancelled || 0}
      + + + + + + + - ); - })} - -
      EventPending{statusCountMode === 'tickets' ? ' (tickets)' : ''}Partial{statusCountMode === 'tickets' ? ' (tickets)' : ''}Paid{statusCountMode === 'tickets' ? ' (tickets)' : ''}Cancelled{statusCountMode === 'tickets' ? ' (tickets)' : ''}
      + + + {Object.keys(registrationsByEvent).map(evId => { + const ev = events.find(e => e.id === evId); + const regs = registrationsByEvent[evId] || []; + const counts: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; + regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + countForStatus(r); }); + return ( + + {ev?.title || evId} + {counts.pending || 0} + {counts.partial_paid || 0} + {counts.paid || 0} + {counts.cancelled || 0} + + ); + })} + + +
      + )}
      )} {report === "donations" && ( -
      +
      {Object.keys(paymentsByEvent).length === 0 ? (
      No data. Select events and view.
      - ) : ( - - - - - - - - - - - {Object.keys(paymentsByEvent).map(evId => { - const ev = filteredEvents.find(e => e.id === evId); - const dons = (paymentsByEvent[evId] || []).filter((p: any) => p.isDonation); - const total = dons.reduce((s: number, p: any) => s + (p.amount || 0), 0); - return ( - - - - - - - ); - })} - - - - - - - -
      EventDonations (R)CountAvg (R)
      {ev?.title || evId}R {total.toFixed(2)}{dons.length}{dons.length ? `R ${(total / dons.length).toFixed(2)}` : '-'}
      TotalR {Object.values(paymentsByEvent).reduce((s, payments) => s + (payments as any[]).filter(p => p.isDonation).reduce((ss: number, p: any) => ss + (p.amount || 0), 0), 0).toFixed(2)}{Object.values(paymentsByEvent).reduce((s, payments) => s + (payments as any[]).filter(p => p.isDonation).length, 0)}
      - )} + ) : (() => { + // A donation is never mutated once assigned — its "used" amount is the + // sum of every leg (isDonation:false, originalPaymentId -> the donation) + // referencing it, which — since a donation and its legs share the event + // it was logged against in the common case — are already present in the + // same per-event payments list. + const usedFor = (evId: string, donationId: string) => + (paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId) + .reduce((s: number, leg: any) => s + (leg.amount || 0), 0); + let grandTotal = 0, grandUsed = 0, grandCount = 0; + const perEvent = Object.keys(paymentsByEvent).map(evId => { + const ev = filteredEvents.find(e => e.id === evId); + const dons = (paymentsByEvent[evId] || []).filter((p: any) => p.isDonation); + const total = dons.reduce((s: number, p: any) => s + (p.amount || 0), 0); + const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0); + const unused = Math.max(total - used, 0); + grandTotal += total; grandUsed += used; grandCount += dons.length; + return { evId, title: ev?.title || evId, total, used, unused, count: dons.length }; + }); + const grandUnused = Math.max(grandTotal - grandUsed, 0); + return ( + <> + + + + + +
      +
      Used vs. unused, across all selected events
      + `R ${v.toFixed(2)}`} /> +
      +
      + + + + + + + + + + + + + {perEvent.map(row => ( + + + + + + + + + ))} + + + + + + + + + +
      EventDonations (R)Used (R)Unused (R)CountAvg (R)
      {row.title}R {row.total.toFixed(2)}R {row.used.toFixed(2)}R {row.unused.toFixed(2)}{row.count}{row.count ? `R ${(row.total / row.count).toFixed(2)}` : '-'}
      TotalR {grandTotal.toFixed(2)}R {grandUsed.toFixed(2)}R {grandUnused.toFixed(2)}{grandCount}
      +
      + + ); + })()}
      )} {report === "masterOrders" && ( @@ -1819,78 +1604,163 @@ export default function ReportsV2() {
      ) : ( <> -
      - Totals: - Order: R {masterTotals?.orderTotal.toFixed(2)} - Paid: R {masterTotals?.totalPaid.toFixed(2)} - Outstanding: R {masterTotals?.outstanding.toFixed(2)} +
      Totals
      + + + + + + + +
      + "Paid" is only what each person actually paid themselves — money that reached their order via an assigned donation shows separately under "Paid via donation" and is attributed to the donor below, not the registrant. "Unassigned donations" is real money already received that hasn't been applied to any order yet.
      -
      - +
      +
      Orders
      +
      + + setMasterOrderSearch(e.target.value)} + /> +
      +
      +
      +
      - - - - + + + + {masterOptions.map(opt => ( - + ))} - - - - + + + + - {masterRows.map((r, idx) => ( - - - - + {masterRows + .filter(r => { + const q = masterOrderSearch.trim().toLowerCase(); + if (!q) return true; + return [r.eventTitle, r.name, r.email].some((v: any) => String(v || "").toLowerCase().includes(q)); + }) + .map((r, idx) => ( + + + + {masterOptions.map(opt => ( - ))} - - - - + + + + ))} - - + + {masterOptions.map(opt => ( - ))} - - - - + + + + - - + + {masterOptions.map(opt => ( - ))} - + + + + + + {masterOptions.map(opt => )} + + +
      EventNameEmail
      EventNameEmail{opt.name}{opt.name}Order TotalPaidOutstandingDonationsOrder TotalPaidPaid via donationOutstanding
      {r.eventTitle}{r.name}{r.email || ""}
      {r.eventTitle}{r.name}{r.email || ""} + {r[opt.name] ?? 0} R {r.orderTotal.toFixed(2)}R {r.totalPaid.toFixed(2)}R {r.outstanding.toFixed(2)}R {r.donations.toFixed(2)}R {r.orderTotal.toFixed(2)}R {r.totalPaid.toFixed(2)}{r.paidViaDonation > 0.000001 ? `R ${r.paidViaDonation.toFixed(2)}` : ''}R {r.outstanding.toFixed(2)}
      TOTAL
      TOTAL + {masterTotals?.[opt.name] ?? 0} R {masterTotals?.orderTotal.toFixed(2)}R {masterTotals?.totalPaid.toFixed(2)}R {masterTotals?.outstanding.toFixed(2)}R {masterTotals?.donations.toFixed(2)}R {masterTotals?.orderTotal.toFixed(2)}R {masterTotals?.totalPaid.toFixed(2)}R {masterTotals?.paidViaDonation.toFixed(2)}R {masterTotals?.outstanding.toFixed(2)}
      Revenue per Ticket
      Revenue per Ticket + R {(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}
      Unassigned donationsR {masterTotals?.unassignedDonations.toFixed(2)}
      + +
      +
      +
      Donations made
      +
      + + setMasterDonationSearch(e.target.value)} + /> +
      +
      +
      + Every donation logged for these events, who gave it, and how much of it has been applied to an order (Used) versus still available (Unused). This is independent of the orders table above — a donation isn't tied to the donor's own order. +
      + {donationsMadeRows.length === 0 ? ( +
      No donations logged for these events.
      + ) : ( +
      + + + + + + + + + + + + + {donationsMadeRows + .filter(d => { + const q = masterDonationSearch.trim().toLowerCase(); + if (!q) return true; + return [d.eventTitle, d.donor, d.email].some((v: any) => String(v || "").toLowerCase().includes(q)); + }) + .map((d, idx) => ( + + + + + + + + + ))} + +
      EventDonorEmailDonatedUsedUnused
      {d.eventTitle}{d.donor}{d.email || ""}R {d.amount.toFixed(2)}R {d.used.toFixed(2)}R {d.unused.toFixed(2)}
      +
      + )} +
      )}
      @@ -1905,32 +1775,39 @@ export default function ReportsV2() { const ev = filteredEvents.find(e => e.id === evId); if (!f) return null; return ( -
      -
      -
      {ev?.title || evId}
      - {f.event?.cashupStatus === "closed" ? "Closed" : "Open"} +
      +
      +
      {ev?.title || evId}
      + {f.event?.cashupStatus === "closed" ? "Closed" : "Open"}
      -
      - - +
      +
      Income by method
      + ({ label: METHOD_LABEL[m], value: f.paymentsByMethod?.[m] || 0 }))} + valueFormatter={v => `R ${v.toFixed(2)}`} + /> +
      +
      +
      MethodIncomeCosts from methodExpected cashActualVariance
      + {METHOD_KEYS.map(m => { const r = f.reconciled?.byMethod?.[m]; return ( - - - - - - - + + + + + + + {m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => ( - - + + - + ))} @@ -1959,41 +1836,48 @@ export default function ReportsV2() { const ev = filteredEvents.find(e => e.id === evId); if (!f) return null; return ( -
      -
      {ev?.title || evId}
      +
      +
      {ev?.title || evId}
      + + + s + (o.revenue || 0), 0).toFixed(2)}`} tone="green" /> + s + (f.paymentsByMethod?.[m] || 0), 0).toFixed(2)}`} tone="blue" /> + + + + + + {(f.salesByOption || []).length > 0 && ( +
      +
      What was sold
      + ({ label: `${s.name} (${s.quantitySold})`, value: s.revenue }))} + valueFormatter={v => `R ${v.toFixed(2)}`} + /> +
      + )}
      -
      What was sold
      -
        - {(f.salesByOption || []).map((s: any) => ( -
      • {s.name} ({s.quantitySold} sold)R {s.revenue.toFixed(2)}
      • - ))} -
      • DonationsR {(f.totalDonations || 0).toFixed(2)}
      • -
      +
      Paid by
      + ({ label: METHOD_LABEL[m], value: f.paymentsByMethod?.[m] || 0 }))} + valueFormatter={v => `R ${v.toFixed(2)}`} + />
      -
      -
      Paid by
      -
        - {METHOD_KEYS.map(m => ( -
      • {METHOD_LABEL[m]}R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}
      • - ))} -
      -
      - -
      -
      Comparison to cashup
      -
      MethodIncomeCosts from methodExpected cashActualVariance
      {METHOD_LABEL[m]}R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}R {(f.costsByMethod?.[m] || 0).toFixed(2)}R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}{r?.actual != null ? `R ${r.actual.toFixed(2)}` : not reconciled}{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}
      {METHOD_LABEL[m]}R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}R {(f.costsByMethod?.[m] || 0).toFixed(2)}R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}{r?.actual != null ? `R ${r.actual.toFixed(2)}` : not reconciled}{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}
      {denomLabel(d.value)} × {d.count}
      {denomLabel(d.value)} × {d.count} R {(d.value * d.count).toFixed(2)}R {(d.value * d.count).toFixed(2)}
      - +
      +
      Comparison to cashup
      +
      MethodExpectedActualVariance
      + {METHOD_KEYS.map(m => { const r = f.reconciled?.byMethod?.[m]; return ( - - - - - + + + + + ); })} @@ -2001,17 +1885,16 @@ export default function ReportsV2() {
      MethodExpectedActualVariance
      {METHOD_LABEL[m]}R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}{r?.actual != null ? `R ${r.actual.toFixed(2)}` : not reconciled}{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}
      {METHOD_LABEL[m]}R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}{r?.actual != null ? `R ${r.actual.toFixed(2)}` : not reconciled}{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}
      -
      -
      Costs
      -
        - {(f.costs || []).map((c: any) => ( -
      • {c.label}{c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}-R {(c.total ?? c.amount).toFixed(2)}
      • - ))} -
      -
      - -
      Donations counted as profitR {(f.unallocatedDonationsTotal || 0).toFixed(2)}
      -
      Net profitR {(f.netProfit || 0).toFixed(2)}
      + {(f.costs || []).length > 0 && ( +
      +
      Costs
      +
        + {(f.costs || []).map((c: any) => ( +
      • {c.label}{c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}-R {(c.total ?? c.amount).toFixed(2)}
      • + ))} +
      +
      + )}
      ); }) @@ -2019,28 +1902,58 @@ export default function ReportsV2() {
      )} {report === "profitReport" && ( -
      +
      {Object.keys(financialsByEvent).length === 0 ? (
      No data available. Select events and view.
      - ) : ( - - - - {Object.keys(financialsByEvent).map(evId => { - const f = financialsByEvent[evId]; - const ev = filteredEvents.find(e => e.id === evId); - return ( - - - - - - - ); - })} - -
      EventRevenueCostsNet profit
      {ev?.title || evId}R {(f?.effectiveTotalRevenue || 0).toFixed(2)}R {(f?.totalCosts || 0).toFixed(2)}R {(f?.netProfit || 0).toFixed(2)}
      - )} + ) : (() => { + const evIds = Object.keys(financialsByEvent); + const totals = evIds.reduce((acc, evId) => { + const f = financialsByEvent[evId]; + acc.revenue += f?.effectiveTotalRevenue || 0; + acc.costs += f?.totalCosts || 0; + acc.profit += f?.netProfit || 0; + return acc; + }, { revenue: 0, costs: 0, profit: 0 }); + return ( + <> + + + + + + + {evIds.length > 1 && ( +
      +
      Net profit by event
      + ({ label: filteredEvents.find(e => e.id === evId)?.title || evId, value: financialsByEvent[evId]?.netProfit || 0 }))} + valueFormatter={v => `R ${v.toFixed(2)}`} + /> +
      + )} + +
      + + + + {evIds.map(evId => { + const f = financialsByEvent[evId]; + const ev = filteredEvents.find(e => e.id === evId); + return ( + + + + + + + ); + })} + +
      EventRevenueCostsNet profit
      {ev?.title || evId}R {(f?.effectiveTotalRevenue || 0).toFixed(2)}R {(f?.totalCosts || 0).toFixed(2)}R {(f?.netProfit || 0).toFixed(2)}
      +
      + + ); + })()}
      )} {report === "cashupAudit" && ( @@ -2083,7 +1996,9 @@ export default function ReportsV2() { )}
      )} -
      + + )} + )}
      ); diff --git a/frontend/src/components/reports/StatTile.tsx b/frontend/src/components/reports/StatTile.tsx new file mode 100644 index 0000000..1b50360 --- /dev/null +++ b/frontend/src/components/reports/StatTile.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; +import type { LucideIcon } from "lucide-react"; + +const TONES = { + green: { bg: "bg-emerald-50", icon: "text-emerald-600" }, + blue: { bg: "bg-blue-50", icon: "text-blue-600" }, + violet: { bg: "bg-violet-50", icon: "text-violet-600" }, + amber: { bg: "bg-amber-50", icon: "text-amber-600" }, + rose: { bg: "bg-rose-50", icon: "text-rose-600" }, + gray: { bg: "bg-gray-100", icon: "text-gray-600" }, +} as const; + +export type StatTileTone = keyof typeof TONES; + +export function StatTile({ icon: Icon, label, value, tone = "gray" }: { icon: LucideIcon; label: string; value: string; tone?: StatTileTone }) { + const t = TONES[tone] || TONES.gray; + return ( +
      +
      + +
      +
      +
      {label}
      +
      {value}
      +
      +
      + ); +} + +export function StatTileRow({ children }: { children: React.ReactNode }) { + return
      {children}
      ; +} diff --git a/frontend/src/components/reports/charts/HorizontalBarChart.tsx b/frontend/src/components/reports/charts/HorizontalBarChart.tsx new file mode 100644 index 0000000..ccec672 --- /dev/null +++ b/frontend/src/components/reports/charts/HorizontalBarChart.tsx @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; + +// Fixed categorical order from the validated reference palette (dataviz skill, +// references/palette.md) — never cycled or reassigned per-render, so the same category +// always gets the same color across a session. +export const CATEGORICAL_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#4a3aa7", "#e34948"]; + +export type BarDatum = { label: string; value: number }; + +// A simple, dependency-free horizontal bar list: thin rounded track, filled bar, direct value +// label. Suited to comparing a handful of categories' magnitude (the job most reports need) — +// per the dataviz skill's form heuristic, magnitude-by-category is exactly a bar chart's job. +export function HorizontalBarChart({ + data, valueFormatter, labelWidthClass = "w-28", +}: { + data: BarDatum[]; + valueFormatter?: (v: number) => string; + labelWidthClass?: string; +}) { + const max = Math.max(1, ...data.map(d => Math.abs(d.value))); + const fmt = valueFormatter || ((v: number) => String(v)); + return ( +
      + {data.map((d, i) => ( +
      +
      {d.label}
      +
      +
      0 ? 2 : 0, (Math.abs(d.value) / max) * 100)}%`, backgroundColor: CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length] }} + /> +
      +
      {fmt(d.value)}
      +
      + ))} + {data.length === 0 &&
      No data to chart.
      } +
      + ); +} diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts index c6c3dea..4797658 100644 --- a/frontend/src/lib/export.ts +++ b/frontend/src/lib/export.ts @@ -31,13 +31,26 @@ function valueToString(v: any): string { return String(v); } -// New: server-side PDF generation and email helpers +// New: server-side PDF/Excel generation and email helpers +export type ReportStat = { label: string; value: string; tone?: 'green' | 'blue' | 'violet' | 'amber' | 'rose' | 'gray' }; +export type ReportChartDatum = { label: string; value: number; displayValue?: string }; + export type ReportPdfPayload = { title: string; + subtitle?: string; kind: 'table' | 'layered'; orientation?: 'portrait' | 'landscape'; + // Optional visual sections rendered above the table/layered body — mirrors the on-screen + // report's stat tiles / bar chart / explanatory note, so exported PDF/Excel/email/WhatsApp + // all look like the same report the web UI shows, not a plain data dump. + stats?: ReportStat[]; + chart?: { title?: string; data: ReportChartDatum[] }; + note?: string; table?: { columns: string[]; rows: (string | number)[][] }; layered?: { header?: string; sections: { title: string; items: string[] }[] }; + // Additional titled tables rendered below the main table/layered body — e.g. Master Orders' + // separate "Donations made" breakdown, which isn't part of the Orders table itself. + extraTables?: { title: string; columns: string[]; rows: (string | number)[][] }[]; }; export async function downloadReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) { @@ -64,6 +77,32 @@ export async function downloadReportPdf(apiBase: string, authToken: string, payl setTimeout(() => URL.revokeObjectURL(objUrl), 2000); } +// Downloads a styled .xlsx mirroring the same branded look as the PDF (colored header, +// stat rows, a data-bar chart, banded table with a highlighted total row). +export async function downloadReportExcel(apiBase: string, authToken: string, payload: ReportPdfPayload) { + const url = `${apiBase}/api/reports/excel`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || `Failed to generate Excel file (${res.status})`); + } + const blob = await res.blob(); + const dl = document.createElement('a'); + const objUrl = URL.createObjectURL(blob); + dl.href = objUrl; + const safe = (payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase(); + dl.download = `${safe}.xlsx`; + dl.click(); + setTimeout(() => URL.revokeObjectURL(objUrl), 2000); +} + export async function emailReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { subject?: string; body?: string }) { const url = `${apiBase}/api/reports/email`; const res = await fetch(url, { @@ -81,6 +120,47 @@ export async function emailReportPdf(apiBase: string, authToken: string, payload return res.json(); } +// Sends the report PDF to the current user's own WhatsApp number (same self-service pattern as +// emailReportPdf — no recipient picker needed). +export async function whatsappReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { caption?: string }) { + const url = `${apiBase}/api/reports/whatsapp`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || `Failed to send PDF via WhatsApp (${res.status})`); + } + return res.json(); +} + +// "Print": fetches the same PDF as downloadReportPdf but opens it in a new tab instead of +// downloading, so the browser's built-in PDF viewer's print button handles printing. +export async function viewReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) { + const url = `${apiBase}/api/reports/pdf`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + }, + body: JSON.stringify(payload) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || `Failed to generate PDF (${res.status})`); + } + const blob = await res.blob(); + const objUrl = URL.createObjectURL(blob); + window.open(objUrl, '_blank'); + setTimeout(() => URL.revokeObjectURL(objUrl), 60000); +} + // Legacy (used elsewhere). Kept in case other code paths still rely on print flow. export function openPrintWindow(title: string, htmlContent: string) { const w = window.open('', '_blank');