Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d74fec3a5c | ||
|
|
4bee1b24f7 | ||
|
|
70605923bf | ||
|
|
424141f216 | ||
|
|
d6ea4c37d7 | ||
|
|
ebf521e64e | ||
|
|
e5fbcc2222 | ||
|
|
1b4446c828 | ||
|
|
0de3f4be7d |
+63
-1
@@ -7,6 +7,66 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.2] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Supervisor Payments page: the mode tabs (Payment/Refund/Donations/Reconcile/Payment Link) now wrap onto multiple lines on mobile instead of overflowing off-screen and becoming unreachable.
|
||||
- Reports: the payments report and its "by method" breakdown/chart now bucket into the same 4 categories used everywhere else (Cash, Card, EFT, Other) instead of showing raw method strings — Apple Pay, Google Pay, and Yoco checkout-portal payments now fold into "Card".
|
||||
- Cashup/Finance/Profit reports: refunds were being dropped entirely from the per-method breakdown (`paymentsByMethod`) instead of netting against the method they were refunded against, so e.g. a card refund silently vanished instead of reducing the "Card" total — the per-method figures now correctly sum back to total revenue.
|
||||
- Refund method now nets against the correct bucket everywhere a payment's method is normalized for display/filtering (My Payments page, reports) — `card-refund` etc. was falling through to "Other" instead of being recognized as a refund of its base method.
|
||||
- Supervisor Payments page: the Refund form's method dropdown now mirrors the actual payment methods (Cash/Card/EFT/Voucher refund) instead of offering an ambiguous generic "Refund" option that couldn't be attributed to any method bucket; it now auto-fills from the original payment's method when refunding a specific payment.
|
||||
- Donations: refunding a donation (fully or partially) was inflating its remaining/unallocated balance by the refunded amount instead of reducing it, since the refund's negative amount was subtracted straight into the balance (subtracting a negative adds). Could let staff over-allocate a donation that had actually shrunk. Fixed in the donation-assignment leg totals used by the assign-donation endpoint, the Cashup/Finance/Profit reports' unallocated-donations figure, the Donations and Master Orders reports' Used/Unused breakdown, and the "Assign donation" panel on the Supervisor Payments page.
|
||||
|
||||
### Added
|
||||
|
||||
- Supervisor Payments page: reconciling a Yoco transaction as a donation now lets staff optionally pick who the donation is from, instead of it always being attributed to whatever the checkout metadata (or the reconciling staff member) happened to resolve to.
|
||||
|
||||
## [1.4.1] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reports: on mobile, the report popup's close button now sits pinned beside the title instead of getting cut off inline with the Print/Email/Excel/WhatsApp buttons, which now have their own wrapping row below.
|
||||
- Reports: on mobile, the page header's search box and Back button no longer run off-screen — they now sit on their own row and shrink to fit.
|
||||
- Reports: the sidebar's custom date range inputs (From/To) are now stacked instead of side-by-side, fixing them overflowing outside the filter box on desktop.
|
||||
|
||||
## [1.4.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- Admin Manage Users page: notification preference (Email/WhatsApp/Both) can now be viewed and edited directly from the user list, instead of only being self-editable from the user's own profile.
|
||||
- Admin Registrations dashboard: added aggregate stat tiles (counts per status, total revenue, total outstanding) and per-registration paid/outstanding amounts, plus a "Payments" detail block per registration showing each payment's amount, method, date, and who recorded it.
|
||||
- Reports: registration status breakdown now has a "Count by" toggle to switch between counting one per registration and counting by ticket quantity (so a registration with 3 tickets counts as 3).
|
||||
- Reports: donations breakdown now shows Used/Unused amounts per event, reflecting the new donation-leg tracking below.
|
||||
- Payment accountability: payments now record who recorded them (`recordedBy`), separate from who they're for. Self-service/webhook payments record the payer as the recorder. Surfaced across the payments report, supervisor payments page, and the registrations dashboard.
|
||||
- Cashup: new "Payment accountability by staff member" section breaking down recorded payments per staff member for an event, by method (Cash/Card/EFT/Other) — cash also shows a live actual-vs-expected variance once staff enter physical denomination counts per person.
|
||||
- Cashup: staff can now enter each other's actual cash denomination counts at any time (not required to close the event); the event's cash "actual" figure is the live sum of these per-person counts instead of one manual event-wide entry.
|
||||
- Cashup: new "Report" tab presenting a clean read-only summary of the cashup, which opens automatically once an event is closed.
|
||||
- Reports: complete redesign — a sidebar of universal filters (events, date range with presets, include past/inactive/closed events) that apply across whichever report is open, a searchable/categorized report card grid, and a popup viewer with report-specific filters and Print/Email/Excel/WhatsApp export actions. Includes a new in-app Reporting Guide.
|
||||
- Reports: WhatsApp added as an export channel alongside Print, Email, and Excel — sends the report PDF to the current user's own WhatsApp number.
|
||||
- Reports: visual pass on the report popup — colored stat tiles for key totals (Order Total, Paid, Paid via donations, Outstanding, Unassigned donations, etc.) and small bar charts (revenue by method, registration status, ticket usage, donations used/unused, registration types, income by method, net profit by event) added to most reports, plus search boxes on the Master Orders' Orders/Donations tables. The Reports page also hides the dashboard sidebar since it's a full-width workspace of its own.
|
||||
- Reports: Master Orders Breakdown moved from "Orders" into the "Registration" category.
|
||||
- Reports: the Reporting Guide's non-Overview tabs now use the same icon-card layout as Overview instead of plain bullet lists; "Need more help?" now points to the site administrator's email (admin@crosscode.co.za).
|
||||
- Email/WhatsApp Attendees: new `{{payment.link}}` placeholder that generates a live, per-recipient Yoco payment link for their outstanding balance at send time.
|
||||
- Reports: PDF exports (download, Print, Email, WhatsApp) now share a branded design matching the web report — a colored title band, the same colored stat tiles and bar chart shown on screen (where applicable), an explanatory note box, and a highlighted totals row in tables — instead of a plain black-and-white dump. Since Email and WhatsApp already reuse the same PDF renderer, both now send this styled PDF automatically.
|
||||
- Reports: the "Excel" export now downloads a styled `.xlsx` workbook (colored header, stat rows, a chart rendered with a native data-bar, a bold colored table header, a highlighted totals row, and frozen header/auto-sized columns) instead of a plain CSV.
|
||||
|
||||
### Changed
|
||||
|
||||
- Donations are no longer mutated when assigned to a registration — assignment now creates an immutable "leg" record referencing the original donation, so a partially-used donation keeps its original amount and remains assignable for its remaining balance instead of losing its history.
|
||||
- Email Attendees / WhatsApp Attendees now respond immediately after queuing recipients and send in the background, instead of blocking the page until every message has been sent.
|
||||
- Dashboard: the sidebar (My Events, Profile, Admin, etc.) is now hidden on every sub-page under Staff, Supervisor, and Admin — not just Reports — so those pages get the full width for their own content and navigation. It's still shown on each role's root landing page (`/dashboard/staff`, `/dashboard/supervisor`, `/dashboard/admin`); the top navbar's "Dashboard" link always leads back there.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reports/Cashup: fixed several places where money was double-counted once a donation was partially or fully assigned to a registration (e.g. a R250 donation with R50 assigned was showing as R300 received). Payments between dates, Revenue summary, Cashup reconciliation, Finance report, and Profit report now count each real inflow exactly once.
|
||||
- Reports: Revenue Detailed and Master Orders Breakdown no longer attribute a donation-funded portion of an order to the registrant as if they'd paid it themselves — "Paid" now reflects only what the person actually paid directly, with the donation-covered amount broken out separately and attributed to the donor.
|
||||
- Reports: Finance report was double-counting a registration's ticket value once under "what was sold" and again under a separate "Donations" line when the order was funded (even partially) by a donation.
|
||||
- Reports: Master Orders Breakdown's "Donations made" table (donor, amount, used/unused) is now included in the PDF, Excel, Email, and WhatsApp exports — previously only the Orders table was exported and the donations breakdown was visible on screen only. Also fixed a PDF rendering bug where a table title following another table (e.g. "Donations made" below the Orders table) could render at the page's right edge instead of the left margin.
|
||||
- Reports: "Email" export failed with `connect ECONNREFUSED 127.0.0.1:587` — it built its own mail transporter directly from `EMAIL_HOST`/etc. env vars instead of using the shared, DB-configurable SMTP settings (Admin → Site Settings) that the rest of the app already sends through, so it never picked up a working mail server. Now reuses the same shared mailer as tickets and account emails, with matching branded HTML styling.
|
||||
- Reports: "WhatsApp" export surfaced an unhelpful `Request failed with status code 500` on failure. WhatsApp send errors now report the actual reason from the WhatsApp API, and a disconnected WhatsApp session is now detected and auto-recovered the same way it already is for other WhatsApp actions (previously only ticket/text sends had this handling — PDF sends did not).
|
||||
- Dashboard mobile: the Reports page's Events filter dropdown and the Email/WhatsApp Attendees recipient-picker dropdown could extend past the right edge of narrow screens instead of staying within the viewport; they now stretch to match their trigger's width like the other dropdowns in the app already do. The Cashup event-costs table on the Admin Cashup detail page now scrolls horizontally on narrow screens instead of squeezing its columns.
|
||||
|
||||
## [1.3.2] - 2026-08-03
|
||||
|
||||
### Added
|
||||
@@ -93,7 +153,9 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
|
||||
|
||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...main
|
||||
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.4.0...main
|
||||
[1.4.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.2...v1.4.0
|
||||
[1.3.2]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...v1.3.2
|
||||
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
|
||||
[1.3.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
|
||||
|
||||
Generated
+841
-7
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.2",
|
||||
"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;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "recordedById" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_recordedById_idx" ON "Payment"("recordedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_originalPaymentId_idx" ON "Payment"("originalPaymentId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupPersonCount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"enteredById" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventCashupPersonCount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupPersonCountDenomination" (
|
||||
"id" TEXT NOT NULL,
|
||||
"countId" TEXT NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "EventCashupPersonCountDenomination_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupPersonCount_eventId_idx" ON "EventCashupPersonCount"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EventCashupPersonCount_eventId_userId_key" ON "EventCashupPersonCount"("eventId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupPersonCountDenomination_countId_idx" ON "EventCashupPersonCountDenomination"("countId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_enteredById_fkey" FOREIGN KEY ("enteredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPersonCountDenomination" ADD CONSTRAINT "EventCashupPersonCountDenomination_countId_fkey" FOREIGN KEY ("countId") REFERENCES "EventCashupPersonCount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -63,6 +63,7 @@ model User {
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
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 {
|
||||
|
||||
@@ -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 });
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1226,6 +1226,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
|
||||
const { sendMail } = require('../utils/email');
|
||||
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
||||
const { replacePlaceholders } = require('../utils/placeholders');
|
||||
|
||||
function fmtAmount(amt) {
|
||||
const n = Number(amt || 0);
|
||||
@@ -1234,17 +1235,6 @@ const emailEventAttendees = async (req, res) => {
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
|
||||
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
|
||||
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
|
||||
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
|
||||
}
|
||||
|
||||
// Build per-recipient registration aggregates for this event
|
||||
const regsByEmail = new Map();
|
||||
@@ -1255,42 +1245,66 @@ const emailEventAttendees = async (req, res) => {
|
||||
regsByEmail.get(em).push(r);
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
// Special handling for tickets template: trigger ticket emails containing attachments
|
||||
// Generates a live Yoco checkout link for the recipient's first registration with an
|
||||
// outstanding balance — only called when the template actually uses {{payment.link}}, to
|
||||
// avoid an unnecessary Yoco API call per recipient otherwise.
|
||||
function makePaymentLinkResolver(regs) {
|
||||
return async () => {
|
||||
const reg = (regs || []).find(r => {
|
||||
const due = computeRegistrationTotalDue(r, new Date());
|
||||
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
return due - paid > 0.01;
|
||||
});
|
||||
if (!reg) return '';
|
||||
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
||||
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
return result.redirectUrl;
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
||||
// Large recipient lists used to block the request until every email was sent — now the
|
||||
// caller gets an instant response and failures are just logged server-side.
|
||||
if (template === 'tickets') {
|
||||
const { emailTickets } = require('./ticketController');
|
||||
for (const rcpt of recipients) {
|
||||
try {
|
||||
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
||||
(async () => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const regs = regsByEmail.get(rcpt.email) || [];
|
||||
// Send tickets for each registration that belongs to this recipient for this event
|
||||
for (const reg of regs) {
|
||||
// Use the controller helper as in other parts of the code
|
||||
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
}
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const eventTitle = event?.title || 'the event';
|
||||
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
|
||||
// Build event and promo links for placeholders
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
||||
const eventLinkHtml = `<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;
|
||||
@@ -1300,6 +1314,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
totalDue += due; totalPaid += paid;
|
||||
}
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const paymentLinkResolver = makePaymentLinkResolver(regs);
|
||||
// Build context for placeholder replacement
|
||||
const ctxBase = {
|
||||
name: rcpt.name || '',
|
||||
@@ -1310,6 +1325,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
promoLink,
|
||||
balance,
|
||||
balanceFmt: fmtAmount(balance),
|
||||
paymentLinkResolver,
|
||||
};
|
||||
const ctxHtml = {
|
||||
...ctxBase,
|
||||
@@ -1347,18 +1363,18 @@ const emailEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
// Always perform placeholder replacement on whatever we have
|
||||
const finalSubject = replacePlaceholders(subj || '', ctxBase);
|
||||
const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined;
|
||||
const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined);
|
||||
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
||||
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
|
||||
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
|
||||
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -1445,6 +1461,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
|
||||
const { sendText } = require('../utils/whatsapp');
|
||||
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
||||
const { replacePlaceholders } = require('../utils/placeholders');
|
||||
|
||||
function fmtAmount(amt) {
|
||||
const n = Number(amt || 0);
|
||||
@@ -1453,15 +1470,6 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
}
|
||||
function replacePlaceholders(str, ctx) {
|
||||
if (!str) return str;
|
||||
return String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '')
|
||||
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
|
||||
}
|
||||
|
||||
const regsByPhone = new Map();
|
||||
for (const r of registrations) {
|
||||
@@ -1476,29 +1484,52 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
||||
|
||||
let sent = 0;
|
||||
// Generates a live Yoco checkout link for the recipient's first registration with an
|
||||
// outstanding balance — only called when the template actually uses {{payment.link}}.
|
||||
function makePaymentLinkResolver(regs) {
|
||||
return async () => {
|
||||
const reg = (regs || []).find(r => {
|
||||
const due = computeRegistrationTotalDue(r, new Date());
|
||||
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
return due - paid > 0.01;
|
||||
});
|
||||
if (!reg) return '';
|
||||
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
||||
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
return result.redirectUrl;
|
||||
};
|
||||
}
|
||||
|
||||
// Special handling for tickets template: send ticket PDFs via WhatsApp
|
||||
// Fire-and-forget: respond immediately with a queued count, then send in the background.
|
||||
if (template === 'tickets') {
|
||||
const { emailTickets } = require('./ticketController');
|
||||
for (const rcpt of recipients) {
|
||||
try {
|
||||
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
|
||||
(async () => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const regs = regsByPhone.get(rcpt.phone) || [];
|
||||
for (const reg of regs) {
|
||||
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
}
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rcpt of recipients) {
|
||||
try {
|
||||
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
|
||||
|
||||
(async () => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const regs = regsByPhone.get(rcpt.phone) || [];
|
||||
let totalDue = 0; let totalPaid = 0;
|
||||
for (const r of regs) {
|
||||
@@ -1514,6 +1545,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
eventLink,
|
||||
balance,
|
||||
balanceFmt: fmtAmount(balance),
|
||||
paymentLinkResolver: makePaymentLinkResolver(regs),
|
||||
};
|
||||
|
||||
let msg = message;
|
||||
@@ -1523,15 +1555,15 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
|
||||
}
|
||||
|
||||
const finalMessage = replacePlaceholders(msg || '', ctx);
|
||||
const finalMessage = await replacePlaceholders(msg || '', ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
sent++;
|
||||
} catch (e) {
|
||||
try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
|
||||
}
|
||||
});
|
||||
})();
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -357,8 +365,16 @@ const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
|
||||
const CARD_ALIASES = ['apple_pay', 'google_pay'];
|
||||
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
|
||||
|
||||
function normalizeUserMethod(method) {
|
||||
// Refund methods are recorded as "<method>-refund" (e.g. "card-refund") so a refund nets
|
||||
// against the same bucket its original payment counted under. Strip that suffix before
|
||||
// bucketing so a card refund still displays/filters as "card", not "other".
|
||||
function stripRefundSuffix(method) {
|
||||
const m = String(method || '').toLowerCase();
|
||||
return m.endsWith('-refund') ? m.slice(0, -'-refund'.length) : m;
|
||||
}
|
||||
|
||||
function normalizeUserMethod(method) {
|
||||
const m = stripRefundSuffix(method);
|
||||
if (USER_FACING_METHODS.includes(m)) return m;
|
||||
if (CARD_ALIASES.includes(m)) return 'card';
|
||||
return 'other';
|
||||
@@ -386,18 +402,21 @@ const getUserPayments = async (req, res) => {
|
||||
if (req.query.method) {
|
||||
const requested = String(req.query.method).toLowerCase();
|
||||
if (requested === 'card') {
|
||||
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
|
||||
// settlement as a card payment, no separate float to reconcile.
|
||||
// "Card" also covers card-network wallet types (apple_pay, google_pay) and card
|
||||
// refunds — same settlement as a card payment, no separate float to reconcile.
|
||||
where.AND = [{
|
||||
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||
OR: ['card', 'card-refund', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||
}];
|
||||
} else if (requested === 'other') {
|
||||
// "Other" covers every method that isn't one of the recognized buckets above.
|
||||
// "Other" covers every method (and its refund variant) that isn't one of the
|
||||
// recognized buckets above.
|
||||
where.NOT = {
|
||||
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||
OR: KNOWN_METHODS.flatMap(m => [m, `${m}-refund`]).map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||
};
|
||||
} else if (USER_FACING_METHODS.includes(requested)) {
|
||||
where.method = { equals: requested, mode: 'insensitive' };
|
||||
where.AND = [{
|
||||
OR: [requested, `${requested}-refund`].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +471,13 @@ const getPaymentById = async (req, res) => {
|
||||
email: true
|
||||
}
|
||||
},
|
||||
recordedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
registration: {
|
||||
include: {
|
||||
event: true
|
||||
@@ -507,6 +533,13 @@ const getPaymentsByRegistration = async (req, res) => {
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
recordedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -533,6 +566,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 +610,21 @@ 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. A refund of the donation
|
||||
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
|
||||
// the remaining balance (money that's left the building) instead of increasing it (which a
|
||||
// raw signed sum would do, since subtracting a negative adds).
|
||||
const existingLegs = await prisma.payment.findMany({
|
||||
where: { originalPaymentId: payment.id, isDonation: false }
|
||||
});
|
||||
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(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 +673,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 +692,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 +737,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 +760,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 +1179,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 +1188,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 +1240,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 +1254,8 @@ const getPaymentStats = async (req, res) => {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfDay
|
||||
}
|
||||
},
|
||||
...excludeDonationLegs
|
||||
}
|
||||
}),
|
||||
prisma.payment.aggregate({
|
||||
@@ -1228,7 +1265,8 @@ const getPaymentStats = async (req, res) => {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: lastWeek
|
||||
}
|
||||
},
|
||||
...excludeDonationLegs
|
||||
}
|
||||
}),
|
||||
prisma.payment.aggregate({
|
||||
@@ -1238,7 +1276,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' }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -354,21 +354,15 @@ const updateUserProfile = async (req, res) => {
|
||||
}
|
||||
|
||||
// Normalize phone
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
let newPhone = user.phoneNumber;
|
||||
if (phoneNumber !== undefined) {
|
||||
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||
}
|
||||
|
||||
// Validate notification preference — WhatsApp requires a valid SA phone number
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let newPref = user.notificationPreference;
|
||||
if (notificationPreference !== undefined) {
|
||||
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
|
||||
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
|
||||
newPref = 'email';
|
||||
}
|
||||
}
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
@@ -554,7 +548,11 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// Prepare data update, allow admin to set a new password
|
||||
const data = {
|
||||
@@ -562,7 +560,8 @@ const updateUser = async (req, res) => {
|
||||
email: email || user.email,
|
||||
role: role || user.role,
|
||||
isActive: isActive !== undefined ? isActive : user.isActive,
|
||||
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
@@ -579,6 +578,7 @@ const updateUser = async (req, res) => {
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,7 +83,7 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { registrationId, eventId } = req.body || {};
|
||||
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
|
||||
|
||||
const ytx = await Yoco.findUnique({ where: { id } });
|
||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
||||
@@ -118,7 +118,15 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
let resolvedUserId = null;
|
||||
if (registration?.userId) {
|
||||
resolvedUserId = registration.userId;
|
||||
} else if (ytx?.raw?.payload?.metadata?.userId) {
|
||||
} else if (bodyUserId) {
|
||||
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
|
||||
// over metadata guesswork, but still verify the user actually exists.
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
|
||||
if (exists) resolvedUserId = String(bodyUserId);
|
||||
} catch {}
|
||||
}
|
||||
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
|
||||
const metaUserId = String(ytx.raw.payload.metadata.userId);
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
|
||||
@@ -138,6 +146,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,35 @@ 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);
|
||||
// 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). Refunds (negative amount) are kept in —
|
||||
// a card refund must subtract from the 'card' bucket it was refunded against, not vanish from
|
||||
// the per-method breakdown while still being netted out of totalRevenue below.
|
||||
const realPayments = payments.filter(p => !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". A refund
|
||||
// of the donation itself also creates such a leg, with a negative amount — Math.abs() so a
|
||||
// refund reduces the unallocated balance instead of inflating it (a raw signed sum would
|
||||
// subtract a negative, adding the refund back on top).
|
||||
const legsByDonationId = new Map();
|
||||
for (const p of payments) {
|
||||
if (p.originalPaymentId && !p.isDonation) {
|
||||
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + Math.abs(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) {
|
||||
for (const p of realPayments) {
|
||||
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 +224,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 };
|
||||
@@ -0,0 +1,30 @@
|
||||
// Shared template placeholder substitution for bulk emails/WhatsApp messages and broadcasts.
|
||||
// Async because {{payment.link}} needs to create a live Yoco checkout session per recipient —
|
||||
// every other placeholder is a plain synchronous string replace.
|
||||
async function replacePlaceholders(str, ctx = {}) {
|
||||
if (!str) return str;
|
||||
let out = String(str)
|
||||
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
|
||||
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
|
||||
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
|
||||
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
|
||||
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
|
||||
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
|
||||
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
|
||||
|
||||
if (/\{\{\s*payment\.link\s*\}\}/.test(out) && typeof ctx.paymentLinkResolver === 'function') {
|
||||
let link = '';
|
||||
try {
|
||||
link = (await ctx.paymentLinkResolver()) || '';
|
||||
} catch (e) {
|
||||
try { console.warn('[placeholders] payment.link resolution failed:', e?.message || e); } catch {}
|
||||
}
|
||||
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, link);
|
||||
} else {
|
||||
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, '');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { replacePlaceholders };
|
||||
@@ -105,6 +105,12 @@ function isSessionNotFound(e) {
|
||||
return msg.includes('session not found') || msg.includes('instance not found');
|
||||
}
|
||||
|
||||
/** 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,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.2",
|
||||
"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'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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 attendee’s registrations for the selected event.</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
</ul>
|
||||
<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,27 @@ 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;
|
||||
}
|
||||
|
||||
// Refund methods must mirror the real payment methods (cash/card/eft/voucher) so a refund
|
||||
// nets against the same bucket its original payment counted under in reports — a card payment
|
||||
// refunded as "cash-refund" would wrongly drain the cash float and leave card overstated.
|
||||
function refundMethodForOriginal(method: string | null | undefined): string {
|
||||
const m = String(method || "").toLowerCase();
|
||||
if (m.includes("cash")) return "cash-refund";
|
||||
if (m.includes("eft")) return "eft-refund";
|
||||
if (m.includes("voucher")) return "voucher-refund";
|
||||
if (m.includes("card") || m.includes("yoco") || m.includes("pay")) return "card-refund";
|
||||
return "";
|
||||
}
|
||||
|
||||
function RegistrationOptions({ regs, regOutstanding }: {
|
||||
regs: any[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
@@ -439,7 +460,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 };
|
||||
@@ -504,7 +526,7 @@ function PaymentsContent() {
|
||||
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
method: 'POST',
|
||||
authToken: token,
|
||||
body: { eventId }
|
||||
body: { eventId, userId: userId || undefined }
|
||||
});
|
||||
setInfo('Reconciled as donation');
|
||||
}
|
||||
@@ -537,7 +559,7 @@ function PaymentsContent() {
|
||||
{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>}
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
|
||||
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
|
||||
Payment
|
||||
@@ -641,7 +663,11 @@ function PaymentsContent() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-3 gap-3 items-end">
|
||||
<div className="sm:col-span-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">From (donor, optional)</label>
|
||||
<UserSearchField allUsers={allUsers} value={reconcileForm.userId} onChange={uid => setReconcileForm(prev => ({ ...prev, userId: uid }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
|
||||
<option value="">Select event…</option>
|
||||
@@ -805,7 +831,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 +844,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>
|
||||
);
|
||||
})}
|
||||
@@ -866,7 +895,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
const [paymentId, setPaymentId] = useState<string>("");
|
||||
const [registrationId, setRegistrationId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [method, setMethod] = useState<string>('refund');
|
||||
const [method, setMethod] = useState<string>("");
|
||||
const [reason, setReason] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
@@ -884,10 +913,14 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
}, [userId, target]);
|
||||
|
||||
useEffect(() => {
|
||||
// If a payment selected, default amount to that payment's amount
|
||||
// If a payment selected, default amount and refund method to that payment's own —
|
||||
// still editable, but staff shouldn't have to remember to change it manually.
|
||||
if (target === 'payment') {
|
||||
const p = payments.find(pp => String(pp.id) === String(paymentId));
|
||||
if (p) setAmount(String(Math.abs(p.amount || 0)));
|
||||
if (p) {
|
||||
setAmount(String(Math.abs(p.amount || 0)));
|
||||
setMethod(refundMethodForOriginal(p.method));
|
||||
}
|
||||
}
|
||||
}, [paymentId, target, payments]);
|
||||
|
||||
@@ -901,6 +934,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
if (!userId) { setErr('Select a user'); return; }
|
||||
if (target === 'payment' && !paymentId) { setErr('Select a payment to refund'); return; }
|
||||
if (target === 'registration' && !registrationId) { setErr('Select a registration to refund against'); return; }
|
||||
if (!method) { setErr('Select a refund method'); return; }
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await apiFetch('/api/payments/refund', {
|
||||
@@ -915,7 +949,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
reason: reason || undefined
|
||||
}
|
||||
});
|
||||
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason("");
|
||||
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to create refund');
|
||||
@@ -968,11 +1002,13 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
|
||||
<option value="refund">Refund</option>
|
||||
<option value="">Select method…</option>
|
||||
<option value="cash-refund">Cash refund</option>
|
||||
<option value="eft-refund">EFT refund</option>
|
||||
<option value="card-refund">Card refund</option>
|
||||
<option value="eft-refund">EFT refund</option>
|
||||
<option value="voucher-refund">Voucher refund</option>
|
||||
</select>
|
||||
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded — this is what nets against that method's total in reports.</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
|
||||
@@ -1025,27 +1061,53 @@ 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. A refund of the donation
|
||||
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces the
|
||||
// remaining balance instead of inflating it (a raw signed sum would subtract a negative,
|
||||
// adding the refund back on top of what's left to allocate).
|
||||
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) + Math.abs(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 +1117,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 +1172,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 +1194,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,{" "}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export default function EventsDropdown({ options, value, onChange }: {
|
||||
options: { value: string; label: string }[];
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
const toggle = (v: string) => {
|
||||
onChange(value.includes(v) ? value.filter(x => x !== v) : [...value, v]);
|
||||
};
|
||||
|
||||
const summary = value.length === 0
|
||||
? "No events selected"
|
||||
: options.length > 0 && value.length === options.length
|
||||
? "All events"
|
||||
: value.length === 1
|
||||
? (options.find(o => o.value === value[0])?.label || "1 event selected")
|
||||
: `${value.length} events selected`;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between gap-2 border rounded-lg px-3 py-2 text-sm bg-white hover:bg-gray-50"
|
||||
>
|
||||
<span className="truncate text-left">{summary}</span>
|
||||
<ChevronDown className={"w-4 h-4 text-gray-400 shrink-0 transition-transform " + (open ? "rotate-180" : "")} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 left-0 right-0 bg-white border rounded-lg shadow-lg max-h-64 overflow-auto p-1">
|
||||
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-gray-500 border-b mb-1">
|
||||
<button type="button" className="hover:underline" onClick={() => onChange(options.map(o => o.value))}>Select all</button>
|
||||
<button type="button" className="hover:underline" onClick={() => onChange([])}>Clear</button>
|
||||
</div>
|
||||
{options.map(opt => (
|
||||
<label key={opt.value} className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{options.length === 0 && <div className="px-2 py-1.5 text-xs text-gray-400">No events available.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
Users,
|
||||
PieChart,
|
||||
Ticket,
|
||||
TrendingUp,
|
||||
FileText,
|
||||
Box,
|
||||
ListChecks,
|
||||
Heart,
|
||||
Camera,
|
||||
FileBarChart,
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
|
||||
|
||||
export const REPORT_CATEGORIES: ReportCategory[] = ["Financial", "Registration", "Ticketing", "Donations", "Cashup", "Other"];
|
||||
|
||||
export const REPORTS = [
|
||||
{ key: "payments", label: "Payments between dates", description: "View payments within a date range", category: "Financial", icon: Calendar, fields: 4 },
|
||||
{ key: "attendees", label: "Attendees per event (grouped by option)", description: "Grouped by option", category: "Registration", icon: Users, fields: 3 },
|
||||
{ key: "regTypes", label: "Registration type counts", description: "Count by registration type", category: "Registration", icon: PieChart, fields: 2 },
|
||||
{ key: "usage", label: "Ticket usage summary", description: "Summary of ticket usage", category: "Ticketing", icon: Ticket, fields: 3 },
|
||||
{ key: "revenue", label: "Revenue summary (by method)", description: "Summary of revenue by payment method", category: "Financial", icon: TrendingUp, fields: 5 },
|
||||
{ key: "revenueDetailed", label: "Revenue detailed", description: "Detailed revenue breakdown", category: "Financial", icon: FileText, fields: 6 },
|
||||
{ key: "masterOrders", label: "Master orders breakdown", description: "Overview of orders, payments, and donations for the selected filters", category: "Registration", icon: Box, fields: 4 },
|
||||
{ key: "regStatus", label: "Registration status breakdown", description: "Registration status overview", category: "Registration", icon: ListChecks, fields: 3 },
|
||||
{ key: "donations", label: "Donations breakdown", description: "Breakdown of donations", category: "Donations", icon: Heart, fields: 3 },
|
||||
{ key: "cashup", label: "Cashup reconciliation", description: "Reconcile cashup totals", category: "Cashup", icon: Camera, fields: 4 },
|
||||
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
|
||||
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
|
||||
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: ReportCategory;
|
||||
icon: LucideIcon;
|
||||
fields: number;
|
||||
}>;
|
||||
|
||||
export type ReportKey = typeof REPORTS[number]["key"];
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { X, Info, RefreshCw, type LucideIcon } from "lucide-react";
|
||||
|
||||
export default function ReportViewerModal({
|
||||
title, description, icon: Icon, onClose, actions, filters, onRefresh, busy, children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
onClose: () => void;
|
||||
actions?: React.ReactNode;
|
||||
filters?: React.ReactNode;
|
||||
onRefresh?: () => void;
|
||||
busy?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// Above the site header (Navbar is `sticky top-0 z-50`) so the popup never sits behind it.
|
||||
<div className="fixed inset-0 z-[60]">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="absolute inset-0 flex items-start justify-center p-4 overflow-auto">
|
||||
<div className="w-full max-w-6xl bg-white rounded-xl shadow-xl my-8" onClick={e => e.stopPropagation()}>
|
||||
<div className="border-b">
|
||||
<div className="flex items-start justify-between gap-3 sm:gap-4 px-5 pt-4 pb-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{Icon && (
|
||||
<div className="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
{description && <p className="text-sm text-gray-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-2 flex-wrap shrink-0 ml-auto">
|
||||
{actions}
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 ml-1" onClick={onClose} aria-label="Close">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<button className="sm:hidden p-2 rounded-lg hover:bg-gray-100 shrink-0" onClick={onClose} aria-label="Close">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex sm:hidden items-center gap-2 flex-wrap px-5 pb-4">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-indigo-50/50">
|
||||
<div className="flex items-center gap-2 text-sm text-indigo-900 flex-1 min-w-0">
|
||||
<Info className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||
{filters || <span>This report has no extra filters beyond Events and Date range in the sidebar.</span>}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={busy}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={"w-3.5 h-3.5 " + (busy ? "animate-spin" : "")} /> {busy ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportActionButton({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 whitespace-nowrap"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-gray-500" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
X, Home, Filter, ListFilter, Download, BarChart2, MessageCircleQuestion,
|
||||
Calendar, CalendarClock, EyeOff, Printer, Mail, FileSpreadsheet, MessageCircle,
|
||||
CreditCard, Gift, Clock, HandHeart, RefreshCw, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
type GuideTab = "overview" | "universal" | "specific" | "exporting" | "fields" | "help";
|
||||
|
||||
const TABS: { key: GuideTab; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "overview", label: "Overview", icon: Home },
|
||||
{ key: "universal", label: "Universal filters", icon: Filter },
|
||||
{ key: "specific", label: "Report-specific filters", icon: ListFilter },
|
||||
{ key: "exporting", label: "Exporting reports", icon: Download },
|
||||
{ key: "fields", label: "Fields & metrics", icon: BarChart2 },
|
||||
{ key: "help", label: "Need more help?", icon: MessageCircleQuestion },
|
||||
];
|
||||
|
||||
const TONES = {
|
||||
indigo: { bg: "bg-indigo-50", icon: "text-indigo-600" },
|
||||
emerald: { bg: "bg-emerald-50", icon: "text-emerald-600" },
|
||||
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
|
||||
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
|
||||
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
|
||||
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
|
||||
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
|
||||
} as const;
|
||||
type Tone = keyof typeof TONES;
|
||||
|
||||
function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) {
|
||||
const t = TONES[tone] || TONES.gray;
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
|
||||
<Icon className={"w-4 h-4 " + t.icon} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{title}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GUIDE_DISMISSED_KEY = "hope_events_reports_guide_dismissed";
|
||||
const ADMIN_EMAIL = "admin@crosscode.co.za";
|
||||
|
||||
export default function ReportingGuideModal({ onClose }: { onClose: (dontShowAgain: boolean) => void }) {
|
||||
const [tab, setTab] = useState<GuideTab>("overview");
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
return (
|
||||
// Above the site header (Navbar is `sticky top-0 z-50`) and above the report popup
|
||||
// (z-[60]), since the guide can be opened while a report is showing.
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => onClose(dontShowAgain)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-3xl bg-white rounded-xl shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between px-5 py-4 border-b">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<MessageCircleQuestion className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Reporting guide</h2>
|
||||
<p className="text-xs text-gray-500">This guide explains how reports work and how to use the available filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="p-1.5 rounded hover:bg-gray-100" onClick={() => onClose(dontShowAgain)} aria-label="Close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
const active = tab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg " + (active ? "bg-indigo-50 text-indigo-700 font-medium" : "text-gray-600 hover:bg-gray-50")}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
|
||||
{tab === "overview" && (
|
||||
<div className="space-y-4">
|
||||
<p>Reports help you view key data about your events. You can filter the data, preview it on screen, and export or email it.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Filter} title="Use filters" tone="indigo">
|
||||
Apply universal filters (like events and date range) that affect all reports, and report-specific filters for more detailed results.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BarChart2} title="Preview & customize" tone="emerald">
|
||||
Preview your report, adjust filters, and choose how you want the data to appear.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Download} title="Export or email" tone="amber">
|
||||
Export your report to Excel, PDF, or send it by email or WhatsApp.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "universal" && (
|
||||
<div className="space-y-4">
|
||||
<p>Universal filters live in the sidebar on the left and apply to whichever report you open — you only set them once, not per report.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Calendar} title="Events" tone="indigo">
|
||||
Pick one or more events. Every report loads data for exactly these events.
|
||||
</GuideItem>
|
||||
<GuideItem icon={EyeOff} title="Include past / inactive / closed events" tone="gray">
|
||||
Controls which events even appear in the Events list to pick from.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CalendarClock} title="Date range" tone="blue">
|
||||
A preset (This month, Last month, This year) or a custom range. Only applies to reports that are inherently date-based (e.g. Payments between dates, Revenue reports, Cashup audit trail) — reports like Attendees or Ticket usage show a live snapshot and ignore the date range.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "specific" && (
|
||||
<div className="space-y-4">
|
||||
<p>Some reports have extra options that only make sense for that report — these appear at the top of the report popup once it's open, separate from the universal filters.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={ListFilter} title="Attendees" tone="violet">
|
||||
Which single event to show (defaults to the first selected event) and whether to include cancelled registrations.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BarChart2} title="Registration status breakdown" tone="emerald">
|
||||
Whether to include cancelled registrations in the counts, and whether to count by number of registrations or by ticket quantity (so someone with 3 tickets counts as 3).
|
||||
</GuideItem>
|
||||
<GuideItem icon={RefreshCw} title="Refresh" tone="indigo">
|
||||
Adjust a report-specific filter, then use the "Refresh" button inside the popup to re-run the report without closing it.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "exporting" && (
|
||||
<div className="space-y-4">
|
||||
<p>Every report can be exported straight from its popup:</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Printer} title="Print" tone="gray">
|
||||
Opens a print-ready PDF in a new tab; use your browser's print button from there.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Mail} title="Email" tone="blue">
|
||||
Sends the PDF to your own account email.
|
||||
</GuideItem>
|
||||
<GuideItem icon={FileSpreadsheet} title="Excel" tone="emerald">
|
||||
Downloads a styled .xlsx workbook — colored header, key totals, and a chart section where available — matching the on-screen report.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MessageCircle} title="WhatsApp" tone="violet">
|
||||
Sends the PDF to your own account's WhatsApp number (needs a valid phone number on file).
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-4">
|
||||
<p>A few terms come up across several financial reports and are easy to misread — here's what each one actually means:</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={CreditCard} title="Paid" tone="blue">
|
||||
Money the person paid themselves directly (cash/card/eft/online). Never includes money that reached their order via someone else's donation.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Gift} title="Paid via donation" tone="violet">
|
||||
The portion of an order that was covered by an assigned donation. This is part of what's "settled" on the order, but it's the donor's money, not the registrant's — so it's broken out separately and attributed to the donor elsewhere in the report.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Outstanding" tone="amber">
|
||||
What's still owed on an order, after direct payments and any donation cover.
|
||||
</GuideItem>
|
||||
<GuideItem icon={HandHeart} title="Unassigned donations" tone="rose">
|
||||
Real money already received as a donation that hasn't been applied to any order yet.
|
||||
</GuideItem>
|
||||
<GuideItem icon={BarChart2} title="Donations: Used / Unused" tone="emerald">
|
||||
How much of a given donation has been assigned to orders (Used) versus what's still available to assign (Unused). A donation is never overwritten when assigned — the original donation record always keeps its full original amount.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "help" && (
|
||||
<div className="space-y-4">
|
||||
<p>Still stuck? Reach out to the site administrator — they can check the underlying data with you or flag anything that looks wrong.</p>
|
||||
<div className="flex items-start gap-3 border border-gray-100 rounded-xl p-4 bg-gray-50">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
|
||||
<Mail className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">Site administrator</div>
|
||||
<a href={`mailto:${ADMIN_EMAIL}`} className="text-sm text-indigo-600 hover:underline">{ADMIN_EMAIL}</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Financial figures matter — if a number in a report doesn't look right, it's always worth asking rather than assuming.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} />
|
||||
Don't show this again
|
||||
</label>
|
||||
<button className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => onClose(dontShowAgain)}>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ArrowLeft, HelpCircle, Search } from "lucide-react";
|
||||
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
|
||||
import EventsDropdown from "./EventsDropdown";
|
||||
|
||||
type EventLite = { id: string; title: string };
|
||||
type DatePreset = "all_time" | "this_month" | "last_month" | "this_year" | "custom";
|
||||
|
||||
export default function ReportsShell({
|
||||
events, isAdmin,
|
||||
showPastEvents, setShowPastEvents,
|
||||
showInactiveEvents, setShowInactiveEvents,
|
||||
showClosedEvents, setShowClosedEvents,
|
||||
selectedEventIds, setSelectedEventIds,
|
||||
dateFrom, setDateFrom, dateTo, setDateTo,
|
||||
report, setReport,
|
||||
onViewReport, busy,
|
||||
onOpenGuide,
|
||||
onBack,
|
||||
}: {
|
||||
events: EventLite[]; isAdmin: boolean;
|
||||
showPastEvents: boolean; setShowPastEvents: (v: boolean) => void;
|
||||
showInactiveEvents: boolean; setShowInactiveEvents: (v: boolean) => void;
|
||||
showClosedEvents: boolean; setShowClosedEvents: (v: boolean) => void;
|
||||
selectedEventIds: string[]; setSelectedEventIds: (v: string[]) => void;
|
||||
dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void;
|
||||
report: ReportKey; setReport: (r: ReportKey) => void;
|
||||
onViewReport: () => void; busy: boolean;
|
||||
onOpenGuide: () => void;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<"All" | ReportCategory>("All");
|
||||
const [datePreset, setDatePreset] = useState<DatePreset>("all_time");
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const applyPreset = (preset: DatePreset) => {
|
||||
setDatePreset(preset);
|
||||
const now = new Date();
|
||||
if (preset === "this_month") {
|
||||
setDateFrom(iso(new Date(now.getFullYear(), now.getMonth(), 1)));
|
||||
setDateTo(iso(new Date(now.getFullYear(), now.getMonth() + 1, 0)));
|
||||
} else if (preset === "last_month") {
|
||||
setDateFrom(iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)));
|
||||
setDateTo(iso(new Date(now.getFullYear(), now.getMonth(), 0)));
|
||||
} else if (preset === "this_year") {
|
||||
setDateFrom(iso(new Date(now.getFullYear(), 0, 1)));
|
||||
setDateTo(iso(new Date(now.getFullYear(), 11, 31)));
|
||||
} else if (preset === "all_time") {
|
||||
setDateFrom(""); setDateTo("");
|
||||
}
|
||||
// 'custom' leaves dateFrom/dateTo as whatever's typed in the fields below
|
||||
};
|
||||
|
||||
const filteredReports = useMemo(() => {
|
||||
return REPORTS.filter(r => {
|
||||
if (category !== "All" && r.category !== category) return false;
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!r.label.toLowerCase().includes(q) && !r.description.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [search, category]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Reports</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">View, export, or email operational reports for events.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="relative flex-1 min-w-0 sm:flex-none">
|
||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
className="w-full sm:w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
|
||||
placeholder="Search reports…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{onBack && (
|
||||
<button type="button" className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar: universal filters */}
|
||||
<aside className="lg:w-72 shrink-0 space-y-5">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
|
||||
<div className="text-sm font-semibold">Filters</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Events</div>
|
||||
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title }))} value={selectedEventIds} onChange={setSelectedEventIds} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={showPastEvents} onChange={e => setShowPastEvents(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
{isAdmin && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={showInactiveEvents} onChange={e => setShowInactiveEvents(e.target.checked)} />
|
||||
Include inactive events
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={showClosedEvents} onChange={e => setShowClosedEvents(e.target.checked)} />
|
||||
Include closed (cashed-up) events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Date range (where applicable)</div>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm mb-2" value={datePreset} onChange={e => applyPreset(e.target.value as DatePreset)}>
|
||||
<option value="all_time">All time</option>
|
||||
<option value="this_month">This month</option>
|
||||
<option value="last_month">Last month</option>
|
||||
<option value="this_year">This year</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
{datePreset === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 mb-0.5">From</div>
|
||||
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-gray-500 mb-0.5">To</div>
|
||||
<input type="date" className="w-full min-w-0 border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="w-full flex items-start gap-3 text-left text-sm border rounded-xl p-4 bg-white shadow-sm hover:bg-gray-50" onClick={onOpenGuide}>
|
||||
<HelpCircle className="w-5 h-5 text-gray-500 shrink-0" />
|
||||
<span>
|
||||
<span className="block font-medium text-gray-800">Need help?</span>
|
||||
<span className="block text-xs text-gray-500">View our reporting guide</span>
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Main: categories, report grid */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{(["All", ...REPORT_CATEGORIES] as const).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
|
||||
onClick={() => setCategory(c)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
{filteredReports.map(r => {
|
||||
const Icon = r.icon;
|
||||
const selected = report === r.key;
|
||||
return (
|
||||
<button
|
||||
key={r.key}
|
||||
type="button"
|
||||
onClick={() => setReport(r.key)}
|
||||
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-indigo-500 ring-2 ring-indigo-100 bg-indigo-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center mb-3">
|
||||
<Icon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900 mb-1">{r.label}</div>
|
||||
<div className="text-xs text-gray-500 mb-3">{r.description}</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-400">
|
||||
<span>{r.category}</span>
|
||||
<span>{r.fields} fields</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredReports.length === 0 && (
|
||||
<div className="col-span-full text-sm text-gray-500 py-8 text-center">No reports match your search.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between bg-gray-50 border rounded-xl px-4 py-3">
|
||||
<div className="text-xs text-gray-500">Filters will be applied to the selected report where relevant.</div>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={onViewReport}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Loading…" : "View report"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const TONES = {
|
||||
green: { bg: "bg-emerald-50", icon: "text-emerald-600" },
|
||||
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
|
||||
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
|
||||
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
|
||||
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
|
||||
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
|
||||
} as const;
|
||||
|
||||
export type StatTileTone = keyof typeof TONES;
|
||||
|
||||
export function StatTile({ icon: Icon, label, value, tone = "gray" }: { icon: LucideIcon; label: string; value: string; tone?: StatTileTone }) {
|
||||
const t = TONES[tone] || TONES.gray;
|
||||
return (
|
||||
<div className={"flex items-center gap-3 rounded-xl p-3 " + t.bg}>
|
||||
<div className="w-9 h-9 rounded-lg bg-white/70 flex items-center justify-center shrink-0">
|
||||
<Icon className={"w-5 h-5 " + t.icon} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-gray-500 truncate">{label}</div>
|
||||
<div className="text-sm font-semibold text-gray-900 truncate">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatTileRow({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
// Fixed categorical order from the validated reference palette (dataviz skill,
|
||||
// references/palette.md) — never cycled or reassigned per-render, so the same category
|
||||
// always gets the same color across a session.
|
||||
export const CATEGORICAL_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#4a3aa7", "#e34948"];
|
||||
|
||||
export type BarDatum = { label: string; value: number };
|
||||
|
||||
// A simple, dependency-free horizontal bar list: thin rounded track, filled bar, direct value
|
||||
// label. Suited to comparing a handful of categories' magnitude (the job most reports need) —
|
||||
// per the dataviz skill's form heuristic, magnitude-by-category is exactly a bar chart's job.
|
||||
export function HorizontalBarChart({
|
||||
data, valueFormatter, labelWidthClass = "w-28",
|
||||
}: {
|
||||
data: BarDatum[];
|
||||
valueFormatter?: (v: number) => string;
|
||||
labelWidthClass?: string;
|
||||
}) {
|
||||
const max = Math.max(1, ...data.map(d => Math.abs(d.value)));
|
||||
const fmt = valueFormatter || ((v: number) => String(v));
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{data.map((d, i) => (
|
||||
<div key={d.label} className="flex items-center gap-3">
|
||||
<div className={labelWidthClass + " text-xs text-gray-600 truncate shrink-0"} title={d.label}>{d.label}</div>
|
||||
<div className="flex-1 h-3 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${Math.max(d.value > 0 ? 2 : 0, (Math.abs(d.value) / max) * 100)}%`, backgroundColor: CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 text-xs text-gray-700 text-right shrink-0 tabular-nums">{fmt(d.value)}</div>
|
||||
</div>
|
||||
))}
|
||||
{data.length === 0 && <div className="text-xs text-gray-400">No data to chart.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,13 +31,26 @@ function valueToString(v: any): string {
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -13,8 +13,16 @@ const METHOD_LABELS: Record<string, string> = {
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
// Refund methods are recorded as "<method>-refund" (e.g. "card-refund") so a refund nets
|
||||
// against the same bucket its original payment counted under. Strip that suffix before
|
||||
// bucketing so a card refund still displays as "Card", not "Other" — the negative amount
|
||||
// already signals it's a refund.
|
||||
function stripRefundSuffix(method: string): string {
|
||||
return method.endsWith("-refund") ? method.slice(0, -"-refund".length) : method;
|
||||
}
|
||||
|
||||
export function normalizePaymentMethod(method: string | null | undefined): string {
|
||||
const m = String(method || "").toLowerCase();
|
||||
const m = stripRefundSuffix(String(method || "").toLowerCase());
|
||||
if (USER_FACING_METHODS.includes(m)) return m;
|
||||
if (CARD_ALIASES.includes(m)) return "card";
|
||||
return "other";
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user