Compare commits

...
Author SHA1 Message Date
joshuaandClaude Sonnet 5 d6ea4c37d7 Sync root package.json version to 1.4.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:49:03 +02:00
joshua ebf521e64e Merge branch 'feature/reports-and-cashup-overhaul' (1.4.0) 2026-08-04 15:46:55 +02:00
joshuaandClaude Sonnet 5 e5fbcc2222 Bump version to 1.4.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:46:09 +02:00
joshuaandClaude Sonnet 5 1b4446c828 Hide dashboard sidebar on staff/supervisor/admin sub-pages; fix mobile overflow
- Sidebar now shows only on each role's root landing page
  (/dashboard/staff, /dashboard/supervisor, /dashboard/admin) and hides on
  every sub-page beneath them, not just Reports. Navbar's "Dashboard" link
  always leads back to the role root.
- Fixed two dropdown panels (EventsDropdown, the Email/WhatsApp Attendees
  recipient picker) that could extend past the viewport's right edge on
  narrow screens; both now stretch to their trigger's width like other
  dropdowns in the app already do.
- Wrapped the Admin Cashup event-costs table in the same overflow-auto
  container every sibling table on that page already uses, so it scrolls
  horizontally on narrow screens instead of squeezing its columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:30:56 +02:00
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:52:54 +02:00
joshua 56f2a9f7fc Merge branch 'fix/self-service-kiosk-lookup' 2026-08-03 13:03:12 +02:00
joshuaandClaude Sonnet 5 b081ed3c8b Rework self-service kiosk account lookup for privacy and safety (1.3.2)
Replaces the explicit "look up existing account" search field with automatic
lookup as email/phone are entered, requires operator confirmation before any
matched account's name/email/phone/preference is changed, adds a password
show/hide toggle, and fixes two bugs found during testing: entering a phone
number belonging to a different account could silently overwrite the form
with that account's details, and re-checking an unchanged field (e.g. from
tapping a ticket quantity button) could revert edits already made. Also adds
a server-side check rejecting registrations whose email and phone resolve to
two different existing accounts, as defense in depth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 12:56:26 +02:00
joshua 8a75c9155b Bump version to 1.3.1 2026-07-28 16:17:37 +02:00
joshua 8850984055 Merge branch 'fix/self-service-kiosk' 2026-07-28 16:16:31 +02:00
joshua 21176e1e0b Populate email/phone from an unmatched kiosk search query
When the account lookup finds no match, carry the typed query into whichever
of Email/Phone it resembles (email-shaped or phone-shaped) instead of
discarding it, so the operator doesn't have to retype it. Leaves both blank
if it matches neither shape, and resets Name/notification preference for a
fresh entry.
2026-07-28 16:05:41 +02:00
joshua b4cd140168 Replace as-you-type account check with an explicit search field
The kiosk previously ran a debounced background lookup on every keystroke
in the visitor Email/Phone fields, silently autofilling matches. Replaced
with a dedicated "Look up existing account" field that only searches when
the operator presses Enter or clicks Search, and matches exactly against
either email or phone (never both from one query, to avoid digits in an
email being misread as an unrelated phone number). Also simplified the
"account already exists" banner wording.
2026-07-28 14:35:28 +02:00
joshua 5167706d1b Update changelog for self-service kiosk fixes 2026-07-28 14:24:21 +02:00
joshua 047f61b627 Fix self-service kiosk: hide closed events and autofill known visitors
- GET /api/events/all gains an opt-in excludeClosed=true param, used only by
  the kiosk, so closed events no longer show as selectable there while other
  admin/supervisor screens that still need to see closed events are unaffected.
- GET /api/users/check-exists now also returns the matched account's name,
  email, phone, and notification preference (safe fields only). The kiosk's
  existing debounced lookup uses this to autofill whichever fields are still
  blank when a visitor enters an email or phone that matches an existing
  account, without overwriting anything already typed.
