Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424141f216 | ||
|
|
d6ea4c37d7 | ||
|
|
ebf521e64e | ||
|
|
e5fbcc2222 | ||
|
|
1b4446c828 | ||
|
|
0de3f4be7d | ||
|
|
56f2a9f7fc |
+46
-1
@@ -7,6 +7,49 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Reports: on mobile, the report popup's close button now sits pinned beside the title instead of getting cut off inline with the Print/Email/Excel/WhatsApp buttons, which now have their own wrapping row below.
|
||||||
|
- Reports: on mobile, the page header's search box and Back button no longer run off-screen — they now sit on their own row and shrink to fit.
|
||||||
|
- Reports: the sidebar's custom date range inputs (From/To) are now stacked instead of side-by-side, fixing them overflowing outside the filter box on desktop.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-08-04
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- Dashboard: the sidebar (My Events, Profile, Admin, etc.) is now hidden on every sub-page under Staff, Supervisor, and Admin — not just Reports — so those pages get the full width for their own content and navigation. It's still shown on each role's root landing page (`/dashboard/staff`, `/dashboard/supervisor`, `/dashboard/admin`); the top navbar's "Dashboard" link always leads back there.
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
- Dashboard mobile: the Reports page's Events filter dropdown and the Email/WhatsApp Attendees recipient-picker dropdown could extend past the right edge of narrow screens instead of staying within the viewport; they now stretch to match their trigger's width like the other dropdowns in the app already do. The Cashup event-costs table on the Admin Cashup detail page now scrolls horizontally on narrow screens instead of squeezing its columns.
|
||||||
|
|
||||||
## [1.3.2] - 2026-08-03
|
## [1.3.2] - 2026-08-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -93,7 +136,9 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
||||||
|
|
||||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...main
|
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.4.0...main
|
||||||
|
[1.4.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.2...v1.4.0
|
||||||
|
[1.3.2]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...v1.3.2
|
||||||
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
|
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
|
||||||
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
|
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
|
||||||
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
|
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
|
||||||
|
|||||||
Generated
+841
-7
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.3.2",
|
"version": "1.4.0",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
|
||||||
+11
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -63,6 +63,7 @@ model User {
|
|||||||
notificationPreference NotificationPreference @default(email)
|
notificationPreference NotificationPreference @default(email)
|
||||||
registrations Registration[]
|
registrations Registration[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
ticketScans TicketUsage[]
|
ticketScans TicketUsage[]
|
||||||
passwordResets PasswordReset[]
|
passwordResets PasswordReset[]
|
||||||
@@ -74,6 +75,9 @@ model User {
|
|||||||
eventsReopened Event[] @relation("EventReopenedBy")
|
eventsReopened Event[] @relation("EventReopenedBy")
|
||||||
cashupsPerformed EventCashup[]
|
cashupsPerformed EventCashup[]
|
||||||
notifyForEvents Event[] @relation("EventNotifyRecipients")
|
notifyForEvents Event[] @relation("EventNotifyRecipients")
|
||||||
|
|
||||||
|
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
|
||||||
|
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Event {
|
model Event {
|
||||||
@@ -112,6 +116,7 @@ model Event {
|
|||||||
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
|
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
|
||||||
costs EventCost[]
|
costs EventCost[]
|
||||||
cashups EventCashup[]
|
cashups EventCashup[]
|
||||||
|
personCashCounts EventCashupPersonCount[]
|
||||||
|
|
||||||
// Users who should receive registration/payment/daily-summary notifications for this
|
// Users who should receive registration/payment/daily-summary notifications for this
|
||||||
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
|
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
|
||||||
@@ -215,6 +220,7 @@ model Payment {
|
|||||||
amount Float
|
amount Float
|
||||||
method String
|
method String
|
||||||
userId String
|
userId String
|
||||||
|
recordedById String?
|
||||||
registrationId String?
|
registrationId String?
|
||||||
eventId String?
|
eventId String?
|
||||||
isDonation Boolean @default(false)
|
isDonation Boolean @default(false)
|
||||||
@@ -223,6 +229,7 @@ model Payment {
|
|||||||
originalPaymentId String?
|
originalPaymentId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
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)
|
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||||
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
|
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
|
||||||
@@ -230,9 +237,11 @@ model Payment {
|
|||||||
YocoTransaction YocoTransaction[]
|
YocoTransaction YocoTransaction[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([recordedById])
|
||||||
@@index([registrationId])
|
@@index([registrationId])
|
||||||
@@index([eventId])
|
@@index([eventId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([originalPaymentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Ticket {
|
model Ticket {
|
||||||
@@ -479,6 +488,36 @@ model EventCashupDenomination {
|
|||||||
@@index([lineId])
|
@@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) ──
|
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
|
||||||
//
|
//
|
||||||
// model Ministry {
|
// model Ministry {
|
||||||
|
|||||||
@@ -29,14 +29,7 @@ function buildEventContext(event) {
|
|||||||
return { eventTitle, eventStart, eventLink, eventLinkHtml };
|
return { eventTitle, eventStart, eventLink, eventLinkHtml };
|
||||||
}
|
}
|
||||||
|
|
||||||
function replacePlaceholders(str, ctx) {
|
const { replacePlaceholders } = require('../utils/placeholders');
|
||||||
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 || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFreeformEmails(lines) {
|
function parseFreeformEmails(lines) {
|
||||||
// Supports formats:
|
// Supports formats:
|
||||||
@@ -166,9 +159,9 @@ const sendBroadcast = async (req, res) => {
|
|||||||
eventLink: escapeHtml(eventCtx.eventLink),
|
eventLink: escapeHtml(eventCtx.eventLink),
|
||||||
eventLinkHtml: eventCtx.eventLinkHtml,
|
eventLinkHtml: eventCtx.eventLinkHtml,
|
||||||
} : ctxBase;
|
} : ctxBase;
|
||||||
const finalSubject = replacePlaceholders(subject, ctxBase);
|
const finalSubject = await replacePlaceholders(subject, ctxBase);
|
||||||
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
|
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
|
||||||
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
|
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
|
||||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const prisma = require('../config/db');
|
const prisma = require('../config/db');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
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
|
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
|
||||||
// @route GET /api/cashups/event/:eventId
|
// @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
|
// @desc Save in-progress reconciliation entries without closing the event
|
||||||
// @route PUT /api/cashups/event/:eventId/draft
|
// @route PUT /api/cashups/event/:eventId/draft
|
||||||
// @access Private/Admin
|
// @access Private/Admin
|
||||||
@@ -49,30 +88,41 @@ const closeEvent = async (req, res) => {
|
|||||||
const financials = await computeEventFinancials(eventId);
|
const financials = await computeEventFinancials(eventId);
|
||||||
const isFullCashup = Array.isArray(lines) && lines.length > 0;
|
const isFullCashup = Array.isArray(lines) && lines.length > 0;
|
||||||
|
|
||||||
const cashupLines = isFullCashup
|
let cashupLines = [];
|
||||||
? lines
|
if (isFullCashup) {
|
||||||
.filter(l => l && ALL_METHODS.includes(l.method))
|
const nonCashLines = lines
|
||||||
|
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
|
||||||
.map(l => {
|
.map(l => {
|
||||||
const expected = financials.expectedCashByMethod[l.method] || 0;
|
const expected = financials.expectedCashByMethod[l.method] || 0;
|
||||||
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
|
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
|
||||||
? 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);
|
|
||||||
return {
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
method: l.method,
|
method: l.method,
|
||||||
expectedAmount: expected,
|
expectedAmount: expected,
|
||||||
actualAmount: actual,
|
actualAmount: actual,
|
||||||
variance: actual !== null ? actual - expected : null,
|
variance: actual !== null ? actual - expected : null,
|
||||||
notes: l.notes || null,
|
notes: l.notes || null
|
||||||
denominations: denominations.length > 0 ? { create: denominations } : undefined
|
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
: [];
|
|
||||||
|
// 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
|
const totalActualRevenue = isFullCashup
|
||||||
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
|
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
|
||||||
@@ -179,6 +229,8 @@ const getCashupAudit = async (req, res) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getEventCashup,
|
getEventCashup,
|
||||||
|
getCashByRecordedUser,
|
||||||
|
savePersonCash,
|
||||||
saveEventCashupDraft,
|
saveEventCashupDraft,
|
||||||
closeEvent,
|
closeEvent,
|
||||||
reopenEvent,
|
reopenEvent,
|
||||||
|
|||||||
@@ -1226,6 +1226,7 @@ const emailEventAttendees = async (req, res) => {
|
|||||||
|
|
||||||
const { sendMail } = require('../utils/email');
|
const { sendMail } = require('../utils/email');
|
||||||
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
||||||
|
const { replacePlaceholders } = require('../utils/placeholders');
|
||||||
|
|
||||||
function fmtAmount(amt) {
|
function fmtAmount(amt) {
|
||||||
const n = Number(amt || 0);
|
const n = Number(amt || 0);
|
||||||
@@ -1234,17 +1235,6 @@ const emailEventAttendees = async (req, res) => {
|
|||||||
function fmtDate(d) {
|
function fmtDate(d) {
|
||||||
try { return new Date(d).toLocaleString(); } catch { return String(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
|
// Build per-recipient registration aggregates for this event
|
||||||
const regsByEmail = new Map();
|
const regsByEmail = new Map();
|
||||||
@@ -1255,42 +1245,66 @@ const emailEventAttendees = async (req, res) => {
|
|||||||
regsByEmail.get(em).push(r);
|
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') {
|
if (template === 'tickets') {
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
for (const rcpt of recipients) {
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
||||||
try {
|
(async () => {
|
||||||
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||||
const regs = regsByEmail.get(rcpt.email) || [];
|
const regs = regsByEmail.get(rcpt.email) || [];
|
||||||
// Send tickets for each registration that belongs to this recipient for this event
|
|
||||||
for (const reg of regs) {
|
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 mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
||||||
const mockRes = { status: () => mockRes, json: () => {} };
|
const mockRes = { status: () => mockRes, json: () => {} };
|
||||||
await emailTickets(mockReq, mockRes);
|
await emailTickets(mockReq, mockRes);
|
||||||
}
|
}
|
||||||
sent++;
|
}));
|
||||||
} catch (e) {
|
results.forEach((r, i) => {
|
||||||
try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {}
|
if (r.status === 'rejected') {
|
||||||
|
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
})();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTitle = event?.title || 'the event';
|
const eventTitle = event?.title || 'the event';
|
||||||
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
||||||
// Build event and promo links for placeholders
|
// 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 eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
||||||
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
|
||||||
const promoTitle = promoEvent?.title || '';
|
const promoTitle = promoEvent?.title || '';
|
||||||
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
|
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
|
||||||
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
|
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
|
||||||
|
|
||||||
// Send individually with personalization
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
||||||
for (const rcpt of recipients) {
|
|
||||||
try {
|
(async () => {
|
||||||
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||||
const regs = regsByEmail.get(rcpt.email) || [];
|
const regs = regsByEmail.get(rcpt.email) || [];
|
||||||
// Sum outstanding balance across this user's registrations for the event
|
// Sum outstanding balance across this user's registrations for the event
|
||||||
let totalDue = 0; let totalPaid = 0;
|
let totalDue = 0; let totalPaid = 0;
|
||||||
@@ -1300,6 +1314,7 @@ const emailEventAttendees = async (req, res) => {
|
|||||||
totalDue += due; totalPaid += paid;
|
totalDue += due; totalPaid += paid;
|
||||||
}
|
}
|
||||||
const balance = Math.max(totalDue - totalPaid, 0);
|
const balance = Math.max(totalDue - totalPaid, 0);
|
||||||
|
const paymentLinkResolver = makePaymentLinkResolver(regs);
|
||||||
// Build context for placeholder replacement
|
// Build context for placeholder replacement
|
||||||
const ctxBase = {
|
const ctxBase = {
|
||||||
name: rcpt.name || '',
|
name: rcpt.name || '',
|
||||||
@@ -1310,6 +1325,7 @@ const emailEventAttendees = async (req, res) => {
|
|||||||
promoLink,
|
promoLink,
|
||||||
balance,
|
balance,
|
||||||
balanceFmt: fmtAmount(balance),
|
balanceFmt: fmtAmount(balance),
|
||||||
|
paymentLinkResolver,
|
||||||
};
|
};
|
||||||
const ctxHtml = {
|
const ctxHtml = {
|
||||||
...ctxBase,
|
...ctxBase,
|
||||||
@@ -1347,18 +1363,18 @@ const emailEventAttendees = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always perform placeholder replacement on whatever we have
|
// Always perform placeholder replacement on whatever we have
|
||||||
const finalSubject = replacePlaceholders(subj || '', ctxBase);
|
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
||||||
const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined;
|
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
|
||||||
const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined);
|
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
|
||||||
|
|
||||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||||
sent++;
|
}));
|
||||||
} catch (e) {
|
results.forEach((r, i) => {
|
||||||
try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {}
|
if (r.status === 'rejected') {
|
||||||
|
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
})();
|
||||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ message: error.message });
|
return res.status(400).json({ message: error.message });
|
||||||
}
|
}
|
||||||
@@ -1445,6 +1461,7 @@ const whatsappEventAttendees = async (req, res) => {
|
|||||||
|
|
||||||
const { sendText } = require('../utils/whatsapp');
|
const { sendText } = require('../utils/whatsapp');
|
||||||
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
||||||
|
const { replacePlaceholders } = require('../utils/placeholders');
|
||||||
|
|
||||||
function fmtAmount(amt) {
|
function fmtAmount(amt) {
|
||||||
const n = Number(amt || 0);
|
const n = Number(amt || 0);
|
||||||
@@ -1453,15 +1470,6 @@ const whatsappEventAttendees = async (req, res) => {
|
|||||||
function fmtDate(d) {
|
function fmtDate(d) {
|
||||||
try { return new Date(d).toLocaleString(); } catch { return String(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();
|
const regsByPhone = new Map();
|
||||||
for (const r of registrations) {
|
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 baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||||
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
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') {
|
if (template === 'tickets') {
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
for (const rcpt of recipients) {
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
||||||
try {
|
(async () => {
|
||||||
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||||
const regs = regsByPhone.get(rcpt.phone) || [];
|
const regs = regsByPhone.get(rcpt.phone) || [];
|
||||||
for (const reg of regs) {
|
for (const reg of regs) {
|
||||||
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
||||||
const mockRes = { status: () => mockRes, json: () => {} };
|
const mockRes = { status: () => mockRes, json: () => {} };
|
||||||
await emailTickets(mockReq, mockRes);
|
await emailTickets(mockReq, mockRes);
|
||||||
}
|
}
|
||||||
sent++;
|
}));
|
||||||
} catch (e) {
|
results.forEach((r, i) => {
|
||||||
try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {}
|
if (r.status === 'rejected') {
|
||||||
|
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
})();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rcpt of recipients) {
|
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
||||||
try {
|
|
||||||
|
(async () => {
|
||||||
|
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||||
const regs = regsByPhone.get(rcpt.phone) || [];
|
const regs = regsByPhone.get(rcpt.phone) || [];
|
||||||
let totalDue = 0; let totalPaid = 0;
|
let totalDue = 0; let totalPaid = 0;
|
||||||
for (const r of regs) {
|
for (const r of regs) {
|
||||||
@@ -1514,6 +1545,7 @@ const whatsappEventAttendees = async (req, res) => {
|
|||||||
eventLink,
|
eventLink,
|
||||||
balance,
|
balance,
|
||||||
balanceFmt: fmtAmount(balance),
|
balanceFmt: fmtAmount(balance),
|
||||||
|
paymentLinkResolver: makePaymentLinkResolver(regs),
|
||||||
};
|
};
|
||||||
|
|
||||||
let msg = message;
|
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!`;
|
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);
|
await sendText(rcpt.phone, finalMessage);
|
||||||
sent++;
|
}));
|
||||||
} catch (e) {
|
results.forEach((r, i) => {
|
||||||
try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {}
|
if (r.status === 'rejected') {
|
||||||
|
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
})();
|
||||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ message: error.message });
|
return res.status(400).json({ message: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ const createPayment = async (req, res) => {
|
|||||||
amount: requestedAmount,
|
amount: requestedAmount,
|
||||||
method,
|
method,
|
||||||
userId,
|
userId,
|
||||||
|
recordedById: req.user.id,
|
||||||
registrationId: null,
|
registrationId: null,
|
||||||
eventId: registration.eventId,
|
eventId: registration.eventId,
|
||||||
isDonation: true,
|
isDonation: true,
|
||||||
@@ -164,6 +165,7 @@ const createPayment = async (req, res) => {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
recordedBy: { select: { id: true, name: true, email: true } },
|
||||||
event: true
|
event: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -175,6 +177,7 @@ const createPayment = async (req, res) => {
|
|||||||
amount: applyAmount,
|
amount: applyAmount,
|
||||||
method,
|
method,
|
||||||
userId,
|
userId,
|
||||||
|
recordedById: req.user.id,
|
||||||
registrationId,
|
registrationId,
|
||||||
eventId: registrationEventId || eventId || null,
|
eventId: registrationEventId || eventId || null,
|
||||||
isDonation: false,
|
isDonation: false,
|
||||||
@@ -182,6 +185,7 @@ const createPayment = async (req, res) => {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
recordedBy: { select: { id: true, name: true, email: true } },
|
||||||
registration: { include: { event: true } },
|
registration: { include: { event: true } },
|
||||||
event: (registrationEventId || eventId) ? true : undefined
|
event: (registrationEventId || eventId) ? true : undefined
|
||||||
}
|
}
|
||||||
@@ -195,6 +199,7 @@ const createPayment = async (req, res) => {
|
|||||||
amount: excess,
|
amount: excess,
|
||||||
method,
|
method,
|
||||||
userId,
|
userId,
|
||||||
|
recordedById: req.user.id,
|
||||||
registrationId: null,
|
registrationId: null,
|
||||||
eventId: registration.eventId,
|
eventId: registration.eventId,
|
||||||
isDonation: true,
|
isDonation: true,
|
||||||
@@ -212,6 +217,7 @@ const createPayment = async (req, res) => {
|
|||||||
amount: parseFloat(amount),
|
amount: parseFloat(amount),
|
||||||
method,
|
method,
|
||||||
userId,
|
userId,
|
||||||
|
recordedById: req.user.id,
|
||||||
registrationId: registrationId || null,
|
registrationId: registrationId || null,
|
||||||
eventId: registrationEventId || eventId || null,
|
eventId: registrationEventId || eventId || null,
|
||||||
isDonation: isDonation || false,
|
isDonation: isDonation || false,
|
||||||
@@ -219,6 +225,7 @@ const createPayment = async (req, res) => {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
recordedBy: { select: { id: true, name: true, email: true } },
|
||||||
registration: registrationId ? { include: { event: true } } : undefined,
|
registration: registrationId ? { include: { event: true } } : undefined,
|
||||||
event: (registrationEventId || eventId) ? true : undefined
|
event: (registrationEventId || eventId) ? true : undefined
|
||||||
}
|
}
|
||||||
@@ -329,6 +336,7 @@ const getPayments = async (req, res) => {
|
|||||||
|
|
||||||
const include = {
|
const include = {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
recordedBy: { select: { id: true, name: true, email: true } },
|
||||||
registration: {
|
registration: {
|
||||||
include: {
|
include: {
|
||||||
event: true,
|
event: true,
|
||||||
@@ -452,6 +460,13 @@ const getPaymentById = async (req, res) => {
|
|||||||
email: true
|
email: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
recordedBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
registration: {
|
registration: {
|
||||||
include: {
|
include: {
|
||||||
event: true
|
event: true
|
||||||
@@ -507,6 +522,13 @@ const getPaymentsByRegistration = async (req, res) => {
|
|||||||
name: true,
|
name: true,
|
||||||
email: true
|
email: true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
recordedBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -533,6 +555,7 @@ const getPaymentsByEvent = async (req, res) => {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
recordedBy: { select: { id: true, name: true, email: true } },
|
||||||
registration: {
|
registration: {
|
||||||
include: { user: { select: { id: true, name: true, email: true } } }
|
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');
|
throw new Error('Only donations can be assigned to registrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if payment is already assigned to a registration
|
// Donations are never mutated once created — their remaining balance is the original
|
||||||
if (payment.registrationId) {
|
// 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);
|
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) {
|
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
|
// 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
|
// donation's remaining balance covers, capped at what's owed) but staff can specify a
|
||||||
// deliberately leave the registrant owing a balance.
|
// smaller amount and deliberately leave the registrant owing a balance.
|
||||||
let allocateAmount = amount != null ? Number(amount) : Math.min(payment.amount, remainingAmount);
|
let allocateAmount = amount != null ? Number(amount) : Math.min(remainingDonation, remainingAmount);
|
||||||
if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) {
|
if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
throw new Error('Allocation amount must be greater than zero');
|
throw new Error('Allocation amount must be greater than zero');
|
||||||
}
|
}
|
||||||
if (allocateAmount > payment.amount) {
|
if (allocateAmount > remainingDonation) {
|
||||||
res.status(400);
|
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) {
|
if (allocateAmount > remainingAmount) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
@@ -647,38 +678,23 @@ const assignDonationToRegistration = async (req, res) => {
|
|||||||
let updatedRegistration;
|
let updatedRegistration;
|
||||||
let generatedTickets = [];
|
let generatedTickets = [];
|
||||||
let originalPaymentId = payment.id;
|
let originalPaymentId = payment.id;
|
||||||
let splitPayment = null;
|
|
||||||
|
|
||||||
// Update the payment to be associated with the registration and adjust amount
|
// Create an immutable leg referencing the donation — the donation row itself is never
|
||||||
await prisma.payment.update({
|
// touched, so its original amount and history stay intact and it can be assigned again
|
||||||
where: { id: payment.id },
|
// later if this leg doesn't use it up.
|
||||||
data: {
|
const leg = await prisma.payment.create({
|
||||||
registrationId,
|
|
||||||
amount: allocateAmount,
|
|
||||||
isDonation: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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: {
|
data: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
amount: leftoverAmount,
|
amount: allocateAmount,
|
||||||
method: payment.method,
|
method: payment.method,
|
||||||
userId: payment.userId,
|
userId: payment.userId,
|
||||||
eventId: payment.eventId,
|
recordedById: req.user.id,
|
||||||
isDonation: true,
|
registrationId,
|
||||||
externalId: payment.externalId ? `${payment.externalId}-split` : null,
|
eventId: registration.eventId,
|
||||||
status: payment.status,
|
isDonation: false,
|
||||||
originalPaymentId: payment.id,
|
originalPaymentId: payment.id,
|
||||||
createdAt: payment.createdAt
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (allocateAmount >= remainingAmount) {
|
if (allocateAmount >= remainingAmount) {
|
||||||
// Fully covers what's owed
|
// Fully covers what's owed
|
||||||
@@ -707,9 +723,9 @@ const assignDonationToRegistration = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails),
|
// 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 { sendDonationAssignmentEmails } = require('../utils/notifications');
|
||||||
const _adPaymentId = payment?.id;
|
const _adPaymentId = leg?.id;
|
||||||
const _adShouldEmailTickets = generatedTickets.length > 0;
|
const _adShouldEmailTickets = generatedTickets.length > 0;
|
||||||
const _adUserId = registration?.userId;
|
const _adUserId = registration?.userId;
|
||||||
const _adRegId = registrationId;
|
const _adRegId = registrationId;
|
||||||
@@ -730,10 +746,8 @@ const assignDonationToRegistration = async (req, res) => {
|
|||||||
originalPaymentId,
|
originalPaymentId,
|
||||||
updatedRegistration,
|
updatedRegistration,
|
||||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
|
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
|
||||||
splitPayment: splitPayment ? {
|
leg,
|
||||||
...splitPayment,
|
donationRemaining: remainingDonation - allocateAmount
|
||||||
originalPaymentId: payment.id
|
|
||||||
} : null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
@@ -1151,6 +1165,7 @@ const createRefund = async (req, res) => {
|
|||||||
amount: -Math.abs(amt),
|
amount: -Math.abs(amt),
|
||||||
method: method || 'refund',
|
method: method || 'refund',
|
||||||
userId,
|
userId,
|
||||||
|
recordedById: req.user.id,
|
||||||
registrationId: linkRegistrationId,
|
registrationId: linkRegistrationId,
|
||||||
eventId: linkEventId,
|
eventId: linkEventId,
|
||||||
isDonation: false,
|
isDonation: false,
|
||||||
@@ -1159,6 +1174,7 @@ const createRefund = async (req, res) => {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, name: true, email: true } },
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
recordedBy: { select: { id: true, name: true, email: true } },
|
||||||
registration: { include: { event: true } },
|
registration: { include: { event: true } },
|
||||||
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 lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const lastMonth = new Date(startOfDay.getTime() - 30 * 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([
|
const [totalToday, totalWeek, totalMonth] = await Promise.all([
|
||||||
prisma.payment.aggregate({
|
prisma.payment.aggregate({
|
||||||
_sum: {
|
_sum: {
|
||||||
@@ -1218,7 +1240,8 @@ const getPaymentStats = async (req, res) => {
|
|||||||
where: {
|
where: {
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: startOfDay
|
gte: startOfDay
|
||||||
}
|
},
|
||||||
|
...excludeDonationLegs
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.payment.aggregate({
|
prisma.payment.aggregate({
|
||||||
@@ -1228,7 +1251,8 @@ const getPaymentStats = async (req, res) => {
|
|||||||
where: {
|
where: {
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: lastWeek
|
gte: lastWeek
|
||||||
}
|
},
|
||||||
|
...excludeDonationLegs
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.payment.aggregate({
|
prisma.payment.aggregate({
|
||||||
@@ -1238,7 +1262,8 @@ const getPaymentStats = async (req, res) => {
|
|||||||
where: {
|
where: {
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: lastMonth
|
gte: lastMonth
|
||||||
}
|
},
|
||||||
|
...excludeDonationLegs
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ const getRegistrations = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const registrations = await prisma.registration.findMany({
|
const registrations = await prisma.registration.findMany({
|
||||||
include: {
|
include: {
|
||||||
payments: true,
|
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||||
registrationOptions: {
|
registrationOptions: {
|
||||||
include: {
|
include: {
|
||||||
eventOption: { include: { earlyBirdTiers: true } },
|
eventOption: { include: { earlyBirdTiers: true } },
|
||||||
@@ -472,7 +472,7 @@ const getRegistrationById = async (req, res) => {
|
|||||||
phoneNumber: true
|
phoneNumber: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
payments: true,
|
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||||
formResponses: { include: { answers: true } }
|
formResponses: { include: { answers: true } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -653,7 +653,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
|||||||
tickets: true,
|
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 } }
|
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' }
|
orderBy: { createdAt: 'asc' }
|
||||||
|
|||||||
@@ -1,177 +1,123 @@
|
|||||||
const PDFDocument = require('pdfkit');
|
const PDFDocument = require('pdfkit');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const nodemailer = require('nodemailer');
|
const { sendMail, emailWrapper } = require('../utils/email');
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function a4Doc(orientation = 'portrait') {
|
function a4Doc(orientation = 'portrait') {
|
||||||
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
|
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/reports/pdf
|
// Brand palette — mirrors the web reports' indigo theme and the dataviz-skill categorical
|
||||||
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
|
// palette used by the on-screen HorizontalBarChart, so exported PDFs/Excel look like the
|
||||||
const generatePdf = async (req, res) => {
|
// same report instead of a plain data dump.
|
||||||
try {
|
const BRAND = '#4f46e5';
|
||||||
const { title, kind, table, layered, orientation } = req.body || {};
|
const BRAND_DARK = '#3730a3';
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
const BRAND_LIGHT = '#eef2ff';
|
||||||
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
const TEXT_DARK = '#111827';
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
const TEXT_MUTED = '#6b7280';
|
||||||
|
const BORDER = '#e5e7eb';
|
||||||
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
|
const TONE_COLORS = {
|
||||||
doc.pipe(res);
|
green: { bg: '#ecfdf5', accent: '#059669' },
|
||||||
|
blue: { bg: '#eff6ff', accent: '#2563eb' },
|
||||||
// Title
|
violet: { bg: '#f5f3ff', accent: '#7c3aed' },
|
||||||
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
|
amber: { bg: '#fffbeb', accent: '#d97706' },
|
||||||
doc.moveDown(0.5);
|
rose: { bg: '#fff1f2', accent: '#e11d48' },
|
||||||
|
gray: { bg: '#f3f4f6', accent: '#4b5563' },
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.end();
|
|
||||||
} catch (e) {
|
|
||||||
res.status(400).json({ message: e.message });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
const CATEGORICAL_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#4a3aa7', '#e34948'];
|
||||||
|
|
||||||
// POST /api/reports/email
|
function isEmphasisRow(firstCell) {
|
||||||
// body: { title, kind, table?, layered?, subject?, body? }
|
const s = String(firstCell ?? '').trim();
|
||||||
const emailPdf = async (req, res) => {
|
return /^total$/i.test(s) || /net profit/i.test(s) || /unassigned donations/i.test(s) || /revenue per ticket/i.test(s);
|
||||||
try {
|
}
|
||||||
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
|
|
||||||
const user = req.user;
|
function drawHeader(doc, title, subtitle) {
|
||||||
if (!user || !user.email) {
|
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
res.status(400);
|
const x = doc.page.margins.left;
|
||||||
throw new Error('User email not available');
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure temp dir
|
function drawStats(doc, stats) {
|
||||||
const tempDir = path.join(__dirname, '..', '..', 'temp');
|
if (!Array.isArray(stats) || stats.length === 0) return;
|
||||||
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// Build PDF to file
|
function drawChart(doc, chart) {
|
||||||
await new Promise((resolve, reject) => {
|
if (!chart || !Array.isArray(chart.data) || chart.data.length === 0) return;
|
||||||
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
|
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
const ws = fs.createWriteStream(filePath);
|
const x = doc.page.margins.left;
|
||||||
doc.pipe(ws);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
|
function drawNote(doc, note) {
|
||||||
doc.moveDown(0.5);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
if (kind === 'table' && table && Array.isArray(table.rows)) {
|
// 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 columns = Array.isArray(table.columns) ? table.columns : [];
|
||||||
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
|
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 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 baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
|
||||||
const colWidths = new Array(colCount).fill(baseWidth);
|
const colWidths = new Array(colCount).fill(baseWidth);
|
||||||
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
|
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
|
||||||
@@ -181,8 +127,8 @@ const emailPdf = async (req, res) => {
|
|||||||
let x = doc.page.margins.left;
|
let x = doc.page.margins.left;
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
doc.save();
|
doc.save();
|
||||||
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
|
doc.rect(x, y, pageWidth, 22).fill(BRAND_LIGHT);
|
||||||
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
|
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(10.5);
|
||||||
columns.forEach((h, i) => {
|
columns.forEach((h, i) => {
|
||||||
const w = colWidths[i] || baseWidth;
|
const w = colWidths[i] || baseWidth;
|
||||||
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
|
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
|
||||||
@@ -192,78 +138,327 @@ const emailPdf = async (req, res) => {
|
|||||||
doc.moveDown(1.6);
|
doc.moveDown(1.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rows zebra
|
// Rows — zebra striped, with a highlighted tint+bold for total/summary rows
|
||||||
const rows = table.rows;
|
const rows = table.rows;
|
||||||
rows.forEach((row, idx) => {
|
rows.forEach((row) => {
|
||||||
|
const emphasis = isEmphasisRow(row[0]);
|
||||||
const rowY = doc.y;
|
const rowY = doc.y;
|
||||||
const rowH = 18;
|
const rowH = 18;
|
||||||
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
|
const bg = emphasis ? BRAND_LIGHT : (rows.indexOf(row) % 2 === 0 ? '#ffffff' : '#f9fafb');
|
||||||
doc.save();
|
doc.save();
|
||||||
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
|
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
|
||||||
let x = doc.page.margins.left;
|
let x = doc.page.margins.left;
|
||||||
row.forEach((cell, i) => {
|
row.forEach((cell, i) => {
|
||||||
const w = colWidths[i] || baseWidth;
|
const w = colWidths[i] || baseWidth;
|
||||||
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
|
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('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
|
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
|
||||||
x += w;
|
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.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);
|
doc.moveDown(1.1);
|
||||||
if (doc.y > doc.page.height - 40) {
|
if (doc.y > doc.page.height - 40) {
|
||||||
doc.addPage();
|
doc.addPage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
|
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)) {
|
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
|
||||||
if (layered.header) {
|
if (layered.header) {
|
||||||
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
|
doc.font('Helvetica-Bold').fontSize(13).fillColor(TEXT_DARK).text(layered.header, leftX, doc.y);
|
||||||
doc.moveDown(0.3);
|
doc.moveDown(0.3);
|
||||||
}
|
}
|
||||||
doc.font('Helvetica').fontSize(11);
|
doc.font('Helvetica').fontSize(11);
|
||||||
for (const section of layered.sections) {
|
for (const section of layered.sections) {
|
||||||
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
|
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').text(String(section.title || ''), leftX, doc.y, { continued: false });
|
||||||
doc.moveDown(0.15);
|
doc.moveDown(0.15);
|
||||||
doc.font('Helvetica').fontSize(10);
|
doc.font('Helvetica').fontSize(10);
|
||||||
if (Array.isArray(section.items) && section.items.length) {
|
if (Array.isArray(section.items) && section.items.length) {
|
||||||
for (const item of section.items) {
|
for (const item of section.items) {
|
||||||
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
|
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill(BRAND).stroke();
|
||||||
doc.fillColor('#111827');
|
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.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);
|
doc.moveDown(0.2);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
doc.text('No items');
|
doc.fillColor(TEXT_MUTED).text('No items', leftX, doc.y);
|
||||||
}
|
}
|
||||||
doc.moveDown(0.5);
|
doc.moveDown(0.5);
|
||||||
if (doc.y > doc.page.height - 60) doc.addPage();
|
if (doc.y > doc.page.height - 60) doc.addPage();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
doc.font('Helvetica').text('No content');
|
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();
|
doc.end();
|
||||||
ws.on('finish', resolve);
|
ws.on('finish', resolve);
|
||||||
ws.on('error', reject);
|
ws.on('error', reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send email using nodemailer (same config as tickets)
|
return filePath;
|
||||||
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({
|
// Builds a styled .xlsx workbook mirroring the same payload shape used for PDF — branded
|
||||||
from: process.env.EMAIL_FROM,
|
// 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, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
|
||||||
|
const generatePdf = async (req, res) => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
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, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? }
|
||||||
|
const emailPdf = async (req, res) => {
|
||||||
|
try {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
|
||||||
|
|
||||||
|
// 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,
|
to: user.email,
|
||||||
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
|
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
|
||||||
text: body || 'Please find your report attached.',
|
text: bodyText,
|
||||||
|
html: emailWrapper(
|
||||||
|
`<p style="margin:0 0 16px 0;color:#374151">${bodyText}</p>` +
|
||||||
|
`<p style="margin:0;color:#374151">Your report <strong>${title || 'Report'}</strong> is attached as a PDF.</p>`,
|
||||||
|
{ preheader: title || 'Report PDF' }
|
||||||
|
),
|
||||||
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
|
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean
|
|
||||||
try { fs.unlinkSync(filePath); } catch {}
|
try { fs.unlinkSync(filePath); } catch {}
|
||||||
|
|
||||||
res.json({ message: `Report emailed to ${user.email}` });
|
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 };
|
||||||
|
|||||||
@@ -354,21 +354,15 @@ const updateUserProfile = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalize phone
|
// Normalize phone
|
||||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||||
let newPhone = user.phoneNumber;
|
let newPhone = user.phoneNumber;
|
||||||
if (phoneNumber !== undefined) {
|
if (phoneNumber !== undefined) {
|
||||||
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate notification preference — WhatsApp requires a valid SA phone number
|
// Validate notification preference — WhatsApp requires a valid SA phone number
|
||||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||||
let newPref = user.notificationPreference;
|
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||||
if (notificationPreference !== undefined) {
|
|
||||||
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
|
|
||||||
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
|
|
||||||
newPref = 'email';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user data
|
// Update user data
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
@@ -554,7 +548,11 @@ const updateUser = async (req, res) => {
|
|||||||
throw new Error('User not found');
|
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
|
// Prepare data update, allow admin to set a new password
|
||||||
const data = {
|
const data = {
|
||||||
@@ -562,7 +560,8 @@ const updateUser = async (req, res) => {
|
|||||||
email: email || user.email,
|
email: email || user.email,
|
||||||
role: role || user.role,
|
role: role || user.role,
|
||||||
isActive: isActive !== undefined ? isActive : user.isActive,
|
isActive: isActive !== undefined ? isActive : user.isActive,
|
||||||
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
|
phoneNumber: newPhone,
|
||||||
|
notificationPreference: newPref,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -579,6 +578,7 @@ const updateUser = async (req, res) => {
|
|||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
phoneNumber: true,
|
phoneNumber: true,
|
||||||
|
notificationPreference: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
isActive: true
|
isActive: true
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ const handlePaymentSucceeded = async (webhookData) => {
|
|||||||
externalId: yocoPaymentId,
|
externalId: yocoPaymentId,
|
||||||
registrationId: registration?.id || null,
|
registrationId: registration?.id || null,
|
||||||
userId: resolvedUserId,
|
userId: resolvedUserId,
|
||||||
|
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
|
||||||
eventId: registration?.eventId || metadata?.eventId || null,
|
eventId: registration?.eventId || metadata?.eventId || null,
|
||||||
isDonation: !registration?.id
|
isDonation: !registration?.id
|
||||||
},
|
},
|
||||||
@@ -334,6 +335,13 @@ const handlePaymentSucceeded = async (webhookData) => {
|
|||||||
email: true
|
email: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
recordedBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
registration: registration ? {
|
registration: registration ? {
|
||||||
include: {
|
include: {
|
||||||
event: true
|
event: true
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ const reconcileYocoTransaction = async (req, res) => {
|
|||||||
amount: amountFloat,
|
amount: amountFloat,
|
||||||
method: ytx.methodType || 'card',
|
method: ytx.methodType || 'card',
|
||||||
userId: resolvedUserId,
|
userId: resolvedUserId,
|
||||||
|
recordedById: req.user?.id || null, // staff who performed the reconciliation
|
||||||
registrationId: registration?.id || null,
|
registrationId: registration?.id || null,
|
||||||
eventId: registration?.eventId || eventId || null,
|
eventId: registration?.eventId || eventId || null,
|
||||||
isDonation: !registration?.id,
|
isDonation: !registration?.id,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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');
|
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||||
router.get('/event/:eventId', protect, supervisor, getEventCashup);
|
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.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { generatePdf, emailPdf } = require('../controllers/reportController');
|
const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController');
|
||||||
const { protect } = require('../middleware/authMiddleware');
|
const { protect } = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
// Generate and download PDF
|
// Generate and download PDF
|
||||||
router.post('/pdf', protect, generatePdf);
|
router.post('/pdf', protect, generatePdf);
|
||||||
|
|
||||||
|
// Generate and download styled Excel (.xlsx)
|
||||||
|
router.post('/excel', protect, generateExcel);
|
||||||
|
|
||||||
// Email PDF to current user
|
// Email PDF to current user
|
||||||
router.post('/email', protect, emailPdf);
|
router.post('/email', protect, emailPdf);
|
||||||
|
|
||||||
|
// Send PDF to current user's own WhatsApp
|
||||||
|
router.post('/whatsapp', protect, whatsappPdf);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ function bucketForMethod(method) {
|
|||||||
return 'other';
|
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
|
// 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).
|
// (res.statusCode is set before throwing, matching the rest of the controllers).
|
||||||
async function assertEventOpen(eventId, res) {
|
async function assertEventOpen(eventId, res) {
|
||||||
@@ -93,16 +103,30 @@ async function computeEventFinancials(eventId) {
|
|||||||
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
|
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonRefundPayments = payments.filter(p => p.amount > 0);
|
// Real inflows only — excludes donation-application legs, which would otherwise double-count
|
||||||
const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId);
|
// money already counted once via the source donation (e.g. a R250 donation with R50 assigned
|
||||||
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0);
|
// to a registration must total R250 received, not R300).
|
||||||
const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0);
|
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();
|
const paymentsByMethod = emptyByMethod();
|
||||||
for (const p of nonRefundPayments) {
|
for (const p of nonRefundPayments) {
|
||||||
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
|
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)
|
// Costs, with computed totals and attribution to a payment method's float (if tagged)
|
||||||
const costBreakdown = costs.map(c => {
|
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 = {
|
module.exports = {
|
||||||
METHOD_BUCKETS,
|
METHOD_BUCKETS,
|
||||||
ALL_METHODS,
|
ALL_METHODS,
|
||||||
ZAR_DENOMINATIONS,
|
ZAR_DENOMINATIONS,
|
||||||
bucketForMethod,
|
bucketForMethod,
|
||||||
|
isDonationLeg,
|
||||||
assertEventOpen,
|
assertEventOpen,
|
||||||
assertRegistrationEventOpen,
|
assertRegistrationEventOpen,
|
||||||
computeEventFinancials
|
computeEventFinancials,
|
||||||
|
computeAccountabilityByUser,
|
||||||
|
computeEventCashActualFromPersonCounts,
|
||||||
|
savePersonCashCount
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -105,6 +105,12 @@ function isSessionNotFound(e) {
|
|||||||
return msg.includes('session not found') || msg.includes('instance not found');
|
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
|
* 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.
|
* 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);
|
const chatId = toChatId(toPhone);
|
||||||
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
|
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
|
||||||
const { token, instanceId } = await getConfig();
|
const { token, instanceId } = await getConfig();
|
||||||
|
try {
|
||||||
await axios.post(`${BASE}/send/text`, {
|
await axios.post(`${BASE}/send/text`, {
|
||||||
access_token: token,
|
access_token: token,
|
||||||
instance_id: instanceId,
|
instance_id: instanceId,
|
||||||
chatId,
|
chatId,
|
||||||
message,
|
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,6 +272,7 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
|||||||
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
|
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
|
||||||
|
|
||||||
const { token, instanceId } = await getConfig();
|
const { token, instanceId } = await getConfig();
|
||||||
|
try {
|
||||||
await axios.post(`${BASE}/send/pdf`, {
|
await axios.post(`${BASE}/send/pdf`, {
|
||||||
access_token: token,
|
access_token: token,
|
||||||
instance_id: instanceId,
|
instance_id: instanceId,
|
||||||
@@ -269,6 +284,14 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
|||||||
},
|
},
|
||||||
caption: caption || '',
|
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
|
// Clean up after 5 minutes — WAWP will have fetched the file by then
|
||||||
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
|
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.3.2",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function EventCashupPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
const [tab, setTab] = useState<"costs" | "reconciliation">("costs");
|
const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs");
|
||||||
const [data, setData] = useState<EventFinancials | null>(null);
|
const [data, setData] = useState<EventFinancials | null>(null);
|
||||||
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
|
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -72,6 +72,7 @@ export default function EventCashupPage() {
|
|||||||
<div className="flex gap-2 border-b">
|
<div className="flex gap-2 border-b">
|
||||||
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
|
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
|
||||||
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
|
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
|
||||||
|
<button className={"px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}>Report</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <div className="text-sm text-gray-400">Loading…</div>}
|
{loading && <div className="text-sm text-gray-400">Loading…</div>}
|
||||||
@@ -89,8 +90,13 @@ export default function EventCashupPage() {
|
|||||||
setBusy={setBusy}
|
setBusy={setBusy}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
onChanged={load}
|
onChanged={load}
|
||||||
|
onClosed={() => setTab("report")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && data && tab === "report" && (
|
||||||
|
<ReportTab eventId={eventId} token={token || ""} data={data} isClosed={isClosed} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,7 +175,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
|||||||
|
|
||||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||||
|
|
||||||
<table className="w-full text-sm">
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-sm min-w-[600px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-gray-500 border-b">
|
<tr className="text-left text-gray-500 border-b">
|
||||||
<th className="py-1">Label</th>
|
<th className="py-1">Label</th>
|
||||||
@@ -212,6 +219,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
)}
|
)}
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{editingId !== null && (
|
{editingId !== null && (
|
||||||
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
||||||
@@ -266,9 +274,349 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
|||||||
|
|
||||||
// ─── Reconciliation tab ─────────────────────────────────────────────────────
|
// ─── 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<AccountabilityResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
if (!token || !eventId) return;
|
||||||
|
setLoading(true);
|
||||||
|
apiFetch<AccountabilityResponse>(`/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 (
|
||||||
|
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||||
|
<button className="text-sm font-medium flex items-center gap-1" onClick={() => setOpen(o => !o)}>
|
||||||
|
<span>{open ? "▾" : "▸"}</span> Payment accountability by staff member
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
loading ? (
|
||||||
|
<div className="text-sm text-gray-400">Loading…</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500">No payments recorded for this event.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-sm min-w-[760px] border-separate border-spacing-0">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-400 text-[10px] uppercase tracking-wide">
|
||||||
|
<th rowSpan={2} className="text-left align-bottom pb-1 pr-3">Staff member</th>
|
||||||
|
<th colSpan={3} className="text-center pb-1 border-l border-gray-100 px-2">Cash</th>
|
||||||
|
<th rowSpan={2} className="text-right align-bottom pb-1 border-l border-gray-100 px-2">Card</th>
|
||||||
|
<th rowSpan={2} className="text-right align-bottom pb-1 px-2">EFT</th>
|
||||||
|
<th rowSpan={2} className="text-right align-bottom pb-1 px-2">Other</th>
|
||||||
|
<th rowSpan={2} className="text-right align-bottom pb-1 border-l border-gray-100 px-2">Total</th>
|
||||||
|
<th rowSpan={2}></th>
|
||||||
|
</tr>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs">
|
||||||
|
<th className="text-right pb-1.5 border-l border-gray-100 px-2 font-normal">Expected</th>
|
||||||
|
<th className="text-right pb-1.5 px-2 font-normal">Actual</th>
|
||||||
|
<th className="text-right pb-1.5 px-2 font-normal">Variance</th>
|
||||||
|
<th className="border-l border-gray-100"></th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
<th className="border-l border-gray-100"></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(r => (
|
||||||
|
<React.Fragment key={r.userId || "unknown"}>
|
||||||
|
<tr className="border-b border-gray-100 last:border-0">
|
||||||
|
<td className="py-2.5 pr-3">
|
||||||
|
<div className="font-medium text-gray-800">{r.name}</div>
|
||||||
|
{r.email && <div className="text-xs text-gray-400">{r.email}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(r.cash.total)}</td>
|
||||||
|
<td className="py-2.5 text-right px-2">{r.cash.actual != null ? money(r.cash.actual) : <span className="text-gray-300">—</span>}</td>
|
||||||
|
<td className="py-2.5 text-right px-2">
|
||||||
|
{r.cash.variance != null ? (
|
||||||
|
Math.abs(r.cash.variance) > 0.01 ? (
|
||||||
|
<span className={"inline-block px-1.5 py-0.5 rounded text-xs font-medium " + (r.cash.variance < 0 ? "bg-rose-50 text-rose-700" : "bg-amber-50 text-amber-700")}>
|
||||||
|
{money(r.cash.variance)}
|
||||||
|
</span>
|
||||||
|
) : <span className="inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-50 text-emerald-700">Matches</span>
|
||||||
|
) : <span className="text-gray-300">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 text-right text-gray-600 border-l border-gray-100 px-2">{r.card.total > 0 ? money(r.card.total) : <span className="text-gray-300">—</span>}</td>
|
||||||
|
<td className="py-2.5 text-right text-gray-600 px-2">{r.eft.total > 0 ? money(r.eft.total) : <span className="text-gray-300">—</span>}</td>
|
||||||
|
<td className="py-2.5 text-right text-gray-600 px-2">{r.other.total > 0 ? money(r.other.total) : <span className="text-gray-300">—</span>}</td>
|
||||||
|
<td className="py-2.5 text-right font-medium border-l border-gray-100 px-2">{money(r.cash.total + r.card.total + r.eft.total + r.other.total)}</td>
|
||||||
|
<td className="py-2.5 text-right pl-2">
|
||||||
|
{r.userId && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-indigo-600 hover:underline whitespace-nowrap"
|
||||||
|
onClick={() => setEditingUserId(editingUserId === r.userId ? null : r.userId)}
|
||||||
|
>
|
||||||
|
{r.cash.actual != null ? "Edit count" : "Enter count"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{r.userId && editingUserId === r.userId && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="pb-3">
|
||||||
|
<PersonCashCountEditor
|
||||||
|
eventId={eventId}
|
||||||
|
token={token}
|
||||||
|
userId={r.userId}
|
||||||
|
initialDenominations={r.cash.denominations}
|
||||||
|
initialNotes={r.cash.notes}
|
||||||
|
enteredBy={r.cash.enteredBy}
|
||||||
|
countUpdatedAt={r.cash.countUpdatedAt}
|
||||||
|
onSaved={() => { setEditingUserId(null); load(); }}
|
||||||
|
onCancel={() => setEditingUserId(null)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<tr className="font-semibold border-t border-gray-200">
|
||||||
|
<td className="py-2.5 pr-3">Total</td>
|
||||||
|
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(totalCash)}</td>
|
||||||
|
<td className="py-2.5 text-right px-2">{money(totalCashActual)}</td>
|
||||||
|
<td className="py-2.5 text-right px-2">{money(totalCashActual - totalCash)}</td>
|
||||||
|
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(sumOf("card", "total"))}</td>
|
||||||
|
<td className="py-2.5 text-right px-2">{money(sumOf("eft", "total"))}</td>
|
||||||
|
<td className="py-2.5 text-right px-2">{money(sumOf("other", "total"))}</td>
|
||||||
|
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(grandTotal)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<number, string> = {};
|
||||||
|
for (const d of initialDenominations) initialCounts[d.value] = String(d.count);
|
||||||
|
const [counts, setCounts] = useState<Record<number, string>>(initialCounts);
|
||||||
|
const [notes, setNotes] = useState(initialNotes || "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(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 (
|
||||||
|
<div className="border rounded-lg p-3 bg-gray-50 space-y-3">
|
||||||
|
{err && <div className="text-xs text-red-600">{err}</div>}
|
||||||
|
{enteredBy && countUpdatedAt && (
|
||||||
|
<div className="text-[11px] text-gray-500">Last entered by {enteredBy.name} on {new Date(countUpdatedAt).toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
{ZAR_DENOMINATIONS.map(v => (
|
||||||
|
<div key={v} className="flex items-center gap-2">
|
||||||
|
<span className="text-sm w-14">{denomLabel(v)}</span>
|
||||||
|
<span className="text-xs text-gray-400">×</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
className="w-16 border rounded px-2 py-1 text-sm"
|
||||||
|
value={counts[v] || ""}
|
||||||
|
onChange={e => setCount(v, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm font-medium">Total: {money(runningTotal)}</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] text-gray-600 mb-1">Notes (optional)</label>
|
||||||
|
<input className="w-full border rounded px-2 py-1.5 text-sm" value={notes} onChange={e => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button className="text-xs px-3 py-1.5 rounded border" onClick={onCancel} disabled={saving}>Cancel</button>
|
||||||
|
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="text-sm font-medium">Cashup report</div>
|
||||||
|
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
|
||||||
|
{isClosed ? "Closed" : "Open (live preview)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{latestClose ? (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{latestClose.action === "closed" ? "Closed (full cashup)" : "Quick closed"} by {latestClose.performedBy?.name || "Unknown"} on {new Date(latestClose.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">This event hasn't been closed yet — figures below are a live preview and will change as more payments come in.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
|
||||||
|
<div className="text-sm font-medium">Revenue by method</div>
|
||||||
|
<table className="w-full text-sm min-w-[560px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b">
|
||||||
|
<th className="py-1">Method</th>
|
||||||
|
<th className="py-1 text-right">Income</th>
|
||||||
|
<th className="py-1 text-right">Expected cash</th>
|
||||||
|
<th className="py-1 text-right">Actual</th>
|
||||||
|
<th className="py-1 text-right">Variance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{METHODS.map(m => {
|
||||||
|
const r = reconciled?.byMethod?.[m];
|
||||||
|
return (
|
||||||
|
<tr key={m} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
||||||
|
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
||||||
|
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
||||||
|
<td className="py-1.5 text-right">{r?.actual != null ? money(r.actual) : "—"}</td>
|
||||||
|
<td className="py-1.5 text-right">{r?.variance != null ? money(r.variance) : "—"}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reconciled?.byMethod?.cash?.denominations?.length ? (
|
||||||
|
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||||
|
<div className="text-sm font-medium">Cash denomination count</div>
|
||||||
|
<table className="text-sm">
|
||||||
|
<tbody>
|
||||||
|
{reconciled.byMethod.cash.denominations.map((d: any) => (
|
||||||
|
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CashByUserSection eventId={eventId} token={token} />
|
||||||
|
|
||||||
|
{data.costs.length > 0 && (
|
||||||
|
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
|
||||||
|
<div className="text-sm font-medium">Costs</div>
|
||||||
|
<table className="w-full text-sm min-w-[480px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b">
|
||||||
|
<th className="py-1">Label</th>
|
||||||
|
<th className="py-1">Paid from</th>
|
||||||
|
<th className="py-1 text-right">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.costs.map((c: any) => (
|
||||||
|
<tr key={c.id} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5">{c.label}</td>
|
||||||
|
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
|
||||||
|
<td className="py-1.5 text-right">{money(c.total ?? c.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||||
|
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">Total costs</div>
|
||||||
|
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs">Net profit</div>
|
||||||
|
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged, onClosed }: {
|
||||||
eventId: string; token: string; data: EventFinancials; busy: boolean;
|
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 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;
|
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 +629,23 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
return base;
|
return base;
|
||||||
}, [draftLines]);
|
}, [draftLines]);
|
||||||
|
|
||||||
const initialDenomCounts: Record<number, string> = useMemo(() => {
|
|
||||||
const cashLine = (draftLines || []).find(l => l.method === "cash");
|
|
||||||
const out: Record<number, string> = {};
|
|
||||||
for (const d of cashLine?.denominations || []) out[d.value] = String(d.count);
|
|
||||||
return out;
|
|
||||||
}, [draftLines]);
|
|
||||||
|
|
||||||
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
|
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
|
||||||
const [denomCounts, setDenomCounts] = useState<Record<number, string>>(initialDenomCounts);
|
|
||||||
const [closeNotes, setCloseNotes] = useState("");
|
const [closeNotes, setCloseNotes] = useState("");
|
||||||
const [reopenNotes, setReopenNotes] = 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) => {
|
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
|
||||||
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
|
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const cashDenominationsPayload = () => ZAR_DENOMINATIONS
|
// Cash is included with notes only — its actual/denominations are always sourced server-side
|
||||||
.map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 }))
|
// from per-person counts (see cashupController.closeEvent), never from this payload.
|
||||||
.filter(d => d.count > 0);
|
|
||||||
|
|
||||||
const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0);
|
|
||||||
|
|
||||||
const buildLinesPayload = () => METHODS.map(m => m === "cash"
|
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 });
|
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
|
||||||
|
|
||||||
const saveDraft = async () => {
|
const saveDraft = async () => {
|
||||||
@@ -327,6 +666,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
try {
|
try {
|
||||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||||
onChanged();
|
onChanged();
|
||||||
|
onClosed();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "Failed to close event");
|
setError(e?.message || "Failed to close event");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -340,6 +680,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
try {
|
try {
|
||||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||||
onChanged();
|
onChanged();
|
||||||
|
onClosed();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "Failed to close event");
|
setError(e?.message || "Failed to close event");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -391,7 +732,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
{isClosed ? (
|
{isClosed ? (
|
||||||
money(r?.actual ?? null)
|
money(r?.actual ?? null)
|
||||||
) : m === "cash" ? (
|
) : m === "cash" ? (
|
||||||
money(cashActualFromDenoms)
|
cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : <span className="text-gray-400">not counted</span>
|
||||||
) : (
|
) : (
|
||||||
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
|
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
|
||||||
)}
|
)}
|
||||||
@@ -400,7 +741,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
{isClosed
|
{isClosed
|
||||||
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
||||||
: (m === "cash"
|
: (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]) : "—"))}
|
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-1.5">
|
||||||
@@ -413,38 +754,31 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{!isClosed && (
|
||||||
|
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CashByUserSection eventId={eventId} token={token} onCashSummaryChange={setCashSummary} />
|
||||||
|
|
||||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||||
<div className="text-sm font-medium">Cash denomination count</div>
|
<div className="text-sm font-medium">Cash denomination count</div>
|
||||||
{isClosed ? (
|
{(() => {
|
||||||
reconciled?.byMethod?.cash?.denominations?.length ? (
|
const denoms = isClosed ? (reconciled?.byMethod?.cash?.denominations || []) : cashSummary.denominations;
|
||||||
|
return denoms.length > 0 ? (
|
||||||
<table className="text-sm">
|
<table className="text-sm">
|
||||||
<tbody>
|
<tbody>
|
||||||
{reconciled.byMethod.cash.denominations.map(d => (
|
{denoms.map((d: any) => (
|
||||||
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
<tr key={d.id || d.value}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : <div className="text-xs text-gray-400">No denomination breakdown recorded for this cashup.</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<div className="text-xs text-gray-400">
|
||||||
{ZAR_DENOMINATIONS.map(v => (
|
{isClosed ? "No denomination breakdown recorded for this cashup." : "No per-staff-member cash counts entered yet — see “Payment accountability by staff member” above."}
|
||||||
<div key={v} className="flex items-center gap-2">
|
|
||||||
<span className="text-sm w-14">{denomLabel(v)}</span>
|
|
||||||
<span className="text-xs text-gray-400">×</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
className="w-16 border rounded px-2 py-1 text-sm"
|
|
||||||
value={denomCounts[v] || ""}
|
|
||||||
onChange={e => setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})()}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||||
|
|||||||
@@ -157,6 +157,32 @@ export default function AdminRegistrationsPage() {
|
|||||||
return "text-gray-700 bg-gray-50";
|
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<string, number> = {};
|
||||||
|
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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full p-6">
|
<div className="max-w-6xl mx-auto w-full p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -173,6 +199,24 @@ export default function AdminRegistrationsPage() {
|
|||||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||||
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
|
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
|
||||||
|
|
||||||
|
{/* Aggregate stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2 mb-4">
|
||||||
|
{STATUS_OPTIONS.map(s => (
|
||||||
|
<div key={s} className="border rounded-lg p-2.5 bg-white shadow-sm">
|
||||||
|
<div className="text-xs text-gray-500 capitalize">{s.replace("_", " ")}</div>
|
||||||
|
<div className="text-lg font-semibold">{stats.counts[s] || 0}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
|
||||||
|
<div className="text-xs text-gray-500">Total revenue</div>
|
||||||
|
<div className="text-lg font-semibold text-green-700">R {stats.totalRevenue.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
|
||||||
|
<div className="text-xs text-gray-500">Total outstanding</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-700">R {stats.totalOutstanding.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
|
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
|
||||||
<div className="flex flex-wrap gap-3 items-end">
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
@@ -221,12 +265,9 @@ export default function AdminRegistrationsPage() {
|
|||||||
<div className="border rounded-xl bg-white shadow-sm">
|
<div className="border rounded-xl bg-white shadow-sm">
|
||||||
<ul className="divide-y text-sm">
|
<ul className="divide-y text-sm">
|
||||||
{filtered.map((r: any) => {
|
{filtered.map((r: any) => {
|
||||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => {
|
const totalDue = totalDueFor(r);
|
||||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
const totalPaid = totalPaidFor(r);
|
||||||
? Number(opt.priceSnapshot)
|
const outstanding = Math.max(totalDue - totalPaid, 0);
|
||||||
: (opt.eventOption?.price || 0);
|
|
||||||
return sum + unit * (opt.quantity || 0);
|
|
||||||
}, 0);
|
|
||||||
const isExpanded = expanded.has(r.id);
|
const isExpanded = expanded.has(r.id);
|
||||||
const responses = formResponses[r.id];
|
const responses = formResponses[r.id];
|
||||||
const loadingResponse = loadingForms.has(r.id);
|
const loadingResponse = loadingForms.has(r.id);
|
||||||
@@ -245,7 +286,9 @@ export default function AdminRegistrationsPage() {
|
|||||||
<div className="text-xs text-gray-500 mt-0.5">
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
{r.user?.email && <span className="mr-2">{r.user.email}</span>}
|
{r.user?.email && <span className="mr-2">{r.user.email}</span>}
|
||||||
{r.user?.phoneNumber && <span className="mr-2">{r.user.phoneNumber}</span>}
|
{r.user?.phoneNumber && <span className="mr-2">{r.user.phoneNumber}</span>}
|
||||||
<span>R {totalDue.toFixed(2)}</span>
|
<span>R {totalPaid.toFixed(2)} paid</span>
|
||||||
|
{outstanding > 0.000001 && <span className="ml-2 text-amber-700">R {outstanding.toFixed(2)} owing</span>}
|
||||||
|
<span className="ml-2 text-gray-400">(R {totalDue.toFixed(2)} total)</span>
|
||||||
<span className="ml-2 text-gray-400">#{String(r.id).slice(0, 8)}</span>
|
<span className="ml-2 text-gray-400">#{String(r.id).slice(0, 8)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,6 +349,31 @@ export default function AdminRegistrationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Payments */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Payments</div>
|
||||||
|
{(r.payments || []).length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-400">No payments recorded.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid sm:grid-cols-2 gap-2">
|
||||||
|
{r.payments.map((p: any) => (
|
||||||
|
<div key={p.id} className="bg-white border rounded p-2 text-xs">
|
||||||
|
<div className="font-medium">
|
||||||
|
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
|
||||||
|
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
|
||||||
|
<div className="text-gray-500">Recorded by: {p.recordedBy.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-700 mt-1 font-medium">
|
||||||
|
Paid: R {totalPaid.toFixed(2)}{outstanding > 0.000001 && <span className="text-amber-700"> · Owing: R {outstanding.toFixed(2)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Form responses */}
|
{/* Form responses */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
|
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UserItem {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
phoneNumber?: string | null;
|
phoneNumber?: string | null;
|
||||||
|
notificationPreference?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -20,6 +21,8 @@ interface UserItem {
|
|||||||
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
|
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
|
||||||
type Role = typeof roleOptions[number];
|
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
|
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
|
||||||
function fuzzyMatch(query: string, target: string): boolean {
|
function fuzzyMatch(query: string, target: string): boolean {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
@@ -71,10 +74,11 @@ export default function AdminUsersPage() {
|
|||||||
const [cRole, setCRole] = useState<Role>("user");
|
const [cRole, setCRole] = useState<Role>("user");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
// Inline edit state
|
// Edit modal state
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
|
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
|
||||||
const [saving, setSaving] = useState(false);
|
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 buildQuery = useCallback((p: number, ps = pageSize) => {
|
||||||
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
|
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
|
||||||
@@ -152,6 +156,7 @@ export default function AdminUsersPage() {
|
|||||||
email: editData.email,
|
email: editData.email,
|
||||||
role: editData.role,
|
role: editData.role,
|
||||||
phoneNumber: editData.phoneNumber || null,
|
phoneNumber: editData.phoneNumber || null,
|
||||||
|
notificationPreference: editData.notificationPreference,
|
||||||
isActive: editData.isActive,
|
isActive: editData.isActive,
|
||||||
};
|
};
|
||||||
if (editData.password && editData.password.trim().length > 0) {
|
if (editData.password && editData.password.trim().length > 0) {
|
||||||
@@ -305,8 +310,8 @@ export default function AdminUsersPage() {
|
|||||||
<th className="p-2">Email</th>
|
<th className="p-2">Email</th>
|
||||||
<th className="p-2">Role</th>
|
<th className="p-2">Role</th>
|
||||||
<th className="p-2">Phone</th>
|
<th className="p-2">Phone</th>
|
||||||
|
<th className="p-2">Notify</th>
|
||||||
<th className="p-2">Active</th>
|
<th className="p-2">Active</th>
|
||||||
<th className="p-2">Password</th>
|
|
||||||
<th className="p-2">Actions</th>
|
<th className="p-2">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -314,63 +319,30 @@ export default function AdminUsersPage() {
|
|||||||
{users.map(u => (
|
{users.map(u => (
|
||||||
<tr key={u.id} className="border-t hover:bg-gray-50">
|
<tr key={u.id} className="border-t hover:bg-gray-50">
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{editingId === u.id ? (
|
|
||||||
<input className="border rounded px-2 py-1 w-44" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
|
|
||||||
) : (
|
|
||||||
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
|
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{editingId === u.id ? (
|
|
||||||
<input className="border rounded px-2 py-1 w-60" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
|
|
||||||
) : (
|
|
||||||
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
|
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{editingId === u.id ? (
|
|
||||||
<select className="border rounded px-2 py-1" value={(editData.role as Role) || (u.role as Role)} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
|
|
||||||
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className="capitalize">{u.role}</span>
|
<span className="capitalize">{u.role}</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{editingId === u.id ? (
|
|
||||||
<input className="border rounded px-2 py-1 w-36" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
|
|
||||||
) : (
|
|
||||||
<span>{u.phoneNumber || ""}</span>
|
<span>{u.phoneNumber || ""}</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{editingId === u.id ? (
|
<span className="capitalize">{u.notificationPreference || "email"}</span>
|
||||||
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
|
</td>
|
||||||
) : (
|
<td className="p-2">
|
||||||
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
|
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{editingId === u.id ? (
|
|
||||||
<input type="password" placeholder="Set new password" className="border rounded px-2 py-1 w-44" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-2">
|
|
||||||
{editingId === u.id ? (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
|
|
||||||
<button className="px-2 py-1 text-xs rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
||||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Sign out all devices">Sessions</button>
|
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
|
||||||
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
||||||
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -431,6 +403,70 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editingUser && (
|
||||||
|
<div className="fixed inset-0 z-20">
|
||||||
|
<div className="absolute inset-0 bg-black/30" onClick={() => !saving && cancelEdit()} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-base font-semibold">Edit user</h2>
|
||||||
|
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Close</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Name</label>
|
||||||
|
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Email</label>
|
||||||
|
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Role</label>
|
||||||
|
<select className="w-full border rounded px-3 py-2 text-sm" value={(editData.role as Role) || "user"} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
|
||||||
|
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Phone</label>
|
||||||
|
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Notification preference</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
value={editData.notificationPreference || "email"}
|
||||||
|
onChange={e => setEditData(d => ({ ...d, notificationPreference: e.target.value }))}
|
||||||
|
>
|
||||||
|
{notificationPreferenceOptions.map(p => (
|
||||||
|
<option key={p} value={p} disabled={p !== "email" && !editData.phoneNumber}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">New password (leave blank to keep current)</label>
|
||||||
|
<input type="password" placeholder="Set new password" className="w-full border rounded px-3 py-2 text-sm" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
|
||||||
|
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,16 +63,29 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub-pages within staff/supervisor/admin (e.g. /dashboard/supervisor/reports,
|
||||||
|
// /dashboard/admin/cashup/[id]) are full-width workspaces with their own internal navigation
|
||||||
|
// and header — the dashboard sidebar's section links (My Events, Profile, Admin, etc.) would
|
||||||
|
// just crowd them. The sidebar stays visible only on each role's root landing page; every
|
||||||
|
// deeper sub-page hides it. Navigating back is never a dead end: Navbar's "Dashboard" link is
|
||||||
|
// always present and /dashboard auto-redirects to the role root.
|
||||||
|
const SIDEBAR_ROOTS = ["/dashboard/staff", "/dashboard/supervisor", "/dashboard/admin"];
|
||||||
|
const hideSidebar = !!pathname && SIDEBAR_ROOTS.some(root => pathname !== root && pathname.startsWith(root + "/"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="flex-1 flex flex-col md:flex-row">
|
<div className="flex-1 flex flex-col md:flex-row">
|
||||||
|
{!hideSidebar && (
|
||||||
|
<>
|
||||||
{/* Mobile dropdown navigation */}
|
{/* Mobile dropdown navigation */}
|
||||||
<MobileSidebar />
|
<MobileSidebar />
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function AttendeesCheckboxDropdown({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-10 mt-1 w-64 max-h-72 overflow-auto bg-white border rounded shadow">
|
<div className="absolute z-10 mt-1 left-0 right-0 max-h-72 overflow-auto bg-white border rounded shadow">
|
||||||
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
|
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
|
||||||
<label className="text-sm flex items-center gap-2">
|
<label className="text-sm flex items-center gap-2">
|
||||||
<input type="checkbox" checked={allSelected} onChange={e => toggleAll(e.target.checked)} />
|
<input type="checkbox" checked={allSelected} onChange={e => toggleAll(e.target.checked)} />
|
||||||
@@ -466,8 +466,8 @@ function EmailAttendeesPageInner() {
|
|||||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
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 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;
|
const queued = res?.queued ?? res?.matched ?? 0;
|
||||||
setInfo(`Sent ${sent} out of ${matched} recipient(s).`);
|
setInfo(`Queued ${queued} recipient(s) for sending.`);
|
||||||
// Reset form to default state
|
// Reset form to default state
|
||||||
resetAttendeesForm();
|
resetAttendeesForm();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -552,7 +552,7 @@ function EmailAttendeesPageInner() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-[11px] text-gray-500 mt-6">Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'} <button type="button" className="ml-2 underline hover:no-underline" onClick={() => setShowInfo(true)}>Learn more</button></div>
|
<div className="text-[11px] text-gray-500 mt-6">Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}, {'{{payment.link}}'} <button type="button" className="ml-2 underline hover:no-underline" onClick={() => setShowInfo(true)}>Learn more</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -572,9 +572,10 @@ function EmailAttendeesPageInner() {
|
|||||||
<li><code>{'{{event.title}}'}</code> — the event title.</li>
|
<li><code>{'{{event.title}}'}</code> — the event title.</li>
|
||||||
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
|
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
|
||||||
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
|
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
|
||||||
|
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>.</p>
|
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
|
||||||
<p className="text-[11px] text-gray-500">To add new placeholders, extend the replacement logic in <span className="font-mono">eventController.emailEventAttendees</span> (replacePlaceholders function) and update this help.</p>
|
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
|||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
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 }: {
|
function RegistrationOptions({ regs, regOutstanding }: {
|
||||||
regs: any[];
|
regs: any[];
|
||||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||||
@@ -439,7 +448,8 @@ function PaymentsContent() {
|
|||||||
// Stats
|
// Stats
|
||||||
const todayTotals = useMemo(() => {
|
const todayTotals = useMemo(() => {
|
||||||
const start = new Date(); start.setHours(0,0,0,0);
|
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 revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
|
||||||
const donations = today.filter(p => p.isDonation).length;
|
const donations = today.filter(p => p.isDonation).length;
|
||||||
return { revenue, donations, count: today.length };
|
return { revenue, donations, count: today.length };
|
||||||
@@ -805,7 +815,7 @@ function PaymentsContent() {
|
|||||||
{loadingList && <span className="text-xs text-gray-500">Loading…</span>}
|
{loadingList && <span className="text-xs text-gray-500">Loading…</span>}
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
|
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
|
||||||
{payments.slice(0, 25).map(p => {
|
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
|
||||||
const amt = p.amount || 0;
|
const amt = p.amount || 0;
|
||||||
const isRefund = amt < 0;
|
const isRefund = amt < 0;
|
||||||
return (
|
return (
|
||||||
@@ -818,6 +828,9 @@ function PaymentsContent() {
|
|||||||
{(p.registration?.user?.name || p.user?.name) && <div className="text-xs text-gray-600">Name: {p.registration?.user?.name || p.user?.name}</div>}
|
{(p.registration?.user?.name || p.user?.name) && <div className="text-xs text-gray-600">Name: {p.registration?.user?.name || p.user?.name}</div>}
|
||||||
{p.registrationId && <div className="text-xs text-gray-600">Registration: #{String(p.registrationId).slice(0,8)}</div>}
|
{p.registrationId && <div className="text-xs text-gray-600">Registration: #{String(p.registrationId).slice(0,8)}</div>}
|
||||||
{p.eventId && <div className="text-xs text-gray-600">Event: {p.event?.title || p.eventId}</div>}
|
{p.eventId && <div className="text-xs text-gray-600">Event: {p.event?.title || p.eventId}</div>}
|
||||||
|
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
|
||||||
|
<div className="text-xs text-gray-500">Recorded by: {p.recordedBy.name}</div>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1025,27 +1038,50 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
|||||||
[registrations, registrationId]
|
[registrations, registrationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Donations "relevant to that event" — unassigned donations logged against the same event
|
// A donation is never mutated once assigned — each assignment creates a separate "leg"
|
||||||
// as the chosen registration.
|
// 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<string, number>();
|
||||||
|
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(() => {
|
const donationsForEvent = useMemo(() => {
|
||||||
if (!selectedRegistration) return [] as any[];
|
if (!selectedRegistration) return [] as any[];
|
||||||
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
|
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
|
||||||
return payments.filter((p: any) => p.isDonation && !p.registrationId && String(p.eventId) === String(eventId));
|
return payments.filter((p: any) => {
|
||||||
}, [payments, selectedRegistration]);
|
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(
|
const selectedDonation = useMemo(
|
||||||
() => payments.find((p: any) => String(p.id) === String(paymentId)),
|
() => payments.find((p: any) => String(p.id) === String(paymentId)),
|
||||||
[payments, paymentId]
|
[payments, paymentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const donationRemaining = selectedDonation
|
||||||
|
? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 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
|
// The most that can be allocated: never more than the donation's remaining balance, never
|
||||||
// what's actually owed. Staff can type a smaller amount to leave a balance outstanding.
|
// more than what's actually owed. Staff can type a smaller amount to leave a balance owing.
|
||||||
const maxAllocatable = useMemo(() => {
|
const maxAllocatable = useMemo(() => {
|
||||||
if (!selectedDonation) return 0;
|
if (!selectedDonation) return 0;
|
||||||
return Math.min(selectedDonation.amount || 0, outstanding);
|
return Math.min(donationRemaining, outstanding);
|
||||||
}, [selectedDonation, outstanding]);
|
}, [selectedDonation, donationRemaining, outstanding]);
|
||||||
|
|
||||||
// Default to "apply as much as needed" whenever a new donation is picked — the common
|
// 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.
|
// 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]);
|
}, [paymentId]);
|
||||||
|
|
||||||
const amountNum = parseFloat(amountStr || "0");
|
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 () => {
|
const assign = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -1110,12 +1146,13 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
|||||||
>
|
>
|
||||||
<option value="">Select donation…</option>
|
<option value="">Select donation…</option>
|
||||||
{donationsForEvent.map((p: any) => {
|
{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 <option key={p.id} value={p.id} title={label}>{label}</option>;
|
return <option key={p.id} value={p.id} title={label}>{label}</option>;
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
{registrationId && donationsForEvent.length === 0 && (
|
{registrationId && donationsForEvent.length === 0 && (
|
||||||
<div className="text-xs text-gray-500">No unassigned donations for this event.</div>
|
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedDonation && (
|
{selectedDonation && (
|
||||||
@@ -1131,8 +1168,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
|||||||
onChange={e => setAmountStr(e.target.value)}
|
onChange={e => setAmountStr(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Donation is R {(selectedDonation.amount || 0).toFixed(2)}; outstanding balance is R {outstanding.toFixed(2)}.
|
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 unassigned as a donation.</>}
|
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.</>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,13 +7,8 @@ import ReportsV2 from "@/components/reports/ReportsV2";
|
|||||||
export default function SupervisorReportsPage() {
|
export default function SupervisorReportsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full p-6">
|
<div className="w-full p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<ReportsV2 onBack={() => router.push('/dashboard')} />
|
||||||
<h1 className="text-2xl font-semibold">Reports</h1>
|
|
||||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">View, export, or email operational reports for events.</p>
|
|
||||||
<ReportsV2 />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function AttendeesCheckboxDropdown({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-10 mt-1 w-64 max-h-72 overflow-auto bg-white border rounded shadow">
|
<div className="absolute z-10 mt-1 left-0 right-0 max-h-72 overflow-auto bg-white border rounded shadow">
|
||||||
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
|
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
|
||||||
<label className="text-sm flex items-center gap-2">
|
<label className="text-sm flex items-center gap-2">
|
||||||
<input type="checkbox" checked={allSelected} onChange={(e) => toggleAll(e.target.checked)} />
|
<input type="checkbox" checked={allSelected} onChange={(e) => toggleAll(e.target.checked)} />
|
||||||
@@ -476,7 +476,7 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
|
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
|
||||||
setSending(true);
|
setSending(true);
|
||||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
|
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();
|
resetAttendeesForm();
|
||||||
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
|
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
|
||||||
};
|
};
|
||||||
@@ -655,7 +655,7 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-[11px] text-gray-500 mt-6">
|
<div className="text-[11px] text-gray-500 mt-6">
|
||||||
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}
|
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}, {"{{payment.link}}"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -676,6 +676,7 @@ function WhatsAppAttendeesPageInner() {
|
|||||||
<li><code>{"{{event.title}}"}</code> — the event title.</li>
|
<li><code>{"{{event.title}}"}</code> — the event title.</li>
|
||||||
<li><code>{"{{event.start}}"}</code> — the event start date/time.</li>
|
<li><code>{"{{event.start}}"}</code> — the event start date/time.</li>
|
||||||
<li><code>{"{{balance}}"}</code> — outstanding balance for the event.</li>
|
<li><code>{"{{balance}}"}</code> — outstanding balance for the event.</li>
|
||||||
|
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-[11px] text-gray-500 mt-2">
|
<p className="text-[11px] text-gray-500 mt-2">
|
||||||
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
|
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 border rounded-lg px-3 py-2 text-sm bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="truncate text-left">{summary}</span>
|
||||||
|
<ChevronDown className={"w-4 h-4 text-gray-400 shrink-0 transition-transform " + (open ? "rotate-180" : "")} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-20 mt-1 left-0 right-0 bg-white border rounded-lg shadow-lg max-h-64 overflow-auto p-1">
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-gray-500 border-b mb-1">
|
||||||
|
<button type="button" className="hover:underline" onClick={() => onChange(options.map(o => o.value))}>Select all</button>
|
||||||
|
<button type="button" className="hover:underline" onClick={() => onChange([])}>Clear</button>
|
||||||
|
</div>
|
||||||
|
{options.map(opt => (
|
||||||
|
<label key={opt.value} className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
|
||||||
|
<span className="truncate">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{options.length === 0 && <div className="px-2 py-1.5 text-xs text-gray-400">No events available.</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"];
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"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.
|
||||||
|
<div className="fixed inset-0 z-[60]">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div className="absolute inset-0 flex items-start justify-center p-4 overflow-auto">
|
||||||
|
<div className="w-full max-w-6xl bg-white rounded-xl shadow-xl my-8" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="border-b">
|
||||||
|
<div className="flex items-start justify-between gap-3 sm:gap-4 px-5 pt-4 pb-4">
|
||||||
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
|
{Icon && (
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
|
||||||
|
<Icon className="w-5 h-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
|
{description && <p className="text-sm text-gray-500 mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center gap-2 flex-wrap shrink-0 ml-auto">
|
||||||
|
{actions}
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 ml-1" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="sm:hidden p-2 rounded-lg hover:bg-gray-100 shrink-0" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex sm:hidden items-center gap-2 flex-wrap px-5 pb-4">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-indigo-50/50">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-indigo-900 flex-1 min-w-0">
|
||||||
|
<Info className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||||
|
{filters || <span>This report has no extra filters beyond Events and Date range in the sidebar.</span>}
|
||||||
|
</div>
|
||||||
|
{onRefresh && (
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={busy}
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={"w-3.5 h-3.5 " + (busy ? "animate-spin" : "")} /> {busy ? "Loading…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportActionButton({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-gray-500" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
|
||||||
|
<Icon className={"w-4 h-4 " + t.icon} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-800">{title}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<GuideTab>("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.
|
||||||
|
<div className="fixed inset-0 z-[70]">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={() => onClose(dontShowAgain)} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-3xl bg-white rounded-xl shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-start justify-between px-5 py-4 border-b">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
|
||||||
|
<MessageCircleQuestion className="w-5 h-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">Reporting guide</h2>
|
||||||
|
<p className="text-xs text-gray-500">This guide explains how reports work and how to use the available filters.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="p-1.5 rounded hover:bg-gray-100" onClick={() => onClose(dontShowAgain)} aria-label="Close">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row">
|
||||||
|
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1">
|
||||||
|
{TABS.map(t => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
const active = tab === t.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg " + (active ? "bg-indigo-50 text-indigo-700 font-medium" : "text-gray-600 hover:bg-gray-50")}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
|
||||||
|
{tab === "overview" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>Reports help you view key data about your events. You can filter the data, preview it on screen, and export or email it.</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<GuideItem icon={Filter} title="Use filters" tone="indigo">
|
||||||
|
Apply universal filters (like events and date range) that affect all reports, and report-specific filters for more detailed results.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={BarChart2} title="Preview & customize" tone="emerald">
|
||||||
|
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||||
|
Export your report to Excel, PDF, or send it by email or WhatsApp.
|
||||||
|
</GuideItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "universal" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>Universal filters live in the sidebar on the left and apply to whichever report you open — you only set them once, not per report.</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<GuideItem icon={Calendar} title="Events" tone="indigo">
|
||||||
|
Pick one or more events. Every report loads data for exactly these events.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={EyeOff} title="Include past / inactive / closed events" tone="gray">
|
||||||
|
Controls which events even appear in the Events list to pick from.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={CalendarClock} title="Date range" tone="blue">
|
||||||
|
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.
|
||||||
|
</GuideItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "specific" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>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.</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<GuideItem icon={ListFilter} title="Attendees" tone="violet">
|
||||||
|
Which single event to show (defaults to the first selected event) and whether to include cancelled registrations.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={BarChart2} title="Registration status breakdown" tone="emerald">
|
||||||
|
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).
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={RefreshCw} title="Refresh" tone="indigo">
|
||||||
|
Adjust a report-specific filter, then use the "Refresh" button inside the popup to re-run the report without closing it.
|
||||||
|
</GuideItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "exporting" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>Every report can be exported straight from its popup:</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<GuideItem icon={Printer} title="Print" tone="gray">
|
||||||
|
Opens a print-ready PDF in a new tab; use your browser's print button from there.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={Mail} title="Email" tone="blue">
|
||||||
|
Sends the PDF to your own account email.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={FileSpreadsheet} title="Excel" tone="emerald">
|
||||||
|
Downloads a styled .xlsx workbook — colored header, key totals, and a chart section where available — matching the on-screen report.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={MessageCircle} title="WhatsApp" tone="violet">
|
||||||
|
Sends the PDF to your own account's WhatsApp number (needs a valid phone number on file).
|
||||||
|
</GuideItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "fields" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>A few terms come up across several financial reports and are easy to misread — here's what each one actually means:</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<GuideItem icon={CreditCard} title="Paid" tone="blue">
|
||||||
|
Money the person paid themselves directly (cash/card/eft/online). Never includes money that reached their order via someone else's donation.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={Gift} title="Paid via donation" tone="violet">
|
||||||
|
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.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={Clock} title="Outstanding" tone="amber">
|
||||||
|
What's still owed on an order, after direct payments and any donation cover.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={HandHeart} title="Unassigned donations" tone="rose">
|
||||||
|
Real money already received as a donation that hasn't been applied to any order yet.
|
||||||
|
</GuideItem>
|
||||||
|
<GuideItem icon={BarChart2} title="Donations: Used / Unused" tone="emerald">
|
||||||
|
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.
|
||||||
|
</GuideItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "help" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>Still stuck? Reach out to the site administrator — they can check the underlying data with you or flag anything that looks wrong.</p>
|
||||||
|
<div className="flex items-start gap-3 border border-gray-100 rounded-xl p-4 bg-gray-50">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
|
||||||
|
<Mail className="w-4 h-4 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-800">Site administrator</div>
|
||||||
|
<a href={`mailto:${ADMIN_EMAIL}`} className="text-sm text-indigo-600 hover:underline">{ADMIN_EMAIL}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Financial figures matter — if a number in a report doesn't look right, it's always worth asking rather than assuming.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-t">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} />
|
||||||
|
Don't show this again
|
||||||
|
</label>
|
||||||
|
<button className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => onClose(dontShowAgain)}>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"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<DatePreset>("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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Reports</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">View, export, or email operational reports for events.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
|
<div className="relative flex-1 min-w-0 sm:flex-none">
|
||||||
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
className="w-full sm:w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
|
||||||
|
placeholder="Search reports…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{onBack && (
|
||||||
|
<button type="button" className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
{/* Sidebar: universal filters */}
|
||||||
|
<aside className="lg:w-72 shrink-0 space-y-5">
|
||||||
|
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
|
||||||
|
<div className="text-sm font-semibold">Filters</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Events</div>
|
||||||
|
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title }))} value={selectedEventIds} onChange={setSelectedEventIds} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={showPastEvents} onChange={e => setShowPastEvents(e.target.checked)} />
|
||||||
|
Include past events
|
||||||
|
</label>
|
||||||
|
{isAdmin && (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={showInactiveEvents} onChange={e => setShowInactiveEvents(e.target.checked)} />
|
||||||
|
Include inactive events
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={showClosedEvents} onChange={e => setShowClosedEvents(e.target.checked)} />
|
||||||
|
Include closed (cashed-up) events
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Date range (where applicable)</div>
|
||||||
|
<select className="w-full border rounded px-2 py-1.5 text-sm mb-2" value={datePreset} onChange={e => applyPreset(e.target.value as DatePreset)}>
|
||||||
|
<option value="all_time">All time</option>
|
||||||
|
<option value="this_month">This month</option>
|
||||||
|
<option value="last_month">Last month</option>
|
||||||
|
<option value="this_year">This year</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
{datePreset === "custom" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-gray-500 mb-0.5">From</div>
|
||||||
|
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-gray-500 mb-0.5">To</div>
|
||||||
|
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="w-full flex items-start gap-3 text-left text-sm border rounded-xl p-4 bg-white shadow-sm hover:bg-gray-50" onClick={onOpenGuide}>
|
||||||
|
<HelpCircle className="w-5 h-5 text-gray-500 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium text-gray-800">Need help?</span>
|
||||||
|
<span className="block text-xs text-gray-500">View our reporting guide</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main: categories, report grid */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{(["All", ...REPORT_CATEGORIES] as const).map(c => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
|
||||||
|
onClick={() => setCategory(c)}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||||
|
{filteredReports.map(r => {
|
||||||
|
const Icon = r.icon;
|
||||||
|
const selected = report === r.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReport(r.key)}
|
||||||
|
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-indigo-500 ring-2 ring-indigo-100 bg-indigo-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
|
||||||
|
>
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center mb-3">
|
||||||
|
<Icon className="w-5 h-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 mb-1">{r.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mb-3">{r.description}</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-gray-400">
|
||||||
|
<span>{r.category}</span>
|
||||||
|
<span>{r.fields} fields</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredReports.length === 0 && (
|
||||||
|
<div className="col-span-full text-sm text-gray-500 py-8 text-center">No reports match your search.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between bg-gray-50 border rounded-xl px-4 py-3">
|
||||||
|
<div className="text-xs text-gray-500">Filters will be applied to the selected report where relevant.</div>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onViewReport}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Loading…" : "View report"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||||
|
<div className={"flex items-center gap-3 rounded-xl p-3 " + t.bg}>
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-white/70 flex items-center justify-center shrink-0">
|
||||||
|
<Icon className={"w-5 h-5 " + t.icon} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs text-gray-500 truncate">{label}</div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 truncate">{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatTileRow({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">{children}</div>;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={d.label} className="flex items-center gap-3">
|
||||||
|
<div className={labelWidthClass + " text-xs text-gray-600 truncate shrink-0"} title={d.label}>{d.label}</div>
|
||||||
|
<div className="flex-1 h-3 rounded-full bg-gray-100 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.max(d.value > 0 ? 2 : 0, (Math.abs(d.value) / max) * 100)}%`, backgroundColor: CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 text-xs text-gray-700 text-right shrink-0 tabular-nums">{fmt(d.value)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && <div className="text-xs text-gray-400">No data to chart.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,13 +31,26 @@ function valueToString(v: any): string {
|
|||||||
return String(v);
|
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 = {
|
export type ReportPdfPayload = {
|
||||||
title: string;
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
kind: 'table' | 'layered';
|
kind: 'table' | 'layered';
|
||||||
orientation?: 'portrait' | 'landscape';
|
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)[][] };
|
table?: { columns: string[]; rows: (string | number)[][] };
|
||||||
layered?: { header?: string; sections: { title: string; items: string[] }[] };
|
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) {
|
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);
|
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 }) {
|
export async function emailReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { subject?: string; body?: string }) {
|
||||||
const url = `${apiBase}/api/reports/email`;
|
const url = `${apiBase}/api/reports/email`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -81,6 +120,47 @@ export async function emailReportPdf(apiBase: string, authToken: string, payload
|
|||||||
return res.json();
|
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.
|
// Legacy (used elsewhere). Kept in case other code paths still rely on print flow.
|
||||||
export function openPrintWindow(title: string, htmlContent: string) {
|
export function openPrintWindow(title: string, htmlContent: string) {
|
||||||
const w = window.open('', '_blank');
|
const w = window.open('', '_blank');
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.3.2",
|
"version": "1.4.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user