"use client"; import React, { useEffect, useMemo, useState } from "react"; 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 ReportingGuideModal, { GUIDE_DISMISSED_KEY } from "./ReportingGuideModal"; import { StatTile, StatTileRow } from "./StatTile"; import { HorizontalBarChart } from "./charts/HorizontalBarChart"; const METHOD_KEYS = ["cash", "card", "eft", "other"] as const; const METHOD_LABEL: Record = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; 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 = {}; 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 = {}; 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"; // Events list const [events, setEvents] = useState([]); const [loadingEvents, setLoadingEvents] = useState(false); const [error, setError] = useState(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("/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("payments"); const [ready, setReady] = useState(false); // toggled by View button const [busy, setBusy] = useState(false); const [popupOpen, setPopupOpen] = useState(false); const [guideOpen, setGuideOpen] = useState(false); // Show the reporting guide automatically on first visit, unless the user dismissed it for good. useEffect(() => { try { if (typeof window !== "undefined" && window.localStorage.getItem(GUIDE_DISMISSED_KEY) !== "1") { setGuideOpen(true); } } catch {} }, []); const closeGuide = (dontShowAgain: boolean) => { setGuideOpen(false); if (dontShowAgain) { try { window.localStorage.setItem(GUIDE_DISMISSED_KEY, "1"); } catch {} } }; // Universal filters — apply to every report's onView loader (per-report filters below stay // report-specific). A single events selection replaces what used to be ~10 separate // per-report "Events" multi-selects, and a single date range replaces payFrom/payTo and // auditFrom/auditTo — both now live in the sidebar (ReportsShell) instead of being re-picked // per report. const [selectedEventIds, setSelectedEventIds] = useState([]); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); // Back-compat aliases so the many existing date-filtered useMemo/CSV/PDF/email blocks below // (written against payFrom/payTo/auditFrom/auditTo) don't all need renaming. const payFrom = dateFrom, payTo = dateTo, auditFrom = dateFrom, auditTo = dateTo; // Attendees (single-event report — defaults to the first universally-selected event, with // its own picker in the popup if more than one event is selected) const [attEventId, setAttEventId] = useState(""); const [attIncludeCancelled, setAttIncludeCancelled] = useState(false); // 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(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>({}); const [registrationsByEvent, setRegistrationsByEvent] = useState>({}); const [ticketUsage, setTicketUsage] = useState>({}); const [financialsByEvent, setFinancialsByEvent] = useState>({}); const [auditRows, setAuditRows] = useState([]); // Initialize default selection when events load useEffect(() => { if (filteredEvents.length === 0) return; if (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; const byEv: Record = {}; for (const id of eventIds) { try { const list = await apiFetch(`/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; const byEv: Record = {}; for (const id of eventIds) { try { const list = await apiFetch(`/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; const result: Record = {}; for (const id of eventIds) { try { const tickets = await apiFetch(`/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; const byEv: Record = {}; for (const id of eventIds) { try { byEv[id] = await apiFetch(`/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(`/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 const getPaymentMethod = (p: any) => { try { if (p?.externalId) return "Yoco Portal"; } catch {} return p?.method || "Unknown"; }; // 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(); // Build quick index of payments per registration from all payments loaded per event const paidByReg = new Map(); const lastPaymentAtByReg = new Map(); 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(); 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(); 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(); Object.keys(paymentsByEvent).forEach(evId => { (paymentsByEvent[evId] || []).forEach((p: any) => { if (p.registrationId && isDonationLeg(p)) { map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.amount || 0)); } }); }); return map; }, [paymentsByEvent]); // Donations made (as donor), with used/unused split — independent of any single registration, // shown as its own breakdown so "who gave what" is never conflated with "what this order cost". // Shared by Master Orders and Revenue Detailed — both need to attribute the full donation // amount to the donor, never to whichever registration a portion of it happened to fund. const donationsMadeRows = useMemo(() => { const rows: { eventId: string; eventTitle: string; donor: string; email?: string; amount: number; used: number; unused: number; createdAt: string }[] = []; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const evTitle = ev?.title || evId; const evPayments = paymentsByEvent[evId] || []; evPayments.filter((p: any) => p.isDonation).forEach((donation: any) => { const used = evPayments .filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donation.id) .reduce((s: number, leg: any) => s + (leg.amount || 0), 0); rows.push({ eventId: evId, eventTitle: evTitle, donor: donation.user?.name || donation.userId || "Donor", email: donation.user?.email, amount: donation.amount || 0, used, unused: Math.max((donation.amount || 0) - used, 0), createdAt: donation.createdAt, }); }); }); rows.sort((a, b) => a.eventTitle.localeCompare(b.eventTitle) || a.donor.localeCompare(b.donor)); return rows; }, [paymentsByEvent, filteredEvents]); const revDetRows = useMemo(() => { if (report !== "revenueDetailed") return [] as any[]; const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; type Row = { eventId: string; eventTitle: string; userName: string; userEmail?: string; totalPaid: number; status?: string; registrationId: string; outstanding: number }; const rows: Row[] = []; // Iterate registrations to include those with zero payments and show each once. "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(); 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 = {}; 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(); 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(); (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 // 👈 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(); 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 = {}; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const list = (paymentsByEvent[evId] || []).filter((p: any) => { if (isDonationLeg(p)) return false; // already counted once, as the source donation const t = new Date(p.createdAt).getTime(); return !(df && t < df) && !(dt && t > dt); }); const byMethod: Record = {}; let total = 0; list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); 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 = { 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 = { 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") { const usedFor = (evId: string, donationId: string) => (paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId) .reduce((s: number, leg: any) => s + (leg.amount || 0), 0); const rows: (string|number)[][] = []; let grandTotal = 0, grandUsed = 0; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); 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 = {}; 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 = {}; 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 = { 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 = `${headers.map(h => `${escapeHtml(h)}`).join("")}`; const tbody = rows.map(r => `${r.map(c => `${escapeHtml(String(c ?? ""))}`).join("")}`).join(""); return `${thead}${tbody}
`; } function htmlLayered(eventTitle: string, options: { optionName: string; users: { name: string; email?: string; status: string; qty?: number }[] }[]) { const optHtml = options.map(opt => `
${escapeHtml(opt.optionName)}
    ${opt.users.map(u => `
  • ${escapeHtml(u.name || "")}${u.email ? ` ` : ""} — ${escapeHtml(u.status)}${typeof u.qty === 'number' ? ` • Qty: ${u.qty}` : ''}
  • `).join("")}