2026-07-28 14:21:47 +02:00
joshuaandClaude Sonnet 5 1815f78c85 Bump version to 1.3.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:06:08 +02:00
joshuaandClaude Sonnet 5 29036d0612 Merge branch 'feature/door-checkin'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:04:51 +02:00
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00
joshuaandClaude Sonnet 5 adb512a7d9 Bump version to 1.2.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:35:21 +02:00
49 changed files with 5000 additions and 1845 deletions
+81 -1
View File
@@ -7,6 +7,81 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
### Added
- Self-service kiosk: all password fields (supervisor sign-in, change event, and the visitor "Choose a password" field) now have a show/hide toggle button, so staff can verify what they've typed on the touchscreen instead of typing blind.
### Fixed
- Self-service kiosk: removed the separate "Look up existing account" search field — for privacy, staff no longer type a visitor's email/phone into a dedicated search box. Instead, entering an email or phone number in the registration form itself (Email and Cell Number are now the first two fields, followed by Name) automatically checks for a matching account once that field is left.
- Self-service kiosk: matched accounts are no longer updated silently. If the operator's typed Name, Email, Cell Number, or "Send tickets via" preference differs from what's on file, a confirmation dialog now lists exactly what will change (old value → new value) and requires the operator to confirm before the account is updated.
- Self-service kiosk / manual registration: an existing account's name is now actually updated when confirmed changed (previously silently discarded), and email/phone corrections are applied even when the account already had a real value on file (previously only blank phone numbers or guest-placeholder emails could be replaced).
- Self-service kiosk: fixed a bug where changing the phone number to one belonging to a different account would silently replace the Name/Email fields with that other account's details, and re-editing the email back to the original value afterward would not re-check it — together this could result in a registration being (or looking like it would be) saved under the wrong account. Email and phone matches are now tracked independently; if they resolve to two different existing accounts, the kiosk shows a clear warning naming both accounts and blocks registration until the operator corrects one of the fields, instead of silently merging or overwriting details.
- Manual registration API: added a server-side check, independent of the kiosk UI, that rejects (`409`) a registration whose submitted email and phone number belong to two different existing accounts — a defense-in-depth safeguard against one account's contact details being overwritten with, or hijacked by, another's.
- Manual registration API: an existing account's notification preference is now validated against its final email/phone after any confirmed update (e.g. falls back off "WhatsApp"/"Both" if no valid phone remains, or onto "WhatsApp" if the email was cleared in favor of a real phone), instead of persisting a preference that no longer matches the account's actual contact info.
- Self-service kiosk: tapping anywhere else on the page (e.g. a ticket quantity +/ button) while Email or Cell Number was focused blurred that field and silently re-ran its account lookup; even though the match hadn't changed, this reset Name/Email/Phone/preference back to the matched account's original values, discarding any edits the operator had just made. The autofill now only applies once per distinct matched account instead of on every re-check.
## [1.3.1] - 2026-07-28
### Fixed
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
## [1.3.0] - 2026-07-27
### Added
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
### Changed
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
## [1.2.0] - 2026-07-27
### Added
- Supervisor dashboard: `/dashboard/supervisor/manual` now has a "Record Payment" tab (alongside "Register"), for capturing a payment without leaving the page. It pre-fills with the user and registration from the most recently created manual registration, but only switches tabs when a supervisor/admin clicks it themselves.
@@ -55,7 +130,12 @@ 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).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...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.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.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
+841 -7
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.1.0",
"version": "1.4.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -21,6 +21,7 @@
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -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;
@@ -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;
+39
View File
@@ -63,6 +63,7 @@ model User {
notificationPreference NotificationPreference @default(email)
registrations Registration[]
payments Payment[]
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
tickets Ticket[]
ticketScans TicketUsage[]
passwordResets PasswordReset[]
@@ -74,6 +75,9 @@ model User {
eventsReopened Event[] @relation("EventReopenedBy")
cashupsPerformed EventCashup[]
notifyForEvents Event[] @relation("EventNotifyRecipients")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
}
model Event {
@@ -112,6 +116,7 @@ model Event {
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
personCashCounts EventCashupPersonCount[]
// Users who should receive registration/payment/daily-summary notifications for this
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
@@ -215,6 +220,7 @@ model Payment {
amount Float
method String
userId String
recordedById String?
registrationId String?
eventId String?
isDonation Boolean @default(false)
@@ -223,6 +229,7 @@ model Payment {
originalPaymentId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
@@ -230,9 +237,11 @@ model Payment {
YocoTransaction YocoTransaction[]
@@index([userId])
@@index([recordedById])
@@index([registrationId])
@@index([eventId])
@@index([createdAt])
@@index([originalPaymentId])
}
model Ticket {
@@ -479,6 +488,36 @@ model EventCashupDenomination {
@@index([lineId])
}
// Actual physical cash counted for one staff member's recorded cash payments, entered any
// time (not part of the close flow) purely for accountability — compared against the
// system-expected amount (computeCashAccountabilityByUser) to show a per-person variance.
model EventCashupPersonCount {
id String @id @default(uuid())
eventId String
userId String
enteredById String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade)
enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull)
denominations EventCashupPersonCountDenomination[]
@@unique([eventId, userId])
@@index([eventId])
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
//
// model Ministry {
+4 -11
View File
@@ -29,14 +29,7 @@ function buildEventContext(event) {
return { eventTitle, eventStart, eventLink, eventLinkHtml };
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''));
}
const { replacePlaceholders } = require('../utils/placeholders');
function parseFreeformEmails(lines) {
// Supports formats:
@@ -166,9 +159,9 @@ const sendBroadcast = async (req, res) => {
eventLink: escapeHtml(eventCtx.eventLink),
eventLinkHtml: eventCtx.eventLinkHtml,
} : ctxBase;
const finalSubject = replacePlaceholders(subject, ctxBase);
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
const finalSubject = await replacePlaceholders(subject, ctxBase);
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
}));
+68 -16
View File
@@ -1,7 +1,7 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
// @route GET /api/cashups/event/:eventId
@@ -15,6 +15,45 @@ const getEventCashup = async (req, res) => {
}
};
// @desc Payments recorded for an event, broken down by staff member and method (cash/card/eft/
// other) — cash also includes the live actual-vs-expected from per-person counts.
// @route GET /api/cashups/event/:eventId/cash-by-user
// @access Private/Supervisor
const getCashByRecordedUser = async (req, res) => {
try {
const [rows, cash] = await Promise.all([
computeAccountabilityByUser(req.params.eventId),
computeEventCashActualFromPersonCounts(req.params.eventId)
]);
res.json({ rows, cashActualTotal: cash.actual, cashDenominations: cash.denominations });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Enter (or update) one staff member's actual physical cash count for an event —
// optional, can be done any time, purely for per-person accountability. Never blocks
// or is required for closing the event.
// @route PUT /api/cashups/event/:eventId/person-cash/:userId
// @access Private/Supervisor
const savePersonCash = async (req, res) => {
try {
const { eventId, userId } = req.params;
const { denominations, notes } = req.body;
const targetUser = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!targetUser) {
res.status(404);
throw new Error('User not found');
}
const record = await savePersonCashCount(eventId, userId, { denominations, notes, enteredById: req.user.id });
res.json(record);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Save in-progress reconciliation entries without closing the event
// @route PUT /api/cashups/event/:eventId/draft
// @access Private/Admin
@@ -49,30 +88,41 @@ const closeEvent = async (req, res) => {
const financials = await computeEventFinancials(eventId);
const isFullCashup = Array.isArray(lines) && lines.length > 0;
const cashupLines = isFullCashup
? lines
.filter(l => l && ALL_METHODS.includes(l.method))
let cashupLines = [];
if (isFullCashup) {
const nonCashLines = lines
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0)
: [];
const actual = denominations.length > 0
? denominations.reduce((sum, d) => sum + d.value * d.count, 0)
: (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null);
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
return {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null,
denominations: denominations.length > 0 ? { create: denominations } : undefined
notes: l.notes || null
};
})
: [];
});
// Cash is never entered as a single event-wide figure — it's always the live sum of every
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
// always sourced here rather than from whatever (if anything) the frontend sent for it.
const cashLineInput = lines.find(l => l && l.method === 'cash');
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
const cashExpected = financials.expectedCashByMethod.cash || 0;
const cashLine = {
id: uuidv4(),
method: 'cash',
expectedAmount: cashExpected,
actualAmount: cashActual,
variance: cashActual !== null ? cashActual - cashExpected : null,
notes: cashLineInput?.notes || null,
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
cashupLines = [cashLine, ...nonCashLines];
}
const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
@@ -179,6 +229,8 @@ const getCashupAudit = async (req, res) => {
module.exports = {
getEventCashup,
getCashByRecordedUser,
savePersonCash,
saveEventCashupDraft,
closeEvent,
reopenEvent,
+100 -66
View File
@@ -241,10 +241,12 @@ const getEventsAll = async (req, res) => {
try {
const includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {};
if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -1224,6 +1226,7 @@ const emailEventAttendees = async (req, res) => {
const { sendMail } = require('../utils/email');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
@@ -1232,17 +1235,6 @@ const emailEventAttendees = async (req, res) => {
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
}
// Build per-recipient registration aggregates for this event
const regsByEmail = new Map();
@@ -1253,42 +1245,66 @@ const emailEventAttendees = async (req, res) => {
regsByEmail.get(em).push(r);
}
let sent = 0;
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// Special handling for tickets template: trigger ticket emails containing attachments
// Generates a live Yoco checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}, to
// avoid an unnecessary Yoco API call per recipient otherwise.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Fire-and-forget: respond immediately with a queued count, then send in the background.
// Large recipient lists used to block the request until every email was sent — now the
// caller gets an instant response and failures are just logged server-side.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
// Send tickets for each registration that belongs to this recipient for this event
for (const reg of regs) {
// Use the controller helper as in other parts of the code
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
sent++;
} catch (e) {
try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
});
})();
return;
}
const eventTitle = event?.title || 'the event';
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
// Build event and promo links for placeholders
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
const promoTitle = promoEvent?.title || '';
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
// Send individually with personalization
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
// Sum outstanding balance across this user's registrations for the event
let totalDue = 0; let totalPaid = 0;
@@ -1298,6 +1314,7 @@ const emailEventAttendees = async (req, res) => {
totalDue += due; totalPaid += paid;
}
const balance = Math.max(totalDue - totalPaid, 0);
const paymentLinkResolver = makePaymentLinkResolver(regs);
// Build context for placeholder replacement
const ctxBase = {
name: rcpt.name || '',
@@ -1308,6 +1325,7 @@ const emailEventAttendees = async (req, res) => {
promoLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver,
};
const ctxHtml = {
...ctxBase,
@@ -1345,18 +1363,18 @@ const emailEventAttendees = async (req, res) => {
}
// Always perform placeholder replacement on whatever we have
const finalSubject = replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined);
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
sent++;
} catch (e) {
try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -1443,6 +1461,7 @@ const whatsappEventAttendees = async (req, res) => {
const { sendText } = require('../utils/whatsapp');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
@@ -1451,15 +1470,6 @@ const whatsappEventAttendees = async (req, res) => {
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '')
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
}
const regsByPhone = new Map();
for (const r of registrations) {
@@ -1474,29 +1484,52 @@ const whatsappEventAttendees = async (req, res) => {
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
let sent = 0;
// Generates a live Yoco checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Special handling for tickets template: send ticket PDFs via WhatsApp
// Fire-and-forget: respond immediately with a queued count, then send in the background.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
for (const reg of regs) {
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
sent++;
} catch (e) {
try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
});
})();
return;
}
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
let totalDue = 0; let totalPaid = 0;
for (const r of regs) {
@@ -1512,6 +1545,7 @@ const whatsappEventAttendees = async (req, res) => {
eventLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver: makePaymentLinkResolver(regs),
};
let msg = message;
@@ -1521,15 +1555,15 @@ const whatsappEventAttendees = async (req, res) => {
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
}
const finalMessage = replacePlaceholders(msg || '', ctx);
const finalMessage = await replacePlaceholders(msg || '', ctx);
await sendText(rcpt.phone, finalMessage);
sent++;
} catch (e) {
try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
+69 -44
View File
@@ -157,6 +157,7 @@ const createPayment = async (req, res) => {
amount: requestedAmount,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -164,6 +165,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
event: true
}
});
@@ -175,6 +177,7 @@ const createPayment = async (req, res) => {
amount: applyAmount,
method,
userId,
recordedById: req.user.id,
registrationId,
eventId: registrationEventId || eventId || null,
isDonation: false,
@@ -182,6 +185,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: (registrationEventId || eventId) ? true : undefined
}
@@ -195,6 +199,7 @@ const createPayment = async (req, res) => {
amount: excess,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -212,6 +217,7 @@ const createPayment = async (req, res) => {
amount: parseFloat(amount),
method,
userId,
recordedById: req.user.id,
registrationId: registrationId || null,
eventId: registrationEventId || eventId || null,
isDonation: isDonation || false,
@@ -219,6 +225,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: registrationId ? { include: { event: true } } : undefined,
event: (registrationEventId || eventId) ? true : undefined
}
@@ -329,6 +336,7 @@ const getPayments = async (req, res) => {
const include = {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: {
event: true,
@@ -452,6 +460,13 @@ const getPaymentById = async (req, res) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: {
include: {
event: true
@@ -507,6 +522,13 @@ const getPaymentsByRegistration = async (req, res) => {
name: true,
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
}
}
});
@@ -533,6 +555,7 @@ const getPaymentsByEvent = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: { user: { select: { id: true, name: true, email: true } } }
}
@@ -576,10 +599,18 @@ const assignDonationToRegistration = async (req, res) => {
throw new Error('Only donations can be assigned to registrations');
}
// Check if payment is already assigned to a registration
if (payment.registrationId) {
// Donations are never mutated once created — their remaining balance is the original
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
// pointing back at this donation) already allocated from it.
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + leg.amount, 0);
const remainingDonation = payment.amount - alreadyUsed;
if (remainingDonation <= 0.000001) {
res.status(400);
throw new Error('This payment is already assigned to a registration');
throw new Error('This donation has already been fully allocated');
}
if (payment.eventId) {
@@ -628,16 +659,16 @@ const assignDonationToRegistration = async (req, res) => {
}
// How much of the donation to apply — defaults to today's behaviour (as much as the
// donation covers, capped at what's owed) but staff can specify a smaller amount and
// deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(payment.amount, remainingAmount);
// donation's remaining balance covers, capped at what's owed) but staff can specify a
// smaller amount and deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(remainingDonation, remainingAmount);
if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) {
res.status(400);
throw new Error('Allocation amount must be greater than zero');
}
if (allocateAmount > payment.amount) {
if (allocateAmount > remainingDonation) {
res.status(400);
throw new Error('Cannot allocate more than the donation amount');
throw new Error(`Cannot allocate more than the donation's remaining balance of R${remainingDonation.toFixed(2)}`);
}
if (allocateAmount > remainingAmount) {
res.status(400);
@@ -647,39 +678,24 @@ const assignDonationToRegistration = async (req, res) => {
let updatedRegistration;
let generatedTickets = [];
let originalPaymentId = payment.id;
let splitPayment = null;
// Update the payment to be associated with the registration and adjust amount
await prisma.payment.update({
where: { id: payment.id },
// Create an immutable leg referencing the donation — the donation row itself is never
// touched, so its original amount and history stay intact and it can be assigned again
// later if this leg doesn't use it up.
const leg = await prisma.payment.create({
data: {
registrationId,
id: uuidv4(),
amount: allocateAmount,
isDonation: false
method: payment.method,
userId: payment.userId,
recordedById: req.user.id,
registrationId,
eventId: registration.eventId,
isDonation: false,
originalPaymentId: payment.id,
}
});
// If less than the full donation was allocated, the remainder stays as an unassigned
// donation (same donor, no notification — it's a bookkeeping split, not a new gift).
if (allocateAmount < payment.amount) {
const leftoverAmount = payment.amount - allocateAmount;
splitPayment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: leftoverAmount,
method: payment.method,
userId: payment.userId,
eventId: payment.eventId,
isDonation: true,
externalId: payment.externalId ? `${payment.externalId}-split` : null,
status: payment.status,
originalPaymentId: payment.id,
createdAt: payment.createdAt
}
});
}
if (allocateAmount >= remainingAmount) {
// Fully covers what's owed
updatedRegistration = await prisma.registration.update({
@@ -707,9 +723,9 @@ const assignDonationToRegistration = async (req, res) => {
}
// Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails),
// then tickets (guarantees order). The split/leftover payment is never notified.
// then tickets (guarantees order).
const { sendDonationAssignmentEmails } = require('../utils/notifications');
const _adPaymentId = payment?.id;
const _adPaymentId = leg?.id;
const _adShouldEmailTickets = generatedTickets.length > 0;
const _adUserId = registration?.userId;
const _adRegId = registrationId;
@@ -730,10 +746,8 @@ const assignDonationToRegistration = async (req, res) => {
originalPaymentId,
updatedRegistration,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
splitPayment: splitPayment ? {
...splitPayment,
originalPaymentId: payment.id
} : null
leg,
donationRemaining: remainingDonation - allocateAmount
};
res.status(200).json(result);
@@ -1151,6 +1165,7 @@ const createRefund = async (req, res) => {
amount: -Math.abs(amt),
method: method || 'refund',
userId,
recordedById: req.user.id,
registrationId: linkRegistrationId,
eventId: linkEventId,
isDonation: false,
@@ -1159,6 +1174,7 @@ const createRefund = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: true
}
@@ -1210,6 +1226,12 @@ const getPaymentStats = async (req, res) => {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
// Exclude donation-application legs — a leg re-labels part of an already-counted donation
// as applied to a registration, it isn't new money. Summing both would double-count it.
const excludeDonationLegs = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] }
};
const [totalToday, totalWeek, totalMonth] = await Promise.all([
prisma.payment.aggregate({
_sum: {
@@ -1218,7 +1240,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: startOfDay
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1228,7 +1251,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastWeek
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1238,7 +1262,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastMonth
}
},
...excludeDonationLegs
}
})
]);
@@ -380,7 +380,7 @@ const getRegistrations = async (req, res) => {
try {
const registrations = await prisma.registration.findMany({
include: {
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
@@ -472,7 +472,7 @@ const getRegistrationById = async (req, res) => {
phoneNumber: true
}
},
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
}
});
@@ -653,7 +653,7 @@ const getRegistrationsByEvent = async (req, res) => {
tickets: true,
}
},
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }
},
orderBy: { createdAt: 'asc' }
@@ -764,32 +764,66 @@ const createManualRegistration = async (req, res) => {
? prefFromBody
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
// Always search by email AND/OR phone regardless of guestOnly
// Also try the alternate format (27xxx ↔ 0xxx) so both representations match
// Always search by email AND/OR phone regardless of guestOnly.
// Resolve each channel independently (rather than a single findFirst with an OR
// across both) so that an email belonging to one account and a phone number
// belonging to a *different* account can never be silently collapsed into
// whichever record happens to match first — that would let a registration
// hijack or corrupt someone else's account. Also try the alternate phone
// format (27xxx ↔ 0xxx) so both representations match.
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
const searchClauses = [
...(hasValidEmail ? [{ email: user.email }] : []),
...(phone ? [{ phoneNumber: phone }] : []),
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
];
const existingUser = searchClauses.length > 0
? await prisma.user.findFirst({ where: { OR: searchClauses } })
const emailUser = hasValidEmail
? await prisma.user.findUnique({ where: { email: user.email } })
: null;
const phoneUser = phone
? await prisma.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } })
: null;
if (emailUser && phoneUser && emailUser.id !== phoneUser.id) {
res.status(409);
throw new Error(
`This email and phone number belong to two different existing accounts (${emailUser.name} vs ${phoneUser.name}). Please verify the visitor's details before registering.`
);
}
const existingUser = emailUser || phoneUser || null;
if (existingUser) {
userId = existingUser.id;
const updateData = {};
// Update preference only when the caller explicitly specified one
if (prefFromBody && validPrefs.includes(prefFromBody)) {
updateData.notificationPreference = prefFromBody;
// The kiosk shows the operator a diff of name/email/phone against the matched
// account and requires explicit confirmation before submitting, so any
// difference reaching this point is an already-confirmed correction — apply
// it as a full overwrite rather than only filling in blanks.
if (user.name && user.name.trim() && user.name.trim() !== existingUser.name) {
updateData.name = user.name.trim();
}
// Fill in a missing contact channel with the newly supplied value, without overwriting an existing one
if (phone && !existingUser.phoneNumber) {
if (phone && phone !== existingUser.phoneNumber) {
updateData.phoneNumber = phone;
}
if (hasValidEmail && existingUser.email !== user.email && existingUser.email.endsWith('@guest.local')) {
if (hasValidEmail && existingUser.email !== user.email) {
updateData.email = user.email;
}
// Update preference only when the caller explicitly specified one, but validate it
// against the contact info that will actually be on the account after this update —
// a stale "whatsapp"/"both" preference must not survive a phone number being
// removed, nor "email" survive an email being cleared in favor of a real phone.
if (prefFromBody && validPrefs.includes(prefFromBody)) {
const { isValidZAPhone } = require('../utils/whatsapp');
const finalPhone = updateData.phoneNumber !== undefined ? updateData.phoneNumber : existingUser.phoneNumber;
const finalEmail = updateData.email !== undefined ? updateData.email : existingUser.email;
const finalEmailValid = !!(finalEmail && !finalEmail.endsWith('@guest.local'));
let candidatePref = prefFromBody;
if ((candidatePref === 'whatsapp' || candidatePref === 'both') && !isValidZAPhone(finalPhone)) {
candidatePref = finalEmailValid ? 'email' : candidatePref;
}
if (candidatePref === 'email' && !finalEmailValid && isValidZAPhone(finalPhone)) {
candidatePref = 'whatsapp';
}
updateData.notificationPreference = candidatePref;
}
if (Object.keys(updateData).length > 0) {
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
}
+453 -229
View File
@@ -1,269 +1,464 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
// Utility: draw a table
function drawTable(doc, startX, startY, colWidths, rows, header) {
let y = startY;
doc.font('Helvetica-Bold');
if (header && header.length) {
let x = startX;
header.forEach((h, i) => {
const w = colWidths[i] || 80;
doc.rect(x, y, w, 20).stroke();
doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 });
x += w;
});
y += 20;
}
doc.font('Helvetica');
rows.forEach((row) => {
let x = startX;
row.forEach((cell, i) => {
const w = colWidths[i] || 80;
const h = 18;
doc.rect(x, y, w, h).stroke();
doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 });
x += w;
});
y += 18;
// New page if overflow
if (y > doc.page.height - 40) {
doc.addPage();
y = 20;
}
});
}
const { sendMail, emailWrapper } = require('../utils/email');
function a4Doc(orientation = 'portrait') {
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
}
// Brand palette — mirrors the web reports' indigo theme and the dataviz-skill categorical
// palette used by the on-screen HorizontalBarChart, so exported PDFs/Excel look like the
// same report instead of a plain data dump.
const BRAND = '#4f46e5';
const BRAND_DARK = '#3730a3';
const BRAND_LIGHT = '#eef2ff';
const TEXT_DARK = '#111827';
const TEXT_MUTED = '#6b7280';
const BORDER = '#e5e7eb';
const TONE_COLORS = {
green: { bg: '#ecfdf5', accent: '#059669' },
blue: { bg: '#eff6ff', accent: '#2563eb' },
violet: { bg: '#f5f3ff', accent: '#7c3aed' },
amber: { bg: '#fffbeb', accent: '#d97706' },
rose: { bg: '#fff1f2', accent: '#e11d48' },
gray: { bg: '#f3f4f6', accent: '#4b5563' },
};
const CATEGORICAL_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#4a3aa7', '#e34948'];
function isEmphasisRow(firstCell) {
const s = String(firstCell ?? '').trim();
return /^total$/i.test(s) || /net profit/i.test(s) || /unassigned donations/i.test(s) || /revenue per ticket/i.test(s);
}
function drawHeader(doc, title, subtitle) {
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
const y = doc.page.margins.top;
const h = subtitle ? 46 : 32;
doc.save();
doc.roundedRect(x, y, pageWidth, h, 6).fill(BRAND);
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(16).text(title || 'Report', x + 14, y + 9, { width: pageWidth - 28 });
if (subtitle) {
doc.font('Helvetica').fontSize(9).fillColor('#e0e7ff').text(subtitle, x + 14, y + 30, { width: pageWidth - 28 });
}
doc.restore();
doc.y = y + h + 14;
}
function drawStats(doc, stats) {
if (!Array.isArray(stats) || stats.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const perRow = Math.max(1, Math.min(5, Math.floor(pageWidth / 110)));
const gap = 8;
const boxW = (pageWidth - gap * (perRow - 1)) / perRow;
const boxH = 34;
let rowY = doc.y;
stats.forEach((s, i) => {
const col = i % perRow;
if (col === 0 && i !== 0) rowY += boxH + gap;
const bx = doc.page.margins.left + col * (boxW + gap);
const tone = TONE_COLORS[s.tone] || TONE_COLORS.gray;
doc.save();
doc.roundedRect(bx, rowY, boxW, boxH, 5).fill(tone.bg);
doc.fillColor(TEXT_MUTED).font('Helvetica').fontSize(7.5).text(String(s.label || ''), bx + 8, rowY + 6, { width: boxW - 16 });
doc.fillColor(tone.accent).font('Helvetica-Bold').fontSize(11).text(String(s.value || ''), bx + 8, rowY + 17, { width: boxW - 16 });
doc.restore();
});
doc.y = rowY + boxH + 16;
if (doc.y > doc.page.height - 60) doc.addPage();
}
function drawChart(doc, chart) {
if (!chart || !Array.isArray(chart.data) || chart.data.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
if (chart.title) {
doc.fillColor(TEXT_MUTED).font('Helvetica-Bold').fontSize(9).text(chart.title, x, doc.y);
doc.moveDown(0.4);
}
const labelW = 110;
const valueW = 80;
const barAreaW = pageWidth - labelW - valueW - 16;
const max = Math.max(1, ...chart.data.map(d => Math.abs(d.value || 0)));
const rowH = 16;
chart.data.forEach((d, i) => {
const y = doc.y;
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(String(d.label || ''), x, y + 3, { width: labelW - 8 });
const trackX = x + labelW;
doc.roundedRect(trackX, y + 2, barAreaW, 8, 4).fill('#f3f4f6');
const w = Math.max(4, (Math.abs(d.value || 0) / max) * barAreaW);
doc.roundedRect(trackX, y + 2, w, 8, 4).fill(CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length]);
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(d.displayValue != null ? String(d.displayValue) : String(d.value), trackX + barAreaW + 8, y + 3, { width: valueW - 8, align: 'right' });
doc.y = y + rowH;
if (doc.y > doc.page.height - 60) doc.addPage();
});
doc.moveDown(0.8);
}
function drawNote(doc, note) {
if (!note) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
const y = doc.y;
doc.font('Helvetica-Oblique').fontSize(8.5);
const h = doc.heightOfString(note, { width: pageWidth - 20 }) + 14;
doc.save();
doc.roundedRect(x, y, pageWidth, h, 5).fill('#f9fafb');
doc.fillColor(TEXT_MUTED).text(note, x + 10, y + 7, { width: pageWidth - 20 });
doc.restore();
doc.y = y + h + 12;
if (doc.y > doc.page.height - 60) doc.addPage();
}
// Draws a single branded table (header band, zebra rows, highlighted total rows) at the
// document's current y — shared by the main table body and any extraTables sections below it.
function drawTable(doc, table) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill(BRAND_LIGHT);
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(10.5);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Rows — zebra striped, with a highlighted tint+bold for total/summary rows
const rows = table.rows;
rows.forEach((row) => {
const emphasis = isEmphasisRow(row[0]);
const rowY = doc.y;
const rowH = 18;
const bg = emphasis ? BRAND_LIGHT : (rows.indexOf(row) % 2 === 0 ? '#ffffff' : '#f9fafb');
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
doc.fillColor(emphasis ? BRAND_DARK : TEXT_DARK).font(emphasis ? 'Helvetica-Bold' : 'Helvetica').fontSize(9.5).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
doc.moveDown(1);
}
// Shared drawing logic for all PDF-producing endpoints (download, email, WhatsApp) — draws a
// branded header, optional stat tiles / bar chart / note, then a 'table' or 'layered' body
// (plus any extraTables sections below it) onto whatever PDFDocument the caller gives it
// (streamed straight to the HTTP response for download, or to a temp file for email/WhatsApp).
function drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }) {
drawHeader(doc, title, subtitle);
drawStats(doc, stats);
drawChart(doc, chart);
drawNote(doc, note);
// Path-drawing ops (rect/moveTo/lineTo, used throughout drawTable's borders) leave PDFKit's
// implicit text cursor (doc.x) at the last point drawn rather than the left margin, so every
// text() call below passes an explicit x — relying on the implicit cursor after a table has
// rendered puts the next label at the table's right edge instead of the margin.
const leftX = doc.page.margins.left;
if (kind === 'table' && table && Array.isArray(table.rows)) {
drawTable(doc, table);
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).fillColor(TEXT_DARK).text(layered.header, leftX, doc.y);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').text(String(section.title || ''), leftX, doc.y, { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill(BRAND).stroke();
doc.fillColor(TEXT_DARK);
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.fillColor(TEXT_MUTED).text('No items', leftX, doc.y);
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.fillColor(TEXT_MUTED).font('Helvetica').text('No content', leftX, doc.y);
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
if (doc.y > doc.page.height - 100) doc.addPage();
doc.moveDown(0.6);
if (t.title) {
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(11).text(t.title, leftX, doc.y);
doc.moveDown(0.4);
}
drawTable(doc, t);
});
}
}
// Renders payload to a temp PDF file and returns its path — shared by emailPdf and whatsappPdf.
async function renderReportPdfToFile(payload) {
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(payload.title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
await new Promise((resolve, reject) => {
const doc = a4Doc(payload.orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
drawReportPdf(doc, payload);
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
return filePath;
}
// Builds a styled .xlsx workbook mirroring the same payload shape used for PDF — branded
// title band, stat label/value rows, a chart rendered as a mini-table with a native Excel
// data-bar conditional format (the closest free/no-extra-viz-dependency equivalent of the
// web's bar chart), then the main table with a bold colored header row and highlighted
// total rows.
async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
const wb = new ExcelJS.Workbook();
wb.creator = 'Hope Family Church Events';
wb.created = new Date();
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
const ws = wb.addWorksheet(sheetName);
const colCount = Math.max(4, (table?.columns?.length || 0));
let r = 1;
ws.mergeCells(r, 1, r, colCount);
const titleCell = ws.getCell(r, 1);
titleCell.value = title || 'Report';
titleCell.font = { bold: true, size: 16, color: { argb: 'FFFFFFFF' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F46E5' } };
titleCell.alignment = { vertical: 'middle' };
ws.getRow(r).height = 26;
r++;
if (subtitle) {
ws.mergeCells(r, 1, r, colCount);
const subCell = ws.getCell(r, 1);
subCell.value = subtitle;
subCell.font = { italic: true, size: 10, color: { argb: 'FF6B7280' } };
r++;
}
r++;
if (Array.isArray(stats) && stats.length) {
stats.forEach(s => {
ws.getCell(r, 1).value = s.label;
ws.getCell(r, 1).font = { color: { argb: 'FF6B7280' }, size: 10 };
ws.getCell(r, 2).value = s.value;
ws.getCell(r, 2).font = { bold: true, size: 11 };
r++;
});
r++;
}
if (chart && Array.isArray(chart.data) && chart.data.length) {
if (chart.title) {
ws.getCell(r, 1).value = chart.title;
ws.getCell(r, 1).font = { bold: true, size: 10 };
r++;
}
const chartStartRow = r;
chart.data.forEach(d => {
ws.getCell(r, 1).value = d.label;
ws.getCell(r, 2).value = typeof d.value === 'number' ? d.value : Number(d.value) || 0;
r++;
});
ws.addConditionalFormatting({
ref: `B${chartStartRow}:B${r - 1}`,
rules: [{ type: 'dataBar', cfvo: [{ type: 'min' }, { type: 'max' }], color: { argb: 'FF2A78D6' } }]
});
r++;
}
if (note) {
ws.mergeCells(r, 1, r, colCount);
ws.getCell(r, 1).value = note;
ws.getCell(r, 1).font = { italic: true, size: 9, color: { argb: 'FF6B7280' } };
ws.getCell(r, 1).alignment = { wrapText: true };
r += 2;
}
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = table.columns || [];
const { nextRow, dataStartRow } = writeTableRows(ws, r, columns, table.rows);
ws.views = [{ state: 'frozen', ySplit: dataStartRow - 1 }];
r = nextRow;
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
ws.getCell(r, 1).value = layered.header;
ws.getCell(r, 1).font = { bold: true, size: 12 };
r += 2;
}
layered.sections.forEach(section => {
ws.getCell(r, 1).value = section.title;
ws.getCell(r, 1).font = { bold: true, color: { argb: 'FF3730A3' } };
r++;
(section.items || []).forEach(item => {
ws.getCell(r, 1).value = `${item}`;
r++;
});
r++;
});
ws.getColumn(1).width = 90;
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
r++;
if (t.title) {
ws.getCell(r, 1).value = t.title;
ws.getCell(r, 1).font = { bold: true, size: 12, color: { argb: 'FF3730A3' } };
r++;
}
const { nextRow } = writeTableRows(ws, r, t.columns || [], t.rows);
r = nextRow;
});
}
return wb;
}
// Writes a header row (bold, colored fill) + zebra/emphasis-highlighted data rows at the given
// sheet row — shared by the main table and any extraTables sections. Returns the next free row
// and the data's start row (for optional freeze-pane use by the caller).
function writeTableRows(ws, startRow, columns, rows) {
let r = startRow;
const headerRow = ws.getRow(r);
columns.forEach((c, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = c;
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE5E7EB' } } };
});
headerRow.commit();
r++;
const dataStartRow = r;
rows.forEach((row, idx) => {
const excelRow = ws.getRow(r);
const emphasis = isEmphasisRow(row[0]);
row.forEach((val, i) => {
const cell = excelRow.getCell(i + 1);
cell.value = val === '' ? null : val;
if (emphasis) {
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
} else if (idx % 2 === 1) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } };
}
});
excelRow.commit();
r++;
});
columns.forEach((c, i) => {
let maxLen = String(c || '').length;
rows.forEach(row => { const v = row[i]; if (v != null && v !== '') maxLen = Math.max(maxLen, String(v).length); });
const col = ws.getColumn(i + 1);
col.width = Math.max(col.width || 0, Math.min(40, Math.max(10, maxLen + 2)));
});
return { nextRow: r, dataStartRow };
}
// POST /api/reports/pdf
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
const generatePdf = async (req, res) => {
try {
const { title, kind, table, layered, orientation } = req.body || {};
const { title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables } = req.body || {};
res.setHeader('Content-Type', 'application/pdf');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
doc.pipe(res);
// Title
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column to mimic site tables
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Draw header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Zebra rows
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
// Cell text
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
// Vertical separators similar to table borders
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
// Right border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
// Bottom border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
// Bullet dot
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
doc.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/excel
// body: same payload shape as /pdf — used to produce a styled .xlsx mirroring the PDF/web report.
const generateExcel = async (req, res) => {
try {
const payload = req.body || {};
const wb = await buildReportWorkbook(payload);
const filename = `${(payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await wb.xlsx.write(res);
res.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/email
// body: { title, kind, table?, layered?, subject?, body? }
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? }
const emailPdf = async (req, res) => {
try {
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
const { title, subtitle, kind, table, layered, subject, body, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
if (!user || !user.email) {
res.status(400);
throw new Error('User email not available');
}
// Ensure temp dir
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
// Build PDF to file
await new Promise((resolve, reject) => {
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Rows zebra
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
// Send email using nodemailer (same config as tickets)
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_PORT === '465',
auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
});
await transporter.sendMail({
from: process.env.EMAIL_FROM,
// Use the shared mail utility (DB-configured SMTP via Admin -> Site Settings, with env
// fallback) instead of a one-off transporter — a bare `process.env.EMAIL_HOST` transporter
// ignores that configuration entirely and fails wherever SMTP is only set up via the DB.
const bodyText = body || 'Please find your report attached.';
await sendMail({
to: user.email,
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
text: body || 'Please find your report attached.',
text: bodyText,
html: emailWrapper(
`<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' }]
});
// Clean
try { fs.unlinkSync(filePath); } catch {}
res.json({ message: `Report emailed to ${user.email}` });
@@ -272,4 +467,33 @@ const emailPdf = async (req, res) => {
}
};
module.exports = { generatePdf, emailPdf };
// POST /api/reports/whatsapp
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, caption? }
// Sends to the current user's own WhatsApp number (same self-service pattern as emailPdf).
const whatsappPdf = async (req, res) => {
let filePath = null;
try {
const { title, subtitle, kind, table, layered, caption, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
const { isValidZAPhone } = require('../utils/whatsapp');
if (!user || !isValidZAPhone(user.phoneNumber)) {
res.status(400);
throw new Error('No valid WhatsApp number on your account. Add one in your profile to use this.');
}
filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
const { sendPdf } = require('../utils/whatsapp');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
res.json({ message: `Report sent to your WhatsApp` });
} catch (e) {
res.status(400).json({ message: e.message });
} finally {
if (filePath) { try { fs.unlinkSync(filePath); } catch {} }
}
};
module.exports = { generatePdf, generateExcel, emailPdf, whatsappPdf };
+10 -1
View File
@@ -321,6 +321,7 @@ const scanTicket = async (req, res) => {
e.title AS "eventTitle",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
@@ -352,7 +353,7 @@ const scanTicket = async (req, res) => {
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
@@ -398,6 +399,14 @@ const scanTicket = async (req, res) => {
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
ticketUsage,
+29 -14
View File
@@ -354,21 +354,15 @@ const updateUserProfile = async (req, res) => {
}
// Normalize phone
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
const { normalizeZAPhone } = require('../utils/whatsapp');
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) {
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
}
// Validate notification preference — WhatsApp requires a valid SA phone number
const allowedPrefs = ['email', 'whatsapp', 'both'];
let newPref = user.notificationPreference;
if (notificationPreference !== undefined) {
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
newPref = 'email';
}
}
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Update user data
const updatedUser = await prisma.user.update({
@@ -482,13 +476,28 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses },
select: { email: true, phoneNumber: true },
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
});
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
const hasPhone = !!existingUser?.phoneNumber;
res.json({
exists: !!existingUser,
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
hasPhone: !!existingUser?.phoneNumber,
hasEmail,
hasPhone,
// Safe-to-display fields only, for autofilling a lookup form — never the password.
// Guest placeholder emails are withheld the same way hasEmail already treats them.
// `id` lets the kiosk tell two different matched accounts apart (e.g. when the
// typed email and phone number resolve to different people) — it's never shown,
// only compared client-side, and this endpoint is already Private/Supervisor.
user: existingUser ? {
id: existingUser.id,
name: existingUser.name,
email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
notificationPreference: existingUser.notificationPreference,
} : null,
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -539,7 +548,11 @@ const updateUser = async (req, res) => {
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password } = req.body;
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Prepare data update, allow admin to set a new password
const data = {
@@ -547,7 +560,8 @@ const updateUser = async (req, res) => {
email: email || user.email,
role: role || user.role,
isActive: isActive !== undefined ? isActive : user.isActive,
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
phoneNumber: newPhone,
notificationPreference: newPref,
updatedAt: new Date()
};
@@ -564,6 +578,7 @@ const updateUser = async (req, res) => {
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
createdAt: true,
updatedAt: true,
isActive: true
@@ -323,6 +323,7 @@ const handlePaymentSucceeded = async (webhookData) => {
externalId: yocoPaymentId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id
},
@@ -334,6 +335,13 @@ const handlePaymentSucceeded = async (webhookData) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: registration ? {
include: {
event: true
@@ -138,6 +138,7 @@ const reconcileYocoTransaction = async (req, res) => {
amount: amountFloat,
method: ytx.methodType || 'card',
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
+3 -1
View File
@@ -1,10 +1,12 @@
const express = require('express');
const router = express.Router();
const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
router.get('/audit', protect, supervisor, getCashupAudit);
router.get('/event/:eventId', protect, supervisor, getEventCashup);
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
router.post('/event/:eventId/close', protect, admin, closeEvent);
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
+7 -1
View File
@@ -1,12 +1,18 @@
const express = require('express');
const router = express.Router();
const { generatePdf, emailPdf } = require('../controllers/reportController');
const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController');
const { protect } = require('../middleware/authMiddleware');
// Generate and download PDF
router.post('/pdf', protect, generatePdf);
// Generate and download styled Excel (.xlsx)
router.post('/excel', protect, generateExcel);
// Email PDF to current user
router.post('/email', protect, emailPdf);
// Send PDF to current user's own WhatsApp
router.post('/whatsapp', protect, whatsappPdf);
module.exports = router;
+161 -6
View File
@@ -21,6 +21,16 @@ function bucketForMethod(method) {
return 'other';
}
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Revenue/cash totals must count each real inflow exactly once, so legs are
// excluded everywhere money is summed — the money was already counted via the donation itself.
// (Refunds also set originalPaymentId, but always with a negative amount, so they're unaffected.)
function isDonationLeg(p) {
return !p.isDonation && !!p.originalPaymentId && p.amount > 0;
}
// Throws if the event is closed. Callers wrap this in their existing try/catch
// (res.statusCode is set before throwing, matching the rest of the controllers).
async function assertEventOpen(eventId, res) {
@@ -93,16 +103,30 @@ async function computeEventFinancials(eventId) {
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
}
const nonRefundPayments = payments.filter(p => p.amount > 0);
const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0);
const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0);
// Real inflows only — excludes donation-application legs, which would otherwise double-count
// money already counted once via the source donation (e.g. a R250 donation with R50 assigned
// to a registration must total R250 received, not R300).
const nonRefundPayments = payments.filter(p => p.amount > 0 && !isDonationLeg(p));
// Donations are never mutated once assigned — assignment creates a separate "leg" Payment
// row (isDonation:false, originalPaymentId -> the donation), so a donation's registrationId
// stays null forever. Its actual unallocated amount is its original amount minus every leg
// that already references it, not simply "every donation with no registrationId".
const legsByDonationId = new Map();
for (const p of payments) {
if (p.originalPaymentId && !p.isDonation) {
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + p.amount);
}
}
const donationPayments = payments.filter(p => p.isDonation);
const unallocatedDonations = donationPayments.filter(p => (p.amount - (legsByDonationId.get(p.id) || 0)) > 0.000001);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + Math.max(p.amount - (legsByDonationId.get(p.id) || 0), 0), 0);
const totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0);
const paymentsByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
}
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => {
@@ -195,12 +219,143 @@ async function computeEventFinancials(eventId) {
};
}
// Payment accountability, per staff member who recorded the payment, broken down by every
// method (not just cash) — lets a cashup reconcile not just the total float but who is
// responsible for which portion of it. Cash also folds in any actual physical count entered for
// that person (EventCashupPersonCount, entered any time, independent of the event-wide close) to
// show an actual-vs-expected variance per person — the event's cash actual is the sum of these
// per-person counts (see computeEventCashActualFromPersonCounts), not a separate manual entry.
// Card/EFT/Other have no physical "count" concept, so they're just recorded amounts.
async function computeAccountabilityByUser(eventId) {
const [payments, personCounts] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] },
include: { recordedBy: { select: { id: true, name: true, email: true } } }
}),
prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: {
user: { select: { id: true, name: true, email: true } },
enteredBy: { select: { id: true, name: true } },
denominations: true
}
})
]);
const emptyMethodTotals = () => ({ total: 0, count: 0 });
const emptyEntry = (userId, name, email) => ({
userId: userId || null,
name: name || 'Unknown / legacy',
email: email || null,
cash: { ...emptyMethodTotals(), actual: null, variance: null, denominations: [], enteredBy: null, countUpdatedAt: null, notes: null },
card: emptyMethodTotals(),
eft: emptyMethodTotals(),
other: emptyMethodTotals()
});
const byUser = new Map();
for (const p of payments) {
// A donation-application leg isn't new money — it's the same money already recorded once,
// as the donation. Counting it again here would double-attribute it to whoever did the
// assignment, on top of whoever originally recorded the donation.
if (isDonationLeg(p)) continue;
const method = bucketForMethod(p.method);
const key = p.recordedById || 'unknown';
const entry = byUser.get(key) || emptyEntry(p.recordedById, p.recordedBy?.name, p.recordedBy?.email);
entry[method].total += p.amount;
entry[method].count += 1;
byUser.set(key, entry);
}
for (const pc of personCounts) {
const key = pc.userId;
const entry = byUser.get(key) || emptyEntry(pc.userId, pc.user?.name, pc.user?.email);
const actual = pc.denominations.reduce((s, d) => s + d.value * d.count, 0);
entry.cash.actual = actual;
entry.cash.variance = actual - entry.cash.total;
entry.cash.denominations = pc.denominations.map(d => ({ value: d.value, count: d.count }));
entry.cash.enteredBy = pc.enteredBy ? { id: pc.enteredBy.id, name: pc.enteredBy.name } : null;
entry.cash.countUpdatedAt = pc.updatedAt;
entry.cash.notes = pc.notes || null;
byUser.set(key, entry);
}
return Array.from(byUser.values()).sort((a, b) => {
const totalA = a.cash.total + a.card.total + a.eft.total + a.other.total;
const totalB = b.cash.total + b.card.total + b.eft.total + b.other.total;
return totalB - totalA;
});
}
// The event's cash "actual" is the live sum of every staff member's entered physical count —
// there is no separate event-wide entry any more. Used both to display a live figure before
// close and to source the closed cashup's permanent Cash line.
async function computeEventCashActualFromPersonCounts(eventId) {
const personCounts = await prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: { denominations: true }
});
if (personCounts.length === 0) return { actual: null, denominations: [] };
const byValue = new Map();
let actual = 0;
for (const pc of personCounts) {
for (const d of pc.denominations) {
actual += d.value * d.count;
byValue.set(d.value, (byValue.get(d.value) || 0) + d.count);
}
}
const denominations = Array.from(byValue.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.value - a.value);
return { actual, denominations };
}
// Upsert one staff member's actual physical cash count for an event — optional, can be entered
// any time (not required to close the event), purely for per-person accountability.
async function savePersonCashCount(eventId, userId, { denominations, notes, enteredById }) {
const cleanDenoms = (Array.isArray(denominations) ? denominations : [])
.map(d => ({ value: Number(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0);
const existing = await prisma.eventCashupPersonCount.findUnique({
where: { eventId_userId: { eventId, userId } }
});
const record = existing
? await prisma.eventCashupPersonCount.update({
where: { id: existing.id },
data: {
notes: notes || null,
enteredById: enteredById || null,
denominations: { deleteMany: {}, create: cleanDenoms }
},
include: { denominations: true }
})
: await prisma.eventCashupPersonCount.create({
data: {
eventId,
userId,
notes: notes || null,
enteredById: enteredById || null,
denominations: { create: cleanDenoms }
},
include: { denominations: true }
});
return record;
}
module.exports = {
METHOD_BUCKETS,
ALL_METHODS,
ZAR_DENOMINATIONS,
bucketForMethod,
isDonationLeg,
assertEventOpen,
assertRegistrationEventOpen,
computeEventFinancials
computeEventFinancials,
computeAccountabilityByUser,
computeEventCashActualFromPersonCounts,
savePersonCashCount
};
@@ -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 };
+66
View File
@@ -80,6 +80,17 @@ async function loadRegistrationFull(registrationId) {
});
}
async function loadTicketFull(ticketId) {
return prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true } },
},
});
}
async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({
where: { id: paymentId },
@@ -488,6 +499,33 @@ function buildPaymentReceipt(payment) {
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
const fullyCheckedIn = remaining <= 0;
const subject = `Checked in ${eventTitle}`;
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! </p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
}`, fullyCheckedIn ? 'success' : 'info')}`;
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
@@ -893,6 +931,32 @@ async function sendRefundEmail(refundPaymentId) {
}
}
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticket.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail && shouldEmail(user)) {
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
if (hasValidEmail) {
sends.push(waText(user, waMsg));
} else {
sends.push(waTextAny(user, waMsg));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send check-in emails:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed
@@ -981,4 +1045,6 @@ module.exports = {
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
};
+30
View File
@@ -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 };
+21
View File
@@ -107,6 +107,26 @@ function buildWAPayment(payment) {
].join('\n');
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const name = ticket.user?.name || 'there';
const fullyCheckedIn = remaining <= 0;
return [
`✅ *Checked In*`,
'',
`Hi ${name},`,
'',
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) {
@@ -255,4 +275,5 @@ module.exports = {
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
buildWACheckIn,
};
+40 -17
View File
@@ -105,6 +105,12 @@ function isSessionNotFound(e) {
return msg.includes('session not found') || msg.includes('instance not found');
}
/** Extracts the WAWP API's own error reason when present, instead of axios's generic
* "Request failed with status code NNN" (which carries no information about what went wrong). */
function wawpErrorMessage(e) {
return e?.response?.data?.message || e?.message || 'Unknown WhatsApp API error';
}
/**
* If the WAWP API reports "Session not found", clear the stale instance ID
* from the DB so the admin UI drops back to the Session Instance setup step.
@@ -221,12 +227,20 @@ async function sendText(toPhone, message) {
const chatId = toChatId(toPhone);
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
const { token, instanceId } = await getConfig();
await axios.post(`${BASE}/send/text`, {
access_token: token,
instance_id: instanceId,
chatId,
message,
});
try {
await axios.post(`${BASE}/send/text`, {
access_token: token,
instance_id: instanceId,
chatId,
message,
});
} catch (e) {
if (isSessionNotFound(e)) {
try { await handleSessionNotFound(e); } catch {}
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
}
throw new Error(`WhatsApp text send failed: ${wawpErrorMessage(e)}`);
}
}
/**
@@ -258,17 +272,26 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
const { token, instanceId } = await getConfig();
await axios.post(`${BASE}/send/pdf`, {
access_token: token,
instance_id: instanceId,
chatId,
file: {
url: pdfUrl,
filename: filename || 'tickets.pdf',
mimetype: 'application/pdf',
},
caption: caption || '',
});
try {
await axios.post(`${BASE}/send/pdf`, {
access_token: token,
instance_id: instanceId,
chatId,
file: {
url: pdfUrl,
filename: filename || 'tickets.pdf',
mimetype: 'application/pdf',
},
caption: caption || '',
});
} catch (e) {
try { fs.unlinkSync(tempPath); } catch {}
if (isSessionNotFound(e)) {
try { await handleSessionNotFound(e); } catch {}
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
}
throw new Error(`WhatsApp PDF send failed: ${wawpErrorMessage(e)}`);
}
// Clean up after 5 minutes — WAWP will have fetched the file by then
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.1.0",
"version": "1.4.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -26,7 +26,7 @@ export default function EventCashupPage() {
const router = useRouter();
const { token } = useAuth();
const [tab, setTab] = useState<"costs" | "reconciliation">("costs");
const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs");
const [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
@@ -72,6 +72,7 @@ export default function EventCashupPage() {
<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 === "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>
{loading && <div className="text-sm text-gray-400">Loading</div>}
@@ -89,8 +90,13 @@ export default function EventCashupPage() {
setBusy={setBusy}
setError={setError}
onChanged={load}
onClosed={() => setTab("report")}
/>
)}
{!loading && data && tab === "report" && (
<ReportTab eventId={eventId} token={token || ""} data={data} isClosed={isClosed} />
)}
</div>
);
}
@@ -169,49 +175,51 @@ 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>}
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Label</th>
<th className="py-1">Type</th>
<th className="py-1">Ticket type</th>
<th className="py-1">Paid from</th>
<th className="py-1 text-right">Amount</th>
<th className="py-1 text-right">Total</th>
{!isClosed && <th className="py-1"></th>}
</tr>
</thead>
<tbody>
{costs.map(c => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5">{c.label}</td>
<td className="py-1.5">{c.costType === "once_off" ? "Once-off" : "Per item"}</td>
<td className="py-1.5">{c.eventOption?.name || "—"}</td>
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
<td className="py-1.5 text-right">{money(c.amount)}</td>
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
{!isClosed && (
<td className="py-1.5 text-right whitespace-nowrap">
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
</td>
)}
<div className="overflow-auto">
<table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Label</th>
<th className="py-1">Type</th>
<th className="py-1">Ticket type</th>
<th className="py-1">Paid from</th>
<th className="py-1 text-right">Amount</th>
<th className="py-1 text-right">Total</th>
{!isClosed && <th className="py-1"></th>}
</tr>
))}
{costs.length === 0 && (
<tr><td colSpan={7} className="py-3 text-gray-400 text-center">No costs added yet.</td></tr>
</thead>
<tbody>
{costs.map(c => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5">{c.label}</td>
<td className="py-1.5">{c.costType === "once_off" ? "Once-off" : "Per item"}</td>
<td className="py-1.5">{c.eventOption?.name || "—"}</td>
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
<td className="py-1.5 text-right">{money(c.amount)}</td>
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
{!isClosed && (
<td className="py-1.5 text-right whitespace-nowrap">
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
</td>
)}
</tr>
))}
{costs.length === 0 && (
<tr><td colSpan={7} className="py-3 text-gray-400 text-center">No costs added yet.</td></tr>
)}
</tbody>
{costs.length > 0 && (
<tfoot>
<tr>
<td colSpan={5} className="pt-2 text-right text-gray-500">Total costs</td>
<td className="pt-2 text-right font-semibold">{money(totalCosts)}</td>
{!isClosed && <td />}
</tr>
</tfoot>
)}
</tbody>
{costs.length > 0 && (
<tfoot>
<tr>
<td colSpan={5} className="pt-2 text-right text-gray-500">Total costs</td>
<td className="pt-2 text-right font-semibold">{money(totalCosts)}</td>
{!isClosed && <td />}
</tr>
</tfoot>
)}
</table>
</table>
</div>
{editingId !== null && (
<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 ─────────────────────────────────────────────────────
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&apos;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;
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void;
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void;
}) {
const isClosed = data.event.cashupStatus === "closed";
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
@@ -281,32 +629,23 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
return base;
}, [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 [denomCounts, setDenomCounts] = useState<Record<number, string>>(initialDenomCounts);
const [closeNotes, setCloseNotes] = useState("");
const [reopenNotes, setReopenNotes] = useState("");
// Cash no longer has its own manual entry — it's the live sum of every staff member's
// per-person count, reported up from CashByUserSection below.
const [cashSummary, setCashSummary] = useState<{ actualTotal: number | null; denominations: PersonDenom[] }>({ actualTotal: null, denominations: [] });
useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]);
useEffect(() => { setLines(initialLines); }, [initialLines]);
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
};
const cashDenominationsPayload = () => ZAR_DENOMINATIONS
.map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 }))
.filter(d => d.count > 0);
const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0);
// Cash is included with notes only — its actual/denominations are always sourced server-side
// from per-person counts (see cashupController.closeEvent), never from this payload.
const buildLinesPayload = () => METHODS.map(m => m === "cash"
? { method: "cash", denominations: cashDenominationsPayload(), notes: lines.cash.notes || null }
? { method: "cash", notes: lines.cash.notes || null }
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
const saveDraft = async () => {
@@ -327,6 +666,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
@@ -340,6 +680,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
@@ -391,7 +732,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed ? (
money(r?.actual ?? null)
) : m === "cash" ? (
money(cashActualFromDenoms)
cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : <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)} />
)}
@@ -400,7 +741,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
: (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]) : "—"))}
</td>
<td className="py-1.5">
@@ -413,38 +754,31 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
})}
</tbody>
</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>
<CashByUserSection eventId={eventId} token={token} onCashSummaryChange={setCashSummary} />
<div className="bg-white border rounded-lg p-4 space-y-2">
<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">
<tbody>
{reconciled.byMethod.cash.denominations.map(d => (
<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>
{denoms.map((d: any) => (
<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>
</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">
{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={denomCounts[v] || ""}
onChange={e => setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))}
/>
</div>
))}
</div>
)}
) : (
<div className="text-xs text-gray-400">
{isClosed ? "No denomination breakdown recorded for this cashup." : "No per-staff-member cash counts entered yet — see “Payment accountability by staff member” above."}
</div>
);
})()}
</div>
<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";
};
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 (
<div className="max-w-6xl mx-auto w-full p-6">
<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>}
{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 */}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<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">
<ul className="divide-y text-sm">
{filtered.map((r: any) => {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const totalDue = totalDueFor(r);
const totalPaid = totalPaidFor(r);
const outstanding = Math.max(totalDue - totalPaid, 0);
const isExpanded = expanded.has(r.id);
const responses = formResponses[r.id];
const loadingResponse = loadingForms.has(r.id);
@@ -245,7 +286,9 @@ export default function AdminRegistrationsPage() {
<div className="text-xs text-gray-500 mt-0.5">
{r.user?.email && <span className="mr-2">{r.user.email}</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>
</div>
</div>
@@ -306,6 +349,31 @@ export default function AdminRegistrationsPage() {
</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 */}
<div>
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
+83 -47
View File
@@ -12,6 +12,7 @@ interface UserItem {
email: string;
role: string;
phoneNumber?: string | null;
notificationPreference?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
@@ -20,6 +21,8 @@ interface UserItem {
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
type Role = typeof roleOptions[number];
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
@@ -71,10 +74,11 @@ export default function AdminUsersPage() {
const [cRole, setCRole] = useState<Role>("user");
const [creating, setCreating] = useState(false);
// Inline edit state
// Edit modal state
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
const [saving, setSaving] = useState(false);
const editingUser = useMemo(() => users.find(u => u.id === editingId) || null, [users, editingId]);
const buildQuery = useCallback((p: number, ps = pageSize) => {
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
@@ -152,6 +156,7 @@ export default function AdminUsersPage() {
email: editData.email,
role: editData.role,
phoneNumber: editData.phoneNumber || null,
notificationPreference: editData.notificationPreference,
isActive: editData.isActive,
};
if (editData.password && editData.password.trim().length > 0) {
@@ -305,8 +310,8 @@ export default function AdminUsersPage() {
<th className="p-2">Email</th>
<th className="p-2">Role</th>
<th className="p-2">Phone</th>
<th className="p-2">Notify</th>
<th className="p-2">Active</th>
<th className="p-2">Password</th>
<th className="p-2">Actions</th>
</tr>
</thead>
@@ -314,63 +319,30 @@ export default function AdminUsersPage() {
{users.map(u => (
<tr key={u.id} className="border-t hover:bg-gray-50">
<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 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 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 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 className="p-2">
{editingId === u.id ? (
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
) : (
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
)}
<span className="capitalize">{u.notificationPreference || "email"}</span>
</td>
<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>
)}
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</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">
<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-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>
</div>
)}
<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-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-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
</td>
</tr>
))}
@@ -431,6 +403,70 @@ export default function AdminUsersPage() {
</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>
);
}
+19 -6
View File
@@ -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 (
<div className="min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 flex flex-col md:flex-row">
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
{!hideSidebar && (
<>
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
</>
)}
<main className="flex-1 p-6 bg-gray-50">{children}</main>
</div>
<Footer />
@@ -7,7 +7,15 @@ import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
type Mode = "registration" | "payment" | "tickets" | "refund";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
const MODE_LABELS: Record<Mode, string> = {
registration: "REGISTRATION",
payment: "PAYMENT",
checkin: "CHECK-IN",
tickets: "TICKETS",
refund: "REFUND",
};
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
@@ -159,8 +167,7 @@ export default function AtTheDoorPage() {
const [error, setError] = useDismissingState<string | null>(null);
const handleRegistrationCreated = (registration: any) => {
setActiveRegistration(registration);
setMode("payment"); // 🚀 Jump automatically
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
};
const handleRegistrationSelected = (registration: any) => {
@@ -171,7 +178,9 @@ export default function AtTheDoorPage() {
if (!activeRegistration) return;
const id = setTimeout(() => {
setMode("payment");
// Fully paid → head straight to check-in; otherwise there's still a
// balance to collect, so go capture payment first.
setMode(activeRegistration.status === "paid" ? "checkin" : "payment");
}, 0);
return () => clearTimeout(id);
@@ -317,28 +326,12 @@ export default function AtTheDoorPage() {
const reg = registration || activeRegistration;
try {
const res = await apiFetch<any>("/api/tickets/generate", {
method: "POST",
authToken: token,
body: {
registrationId: reg.id
}
});
const tickets = res?.tickets || [];
if (tickets.length) {
printTickets(tickets);
}
setInfo("Payment recorded & tickets printed");
setActiveRegistration(null);
setMode("registration");
} catch (e: any) {
setError(e?.message || "Tickets failed to generate");
}
// Tickets are generated and emailed/WhatsApped automatically server-side
// once a registration reaches "paid" (see paymentController) — no need to
// fetch/print them here, just move straight to checking the attendee in.
setActiveRegistration(reg);
setInfo("Payment recorded — tickets sent. Ready to check in.");
setMode("checkin");
};
const handleDonation = (user?: any) => {
@@ -346,97 +339,6 @@ export default function AtTheDoorPage() {
setShowDonationModal(true);
};
const buildTicketHtmlCard = (t: any) => {
const eventTitle =
t.event?.title ||
t.registrationOption?.registration?.event?.title ||
"Event";
const ticketType = ticketLabel(t);
const qty = t.quantity || 1;
const holder =
t.user?.name ||
t.registrationOption?.registration?.user?.name ||
"";
const qrValue = t.qrCode || t.id;
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrValue)}`;
return `<div class="ticket">
<div class="top">
<div class="evt">${eventTitle}</div>
<div class="type">${ticketType}</div>
${holder ? `<div class="holder">${holder}</div>` : ""}
<div class="qty">Qty: <strong>${qty}</strong></div>
</div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">${t.id}</div>
</div>`;
};
const openPrintWindow = (cardsHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><style>
@page { size: A4; margin: 8mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; background: #fff; }
.page {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
width: 194mm;
height: 281mm;
gap: 4mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket {
border: 1.5px solid #222;
border-radius: 3mm;
padding: 5mm;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
min-height: 0;
}
.top { flex: 0 0 auto; }
.evt { font-weight: bold; font-size: 13pt; line-height: 1.2; }
.type { font-size: 11pt; margin-top: 2mm; color: #333; }
.holder { font-size: 10pt; margin-top: 1mm; color: #555; }
.qty { font-size: 10pt; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; align-items: center; flex: 1 1 auto; padding: 3mm 0; }
.qrwrap img { width: 48mm; height: 48mm; display: block; }
.meta { font-size: 7pt; color: #777; text-align: center; flex: 0 0 auto; word-break: break-all; }
</style></head><body>${cardsHtml}<script>
(function(){
function go(){
var imgs=Array.prototype.slice.call(document.images);
if(!imgs.length){window.print();return;}
var n=imgs.length;
function done(){if(--n<=0)setTimeout(function(){window.print();},150);}
imgs.forEach(function(i){if(i.complete)done();else{i.addEventListener('load',done,{once:true});i.addEventListener('error',done,{once:true});}});
}
if(document.readyState==='complete')go();else window.addEventListener('load',go);
})();
</script></body></html>`);
w.document.close();
};
const printTickets = (tickets: any[]) => {
if (!tickets?.length) return;
// 4 per A4 page (2 columns × 2 rows)
const PAGE_SIZE = 4;
const pages: string[] = [];
for (let i = 0; i < tickets.length; i += PAGE_SIZE) {
const chunk = tickets.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
@@ -482,7 +384,7 @@ export default function AtTheDoorPage() {
{/* Mode Buttons */}
<div className="flex gap-2 mb-4 flex-wrap">
{(["registration", "payment", "tickets", "refund"] as Mode[]).map(m => (
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
<button
key={m}
onClick={() => setMode(m)}
@@ -494,7 +396,7 @@ export default function AtTheDoorPage() {
: "bg-white hover:bg-gray-50"
}`}
>
{m.toUpperCase()}
{MODE_LABELS[m]}
</button>
))}
</div>
@@ -522,6 +424,10 @@ export default function AtTheDoorPage() {
/>
)}
{mode === "checkin" && (
<DoorCheckInPanel token={token} eventId={eventId} registration={activeRegistration} setError={setError} setInfo={setInfo} />
)}
{mode === "tickets" && (
<DoorTicketsPanel token={token} eventId={eventId} />
)}
@@ -1081,6 +987,148 @@ function DoorTicketsPanel({ token, eventId }: any) {
);
}
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const load = async () => {
if (!token || !eventId) return;
try {
setLoading(true);
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
} catch {
setTicketsForEvent([]);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [token, eventId]);
if (!registration) {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm text-sm text-gray-500">
No registration selected
</div>
);
}
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
const setQtyFor = (t: any, n: number) =>
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
const commit = async (ticket: any) => {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<any>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
res?.remaining > 0 ? `${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e: any) {
setError(e?.message || "Check-in failed");
} finally {
setSubmittingId(null);
}
};
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
{/* ✅ User */}
<div className="mb-4">
<div className="text-lg font-semibold">
{registration.user?.name || "Guest"}
</div>
</div>
{loading && (
<div className="text-sm text-gray-500">Loading</div>
)}
{!loading && myTickets.length === 0 && (
<div className="text-sm text-gray-500">
No Main Tickets on this registration
</div>
)}
<div className="space-y-4">
{myTickets.map(t => {
const qty = qtyFor(t);
return (
<div key={t.id} className="border rounded-lg p-4">
<div className="text-sm font-medium mb-3">{ticketLabel(t)}</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="text-lg font-semibold">{t.quantity || 1}</div>
</div>
<div className="border rounded-lg p-3 text-center">
<div className="text-xs text-gray-500">CHECKED IN</div>
<div className="text-lg font-semibold">{t.totalRedeemed}</div>
</div>
<div className="border rounded-lg p-3 text-center bg-emerald-50">
<div className="text-xs text-gray-500">REMAINING</div>
<div className="text-2xl font-bold text-emerald-600">{t.remaining}</div>
</div>
</div>
{t.remaining > 0 ? (
<div className="flex items-center gap-3">
<button
onClick={() => setQtyFor(t, qty - 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold"
>
</button>
<div className="w-10 text-center text-lg font-semibold">{qty}</div>
<button
onClick={() => setQtyFor(t, qty + 1)}
className="w-9 h-9 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700"
>
+
</button>
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
</div>
) : (
<div className="text-sm text-emerald-600 font-medium">Fully checked in </div>
)}
</div>
);
})}
</div>
</div>
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
if (!open) return null;
@@ -75,7 +75,7 @@ function AttendeesCheckboxDropdown({
)}
</button>
{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">
<label className="text-sm flex items-center gap-2">
<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');
}
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const sent = res?.sent ?? 0; const matched = res?.matched ?? 0;
setInfo(`Sent ${sent} out of ${matched} recipient(s).`);
const queued = res?.queued ?? res?.matched ?? 0;
setInfo(`Queued ${queued} recipient(s) for sending.`);
// Reset form to default state
resetAttendeesForm();
} catch (e: any) {
@@ -552,7 +552,7 @@ function EmailAttendeesPageInner() {
</select>
</div>
<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>
@@ -572,9 +572,10 @@ function EmailAttendeesPageInner() {
<li><code>{'{{event.title}}'}</code> the event title.</li>
<li><code>{'{{event.start}}'}</code> the event start date/time (local).</li>
<li><code>{'{{balance}}'}</code> outstanding amount across the attendees 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>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</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="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 <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
</div>
</div>
</div>
@@ -7,6 +7,15 @@ import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
// excluded — the money was already counted via the original donation row.
function isDonationLeg(p: any): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
@@ -439,7 +448,8 @@ function PaymentsContent() {
// Stats
const todayTotals = useMemo(() => {
const start = new Date(); start.setHours(0,0,0,0);
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime());
// Exclude donation-application legs — that money was already counted once, as the donation.
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
const donations = today.filter(p => p.isDonation).length;
return { revenue, donations, count: today.length };
@@ -805,7 +815,7 @@ function PaymentsContent() {
{loadingList && <span className="text-xs text-gray-500">Loading</span>}
</div>
<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 isRefund = amt < 0;
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.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.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
<div className="text-xs text-gray-500">Recorded by: {p.recordedBy.name}</div>
)}
</li>
);
})}
@@ -1025,27 +1038,50 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
[registrations, registrationId]
);
// Donations "relevant to that event" — unassigned donations logged against the same event
// as the chosen registration.
// A donation is never mutated once assigned — each assignment creates a separate "leg"
// Payment row (isDonation:false, originalPaymentId -> the donation). A donation's remaining
// balance is its original amount minus every leg that references it, so it stays offerable
// (and its registrationId stays null forever) until fully used up.
const legsById = useMemo(() => {
const m = new Map<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(() => {
if (!selectedRegistration) return [] as any[];
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
return payments.filter((p: any) => p.isDonation && !p.registrationId && String(p.eventId) === String(eventId));
}, [payments, selectedRegistration]);
return payments.filter((p: any) => {
if (!p.isDonation || p.registrationId) return false;
if (String(p.eventId) !== String(eventId)) return false;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
return remaining > 0.000001;
});
}, [payments, selectedRegistration, legsById]);
const selectedDonation = useMemo(
() => payments.find((p: any) => String(p.id) === String(paymentId)),
[payments, paymentId]
);
const donationRemaining = selectedDonation
? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0)
: 0;
const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 0) : 0;
// The most that can be allocated: never more than the donation itself, never more than
// what's actually owed. Staff can type a smaller amount to leave a balance outstanding.
// The most that can be allocated: never more than the donation's remaining balance, never
// more than what's actually owed. Staff can type a smaller amount to leave a balance owing.
const maxAllocatable = useMemo(() => {
if (!selectedDonation) return 0;
return Math.min(selectedDonation.amount || 0, outstanding);
}, [selectedDonation, outstanding]);
return Math.min(donationRemaining, outstanding);
}, [selectedDonation, donationRemaining, outstanding]);
// Default to "apply as much as needed" whenever a new donation is picked — the common
// case needs no typing, but the field stays editable for a deliberate partial allocation.
@@ -1055,7 +1091,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [paymentId]);
const amountNum = parseFloat(amountStr || "0");
const leftover = selectedDonation ? Math.max(0, (selectedDonation.amount || 0) - amountNum) : 0;
const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0;
const assign = async () => {
if (!token) return;
@@ -1110,12 +1146,13 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
>
<option value="">Select donation</option>
{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>;
})}
</select>
{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 && (
@@ -1131,8 +1168,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
onChange={e => setAmountStr(e.target.value)}
/>
<div className="text-xs text-gray-500">
Donation is R {(selectedDonation.amount || 0).toFixed(2)}; outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay unassigned as a donation.</>}
Donation has R {donationRemaining.toFixed(2)} remaining (of R {(selectedDonation.amount || 0).toFixed(2)} total); outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.</>}
</div>
</>
)}
@@ -7,13 +7,8 @@ import ReportsV2 from "@/components/reports/ReportsV2";
export default function SupervisorReportsPage() {
const router = useRouter();
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<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 className="w-full p-6">
<ReportsV2 onBack={() => router.push('/dashboard')} />
</div>
);
}
@@ -86,7 +86,7 @@ function AttendeesCheckboxDropdown({
)}
</button>
{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">
<label className="text-sm flex items-center gap-2">
<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; }
setSending(true);
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
};
@@ -655,7 +655,7 @@ function WhatsAppAttendeesPageInner() {
</div>
<div className="sm:col-span-2">
<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>
@@ -676,6 +676,7 @@ function WhatsAppAttendeesPageInner() {
<li><code>{"{{event.title}}"}</code> the event title.</li>
<li><code>{"{{event.start}}"}</code> the event start date/time.</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>
<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,{" "}
+268 -73
View File
@@ -85,6 +85,43 @@ function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
}
function prefLabel(pref: "email" | "whatsapp" | "both") {
return pref === "whatsapp" ? "WhatsApp" : pref === "both" ? "Email & WhatsApp" : "Email";
}
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
function looksLikePhone(s: string): boolean {
const digits = s.replace(/\D/g, "");
return digits.length >= 9 && digits.length <= 11;
}
// Show/hide toggle rendered inside a password input — absolutely positioned on its
// right edge, so callers must wrap the input in a `relative` container and add
// enough right padding (`pr-12`) for it not to overlap the typed text.
function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
return (
<button
type="button"
onClick={onToggle}
tabIndex={-1}
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
aria-label={shown ? "Hide password" : "Show password"}
>
{shown ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c1.563 0 3.042-.34 4.377-.955M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)}
</button>
);
}
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup");
@@ -92,6 +129,7 @@ export default function SelfServicePage() {
// ── Setup state ─────────────────────────────────────────────────
const [setupEmail, setSetupEmail] = useState("");
const [setupPassword, setSetupPassword] = useState("");
const [showSetupPassword, setShowSetupPassword] = useState(false);
const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
@@ -102,6 +140,7 @@ export default function SelfServicePage() {
// ── Change-event modal ───────────────────────────────────────────
const [showChangeModal, setShowChangeModal] = useState(false);
const [changePassword, setChangePassword] = useState("");
const [showChangePassword, setShowChangePassword] = useState(false);
const [changeError, setChangeError] = useState<string | null>(null);
const [changeLoading, setChangeLoading] = useState(false);
@@ -112,9 +151,23 @@ export default function SelfServicePage() {
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState("");
const [accountExists, setAccountExists] = useState(false);
const [checkingAccount, setCheckingAccount] = useState(false);
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (automatic, triggered when email/phone is entered) ──
// Email and phone are resolved to an existing account independently. If they
// resolve to the SAME account, that account is "matched". If they resolve to
// two DIFFERENT accounts, that's a conflict — surfaced to the operator instead
// of silently overwriting one field's details with the other's account.
type MatchedAccount = { id: string; name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" };
const [emailMatch, setEmailMatch] = useState<MatchedAccount | null>(null);
const [phoneMatch, setPhoneMatch] = useState<MatchedAccount | null>(null);
const accountConflict = !!(emailMatch && phoneMatch && emailMatch.id !== phoneMatch.id);
const matchedAccount = accountConflict ? null : (emailMatch || phoneMatch);
const accountExists = !!matchedAccount;
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [showUpdateConfirm, setShowUpdateConfirm] = useState(false);
const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
@@ -175,12 +228,39 @@ export default function SelfServicePage() {
}, 0);
}, [selectedEvent, quantities]);
// Differences between what's on file for the matched account and what's currently
// typed in the form — shown to the operator for confirmation before anything is saved.
const pendingChanges = useMemo(() => {
if (!matchedAccount) return [];
const changes: { field: string; from: string; to: string }[] = [];
const name = visitorName.trim();
const email = visitorEmail.trim();
const phone = visitorPhone.trim();
if (name && name !== matchedAccount.name) {
changes.push({ field: "Name", from: matchedAccount.name, to: name });
}
if (email && email !== (matchedAccount.email || "")) {
changes.push({ field: "Email", from: matchedAccount.email || "(none on file)", to: email });
}
if (phone && phone !== (matchedAccount.phoneNumber || "")) {
changes.push({ field: "Phone", from: matchedAccount.phoneNumber || "(none on file)", to: phone });
}
if (notificationPref !== matchedAccount.notificationPreference) {
changes.push({
field: "Send tickets via",
from: prefLabel(matchedAccount.notificationPreference),
to: prefLabel(notificationPref),
});
}
return changes;
}, [matchedAccount, visitorName, visitorEmail, visitorPhone, notificationPref]);
// ─── Load events after supervisor login ─────────────────────────
const loadEvents = useCallback(async (token: string) => {
setEventsLoading(true);
try {
const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false",
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token }
);
setEvents(data);
@@ -262,7 +342,9 @@ export default function SelfServicePage() {
}
// ─── Visitor registration ────────────────────────────────────────
async function handleRegister(e: React.FormEvent) {
// Validates the form and, if the typed name/email/phone differ from the matched
// account's details, shows a confirmation modal before anything is saved.
function handleRegisterSubmit(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
@@ -277,7 +359,18 @@ export default function SelfServicePage() {
setFormError("No event selected.");
return;
}
if (accountConflict) {
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
return;
}
if (pendingChanges.length > 0) {
setShowUpdateConfirm(true);
return;
}
handleRegister();
}
async function handleRegister() {
setFormLoading(true);
try {
const payload: any = {
@@ -415,7 +508,12 @@ export default function SelfServicePage() {
setNotificationPref("email");
setCreateAccount(false);
setVisitorPassword("");
setAccountExists(false);
setShowVisitorPassword(false);
setEmailMatch(null);
setPhoneMatch(null);
appliedMatchIdRef.current = null;
setShowUpdateConfirm(false);
setLookupMessage(null);
setFormError(null);
setCurrentRegistrationId(null);
setCurrentUserId(null);
@@ -431,33 +529,67 @@ export default function SelfServicePage() {
};
}, []);
// ─── Check whether an account already exists for the entered email/phone ──
useEffect(() => {
const email = visitorEmail.trim();
const phone = visitorPhone.trim();
if (!supervisorToken || (!email && !phone)) {
setAccountExists(false);
return;
}
const handle = setTimeout(async () => {
setCheckingAccount(true);
try {
const params = new URLSearchParams();
if (email) params.set("email", email);
if (phone) params.set("phone", phone);
const data = await apiFetch<{ exists: boolean }>(
`/api/users/check-exists?${params.toString()}`,
{ authToken: supervisorToken }
);
setAccountExists(!!data?.exists);
} catch {
setAccountExists(false);
} finally {
setCheckingAccount(false);
// ─── Automatic account lookup — runs once the operator finishes entering the
// email or phone field (on blur), never on keystroke. Matches exactly against
// that one value and only ever returns that one matched account (or nothing) —
// never a broader/fuzzy match. Email and phone are tracked as two independent
// matches (emailMatch/phoneMatch, above) rather than being merged into a single
// "last found account" — that's what previously let changing the phone number
// silently pull in and overwrite the form with an unrelated account's details. ──
async function performLookup(kind: "email" | "phone", rawValue: string) {
const value = rawValue.trim();
const setMatch = kind === "email" ? setEmailMatch : setPhoneMatch;
if (!value || !supervisorToken) { setMatch(null); return; }
if (kind === "email" ? !value.includes("@") : !looksLikePhone(value)) { setMatch(null); return; }
setLookupLoading(true);
setLookupMessage(null);
try {
const params = new URLSearchParams();
if (kind === "email") params.set("email", value);
else params.set("phone", value);
const data = await apiFetch<{
exists: boolean;
user?: MatchedAccount | null;
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
if (data?.exists && data.user) {
setMatch(data.user);
setLookupMessage("Account found — details filled in below.");
} else {
setMatch(null);
setLookupMessage(null);
}
}, 400);
return () => clearTimeout(handle);
}, [visitorEmail, visitorPhone, supervisorToken]);
} catch {
setLookupMessage("Lookup failed. Please try again.");
} finally {
setLookupLoading(false);
}
}
function handleEmailBlur() {
performLookup("email", visitorEmail);
}
function handlePhoneBlur() {
performLookup("phone", visitorPhone);
}
// Autofill Name/Email/Phone/preference from the matched account — but only once per
// distinct account id. Re-focusing elsewhere on the page (e.g. tapping a ticket
// quantity button) blurs whatever field was last active and re-runs its lookup;
// that returns the same account as a new object each time, and keying this off
// object identity instead of id made it re-fire and stomp every field — including
// ones the operator had already deliberately edited — back to the account's
// original values on every unrelated tap.
const appliedMatchIdRef = useRef<string | null>(null);
useEffect(() => {
if (!matchedAccount || appliedMatchIdRef.current === matchedAccount.id) return;
appliedMatchIdRef.current = matchedAccount.id;
setVisitorName(matchedAccount.name);
if (matchedAccount.email) setVisitorEmail(matchedAccount.email);
if (matchedAccount.phoneNumber) setVisitorPhone(matchedAccount.phoneNumber);
setNotificationPref(matchedAccount.notificationPreference);
}, [matchedAccount]);
// Existing accounts are linked automatically — never show the "create account" toggle for them
useEffect(() => {
@@ -494,15 +626,18 @@ export default function SelfServicePage() {
<form onSubmit={handleChangeEvent} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your password"
autoFocus
required
/>
<div className="relative">
<input
type={showChangePassword ? "text" : "password"}
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your password"
autoFocus
required
/>
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
</div>
</div>
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
<div className="flex gap-3 pt-1">
@@ -551,15 +686,18 @@ export default function SelfServicePage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
required
autoComplete="current-password"
/>
<div className="relative">
<input
type={showSetupPassword ? "text" : "password"}
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="••••••••"
required
autoComplete="current-password"
/>
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
</div>
</div>
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
<button
@@ -628,18 +766,7 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div>
<form onSubmit={handleRegister} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
<input
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Smith"
required
/>
</div>
<form onSubmit={handleRegisterSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
@@ -656,6 +783,7 @@ export default function SelfServicePage() {
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
}}
onBlur={handleEmailBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="john@example.com"
/>
@@ -675,11 +803,40 @@ export default function SelfServicePage() {
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
}}
onBlur={handlePhoneBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="+27 82 000 0000"
/>
</div>
{lookupLoading && (
<p className="text-sm text-gray-500 -mt-2">Checking for an existing account</p>
)}
{!lookupLoading && accountConflict && emailMatch && phoneMatch && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm -mt-2">
This email matches an existing account for <strong>{emailMatch.name}</strong>, but this phone number
matches a different existing account for <strong>{phoneMatch.name}</strong>. Please check and correct
one of these fields before continuing.
</div>
)}
{!lookupLoading && !accountConflict && lookupMessage && (
<p className={`text-sm -mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
<input
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Smith"
required
/>
</div>
{/* Preference selector — only shown when both channels are available */}
{visitorEmail.trim() && visitorPhone.trim() && (
<div>
@@ -808,22 +965,23 @@ export default function SelfServicePage() {
</div>
<div>
<p className="font-medium text-gray-800">Create an account</p>
<p className="text-sm text-gray-500">
{checkingAccount ? "Checking for an existing account…" : "Save your details for future events"}
</p>
<p className="text-sm text-gray-500">Save your details for future events</p>
</div>
</label>
{createAccount && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
<input
type="password"
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Min. 6 characters"
autoComplete="new-password"
/>
<div className="relative">
<input
type={showVisitorPassword ? "text" : "password"}
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Min. 6 characters"
autoComplete="new-password"
/>
<PasswordToggleButton shown={showVisitorPassword} onToggle={() => setShowVisitorPassword((v) => !v)} />
</div>
</div>
)}
</div>
@@ -837,7 +995,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={formLoading}
disabled={formLoading || accountConflict}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
>
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
@@ -847,6 +1005,43 @@ export default function SelfServicePage() {
</div>
)}
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
{showUpdateConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
<p className="text-sm text-gray-500 mb-5">
These details differ from what&apos;s on file for this account. Confirm to update them.
</p>
<div className="space-y-3 mb-6">
{pendingChanges.map((c) => (
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
<p className="text-sm text-gray-400 line-through">{c.from}</p>
<p className="text-sm text-green-700 font-medium">{c.to}</p>
</div>
))}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowUpdateConfirm(false)}
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-3 text-base font-medium hover:bg-gray-50 transition"
>
Cancel
</button>
<button
type="button"
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
>
Confirm &amp; Update
</button>
</div>
</div>
</div>
)}
{/* ── Event Form Screen ──────────────────────────────────────── */}
{screen === "eventform" && selectedEvent?.form && (
<div className="flex-1 flex items-center justify-center p-6">
@@ -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,81 @@
"use client";
import React from "react";
import { X, Info, RefreshCw, type LucideIcon } from "lucide-react";
export default function ReportViewerModal({
title, description, icon: Icon, onClose, actions, filters, onRefresh, busy, children,
}: {
title: string;
description?: string;
icon?: LucideIcon;
onClose: () => void;
actions?: React.ReactNode;
filters?: React.ReactNode;
onRefresh?: () => void;
busy?: boolean;
children: React.ReactNode;
}) {
return (
// Above the site header (Navbar is `sticky top-0 z-50`) so the popup never sits behind it.
<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="flex items-start justify-between gap-4 px-5 py-4 border-b">
<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="flex items-center gap-2 shrink-0">
{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>
</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&apos;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 &quot;Refresh&quot; 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&apos;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&apos;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&apos;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&apos;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&apos;s &quot;settled&quot; on the order, but it&apos;s the donor&apos;s money, not the registrant&apos;s so it&apos;s broken out separately and attributed to the donor elsewhere in the report.
</GuideItem>
<GuideItem icon={Clock} title="Outstanding" tone="amber">
What&apos;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&apos;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&apos;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&apos;t look right, it&apos;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&apos;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,205 @@
"use client";
import React, { useMemo, useState } from "react";
import { ArrowLeft, HelpCircle, Search } from "lucide-react";
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
import EventsDropdown from "./EventsDropdown";
type EventLite = { id: string; title: string };
type DatePreset = "all_time" | "this_month" | "last_month" | "this_year" | "custom";
export default function ReportsShell({
events, isAdmin,
showPastEvents, setShowPastEvents,
showInactiveEvents, setShowInactiveEvents,
showClosedEvents, setShowClosedEvents,
selectedEventIds, setSelectedEventIds,
dateFrom, setDateFrom, dateTo, setDateTo,
report, setReport,
onViewReport, busy,
onOpenGuide,
onBack,
}: {
events: EventLite[]; isAdmin: boolean;
showPastEvents: boolean; setShowPastEvents: (v: boolean) => void;
showInactiveEvents: boolean; setShowInactiveEvents: (v: boolean) => void;
showClosedEvents: boolean; setShowClosedEvents: (v: boolean) => void;
selectedEventIds: string[]; setSelectedEventIds: (v: string[]) => void;
dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void;
report: ReportKey; setReport: (r: ReportKey) => void;
onViewReport: () => void; busy: boolean;
onOpenGuide: () => void;
onBack?: () => void;
}) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<"All" | ReportCategory>("All");
const [datePreset, setDatePreset] = useState<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 items-start justify-between gap-4 mb-6">
<div>
<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">
<div className="relative">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="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="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="flex gap-2">
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
</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>
);
}
+81 -1
View File
@@ -31,13 +31,26 @@ function valueToString(v: any): string {
return String(v);
}
// New: server-side PDF generation and email helpers
// New: server-side PDF/Excel generation and email helpers
export type ReportStat = { label: string; value: string; tone?: 'green' | 'blue' | 'violet' | 'amber' | 'rose' | 'gray' };
export type ReportChartDatum = { label: string; value: number; displayValue?: string };
export type ReportPdfPayload = {
title: string;
subtitle?: string;
kind: 'table' | 'layered';
orientation?: 'portrait' | 'landscape';
// Optional visual sections rendered above the table/layered body — mirrors the on-screen
// report's stat tiles / bar chart / explanatory note, so exported PDF/Excel/email/WhatsApp
// all look like the same report the web UI shows, not a plain data dump.
stats?: ReportStat[];
chart?: { title?: string; data: ReportChartDatum[] };
note?: string;
table?: { columns: string[]; rows: (string | number)[][] };
layered?: { header?: string; sections: { title: string; items: string[] }[] };
// Additional titled tables rendered below the main table/layered body — e.g. Master Orders'
// separate "Donations made" breakdown, which isn't part of the Orders table itself.
extraTables?: { title: string; columns: string[]; rows: (string | number)[][] }[];
};
export async function downloadReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) {
@@ -64,6 +77,32 @@ export async function downloadReportPdf(apiBase: string, authToken: string, payl
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
}
// Downloads a styled .xlsx mirroring the same branded look as the PDF (colored header,
// stat rows, a data-bar chart, banded table with a highlighted total row).
export async function downloadReportExcel(apiBase: string, authToken: string, payload: ReportPdfPayload) {
const url = `${apiBase}/api/reports/excel`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to generate Excel file (${res.status})`);
}
const blob = await res.blob();
const dl = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
dl.href = objUrl;
const safe = (payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase();
dl.download = `${safe}.xlsx`;
dl.click();
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
}
export async function emailReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { subject?: string; body?: string }) {
const url = `${apiBase}/api/reports/email`;
const res = await fetch(url, {
@@ -81,6 +120,47 @@ export async function emailReportPdf(apiBase: string, authToken: string, payload
return res.json();
}
// Sends the report PDF to the current user's own WhatsApp number (same self-service pattern as
// emailReportPdf — no recipient picker needed).
export async function whatsappReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { caption?: string }) {
const url = `${apiBase}/api/reports/whatsapp`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to send PDF via WhatsApp (${res.status})`);
}
return res.json();
}
// "Print": fetches the same PDF as downloadReportPdf but opens it in a new tab instead of
// downloading, so the browser's built-in PDF viewer's print button handles printing.
export async function viewReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) {
const url = `${apiBase}/api/reports/pdf`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to generate PDF (${res.status})`);
}
const blob = await res.blob();
const objUrl = URL.createObjectURL(blob);
window.open(objUrl, '_blank');
setTimeout(() => URL.revokeObjectURL(objUrl), 60000);
}
// Legacy (used elsewhere). Kept in case other code paths still rely on print flow.
export function openPrintWindow(title: string, htmlContent: string) {
const w = window.open('', '_blank');
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.1.0",
"version": "1.4.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
@@ -8,7 +8,8 @@
"start:backend": "cd backend && npm run start",
"start:frontend": "cd frontend && npm run start",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\""
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
},
"keywords": [],
"author": "",