Files
hope-events/frontend/src/components/reports/ReportsV2.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00

2037 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
import { StatTile, StatTileRow } from "./StatTile";
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
function denomLabel(v: number): string {
return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`;
}
function money2(n: number | null | undefined): string {
return `R${Number(n || 0).toFixed(2)}`;
}
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Aggregate revenue/received totals must count each real inflow exactly
// once, so legs are excluded — the money was already counted via the original donation row.
// (Refunds also set originalPaymentId, but always with a negative amount, so they're unaffected.)
function isDonationLeg(p: any): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
function actionLabel(action: string): string {
return action === "closed" ? "Closed (full cashup)" : action === "quick_closed" ? "Quick closed" : "Reopened";
}
// Annotates closed/quick_closed audit rows with the per-method delta versus the previous
// close for the same event, so the audit trail shows differences between successive cashups.
function computeAuditDeltas(rows: any[]): any[] {
const byEvent: Record<string, any[]> = {};
rows.forEach(r => { (byEvent[r.eventId] = byEvent[r.eventId] || []).push(r); });
Object.values(byEvent).forEach(list => {
list.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
let prevClose: any = null;
list.forEach(r => {
if (r.action === "closed" || r.action === "quick_closed") {
if (prevClose) {
const delta: Record<string, number | null> = {};
METHOD_KEYS.forEach(m => {
const curLine = (r.lines || []).find((l: any) => l.method === m);
const prevLine = (prevClose.lines || []).find((l: any) => l.method === m);
const curActual = curLine?.actualAmount;
const prevActual = prevLine?.actualAmount;
delta[m] = (curActual != null && prevActual != null) ? curActual - prevActual : null;
});
r._deltaVsPrevious = delta;
r._previousCloseAt = prevClose.createdAt;
}
prevClose = r;
}
});
});
return rows;
}
// Effective per-unit price for a registration option, honoring an already-locked
// priceSnapshot first, then early-bird tier deadlines, then falling back to the
// base (variant or option) price. Mirrors optionUnitPrice on the Payments page.
function optionUnitPrice(ro: any, referenceTime: any, atTime: Date): number {
if (ro?.priceSnapshot !== null && ro?.priceSnapshot !== undefined) {
return Number(ro.priceSnapshot);
}
const eo = ro?.eventOption;
const variantId = ro?.variantId || null;
const base = (ro?.variant?.price !== null && ro?.variant?.price !== undefined)
? Number(ro.variant.price) : Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
const tiers = variantId ? allTiers.filter((t: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const t = atTime ? new Date(atTime) : new Date();
const ref = referenceTime ? new Date(referenceTime) : t;
const applicable = tiers
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price);
if (applicable.length === 0) return base;
const price = Number(applicable[0].price);
return (price >= 0) ? price : base;
}
// Simple MultiSelect control used in filters
export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const { token, user } = useAuth();
const role = user?.role || "user";
const canView = role === "admin" || role === "supervisor";
const searchParams = useSearchParams();
// Events list
const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPastEvents, setShowPastEvents] = useState(false);
const [showInactiveEvents, setShowInactiveEvents] = useState(false);
// Unlike past/inactive, closed (cashed-up) events are shown by default — they're normal
// historical data for most reports (Payments, Attendees, Revenue, etc.), and the
// Cashup/Finance/Profit reports specifically exist to review already-closed events, so
// hiding them by default would work against those reports' whole purpose. This toggle
// lets staff exclude them when they only want currently-open events.
const [showClosedEvents, setShowClosedEvents] = useState(true);
const isAdmin = role === "admin";
const filteredEvents = useMemo(() => {
const now = new Date();
return events.filter(ev => {
if (!showPastEvents && ev.endDate && new Date(ev.endDate) < now) return false;
if (!showInactiveEvents && ev.isActive === false) return false;
if (!showClosedEvents && ev.cashupStatus === "closed") return false;
return true;
});
}, [events, showPastEvents, showInactiveEvents, showClosedEvents]);
useEffect(() => {
(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token || undefined });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e: any) {
setError(e?.message || "Failed to load events");
} finally {
setLoadingEvents(false);
}
})();
}, [token, role]);
// General state for UI flow
const [report, setReport] = useState<ReportKey>("payments");
const [ready, setReady] = useState(false); // toggled by View button
const [busy, setBusy] = useState(false);
const [popupOpen, setPopupOpen] = useState(false);
// Universal filters — apply to every report's onView loader (per-report filters below stay
// report-specific). A single events selection replaces what used to be ~10 separate
// per-report "Events" multi-selects, and a single date range replaces payFrom/payTo and
// auditFrom/auditTo — both now live in the sidebar (ReportsShell) instead of being re-picked
// per report.
const [selectedEventIds, setSelectedEventIds] = useState<string[]>([]);
const [dateFrom, setDateFrom] = useState<string>("");
const [dateTo, setDateTo] = useState<string>("");
// Back-compat aliases so the many existing date-filtered useMemo/CSV/PDF/email blocks below
// (written against payFrom/payTo/auditFrom/auditTo) don't all need renaming.
const payFrom = dateFrom, payTo = dateTo, auditFrom = dateFrom, auditTo = dateTo;
// Attendees (single-event report — defaults to the first universally-selected event, with
// its own picker in the popup if more than one event is selected)
const [attEventId, setAttEventId] = useState<string>("");
const [attIncludeCancelled, setAttIncludeCancelled] = useState(false);
// Master orders breakdown — in-popup search boxes for the Orders / Donations made tables
const [masterOrderSearch, setMasterOrderSearch] = useState("");
const [masterDonationSearch, setMasterDonationSearch] = useState("");
// Registration status breakdown
const [statusIncludeCancelled, setStatusIncludeCancelled] = useState<boolean>(true);
const [statusCountMode, setStatusCountMode] = useState<'registrations' | 'tickets'>('registrations');
// 'registrations' mode: one count per registration row. 'tickets' mode: sum of ticket
// quantity across the registration's options, so someone with 3 tickets counts as 3.
const countForStatus = (r: any) => statusCountMode === 'tickets'
? (r.registrationOptions || []).reduce((s: number, ro: any) => s + (ro.quantity || 0), 0)
: 1;
// Data stores
const [paymentsByEvent, setPaymentsByEvent] = useState<Record<string, any[]>>({});
const [registrationsByEvent, setRegistrationsByEvent] = useState<Record<string, any[]>>({});
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
const [auditRows, setAuditRows] = useState<any[]>([]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
// trailing month (today back one month, matching the dashboard KPI window — see
// trailingMonthRanges in backend/src/controllers/statsController.js), instead of landing
// on the plain report grid. deepLinkHandledRef is a ref (not state) so it updates
// synchronously — the "Initialize default selection" effect right below reads it in the
// same commit to skip its own single-event default.
const deepLinkHandledRef = useRef(false);
const [autoViewPending, setAutoViewPending] = useState(false);
useEffect(() => {
if (deepLinkHandledRef.current) return;
if (filteredEvents.length === 0) return;
const reportParam = searchParams.get("report");
if (!reportParam || !REPORTS.some(r => r.key === reportParam)) return;
deepLinkHandledRef.current = true;
setReport(reportParam as ReportKey);
setSelectedEventIds(filteredEvents.map((e: any) => e.id));
if (searchParams.get("range") === "trailing_month") {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
const iso = (d: Date) => d.toISOString().slice(0, 10);
setDateFrom(iso(start));
setDateTo(iso(now));
}
setPopupOpen(true);
setAutoViewPending(true);
}, [filteredEvents, searchParams]);
// Fires once the state set above has actually committed (selectedEventIds reflects the
// deep-link's full event list), so onView() below closes over the updated values instead
// of the stale defaults from the render that scheduled it.
useEffect(() => {
if (!autoViewPending || selectedEventIds.length === 0) return;
setAutoViewPending(false);
onView();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoViewPending, selectedEventIds, report]);
// Initialize default selection when events load
useEffect(() => {
if (filteredEvents.length === 0) return;
if (!deepLinkHandledRef.current && selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id));
if (!attEventId) setAttEventId(filteredEvents[0].id);
}, [filteredEvents]);
// Attendees' single-event picker defaults to (and stays within) the universal selection.
useEffect(() => {
if (selectedEventIds.length > 0 && !selectedEventIds.includes(attEventId)) {
setAttEventId(selectedEventIds[0]);
}
}, [selectedEventIds]);
// Loaders
const loadPayments = async (eventIds: string[]) => {
if (!token || !eventIds.length) return {} as Record<string, any[]>;
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
return byEv;
};
const loadRegistrations = async (eventIds: string[]) => {
if (!token || !eventIds.length) return {} as Record<string, any[]>;
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
return byEv;
};
const loadTicketUsage = async (eventIds: string[]) => {
if (!token || !eventIds.length) return {} as Record<string, { used: number; unused: number }>;
const result: Record<string, { used: number; unused: number }> = {};
for (const id of eventIds) {
try {
const tickets = await apiFetch<any[]>(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token });
result[id] = { used: tickets.filter(t => t.isUsed).length, unused: tickets.filter(t => !t.isUsed).length };
} catch (e) {
result[id] = { used: 0, unused: 0 };
}
}
return result;
};
const loadFinancials = async (eventIds: string[]) => {
if (!token || !eventIds.length) return {} as Record<string, any>;
const byEv: Record<string, any> = {};
for (const id of eventIds) {
try {
byEv[id] = await apiFetch<any>(`/api/cashups/event/${encodeURIComponent(id)}`, { authToken: token });
} catch (e) {
byEv[id] = null;
}
}
return byEv;
};
const loadCashupAudit = async (eventIds: string[]) => {
if (!token) return [] as any[];
const query = new URLSearchParams();
if (auditFrom) query.set("from", auditFrom);
if (auditTo) query.set("to", auditTo);
if (eventIds.length === 1) query.set("eventId", eventIds[0]);
try {
const rows = await apiFetch<any[]>(`/api/cashups/audit?${query.toString()}`, { authToken: token });
const idSet = new Set(eventIds);
const filtered = eventIds.length > 1 ? (rows || []).filter(r => idSet.has(r.eventId)) : (rows || []);
return computeAuditDeltas(filtered).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
} catch (e) {
return [];
}
};
// View handler according to selected report
const onView = async () => {
setBusy(true);
setReady(false);
try {
if (report === "payments") {
const byEv = await loadPayments(selectedEventIds);
setPaymentsByEvent(byEv);
} else if (report === "attendees") {
const byEv = await loadRegistrations([attEventId || selectedEventIds[0]].filter(Boolean));
setRegistrationsByEvent(byEv);
} else if (report === "regTypes") {
const byEv = await loadRegistrations(selectedEventIds);
setRegistrationsByEvent(byEv);
} else if (report === "usage") {
const usage = await loadTicketUsage(selectedEventIds);
setTicketUsage(usage);
} else if (report === "revenue") {
const byEv = await loadPayments(selectedEventIds);
setPaymentsByEvent(byEv);
} else if (report === "revenueDetailed") {
const [byEvP, byEvR] = await Promise.all([
loadPayments(selectedEventIds),
loadRegistrations(selectedEventIds)
]);
setPaymentsByEvent(byEvP);
setRegistrationsByEvent(byEvR);
} else if (report === "regStatus") {
const byEv = await loadRegistrations(selectedEventIds);
setRegistrationsByEvent(byEv);
} else if (report === "masterOrders") {
const [byEvP, byEvR] = await Promise.all([
loadPayments(selectedEventIds),
loadRegistrations(selectedEventIds)
]);
setPaymentsByEvent(byEvP);
setRegistrationsByEvent(byEvR);
} else if (report === "donations") {
const byEv = await loadPayments(selectedEventIds);
setPaymentsByEvent(byEv);
} else if (report === "cashup") {
const byEv = await loadFinancials(selectedEventIds);
setFinancialsByEvent(byEv);
} else if (report === "financeReport") {
const byEv = await loadFinancials(selectedEventIds);
setFinancialsByEvent(byEv);
} else if (report === "profitReport") {
const byEv = await loadFinancials(selectedEventIds);
setFinancialsByEvent(byEv);
} else if (report === "cashupAudit") {
const rows = await loadCashupAudit(selectedEventIds);
setAuditRows(rows);
}
setReady(true);
} finally {
setBusy(false);
}
};
// Payment method label mapping — bucket into the same 4 categories used by the finance/cashup
// reports (cash, card, eft, other). Card-network wallets (Apple Pay, Google Pay) and Yoco
// checkout-portal payments settle exactly like a card — no separate float to reconcile — so
// they fold into "Card" rather than showing as their own categories. Mirrors bucketForMethod
// in backend/src/utils/cashupUtils.js.
const getPaymentMethod = (p: any) => {
const m = String(p?.method || "").toLowerCase();
if (m.includes("cash")) return "Cash";
if (m.includes("eft")) return "EFT";
if (m.includes("card") || m.includes("yoco") || m.includes("pay") || p?.externalId) return "Card";
return "Other";
};
// Derived rows for payments (apply date filter)
const paymentRows = useMemo(() => {
if (report !== "payments") return [] as any[];
const df = payFrom ? new Date(payFrom).getTime() : null;
const dt = payTo ? new Date(payTo).getTime() : null;
const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string; recordedByName: string }[] = [];
for (const evId of Object.keys(paymentsByEvent)) {
const ev = filteredEvents.find(e => e.id === evId);
const evTitle = ev?.title || evId;
for (const p of paymentsByEvent[evId] || []) {
// Exclude donation-application legs — that money was already counted once, as the
// donation itself. Only real inflows (payments and donations) belong here.
if (isDonationLeg(p)) continue;
const t = new Date(p.createdAt).getTime();
if ((df && t < df) || (dt && t > dt)) continue;
// Reporting rule: if donation, attribute to payer; otherwise attribute to the registration's assigned user
const u = p.isDonation ? (p.user || {}) : ((p.registration?.user || p.user) || {});
// Self-service (webhook) payments record the payer as recordedBy too — only call it
// out when a different staff member actually recorded it, to avoid noise.
const recordedByName = (p.recordedBy && String(p.recordedBy.id) !== String(p.userId))
? (p.recordedBy.name || p.recordedBy.email || "")
: "";
rows.push({
eventId: evId,
eventTitle: evTitle,
userName: u?.name || u?.email || p.userId || "User",
userEmail: u?.email,
amount: p.amount,
method: getPaymentMethod(p),
isDonation: p.isDonation,
createdAt: p.createdAt,
recordedByName,
});
}
}
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '') || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()));
return rows;
}, [report, paymentsByEvent, payFrom, payTo, filteredEvents]);
// Derived rows for revenue detailed (apply date filter)
// Outstanding by registration respecting early-bird tiers and last payment lock-in
const outstandingByReg = useMemo(() => {
const map = new Map<string, number>();
// Build quick index of payments per registration from all payments loaded per event
const paidByReg = new Map<string, number>();
const lastPaymentAtByReg = new Map<string, Date | null>();
Object.keys(paymentsByEvent).forEach(evId => {
(paymentsByEvent[evId] || []).forEach((p: any) => {
if (p.registrationId) {
const regId = p.registrationId;
paidByReg.set(regId, (paidByReg.get(regId) || 0) + (p.amount || 0));
const t = new Date(p.createdAt).getTime();
const prev = lastPaymentAtByReg.get(regId)?.getTime() || -Infinity;
if (t > prev) lastPaymentAtByReg.set(regId, new Date(t));
}
});
});
const now = new Date();
// For each registration in loaded events, compute due and outstanding with lock-in rule
Object.keys(registrationsByEvent).forEach(evId => {
(registrationsByEvent[evId] || []).forEach((r: any) => {
const lastAt = lastPaymentAtByReg.get(r.id) || null;
const dueNow = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0);
const paid = paidByReg.get(r.id) || 0;
let outstanding = Math.max(dueNow - paid, 0);
if (lastAt) {
const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0);
if (paid >= dueAtLast) outstanding = 0;
}
map.set(r.id, outstanding);
});
});
return map;
}, [paymentsByEvent, registrationsByEvent]);
const donationsByEvent = useMemo(() => {
const map = new Map<string, number>();
Object.keys(paymentsByEvent).forEach(evId => {
const total = (paymentsByEvent[evId] || [])
.filter((p: any) => p.isDonation)
.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
map.set(evId, total);
});
return map;
}, [paymentsByEvent]);
// Build payment totals per registration (all payments, no date filter)
const paidByReg = useMemo(() => {
const map = new Map<string, number>();
Object.keys(paymentsByEvent).forEach(evId => {
(paymentsByEvent[evId] || []).forEach((p: any) => {
if (p.registrationId) {
map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.amount || 0));
}
});
});
return map;
}, [paymentsByEvent]);
// How much of each registration's Total paid (above) came from an assigned donation leg,
// rather than a direct cash/card/eft/online payment — this is a subset of paidByReg, not
// additional money, and is unambiguous per-registration (unlike "this donor's total
// donations", which isn't specific to this order and shouldn't be repeated per row).
const paidViaDonationByReg = useMemo(() => {
const map = new Map<string, number>();
Object.keys(paymentsByEvent).forEach(evId => {
(paymentsByEvent[evId] || []).forEach((p: any) => {
if (p.registrationId && isDonationLeg(p)) {
map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.amount || 0));
}
});
});
return map;
}, [paymentsByEvent]);
// Donations made (as donor), with used/unused split — independent of any single registration,
// shown as its own breakdown so "who gave what" is never conflated with "what this order cost".
// Shared by Master Orders and Revenue Detailed — both need to attribute the full donation
// amount to the donor, never to whichever registration a portion of it happened to fund.
const donationsMadeRows = useMemo(() => {
const rows: { eventId: string; eventTitle: string; donor: string; email?: string; amount: number; used: number; unused: number; createdAt: string }[] = [];
Object.keys(paymentsByEvent).forEach(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const evTitle = ev?.title || evId;
const evPayments = paymentsByEvent[evId] || [];
evPayments.filter((p: any) => p.isDonation).forEach((donation: any) => {
// A refund of the donation itself also creates a leg (originalPaymentId -> donation),
// with a negative amount — Math.abs() so it reduces "used" (money no longer available)
// instead of a raw signed sum subtracting a negative and inflating "unused" below.
const used = evPayments
.filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donation.id)
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
rows.push({
eventId: evId,
eventTitle: evTitle,
donor: donation.user?.name || donation.userId || "Donor",
email: donation.user?.email,
amount: donation.amount || 0,
used,
unused: Math.max((donation.amount || 0) - used, 0),
createdAt: donation.createdAt,
});
});
});
rows.sort((a, b) => a.eventTitle.localeCompare(b.eventTitle) || a.donor.localeCompare(b.donor));
return rows;
}, [paymentsByEvent, filteredEvents]);
const revDetRows = useMemo(() => {
if (report !== "revenueDetailed") return [] as any[];
const df = payFrom ? new Date(payFrom).getTime() : null;
const dt = payTo ? new Date(payTo).getTime() : null;
type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number };
const rows: Row[] = [];
// Iterate registrations to include those with zero payments and show each once. "Total paid"
// is what THIS person actually paid directly — money that reached them via an assigned
// donation is not their payment, so it's excluded here and attributed to the donor instead
// (below). Outstanding still reflects the full picture (direct + donation-funded), since
// that's genuinely how much of the order remains unsettled.
Object.keys(registrationsByEvent).forEach(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const evTitle = ev?.title || evId;
(registrationsByEvent[evId] || []).forEach((r: any) => {
const created = r.createdAt ? new Date(r.createdAt).getTime() : null;
if (df && created && created < df) return;
if (dt && created && created > dt) return;
const directPaid = Math.max((paidByReg.get(r.id) || 0) - (paidViaDonationByReg.get(r.id) || 0), 0);
const outstanding = outstandingByReg.get(r.id) || 0;
rows.push({
eventId: evId,
eventTitle: evTitle,
userName: r.user?.name || r.userId || "User",
userEmail: r.user?.email,
totalPaid: directPaid,
status: r.status,
registrationId: r.id,
outstanding,
});
});
});
// Every donation is real money received from the donor, whether or not (or how much of) it
// has since been assigned to fund someone else's registration — so it's attributed here in
// full, under the donor, never under whichever registration it happened to fund.
donationsMadeRows.forEach(d => {
const created = d.createdAt ? new Date(d.createdAt).getTime() : null;
if (df && created && created < df) return;
if (dt && created && created > dt) return;
rows.push({
eventId: d.eventId,
eventTitle: d.eventTitle,
userName: d.donor,
userEmail: d.email,
totalPaid: d.amount,
status: d.used <= 0.000001 ? "donation (unassigned)" : (d.unused > 0.000001 ? "donation (partly assigned)" : "donation (fully assigned)"),
registrationId: "",
outstanding: 0,
});
});
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '')));
return rows;
}, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, paidViaDonationByReg, outstandingByReg, donationsMadeRows]);
// Derived for attendees (layered) for a single event
const attendeesLayer = useMemo(() => {
if (report !== "attendees") return { eventTitle: "", options: [] as any[] };
const regs = registrationsByEvent[attEventId] || [];
const ev = filteredEvents.find(e => e.id === attEventId);
// Option -> users
const byOption = new Map<string, { optionName: string; users: { name: string; email?: string; status: string; qty: number }[] }>();
for (const r of regs) {
if (!attIncludeCancelled && r.status === "cancelled") continue;
const baseUser = {
name: r.user?.name || r.userId,
email: r.user?.email,
status: r.status,
};
const options = (r.registrationOptions || []);
if (options.length === 0) {
const key = "(No option)";
if (!byOption.has(key)) byOption.set(key, { optionName: key, users: [] });
byOption.get(key)!.users.push({
...baseUser,
qty: 0
});
}
else {
for (const ro of options) {
const key = ro.eventOption?.name || "Option";
if (!byOption.has(key)) {
byOption.set(key, { optionName: key, users: [] });
}
byOption.get(key)!.users.push({
...baseUser,
qty: ro.quantity || 0
});
}
}
}
const options = Array.from(byOption.values()).sort((a,b) => a.optionName.localeCompare(b.optionName));
options.forEach(o => o.users.sort((a,b) => (a.name || '').localeCompare(b.name || '')));
return { eventTitle: ev?.title || attEventId, options };
}, [report, registrationsByEvent, attEventId, attIncludeCancelled, filteredEvents]);
// Derived for reg type counts
const regTypeRows = useMemo(() => {
if (report !== "regTypes") return [] as { eventTitle: string; type: string; qty: number }[];
const rows: { eventTitle: string; type: string; qty: number }[] = [];
for (const evId of Object.keys(registrationsByEvent)) {
const ev = filteredEvents.find(e => e.id === evId);
const title = ev?.title || evId;
const regs = registrationsByEvent[evId] || [];
const counts: Record<string, number> = {};
for (const r of regs) {
if (r.status === "cancelled") continue;
for (const ro of (r.registrationOptions || [])) {
const name = ro.eventOption?.name || "Option";
counts[name] = (counts[name] || 0) + (ro.quantity || 0);
}
}
for (const type of Object.keys(counts)) rows.push({ eventTitle: title, type, qty: counts[type] });
}
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || a.type.localeCompare(b.type)));
return rows;
}, [report, registrationsByEvent, filteredEvents]);
//Master report options
const masterOptions = useMemo(() => {
if (report !== "masterOrders") return [];
const map = new Map<string, string>();
Object.values(registrationsByEvent).forEach((regs: any) => {
regs.forEach((r: any) => {
(r.registrationOptions || []).forEach((ro: any) => {
const id = ro.eventOption?.id;
const name = ro.eventOption?.name;
if (id && name) {
map.set(id, name);
}
});
});
});
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [report, registrationsByEvent]);
//Master Report Data Rows
const masterRows = useMemo(() => {
if (report !== "masterOrders") return [];
const rows: any[] = [];
Object.keys(registrationsByEvent).forEach(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const eventTitle = ev?.title || evId;
(registrationsByEvent[evId] || []).forEach((r: any) => {
if (r.status === "cancelled") return;
const optionQtyMap = new Map<string, number>();
(r.registrationOptions || []).forEach((ro: any) => {
const id = ro.eventOption?.id;
const qty = ro.quantity || 0;
if (id) {
optionQtyMap.set(id, (optionQtyMap.get(id) || 0) + qty);
}
});
// "Paid" is what this person actually paid directly — money that reached this
// registration via an assigned donation is the donor's money, not theirs, so it's
// excluded here and shown separately in "Paid via donation" instead.
const paidViaDonation = paidViaDonationByReg.get(r.id) || 0;
const directPaid = Math.max((paidByReg.get(r.id) || 0) - paidViaDonation, 0);
const baseRow: any = {
eventTitle,
name: r.user?.name || "User",
email: r.user?.email,
orderTotal: 0,
totalPaid: directPaid,
outstanding: outstandingByReg.get(r.id) || 0,
paidViaDonation,
__prices: {} as Record<string, number> // 👈 hidden helper
};
// 👇 Dynamic columns
masterOptions.forEach(opt => {
const qty = optionQtyMap.get(opt.id) || 0;
baseRow[opt.name] = qty;
const ro = (r.registrationOptions || [])
.find((x: any) => x.eventOption?.id === opt.id);
const price = ro ? optionUnitPrice(ro, null, new Date()) : 0;
baseRow.__prices[opt.name] = price; // 👈 store price
baseRow.orderTotal += price * qty;
});
rows.push(baseRow);
});
});
rows.sort((a,b) =>
a.eventTitle.localeCompare(b.eventTitle) ||
a.name.localeCompare(b.name)
);
return rows;
}, [report, registrationsByEvent, filteredEvents, masterOptions, paidByReg, outstandingByReg, paidViaDonationByReg]);
//Master Report Totals
const masterTotals = useMemo(() => {
if (report !== "masterOrders") return null;
const totals: any = {
orderTotal: 0,
totalPaid: 0,
outstanding: 0,
paidViaDonation: 0,
};
// 👇 initialise dynamic option totals
masterOptions.forEach(opt => {
totals[opt.name] = 0; // qty total
totals[`${opt.name}_revenue`] = 0; // revenue total
});
masterRows.forEach(row => {
totals.orderTotal += row.orderTotal;
totals.totalPaid += row.totalPaid;
totals.outstanding += row.outstanding;
totals.paidViaDonation += row.paidViaDonation || 0;
masterOptions.forEach(opt => {
const qty = row[opt.name] || 0;
totals[opt.name] += qty;
// revenue per option
const price =
Number(
masterRows
.find(r => r === row)?.__prices?.[opt.name] ?? 0
);
totals[`${opt.name}_revenue`] += qty * price;
});
});
// Donations logged for these events that haven't (fully) been applied to any order yet —
// real money already received, sitting unused, distinct from what orders actually cost.
totals.unassignedDonations = donationsMadeRows.reduce((s, d) => s + d.unused, 0);
return totals;
}, [masterRows, masterOptions, report, donationsMadeRows]);
// Export/print/email handlers per report
// Builds the export payload for the current report — one shared builder consumed by Print,
// Email, WhatsApp, and Excel, so all four channels always show identical data/styling instead
// of four independently-maintained copies drifting apart. Stats/chart data (when present)
// mirror exactly what the on-screen report shows above its table, computed the same way.
const buildReportPayload = (): ReportPdfPayload | null => {
const subtitle = `Generated ${new Date().toLocaleString()}`;
if (report === "payments") {
return {
title: 'Payments Report', subtitle, kind: 'table', orientation: 'portrait',
table: {
columns: ["Event","User","Email","Amount","Method","Donation","Recorded by","Date"],
rows: paymentRows.map(r => [r.eventTitle, r.userName, r.userEmail || "", `R ${Number(r.amount).toFixed(2)}`, r.method, r.isDonation ? "Yes" : "No", r.recordedByName || "", new Date(r.createdAt).toLocaleString()])
}
};
}
if (report === "attendees") {
const sections = attendeesLayer.options.map(opt => ({
title: opt.optionName,
items: opt.users.map((u: any) => `${u.name}${u.email ? ` (${u.email})` : ''}${u.status}${typeof u.qty==='number' ? ` • Qty: ${u.qty}` : ''}`)
}));
return { title: 'Attendees Report', subtitle, kind: 'layered', orientation: 'portrait', layered: { header: `Event: ${attendeesLayer.eventTitle}`, sections } };
}
if (report === "regTypes") {
const byType = new Map<string, number>();
regTypeRows.forEach(r => byType.set(r.type, (byType.get(r.type) || 0) + r.qty));
return {
title: 'Registration Types Report', subtitle, kind: 'table', orientation: 'portrait',
chart: { title: 'Across all selected events', data: Array.from(byType.entries()).map(([label, value]) => ({ label, value })) },
table: { columns: ["Event","Type","Quantity"], rows: regTypeRows.map(r => [r.eventTitle, r.type, String(r.qty)]) }
};
}
if (report === "usage") {
const rows: (string|number)[][] = [];
let totalUsed = 0, totalUnused = 0;
Object.keys(ticketUsage).forEach(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const tu = ticketUsage[evId];
rows.push([ev?.title || evId, tu.used, tu.unused]);
totalUsed += tu.used; totalUnused += tu.unused;
});
return {
title: 'Ticket Usage Report', subtitle, kind: 'table', orientation: 'portrait',
chart: { title: 'Used vs. unused, across all selected events', data: [{ label: "Used", value: totalUsed }, { label: "Unused", value: totalUnused }] },
table: { columns: ["Event","Used","Unused"], rows }
};
}
if (report === "revenue") {
const df = payFrom ? new Date(payFrom).getTime() : null;
const dt = payTo ? new Date(payTo).getTime() : null;
const rows: (string|number)[][] = [];
const byMethodTotal: Record<string, number> = {};
Object.keys(paymentsByEvent).forEach(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const list = (paymentsByEvent[evId] || []).filter((p: any) => {
if (isDonationLeg(p)) return false; // already counted once, as the source donation
const t = new Date(p.createdAt).getTime();
return !(df && t < df) && !(dt && t > dt);
});
const byMethod: Record<string, number> = {};
let total = 0;
list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); byMethodTotal[m] = (byMethodTotal[m] || 0) + (p.amount || 0); total += (p.amount || 0); });
if (Object.keys(byMethod).length === 0) rows.push([ev?.title || evId, '-', 0, 0]);
Object.keys(byMethod).forEach(m => rows.push([ev?.title || evId, m, Number(byMethod[m].toFixed(2)), Number(total.toFixed(2))]));
});
return {
title: 'Revenue Summary', subtitle, kind: 'table', orientation: 'portrait',
chart: { title: 'By method, across all selected events', data: Object.keys(byMethodTotal).sort().map(m => ({ label: m, value: byMethodTotal[m], displayValue: `R ${byMethodTotal[m].toFixed(2)}` })) },
table: { columns: ["Event","Method","Amount","Event Total"], rows }
};
}
if (report === "revenueDetailed") {
const rows: (string|number)[][] = (revDetRows as any[]).map(r => [
r.eventTitle, r.userName, r.userEmail || "", r.status || "",
Number((r.totalPaid || 0).toFixed(2)), Number((r.outstanding || 0).toFixed(2)), r.registrationId || "",
]);
return {
title: 'Revenue Detailed', subtitle, kind: 'table', orientation: 'landscape',
table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }
};
}
if (report === "regStatus") {
const rows: (string|number)[][] = [];
const agg: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 };
Object.keys(registrationsByEvent).forEach(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const regs = registrationsByEvent[evId] || [];
const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 };
regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + countForStatus(r); agg[r.status] = (agg[r.status] || 0) + countForStatus(r); });
rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]);
});
const unit = statusCountMode === 'tickets' ? 'tickets' : 'registrations';
return {
title: 'Registration Status Breakdown', subtitle, kind: 'table',
chart: { title: `Across all selected events, by ${unit}`, data: [
{ label: "Pending", value: agg.pending || 0 }, { label: "Partial", value: agg.partial_paid || 0 },
{ label: "Paid", value: agg.paid || 0 }, { label: "Cancelled", value: agg.cancelled || 0 },
] },
table: { columns: ["Event", statusCountMode === 'tickets' ? "Pending (tickets)" : "Pending", statusCountMode === 'tickets' ? "Partial (tickets)" : "Partial", statusCountMode === 'tickets' ? "Paid (tickets)" : "Paid", statusCountMode === 'tickets' ? "Cancelled (tickets)" : "Cancelled"], rows }
};
}
if (report === "donations") {
// Math.abs(): a refund of the donation itself also creates a leg (originalPaymentId ->
// donation) with a negative amount, which must reduce "used" rather than inflate "unused".
const usedFor = (evId: string, donationId: string) =>
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
const rows: (string|number)[][] = [];
let grandTotal = 0, grandUsed = 0;
Object.keys(paymentsByEvent).forEach(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const evPayments = paymentsByEvent[evId] || [];
const dons = evPayments.filter((p: any) => p.isDonation);
const total = dons.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
const unused = Math.max(total - used, 0);
grandTotal += total; grandUsed += used;
rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length]);
});
const grandUnused = Math.max(grandTotal - grandUsed, 0);
return {
title: 'Donations Breakdown', subtitle, kind: 'table', orientation: 'portrait',
stats: [
{ label: 'Total donated', value: `R ${grandTotal.toFixed(2)}`, tone: 'rose' },
{ label: 'Used', value: `R ${grandUsed.toFixed(2)}`, tone: 'violet' },
{ label: 'Unused', value: `R ${grandUnused.toFixed(2)}`, tone: 'amber' },
],
chart: { title: 'Used vs. unused, across all selected events', data: [{ label: "Used", value: grandUsed, displayValue: `R ${grandUsed.toFixed(2)}` }, { label: "Unused", value: grandUnused, displayValue: `R ${grandUnused.toFixed(2)}` }] },
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
};
}
if (report === "cashup") {
const rows: (string | number)[][] = [];
const incomeByMethod: Record<string, number> = {};
Object.keys(financialsByEvent).forEach(evId => {
const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return;
const title = ev?.title || evId;
METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m];
incomeByMethod[m] = (incomeByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0);
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
if (m === "cash") {
(r?.denominations || []).forEach((d: any) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
}
});
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]);
rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
});
return {
title: 'Cashup Report', subtitle, kind: 'table', orientation: 'landscape',
chart: { title: 'Income by method, across all selected events', data: METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: incomeByMethod[m] || 0, displayValue: `R ${(incomeByMethod[m] || 0).toFixed(2)}` })) },
table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows }
};
}
if (report === "financeReport") {
const rows: (string | number)[][] = [];
let sold = 0, paidByAll = 0, donationsProfit = 0, costsTotal = 0, netProfitTotal = 0;
const paidByMethod: Record<string, number> = {};
Object.keys(financialsByEvent).forEach(evId => {
const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return;
const title = ev?.title || evId;
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0);
paidByAll += METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0);
METHOD_KEYS.forEach(m => { paidByMethod[m] = (paidByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); });
donationsProfit += f.unallocatedDonationsTotal || 0;
costsTotal += f.totalCosts || 0;
netProfitTotal += f.netProfit || 0;
// "Sales" is ticket-price revenue for orders that reached 'paid' status — whether that
// order was funded directly or via an assigned donation, its full price is already
// counted here once. A separate "Donations" line for the full donation total would
// double-count the assigned portion; only the still-unassigned remainder is money not
// otherwise reflected in Sales, and that's already shown below under "Donations counted
// as profit" — no need to repeat it here.
rows.push([title, "Sales", "What was sold", ""]);
(f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))]));
rows.push([title, "Paid by", "", ""]);
METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))]));
rows.push([title, "Cashup comparison", "", ""]);
METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m];
rows.push([title, "", `${METHOD_LABEL[m]} — expected / actual / variance`, `${money2(f.expectedCashByMethod?.[m])} / ${r?.actual != null ? money2(r.actual) : "—"} / ${r?.variance != null ? money2(r.variance) : "—"}`]);
});
rows.push([title, "Costs", "", ""]);
(f.costs || []).forEach((c: any) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
});
return {
title: 'Finance Report', subtitle, kind: 'table', orientation: 'landscape',
stats: [
{ label: 'What was sold', value: `R ${sold.toFixed(2)}`, tone: 'green' },
{ label: 'Paid by (all methods)', value: `R ${paidByAll.toFixed(2)}`, tone: 'blue' },
{ label: 'Donations counted as profit', value: `R ${donationsProfit.toFixed(2)}`, tone: 'rose' },
{ label: 'Total costs', value: `R ${costsTotal.toFixed(2)}`, tone: 'amber' },
{ label: 'Net profit', value: `R ${netProfitTotal.toFixed(2)}`, tone: 'violet' },
],
chart: { title: 'Paid by, across all selected events', data: METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: paidByMethod[m] || 0, displayValue: `R ${(paidByMethod[m] || 0).toFixed(2)}` })) },
table: { columns: ["Event", "Section", "Detail", "Amount"], rows }
};
}
if (report === "profitReport") {
const evIds = Object.keys(financialsByEvent);
const totals = evIds.reduce((acc, evId) => {
const f = financialsByEvent[evId];
acc.revenue += f?.effectiveTotalRevenue || 0;
acc.costs += f?.totalCosts || 0;
acc.profit += f?.netProfit || 0;
return acc;
}, { revenue: 0, costs: 0, profit: 0 });
const rows: (string | number)[][] = evIds.map(evId => {
const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId);
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))];
});
return {
title: 'Profit Report', subtitle, kind: 'table', orientation: 'portrait',
stats: [
{ label: 'Revenue', value: `R ${totals.revenue.toFixed(2)}`, tone: 'green' },
{ label: 'Costs', value: `R ${totals.costs.toFixed(2)}`, tone: 'amber' },
{ label: 'Net profit', value: `R ${totals.profit.toFixed(2)}`, tone: 'violet' },
],
chart: evIds.length > 1 ? { title: 'Net profit by event', data: evIds.map(evId => ({ label: filteredEvents.find(e => e.id === evId)?.title || evId, value: financialsByEvent[evId]?.netProfit || 0, displayValue: `R ${(financialsByEvent[evId]?.netProfit || 0).toFixed(2)}` })) } : undefined,
table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows }
};
}
if (report === "cashupAudit") {
const rows: (string | number)[][] = [];
auditRows.forEach((r: any) => {
const base = [r.event?.title || r.eventId, actionLabel(r.action), r.performedBy?.name || "", new Date(r.createdAt).toLocaleString()];
if (r.action === "reopened") {
rows.push([...base, "", "", "", "", "", r.notes || ""]);
} else {
(r.lines || []).forEach((l: any) => rows.push([...base, METHOD_LABEL[l.method] || l.method, Number((l.expectedAmount || 0).toFixed(2)), l.actualAmount != null ? Number(l.actualAmount.toFixed(2)) : "", l.variance != null ? Number(l.variance.toFixed(2)) : "", r._deltaVsPrevious?.[l.method] != null ? Number(r._deltaVsPrevious[l.method].toFixed(2)) : "", l.notes || ""]));
if (!r.lines || r.lines.length === 0) rows.push([...base, "", "", "", "", "", `Donations counted as profit: ${money2(r.unallocatedDonationsTotal)}`]);
}
});
return {
title: 'Cashup Audit Trail', subtitle, kind: 'table', orientation: 'landscape',
table: { columns: ["Event", "Action", "Performed by", "Date", "Method", "Expected", "Actual", "Variance", "Δ vs previous close", "Notes"], rows }
};
}
if (report === "masterOrders") {
const bodyRows: (string | number)[][] = masterRows.map(r => [
r.eventTitle, r.name, r.email || "",
...masterOptions.map(opt => r[opt.name] ?? 0),
Number(r.orderTotal.toFixed(2)), Number(r.totalPaid.toFixed(2)), Number((r.paidViaDonation || 0).toFixed(2)), Number(r.outstanding.toFixed(2)),
]);
bodyRows.push([
"TOTAL", "", "",
...masterOptions.map(opt => masterTotals?.[opt.name] ?? 0),
Number((masterTotals?.orderTotal ?? 0).toFixed(2)), Number((masterTotals?.totalPaid ?? 0).toFixed(2)), Number((masterTotals?.paidViaDonation ?? 0).toFixed(2)), Number((masterTotals?.outstanding ?? 0).toFixed(2)),
]);
bodyRows.push([
"Revenue per Ticket", "", "",
...masterOptions.map(opt => `R ${(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}`),
"", "", "", "",
]);
bodyRows.push([
"Unassigned donations", "", "",
...masterOptions.map(() => ""),
"", "", "", Number((masterTotals?.unassignedDonations ?? 0).toFixed(2)),
]);
return {
title: "Master Orders Breakdown", subtitle, kind: "table", orientation: "landscape",
stats: [
{ label: 'Order Total', value: `R ${(masterTotals?.orderTotal ?? 0).toFixed(2)}`, tone: 'green' },
{ label: 'Paid', value: `R ${(masterTotals?.totalPaid ?? 0).toFixed(2)}`, tone: 'blue' },
{ label: 'Paid via donations', value: `R ${(masterTotals?.paidViaDonation ?? 0).toFixed(2)}`, tone: 'violet' },
{ label: 'Outstanding', value: `R ${(masterTotals?.outstanding ?? 0).toFixed(2)}`, tone: 'amber' },
{ label: 'Unassigned donations', value: `R ${(masterTotals?.unassignedDonations ?? 0).toFixed(2)}`, tone: 'rose' },
],
note: `"Paid" is only what each person actually paid themselves — money that reached their order via an assigned donation shows separately under "Paid via donation" and is attributed to the donor below, not the registrant. "Unassigned donations" is real money already received that hasn't been applied to any order yet.`,
table: {
columns: ["Event", "Name", "Email", ...masterOptions.map(o => o.name), "Order Total", "Paid", "Paid via donation", "Outstanding"],
rows: bodyRows
},
extraTables: donationsMadeRows.length > 0 ? [{
title: "Donations made",
columns: ["Event", "Donor", "Email", "Donated", "Used", "Unused"],
rows: donationsMadeRows.map(d => [d.eventTitle, d.donor, d.email || "", Number(d.amount.toFixed(2)), Number(d.used.toFixed(2)), Number(d.unused.toFixed(2))])
}] : undefined,
};
}
return null;
};
const doExportExcel = async () => {
if (!token) { alert('Please login to export Excel'); return; }
try {
const payload = buildReportPayload();
if (!payload) return;
await downloadReportExcel(API_BASE, token, payload);
} catch (e: any) {
alert(e?.message || 'Failed to generate Excel file');
}
};
const REPORT_LABEL: Record<string, string> = {
payments: "Payments Report",
attendees: "Attendees Report",
regTypes: "Registration Types Report",
usage: "Ticket Usage Report",
revenue: "Revenue Summary",
revenueDetailed: "Revenue Detailed Report",
regStatus: "Registration Status Breakdown",
masterOrders: "Master Orders Report",
donations: "Donations Breakdown",
cashup: "Cashup Report",
financeReport: "Finance Report",
profitReport: "Profit Report",
cashupAudit: "Cashup Audit Trail",
};
const doPrintPdf = async () => {
if (!token) { alert('Please login to export PDF'); return; }
try {
const payload = buildReportPayload();
if (!payload) return;
await viewReportPdf(API_BASE, token, payload);
} catch (e: any) {
alert(e?.message || 'Failed to generate PDF');
}
};
const doEmail = async () => {
if (!token) { alert('Please login to email PDF'); return; }
try {
const payload = buildReportPayload();
if (!payload) return;
await emailReportPdf(API_BASE, token, { ...payload, subject: REPORT_LABEL[report], body: "Report generated on " + new Date().toLocaleString() });
alert('Email sent with PDF attachment');
} catch (e: any) {
alert(e?.message || 'Failed to email PDF');
}
};
const doWhatsApp = async () => {
if (!token) { alert('Please login to send via WhatsApp'); return; }
try {
const payload = buildReportPayload();
if (!payload) return;
await whatsappReportPdf(API_BASE, token, { ...payload, caption: REPORT_LABEL[report] });
alert('Report sent to your WhatsApp');
} catch (e: any) {
alert(e?.message || 'Failed to send via WhatsApp');
}
};
// Rendering helpers
function htmlTable(headers: string[], rows: (string | number)[][]) {
const thead = `<tr>${headers.map(h => `<th>${escapeHtml(h)}</th>`).join("")}</tr>`;
const tbody = rows.map(r => `<tr>${r.map(c => `<td>${escapeHtml(String(c ?? ""))}</td>`).join("")}</tr>`).join("");
return `<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`;
}
function htmlLayered(eventTitle: string, options: { optionName: string; users: { name: string; email?: string; status: string; qty?: number }[] }[]) {
const optHtml = options.map(opt => `
<div class="layer-option">
<div class="layer-option-title">${escapeHtml(opt.optionName)}</div>
<ul class="layer-users">
${opt.users.map(u => `<li><span class="name">${escapeHtml(u.name || "")}</span>${u.email ? ` <span class="email">(${escapeHtml(u.email)})</span>` : ""} <span class="status">— ${escapeHtml(u.status)}</span>${typeof u.qty === 'number' ? ` <span class="status"> • Qty: ${u.qty}</span>` : ''}</li>`).join("")}
</ul>
</div>
`).join("");
return `
<style>
.layer-event{font-size:16px;font-weight:700;margin:4px 0 10px 0}
.layer-option{margin:8px 0 12px 16px;page-break-inside:avoid}
.layer-option-title{font-weight:600;margin-bottom:6px}
.layer-users{list-style:disc;margin-left:20px}
.layer-users li{margin:3px 0;font-size:12px}
.layer-users .name{font-weight:500}
.layer-users .email{color:#555}
.layer-users .status{color:#666}
</style>
<div class="layer-event">${escapeHtml(eventTitle)}</div>
${optHtml}
`;
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c] as string));
}
// Report-specific filters shown inside the popup — whatever each report had beyond the
// universal Events/Date range, which now live in the sidebar (ReportsShell) instead. `null`
// when a report has none, so the modal chrome shows its own fallback note.
const reportSpecificFilters = report === "attendees" ? (
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm">
<span className="mr-2">Event</span>
<select className="border rounded px-2 py-1 text-sm" value={attEventId} onChange={e => setAttEventId(e.target.value)}>
{selectedEventIds.map(id => {
const ev = filteredEvents.find(e => e.id === id);
return <option key={id} value={id}>{ev?.title || id}</option>;
})}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={attIncludeCancelled} onChange={e => setAttIncludeCancelled(e.target.checked)} /> Include cancelled registrations
</label>
</div>
) : report === "regStatus" ? (
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={statusIncludeCancelled} onChange={e => setStatusIncludeCancelled(e.target.checked)} /> Include cancelled in counts
</label>
<label className="flex items-center gap-2 text-sm">
Count by:
<select className="border rounded px-2 py-1 text-sm" value={statusCountMode} onChange={e => setStatusCountMode(e.target.value as 'registrations' | 'tickets')}>
<option value="registrations">Registrations</option>
<option value="tickets">Ticket quantity</option>
</select>
</label>
</div>
) : null;
const reportActions = ready && (
<>
<ReportActionButton icon={Printer} label="Print" onClick={doPrintPdf} />
<ReportActionButton icon={Mail} label="Email" onClick={doEmail} />
<ReportActionButton icon={FileSpreadsheet} label="Excel" onClick={doExportExcel} />
<ReportActionButton icon={MessageCircle} label="WhatsApp" onClick={doWhatsApp} />
</>
);
const activeReportMeta = REPORTS.find(r => r.key === report);
// UI
return (
<div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor or admin access to view reports.</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<ReportsShell
events={filteredEvents}
isAdmin={isAdmin}
showPastEvents={showPastEvents} setShowPastEvents={setShowPastEvents}
showInactiveEvents={showInactiveEvents} setShowInactiveEvents={setShowInactiveEvents}
showClosedEvents={showClosedEvents} setShowClosedEvents={setShowClosedEvents}
selectedEventIds={selectedEventIds} setSelectedEventIds={setSelectedEventIds}
dateFrom={dateFrom} setDateFrom={setDateFrom} dateTo={dateTo} setDateTo={setDateTo}
report={report} setReport={(r: ReportKey) => { setReport(r); setReady(false); }}
onViewReport={() => { setPopupOpen(true); onView(); }}
busy={busy}
onBack={onBack}
/>
{popupOpen && (
<ReportViewerModal
title={activeReportMeta?.label || "Report"}
description={activeReportMeta?.description}
icon={activeReportMeta?.icon}
onClose={() => setPopupOpen(false)}
filters={reportSpecificFilters}
actions={reportActions}
onRefresh={onView}
busy={busy}
>
{!ready && <div className="text-sm text-gray-400">Loading</div>}
{ready && (
<>
{report === "payments" && (
<div>
{paymentRows.length === 0 ? (
<div className="text-sm text-gray-500">No payments match the selected filters.</div>
) : (
<div className="space-y-5">
{Object.entries(paymentRows.reduce((acc: any, r: any) => {
acc[r.eventTitle] = acc[r.eventTitle] || [];
acc[r.eventTitle].push(r);
return acc;
}, {})).map(([eventTitle, rows]: any) => {
// group by user
const byUser: Record<string, any[]> = {};
rows.forEach((r: any) => {
const key = r.userName || 'User';
byUser[key] = byUser[key] || [];
byUser[key].push(r);
});
const eventTotal = rows.reduce((s: number, r: any) => s + (r.amount || 0), 0);
return (
<div key={eventTitle} className="border rounded-xl p-4 bg-gray-50">
<div className="flex items-center justify-between mb-2">
<div className="text-base font-semibold">{eventTitle}</div>
<div className="text-sm font-medium">Total: R {eventTotal.toFixed(2)}</div>
</div>
<div className="space-y-3">
{Object.keys(byUser).sort().map(userName => {
const list = byUser[userName];
const userTotal = list.reduce((s: number, r: any) => s + (r.amount || 0), 0);
const email = list.find((r: any) => r.userEmail)?.userEmail || '';
return (
<div key={userName} className="border rounded-lg p-3 bg-white">
<div className="flex items-center justify-between mb-2">
<div className="font-medium">{userName} {email ? <span className="text-gray-600 font-normal">({email})</span> : null}</div>
<div className="text-sm">Subtotal: R {userTotal.toFixed(2)}</div>
</div>
<ul className="text-sm text-gray-700 space-y-1">
{list.map((r: any, i: number) => (
<li key={i} className="flex items-center justify-between">
<span>{r.method}{r.isDonation ? ' • Donation' : ''}{r.recordedByName ? ` • Recorded by ${r.recordedByName}` : ''}</span>
<span>R {Number(r.amount).toFixed(2)} {new Date(r.createdAt).toLocaleString()}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{report === "attendees" && (
<div>
<div className="text-sm text-gray-700 mb-3">Event: <span className="font-medium">{attendeesLayer.eventTitle}</span></div>
{attendeesLayer.options.length === 0 ? (
<div className="text-sm text-gray-500">No attendees for selected filters.</div>
) : (
<div className="space-y-4">
{attendeesLayer.options.map(opt => (
<div key={opt.optionName} className="border rounded-lg p-3">
<div className="font-medium mb-2">{opt.optionName}</div>
<ul className="list-disc ml-5 space-y-1">
{opt.users.map((u: any, i: number) => (
<li key={opt.optionName + i} className="text-sm">
<span className="font-medium">{u.name}</span>
{u.email && <span className="text-gray-600"> ({u.email})</span>}
<span className="text-gray-600"> {u.status}</span>
<span className="text-gray-700"> Qty {u.qty}</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
)}
{report === "regTypes" && (
<div>
{regTypeRows.length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (() => {
const byType = new Map<string, number>();
regTypeRows.forEach(r => byType.set(r.type, (byType.get(r.type) || 0) + r.qty));
return (
<>
<div className="border border-gray-100 rounded-xl p-4 mb-4">
<div className="text-xs text-gray-500 mb-3">Across all selected events</div>
<HorizontalBarChart data={Array.from(byType.entries()).map(([label, value]) => ({ label, value }))} valueFormatter={v => String(v)} />
</div>
<div className="overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[520px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3">Registration Type</th>
<th className="py-2 px-3 text-right">Quantity</th>
</tr>
</thead>
<tbody>
{regTypeRows.map((r, idx) => (
<tr key={idx} className="border-t border-gray-100">
<td className="py-2 px-3">{r.eventTitle}</td>
<td className="py-2 px-3">{r.type}</td>
<td className="py-2 px-3 text-right">{r.qty}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
})()}
</div>
)}
{report === "usage" && (
<div className="space-y-3">
{Object.keys(ticketUsage).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
Object.keys(ticketUsage).map(evId => {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
const total = tu.used + tu.unused;
return (
<div key={evId} className="border border-gray-100 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<div className="text-xs text-gray-500">{total} tickets total</div>
</div>
<HorizontalBarChart
data={[{ label: "Used", value: tu.used }, { label: "Unused", value: tu.unused }]}
valueFormatter={v => String(v)}
/>
</div>
);
})
)}
</div>
)}
{report === "revenue" && (
<div className="space-y-3">
{Object.keys(paymentsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
Object.keys(paymentsByEvent).map(evId => {
const ev = events.find(e => e.id === evId);
const df = payFrom ? new Date(payFrom).getTime() : null;
const dt = payTo ? new Date(payTo).getTime() : null;
const list = (paymentsByEvent[evId] || []).filter((p: any) => {
if (isDonationLeg(p)) return false; // already counted once, as the source donation
const t = new Date(p.createdAt).getTime();
return !(df && t < df) && !(dt && t > dt);
});
const byMethod: Record<string, number> = {};
let total = 0;
list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); });
const chartData = Object.keys(byMethod).sort().map(m => ({ label: m, value: byMethod[m] }));
return (
<div key={evId} className="border border-gray-100 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<div className="text-sm font-semibold">R {total.toFixed(2)}</div>
</div>
{chartData.length === 0 ? (
<div className="text-sm text-gray-500">No payments in date range.</div>
) : (
<HorizontalBarChart data={chartData} valueFormatter={v => `R ${v.toFixed(2)}`} />
)}
</div>
);
})
)}
</div>
)}
{report === "revenueDetailed" && (
<div className="space-y-3">
{Object.keys(registrationsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
Object.keys(registrationsByEvent).map(evId => {
const ev = events.find(e => e.id === evId);
const rows = (revDetRows as any[]).filter(r => r.eventId === evId);
const total = rows.reduce((s, r) => s + (r.totalPaid || 0), 0);
const regIds = rows.map(r => r.registrationId);
const outstandingTotal = regIds.reduce((s, id) => s + (outstandingByReg.get(id) || 0), 0);
return (
<div key={evId} className="border border-gray-100 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<div className="text-sm text-gray-600">Paid <span className="font-semibold text-gray-900">R {total.toFixed(2)}</span> · Outstanding <span className="font-semibold text-gray-900">R {outstandingTotal.toFixed(2)}</span></div>
</div>
{rows.length === 0 ? (
<div className="text-sm text-gray-500">No registrations in date range.</div>
) : (
<div className="overflow-auto border border-gray-100 rounded-lg">
<table className="min-w-[760px] text-sm w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Name</th>
<th className="py-2 px-3">Email</th>
<th className="py-2 px-3">Status</th>
<th className="py-2 px-3 text-right">Total paid</th>
<th className="py-2 px-3 text-right">Outstanding</th>
<th className="py-2 px-3">Registration</th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => (
<tr key={evId + idx} className="border-t border-gray-100">
<td className="py-2 px-3">{r.userName}</td>
<td className="py-2 px-3 text-gray-500">{r.userEmail || ''}</td>
<td className="py-2 px-3 capitalize">{r.status || ''}</td>
<td className="py-2 px-3 text-right">R {Number(r.totalPaid || 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {Number(r.outstanding || 0).toFixed(2)}</td>
<td className="py-2 px-3 font-mono text-xs text-gray-400">{r.registrationId || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})
)}
</div>
)}
{report === "regStatus" && (
<div className="space-y-4">
{Object.keys(registrationsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
<>
{(() => {
const agg: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 };
Object.values(registrationsByEvent).forEach((regs: any) => {
(regs || []).forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; agg[r.status] = (agg[r.status] || 0) + countForStatus(r); });
});
const unit = statusCountMode === 'tickets' ? 'tickets' : 'registrations';
return (
<div className="border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Across all selected events, by {unit}</div>
<HorizontalBarChart
data={[
{ label: "Pending", value: agg.pending || 0 },
{ label: "Partial", value: agg.partial_paid || 0 },
{ label: "Paid", value: agg.paid || 0 },
{ label: "Cancelled", value: agg.cancelled || 0 },
]}
valueFormatter={v => String(v)}
/>
</div>
);
})()}
<div className="overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[560px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3">Pending{statusCountMode === 'tickets' ? ' (tickets)' : ''}</th>
<th className="py-2 px-3">Partial{statusCountMode === 'tickets' ? ' (tickets)' : ''}</th>
<th className="py-2 px-3">Paid{statusCountMode === 'tickets' ? ' (tickets)' : ''}</th>
<th className="py-2 px-3">Cancelled{statusCountMode === 'tickets' ? ' (tickets)' : ''}</th>
</tr>
</thead>
<tbody>
{Object.keys(registrationsByEvent).map(evId => {
const ev = events.find(e => e.id === evId);
const regs = registrationsByEvent[evId] || [];
const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 };
regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + countForStatus(r); });
return (
<tr key={evId} className="border-t border-gray-100">
<td className="py-2 px-3">{ev?.title || evId}</td>
<td className="py-2 px-3">{counts.pending || 0}</td>
<td className="py-2 px-3">{counts.partial_paid || 0}</td>
<td className="py-2 px-3">{counts.paid || 0}</td>
<td className="py-2 px-3">{counts.cancelled || 0}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{report === "donations" && (
<div>
{Object.keys(paymentsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data. Select events and view.</div>
) : (() => {
// A donation is never mutated once assigned — its "used" amount is the
// sum of every leg (isDonation:false, originalPaymentId -> the donation)
// referencing it, which — since a donation and its legs share the event
// it was logged against in the common case — are already present in the
// same per-event payments list. Math.abs(): a refund of the donation itself
// also creates such a leg, with a negative amount, which must reduce "used"
// rather than inflate "unused" via a raw signed sum.
const usedFor = (evId: string, donationId: string) =>
(paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId)
.reduce((s: number, leg: any) => s + Math.abs(leg.amount || 0), 0);
let grandTotal = 0, grandUsed = 0, grandCount = 0;
const perEvent = Object.keys(paymentsByEvent).map(evId => {
const ev = filteredEvents.find(e => e.id === evId);
const dons = (paymentsByEvent[evId] || []).filter((p: any) => p.isDonation);
const total = dons.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
const unused = Math.max(total - used, 0);
grandTotal += total; grandUsed += used; grandCount += dons.length;
return { evId, title: ev?.title || evId, total, used, unused, count: dons.length };
});
const grandUnused = Math.max(grandTotal - grandUsed, 0);
return (
<>
<StatTileRow>
<StatTile icon={HandHeart} label="Total donated" value={`R ${grandTotal.toFixed(2)}`} tone="rose" />
<StatTile icon={Gift} label="Used" value={`R ${grandUsed.toFixed(2)}`} tone="violet" />
<StatTile icon={Clock} label="Unused" value={`R ${grandUnused.toFixed(2)}`} tone="amber" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Used vs. unused, across all selected events</div>
<HorizontalBarChart data={[{ label: "Used", value: grandUsed }, { label: "Unused", value: grandUnused }]} valueFormatter={v => `R ${v.toFixed(2)}`} />
</div>
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[640px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3 text-right">Donations (R)</th>
<th className="py-2 px-3 text-right">Used (R)</th>
<th className="py-2 px-3 text-right">Unused (R)</th>
<th className="py-2 px-3 text-right">Count</th>
<th className="py-2 px-3 text-right">Avg (R)</th>
</tr>
</thead>
<tbody>
{perEvent.map(row => (
<tr key={row.evId} className="border-t border-gray-100">
<td className="py-2 px-3">{row.title}</td>
<td className="py-2 px-3 text-right">R {row.total.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {row.used.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {row.unused.toFixed(2)}</td>
<td className="py-2 px-3 text-right">{row.count}</td>
<td className="py-2 px-3 text-right">{row.count ? `R ${(row.total / row.count).toFixed(2)}` : '-'}</td>
</tr>
))}
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
<td className="py-2 px-3">Total</td>
<td className="py-2 px-3 text-right">R {grandTotal.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {grandUsed.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {grandUnused.toFixed(2)}</td>
<td className="py-2 px-3 text-right">{grandCount}</td>
<td className="py-2 px-3"></td>
</tr>
</tbody>
</table>
</div>
</>
);
})()}
</div>
)}
{report === "masterOrders" && (
<div>
{masterRows.length === 0 ? (
<div className="text-sm text-gray-500">
No registrations found.
</div>
) : (
<>
<div className="text-sm font-semibold text-gray-900 mb-2">Totals</div>
<StatTileRow>
<StatTile icon={ShoppingCart} label="Order Total" value={`R ${masterTotals?.orderTotal.toFixed(2)}`} tone="green" />
<StatTile icon={CreditCard} label="Paid" value={`R ${masterTotals?.totalPaid.toFixed(2)}`} tone="blue" />
<StatTile icon={Gift} label="Paid via donations" value={`R ${masterTotals?.paidViaDonation.toFixed(2)}`} tone="violet" />
<StatTile icon={Clock} label="Outstanding" value={`R ${masterTotals?.outstanding.toFixed(2)}`} tone="amber" />
<StatTile icon={HandHeart} label="Unassigned donations" value={`R ${masterTotals?.unassignedDonations.toFixed(2)}`} tone="rose" />
</StatTileRow>
<div className="mt-3 mb-6 text-xs text-gray-500 bg-gray-50 border border-gray-100 rounded-lg p-3">
&quot;Paid&quot; is only what each person actually paid themselves money that reached their order via an assigned donation shows separately under &quot;Paid via donation&quot; and is attributed to the donor below, not the registrant. &quot;Unassigned donations&quot; is real money already received that hasn&apos;t been applied to any order yet.
</div>
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-gray-900">Orders</div>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="w-56 border rounded-lg pl-8 pr-3 py-1.5 text-xs"
placeholder="Search orders…"
value={masterOrderSearch}
onChange={e => setMasterOrderSearch(e.target.value)}
/>
</div>
</div>
<div className="overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[1000px] text-sm w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3">Name</th>
<th className="py-2 px-3">Email</th>
{masterOptions.map(opt => (
<th key={opt.id} className="py-2 px-3">{opt.name}</th>
))}
<th className="py-2 px-3 text-right">Order Total</th>
<th className="py-2 px-3 text-right">Paid</th>
<th className="py-2 px-3 text-right">Paid via donation</th>
<th className="py-2 px-3 text-right">Outstanding</th>
</tr>
</thead>
<tbody>
{masterRows
.filter(r => {
const q = masterOrderSearch.trim().toLowerCase();
if (!q) return true;
return [r.eventTitle, r.name, r.email].some((v: any) => String(v || "").toLowerCase().includes(q));
})
.map((r, idx) => (
<tr key={idx} className="border-t border-gray-100">
<td className="py-2 px-3">{r.eventTitle}</td>
<td className="py-2 px-3">{r.name}</td>
<td className="py-2 px-3 text-gray-500">{r.email || ""}</td>
{masterOptions.map(opt => (
<td key={opt.id} className="py-2 px-3">
{r[opt.name] ?? 0}
</td>
))}
<td className="py-2 px-3 text-right">R {r.orderTotal.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {r.totalPaid.toFixed(2)}</td>
<td className="py-2 px-3 text-right">{r.paidViaDonation > 0.000001 ? `R ${r.paidViaDonation.toFixed(2)}` : ''}</td>
<td className="py-2 px-3 text-right">R {r.outstanding.toFixed(2)}</td>
</tr>
))}
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
<td className="py-2 px-3" colSpan={3}>TOTAL</td>
{masterOptions.map(opt => (
<td key={opt.id} className="py-2 px-3">
{masterTotals?.[opt.name] ?? 0}
</td>
))}
<td className="py-2 px-3 text-right">R {masterTotals?.orderTotal.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {masterTotals?.totalPaid.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {masterTotals?.paidViaDonation.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {masterTotals?.outstanding.toFixed(2)}</td>
</tr>
<tr className="text-xs bg-brand-50/60">
<td className="py-1.5 px-3" colSpan={3}>Revenue per Ticket</td>
{masterOptions.map(opt => (
<td key={opt.id} className="py-1.5 px-3">
R {(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}
</td>
))}
<td className="py-1.5 px-3" colSpan={4}></td>
</tr>
<tr className="text-xs bg-amber-50/60">
<td className="py-1.5 px-3" colSpan={3}>Unassigned donations</td>
{masterOptions.map(opt => <td key={opt.id} className="py-1.5 px-3"></td>)}
<td className="py-1.5 px-3" colSpan={3}></td>
<td className="py-1.5 px-3 text-right">R {masterTotals?.unassignedDonations.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-6">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold text-gray-900">Donations made</div>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="w-56 border rounded-lg pl-8 pr-3 py-1.5 text-xs"
placeholder="Search donations…"
value={masterDonationSearch}
onChange={e => setMasterDonationSearch(e.target.value)}
/>
</div>
</div>
<div className="text-xs text-gray-500 mb-2">
Every donation logged for these events, who gave it, and how much of it has been applied to an order (Used) versus still available (Unused). This is independent of the orders table above a donation isn't tied to the donor's own order.
</div>
{donationsMadeRows.length === 0 ? (
<div className="text-sm text-gray-500">No donations logged for these events.</div>
) : (
<div className="overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[640px] text-sm w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3">Donor</th>
<th className="py-2 px-3">Email</th>
<th className="py-2 px-3 text-right">Donated</th>
<th className="py-2 px-3 text-right">Used</th>
<th className="py-2 px-3 text-right">Unused</th>
</tr>
</thead>
<tbody>
{donationsMadeRows
.filter(d => {
const q = masterDonationSearch.trim().toLowerCase();
if (!q) return true;
return [d.eventTitle, d.donor, d.email].some((v: any) => String(v || "").toLowerCase().includes(q));
})
.map((d, idx) => (
<tr key={idx} className="border-t border-gray-100">
<td className="py-2 px-3">{d.eventTitle}</td>
<td className="py-2 px-3">{d.donor}</td>
<td className="py-2 px-3 text-gray-500">{d.email || ""}</td>
<td className="py-2 px-3 text-right">R {d.amount.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {d.used.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {d.unused.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</div>
)}
{report === "cashup" && (
<div className="space-y-4">
{Object.keys(financialsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return null;
return (
<div key={evId} className="border border-gray-100 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<span className={"text-xs px-2 py-0.5 rounded-full font-medium " + (f.event?.cashupStatus === "closed" ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>{f.event?.cashupStatus === "closed" ? "Closed" : "Open"}</span>
</div>
<div className="mb-4">
<div className="text-xs text-gray-500 mb-2">Income by method</div>
<HorizontalBarChart
data={METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: f.paymentsByMethod?.[m] || 0 }))}
valueFormatter={v => `R ${v.toFixed(2)}`}
/>
</div>
<div className="overflow-auto border border-gray-100 rounded-lg">
<table className="min-w-[640px] text-sm w-full">
<thead><tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50"><th className="py-2 px-3">Method</th><th className="py-2 px-3 text-right">Income</th><th className="py-2 px-3 text-right">Costs from method</th><th className="py-2 px-3 text-right">Expected cash</th><th className="py-2 px-3 text-right">Actual</th><th className="py-2 px-3 text-right">Variance</th></tr></thead>
<tbody>
{METHOD_KEYS.map(m => {
const r = f.reconciled?.byMethod?.[m];
return (
<React.Fragment key={m}>
<tr className="border-t border-gray-100">
<td className="py-2 px-3">{METHOD_LABEL[m]}</td>
<td className="py-2 px-3 text-right">R {(f.paymentsByMethod?.[m] || 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {(f.costsByMethod?.[m] || 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
<td className={"py-2 px-3 text-right " + (r?.variance != null && r.variance !== 0 ? (r.variance < 0 ? "text-rose-600" : "text-emerald-600") : "")}>{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td>
</tr>
{m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => (
<tr key={d.id} className="text-xs text-gray-500 border-t border-gray-100">
<td className="py-1 px-3 pl-6">{denomLabel(d.value)} × {d.count}</td>
<td colSpan={4}></td>
<td className="py-1 px-3 text-right">R {(d.value * d.count).toFixed(2)}</td>
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{(f.untaggedCostsTotal || 0) > 0 && (
<div className="text-sm text-gray-600 flex items-center justify-between mt-2"><span>Untagged costs (not tied to a method)</span><span>R {f.untaggedCostsTotal.toFixed(2)}</span></div>
)}
<div className="text-sm font-medium flex items-center justify-between mt-2 border-t pt-1"><span>Donations counted as profit</span><span>R {(f.unallocatedDonationsTotal || 0).toFixed(2)}</span></div>
</div>
);
})
)}
</div>
)}
{report === "financeReport" && (
<div className="space-y-4">
{Object.keys(financialsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
Object.keys(financialsByEvent).map(evId => {
const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return null;
return (
<div key={evId} className="border border-gray-100 rounded-xl p-4 space-y-4">
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<StatTileRow>
<StatTile icon={ShoppingCart} label="What was sold" value={`R ${(f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0).toFixed(2)}`} tone="green" />
<StatTile icon={CreditCard} label="Paid by (all methods)" value={`R ${METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0).toFixed(2)}`} tone="blue" />
<StatTile icon={HandHeart} label="Donations counted as profit" value={`R ${(f.unallocatedDonationsTotal || 0).toFixed(2)}`} tone="rose" />
<StatTile icon={FileSpreadsheet} label="Total costs" value={`R ${(f.totalCosts || 0).toFixed(2)}`} tone="amber" />
<StatTile icon={TrendingUp} label="Net profit" value={`R ${(f.netProfit || 0).toFixed(2)}`} tone="violet" />
</StatTileRow>
{(f.salesByOption || []).length > 0 && (
<div>
<div className="text-xs font-medium text-gray-500 mb-2">What was sold</div>
<HorizontalBarChart
data={(f.salesByOption || []).map((s: any) => ({ label: `${s.name} (${s.quantitySold})`, value: s.revenue }))}
valueFormatter={v => `R ${v.toFixed(2)}`}
/>
</div>
)}
<div>
<div className="text-xs font-medium text-gray-500 mb-2">Paid by</div>
<HorizontalBarChart
data={METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: f.paymentsByMethod?.[m] || 0 }))}
valueFormatter={v => `R ${v.toFixed(2)}`}
/>
</div>
<div className="overflow-auto border border-gray-100 rounded-lg">
<div className="text-xs font-medium text-gray-500 px-3 pt-2">Comparison to cashup</div>
<table className="min-w-[480px] text-sm w-full">
<thead><tr className="text-left text-gray-500 text-xs uppercase tracking-wide"><th className="py-2 px-3">Method</th><th className="py-2 px-3 text-right">Expected</th><th className="py-2 px-3 text-right">Actual</th><th className="py-2 px-3 text-right">Variance</th></tr></thead>
<tbody>
{METHOD_KEYS.map(m => {
const r = f.reconciled?.byMethod?.[m];
return (
<tr key={m} className="border-t border-gray-100">
<td className="py-2 px-3">{METHOD_LABEL[m]}</td>
<td className="py-2 px-3 text-right">R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">{r?.actual != null ? `R ${r.actual.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
<td className="py-2 px-3 text-right">{r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{(f.costs || []).length > 0 && (
<div>
<div className="text-xs font-medium text-gray-500 mb-1">Costs</div>
<ul className="text-sm text-gray-700 divide-y divide-gray-100">
{(f.costs || []).map((c: any) => (
<li key={c.id} className="flex items-center justify-between py-1"><span>{c.label}{c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}</span><span>-R {(c.total ?? c.amount).toFixed(2)}</span></li>
))}
</ul>
</div>
)}
</div>
);
})
)}
</div>
)}
{report === "profitReport" && (
<div>
{Object.keys(financialsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (() => {
const evIds = Object.keys(financialsByEvent);
const totals = evIds.reduce((acc, evId) => {
const f = financialsByEvent[evId];
acc.revenue += f?.effectiveTotalRevenue || 0;
acc.costs += f?.totalCosts || 0;
acc.profit += f?.netProfit || 0;
return acc;
}, { revenue: 0, costs: 0, profit: 0 });
return (
<>
<StatTileRow>
<StatTile icon={TrendingUp} label="Revenue" value={`R ${totals.revenue.toFixed(2)}`} tone="green" />
<StatTile icon={FileSpreadsheet} label="Costs" value={`R ${totals.costs.toFixed(2)}`} tone="amber" />
<StatTile icon={CreditCard} label="Net profit" value={`R ${totals.profit.toFixed(2)}`} tone="violet" />
</StatTileRow>
{evIds.length > 1 && (
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Net profit by event</div>
<HorizontalBarChart
data={evIds.map(evId => ({ label: filteredEvents.find(e => e.id === evId)?.title || evId, value: financialsByEvent[evId]?.netProfit || 0 }))}
valueFormatter={v => `R ${v.toFixed(2)}`}
/>
</div>
)}
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[600px] text-sm w-full">
<thead><tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50"><th className="py-2 px-3">Event</th><th className="py-2 px-3 text-right">Revenue</th><th className="py-2 px-3 text-right">Costs</th><th className="py-2 px-3 text-right">Net profit</th></tr></thead>
<tbody>
{evIds.map(evId => {
const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId);
return (
<tr key={evId} className="border-t border-gray-100">
<td className="py-2 px-3">{ev?.title || evId}</td>
<td className="py-2 px-3 text-right">R {(f?.effectiveTotalRevenue || 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {(f?.totalCosts || 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right font-medium">R {(f?.netProfit || 0).toFixed(2)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
})()}
</div>
)}
{report === "cashupAudit" && (
<div className="space-y-3">
{auditRows.length === 0 ? (
<div className="text-sm text-gray-500">No close/reopen actions match the selected filters.</div>
) : (
auditRows.map((r: any) => (
<div key={r.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="text-sm">
<span className="font-medium">{r.event?.title || r.eventId}</span>
<span className="text-gray-500"> {actionLabel(r.action)} {r.performedBy?.name || "Unknown"} {new Date(r.createdAt).toLocaleString()}</span>
</div>
{(r.action === "closed" || r.action === "quick_closed") && (
<span className="text-xs text-gray-500">Donations counted as profit: R {(r.unallocatedDonationsTotal || 0).toFixed(2)}</span>
)}
</div>
{r.notes && <div className="text-xs text-gray-500 mt-1">Notes: {r.notes}</div>}
{(r.action === "closed" || r.action === "quick_closed") && (
<div className="overflow-auto mt-2">
<table className="min-w-[560px] text-sm">
<thead><tr><th className="text-left">Method</th><th className="text-left">Expected</th><th className="text-left">Actual</th><th className="text-left">Variance</th><th className="text-left">Δ vs previous close</th></tr></thead>
<tbody>
{(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, actualAmount: null, variance: null }))).map((l: any) => (
<tr key={l.method}>
<td>{METHOD_LABEL[l.method] || l.method}</td>
<td>{l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"}</td>
<td>{l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : <span className="text-gray-400">not reconciled</span>}</td>
<td>{l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"}</td>
<td>{r._deltaVsPrevious?.[l.method] != null ? `R ${r._deltaVsPrevious[l.method].toFixed(2)}` : <span className="text-gray-400">{r._previousCloseAt ? "n/a" : "no previous close"}</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))
)}
</div>
)}
</>
)}
</ReportViewerModal>
)}
</div>
);
}