`).join(""); return `
${escapeHtml(eventTitle)}
${optHtml} `; } function escapeHtml(s: string) { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c] as string)); } // Report-specific filters shown inside the popup — whatever each report had beyond the // universal Events/Date range, which now live in the sidebar (ReportsShell) instead. `null` // when a report has none, so the modal chrome shows its own fallback note. const reportSpecificFilters = report === "attendees" ? (
) : report === "regStatus" ? (
) : null; const reportActions = ready && ( <> ); const activeReportMeta = REPORTS.find(r => r.key === report); // UI return (
{!canView && (
You need supervisor or admin access to view reports.
)} {error &&
{error}
} { setReport(r); setReady(false); }} onViewReport={() => { setPopupOpen(true); onView(); }} busy={busy} onOpenGuide={() => setGuideOpen(true)} onBack={onBack} /> {guideOpen && } {popupOpen && ( setPopupOpen(false)} filters={reportSpecificFilters} actions={reportActions} onRefresh={onView} busy={busy} > {!ready &&
Loading…
} {ready && ( <> {report === "payments" && (
{paymentRows.length === 0 ? (
No payments match the selected filters.
) : (
{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 = {}; 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 (
{eventTitle}
Total: R {eventTotal.toFixed(2)}
{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 (
{userName} {email ? ({email}) : null}
Subtotal: R {userTotal.toFixed(2)}
    {list.map((r: any, i: number) => (
  • {r.method}{r.isDonation ? ' • Donation' : ''}{r.recordedByName ? ` • Recorded by ${r.recordedByName}` : ''} R {Number(r.amount).toFixed(2)} • {new Date(r.createdAt).toLocaleString()}
  • ))}
); })}
); })}
)}
)} {report === "attendees" && (
Event: {attendeesLayer.eventTitle}
{attendeesLayer.options.length === 0 ? (
No attendees for selected filters.
) : (
{attendeesLayer.options.map(opt => (
{opt.optionName}
    {opt.users.map((u: any, i: number) => (
  • {u.name} {u.email && ({u.email})} — {u.status} • Qty {u.qty}
  • ))}
))}
)}
)} {report === "regTypes" && (
{regTypeRows.length === 0 ? (
No data available. Select events and view.
) : (() => { const byType = new Map(); regTypeRows.forEach(r => byType.set(r.type, (byType.get(r.type) || 0) + r.qty)); return ( <>
Across all selected events
({ label, value }))} valueFormatter={v => String(v)} />
{regTypeRows.map((r, idx) => ( ))}
Event Registration Type Quantity
{r.eventTitle} {r.type} {r.qty}
); })()}
)} {report === "usage" && (
{Object.keys(ticketUsage).length === 0 ? (
No data available. Select events and view.
) : ( Object.keys(ticketUsage).map(evId => { const ev = events.find(e => e.id === evId); const tu = ticketUsage[evId]; const total = tu.used + tu.unused; return (
{ev?.title || evId}
{total} tickets total
String(v)} />
); }) )}
)} {report === "revenue" && (
{Object.keys(paymentsByEvent).length === 0 ? (
No data available. Select events and view.
) : ( 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 = {}; let total = 0; list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); }); const chartData = Object.keys(byMethod).sort().map(m => ({ label: m, value: byMethod[m] })); return (
{ev?.title || evId}
R {total.toFixed(2)}
{chartData.length === 0 ? (
No payments in date range.
) : ( `R ${v.toFixed(2)}`} /> )}
); }) )}
)} {report === "revenueDetailed" && (
{Object.keys(registrationsByEvent).length === 0 ? (
No data available. Select events and view.
) : ( 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 (
{ev?.title || evId}
Paid R {total.toFixed(2)} · Outstanding R {outstandingTotal.toFixed(2)}
{rows.length === 0 ? (
No registrations in date range.
) : (
{rows.map((r, idx) => ( ))}
Name Email Status Total paid Outstanding Registration
{r.userName} {r.userEmail || ''} {r.status || ''} R {Number(r.totalPaid || 0).toFixed(2)} R {Number(r.outstanding || 0).toFixed(2)} {r.registrationId || ''}
)}
); }) )}
)} {report === "regStatus" && (
{Object.keys(registrationsByEvent).length === 0 ? (
No data available. Select events and view.
) : ( <> {(() => { const agg: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; Object.values(registrationsByEvent).forEach((regs: any) => { (regs || []).forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; agg[r.status] = (agg[r.status] || 0) + countForStatus(r); }); }); const unit = statusCountMode === 'tickets' ? 'tickets' : 'registrations'; return (
Across all selected events, by {unit}
String(v)} />
); })()}
{Object.keys(registrationsByEvent).map(evId => { const ev = events.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + countForStatus(r); }); return ( ); })}
Event Pending{statusCountMode === 'tickets' ? ' (tickets)' : ''} Partial{statusCountMode === 'tickets' ? ' (tickets)' : ''} Paid{statusCountMode === 'tickets' ? ' (tickets)' : ''} Cancelled{statusCountMode === 'tickets' ? ' (tickets)' : ''}
{ev?.title || evId} {counts.pending || 0} {counts.partial_paid || 0} {counts.paid || 0} {counts.cancelled || 0}
)}
)} {report === "donations" && (
{Object.keys(paymentsByEvent).length === 0 ? (
No data. Select events and view.
) : (() => { // A donation is never mutated once assigned — its "used" amount is the // sum of every leg (isDonation:false, originalPaymentId -> the donation) // referencing it, which — since a donation and its legs share the event // it was logged against in the common case — are already present in the // same per-event payments list. const usedFor = (evId: string, donationId: string) => (paymentsByEvent[evId] || []).filter((leg: any) => !leg.isDonation && leg.originalPaymentId === donationId) .reduce((s: number, leg: any) => s + (leg.amount || 0), 0); let grandTotal = 0, grandUsed = 0, grandCount = 0; const perEvent = Object.keys(paymentsByEvent).map(evId => { const ev = filteredEvents.find(e => e.id === evId); const dons = (paymentsByEvent[evId] || []).filter((p: any) => p.isDonation); const total = dons.reduce((s: number, p: any) => s + (p.amount || 0), 0); const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0); const unused = Math.max(total - used, 0); grandTotal += total; grandUsed += used; grandCount += dons.length; return { evId, title: ev?.title || evId, total, used, unused, count: dons.length }; }); const grandUnused = Math.max(grandTotal - grandUsed, 0); return ( <>
Used vs. unused, across all selected events
`R ${v.toFixed(2)}`} />
{perEvent.map(row => ( ))}
Event Donations (R) Used (R) Unused (R) Count Avg (R)
{row.title} R {row.total.toFixed(2)} R {row.used.toFixed(2)} R {row.unused.toFixed(2)} {row.count} {row.count ? `R ${(row.total / row.count).toFixed(2)}` : '-'}
Total R {grandTotal.toFixed(2)} R {grandUsed.toFixed(2)} R {grandUnused.toFixed(2)} {grandCount}
); })()}
)} {report === "masterOrders" && (
{masterRows.length === 0 ? (
No registrations found.
) : ( <>
Totals
"Paid" is only what each person actually paid themselves — money that reached their order via an assigned donation shows separately under "Paid via donation" and is attributed to the donor below, not the registrant. "Unassigned donations" is real money already received that hasn't been applied to any order yet.
Orders
setMasterOrderSearch(e.target.value)} />
{masterOptions.map(opt => ( ))} {masterRows .filter(r => { const q = masterOrderSearch.trim().toLowerCase(); if (!q) return true; return [r.eventTitle, r.name, r.email].some((v: any) => String(v || "").toLowerCase().includes(q)); }) .map((r, idx) => ( {masterOptions.map(opt => ( ))} ))} {masterOptions.map(opt => ( ))} {masterOptions.map(opt => ( ))} {masterOptions.map(opt => )}
Event Name Email{opt.name}Order Total Paid Paid via donation Outstanding
{r.eventTitle} {r.name} {r.email || ""} {r[opt.name] ?? 0} R {r.orderTotal.toFixed(2)} R {r.totalPaid.toFixed(2)} {r.paidViaDonation > 0.000001 ? `R ${r.paidViaDonation.toFixed(2)}` : ''} R {r.outstanding.toFixed(2)}
TOTAL {masterTotals?.[opt.name] ?? 0} R {masterTotals?.orderTotal.toFixed(2)} R {masterTotals?.totalPaid.toFixed(2)} R {masterTotals?.paidViaDonation.toFixed(2)} R {masterTotals?.outstanding.toFixed(2)}
Revenue per Ticket R {(masterTotals?.[`${opt.name}_revenue`] ?? 0).toFixed(2)}
Unassigned donations R {masterTotals?.unassignedDonations.toFixed(2)}
Donations made
setMasterDonationSearch(e.target.value)} />
Every donation logged for these events, who gave it, and how much of it has been applied to an order (Used) versus still available (Unused). This is independent of the orders table above — a donation isn't tied to the donor's own order.
{donationsMadeRows.length === 0 ? (
No donations logged for these events.
) : (
{donationsMadeRows .filter(d => { const q = masterDonationSearch.trim().toLowerCase(); if (!q) return true; return [d.eventTitle, d.donor, d.email].some((v: any) => String(v || "").toLowerCase().includes(q)); }) .map((d, idx) => ( ))}
Event Donor Email Donated Used Unused
{d.eventTitle} {d.donor} {d.email || ""} R {d.amount.toFixed(2)} R {d.used.toFixed(2)} R {d.unused.toFixed(2)}
)}
)}
)} {report === "cashup" && (
{Object.keys(financialsByEvent).length === 0 ? (
No data available. Select events and view.
) : ( Object.keys(financialsByEvent).map(evId => { const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); if (!f) return null; return (
{ev?.title || evId}
{f.event?.cashupStatus === "closed" ? "Closed" : "Open"}
Income by method
({ label: METHOD_LABEL[m], value: f.paymentsByMethod?.[m] || 0 }))} valueFormatter={v => `R ${v.toFixed(2)}`} />
{METHOD_KEYS.map(m => { const r = f.reconciled?.byMethod?.[m]; return ( {m === "cash" && (r?.denominations || []).length > 0 && (r?.denominations || []).map((d: any) => ( ))} ); })}
MethodIncomeCosts from methodExpected cashActualVariance
{METHOD_LABEL[m]} R {(f.paymentsByMethod?.[m] || 0).toFixed(2)} R {(f.costsByMethod?.[m] || 0).toFixed(2)} R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)} {r?.actual != null ? `R ${r.actual.toFixed(2)}` : not reconciled} {r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}
{denomLabel(d.value)} × {d.count} R {(d.value * d.count).toFixed(2)}
{(f.untaggedCostsTotal || 0) > 0 && (
Untagged costs (not tied to a method)R {f.untaggedCostsTotal.toFixed(2)}
)}
Donations counted as profitR {(f.unallocatedDonationsTotal || 0).toFixed(2)}
); }) )}
)} {report === "financeReport" && (
{Object.keys(financialsByEvent).length === 0 ? (
No data available. Select events and view.
) : ( Object.keys(financialsByEvent).map(evId => { const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); if (!f) return null; return (
{ev?.title || evId}
s + (o.revenue || 0), 0).toFixed(2)}`} tone="green" /> s + (f.paymentsByMethod?.[m] || 0), 0).toFixed(2)}`} tone="blue" /> {(f.salesByOption || []).length > 0 && (
What was sold
({ label: `${s.name} (${s.quantitySold})`, value: s.revenue }))} valueFormatter={v => `R ${v.toFixed(2)}`} />
)}
Paid by
({ label: METHOD_LABEL[m], value: f.paymentsByMethod?.[m] || 0 }))} valueFormatter={v => `R ${v.toFixed(2)}`} />
Comparison to cashup
{METHOD_KEYS.map(m => { const r = f.reconciled?.byMethod?.[m]; return ( ); })}
MethodExpectedActualVariance
{METHOD_LABEL[m]} R {(f.expectedCashByMethod?.[m] || 0).toFixed(2)} {r?.actual != null ? `R ${r.actual.toFixed(2)}` : not reconciled} {r?.variance != null ? `R ${r.variance.toFixed(2)}` : "—"}
{(f.costs || []).length > 0 && (
Costs
    {(f.costs || []).map((c: any) => (
  • {c.label}{c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}-R {(c.total ?? c.amount).toFixed(2)}
  • ))}
)}
); }) )}
)} {report === "profitReport" && (
{Object.keys(financialsByEvent).length === 0 ? (
No data available. Select events and view.
) : (() => { const evIds = Object.keys(financialsByEvent); const totals = evIds.reduce((acc, evId) => { const f = financialsByEvent[evId]; acc.revenue += f?.effectiveTotalRevenue || 0; acc.costs += f?.totalCosts || 0; acc.profit += f?.netProfit || 0; return acc; }, { revenue: 0, costs: 0, profit: 0 }); return ( <> {evIds.length > 1 && (
Net profit by event
({ label: filteredEvents.find(e => e.id === evId)?.title || evId, value: financialsByEvent[evId]?.netProfit || 0 }))} valueFormatter={v => `R ${v.toFixed(2)}`} />
)}
{evIds.map(evId => { const f = financialsByEvent[evId]; const ev = filteredEvents.find(e => e.id === evId); return ( ); })}
EventRevenueCostsNet profit
{ev?.title || evId} R {(f?.effectiveTotalRevenue || 0).toFixed(2)} R {(f?.totalCosts || 0).toFixed(2)} R {(f?.netProfit || 0).toFixed(2)}
); })()}
)} {report === "cashupAudit" && (
{auditRows.length === 0 ? (
No close/reopen actions match the selected filters.
) : ( auditRows.map((r: any) => (
{r.event?.title || r.eventId} — {actionLabel(r.action)} — {r.performedBy?.name || "Unknown"} — {new Date(r.createdAt).toLocaleString()}
{(r.action === "closed" || r.action === "quick_closed") && ( Donations counted as profit: R {(r.unallocatedDonationsTotal || 0).toFixed(2)} )}
{r.notes &&
Notes: {r.notes}
} {(r.action === "closed" || r.action === "quick_closed") && (
{(r.lines && r.lines.length > 0 ? r.lines : METHOD_KEYS.map(m => ({ method: m, expectedAmount: null, actualAmount: null, variance: null }))).map((l: any) => ( ))}
MethodExpectedActualVarianceΔ vs previous close
{METHOD_LABEL[l.method] || l.method} {l.expectedAmount != null ? `R ${l.expectedAmount.toFixed(2)}` : "—"} {l.actualAmount != null ? `R ${l.actualAmount.toFixed(2)}` : not reconciled} {l.variance != null ? `R ${l.variance.toFixed(2)}` : "—"} {r._deltaVsPrevious?.[l.method] != null ? `R ${r._deltaVsPrevious[l.method].toFixed(2)}` : {r._previousCloseAt ? "n/a" : "no previous close"}}
)}
)) )}
)} )}
)}
); }