"use client"; import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch, API_BASE } from "@/lib/api"; import { downloadCsv, emailReportPdf, downloadReportPdf } from "@/lib/export"; // Types for report selection const REPORTS = [ { key: "payments", label: "Payments between dates" }, { key: "attendees", label: "Attendees per event (grouped by option)" }, { key: "regTypes", label: "Registration type counts" }, { key: "usage", label: "Ticket usage summary" }, { key: "revenue", label: "Revenue summary (by method)" }, { key: "revenueDetailed", label: "Revenue detailed" }, { key: "regStatus", label: "Registration status breakdown" } ] as const; type ReportKey = typeof REPORTS[number]["key"]; // Simple MultiSelect control used in filters function MultiSelect({ options, value, onChange }: { options: { value: string; label: string }[]; value: string[]; onChange: (v: string[]) => void }) { const toggle = (v: string) => { const set = new Set(value); if (set.has(v)) set.delete(v); else set.add(v); onChange(Array.from(set)); }; return (
{options.map(opt => ( ))}
); } export default function ReportsV2() { 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 [showCancelledEvents, setShowCancelledEvents] = useState(false); const filteredEvents = useMemo(() => { const now = new Date(); return events.filter(ev => { const isPast = ev.endDate && new Date(ev.endDate) < now; const isCancelled = ev.status === "cancelled"; if (!showPastEvents && isPast) return false; if (!showCancelledEvents && isCancelled) return false; return true; }); }, [events, showPastEvents, showCancelledEvents]); useEffect(() => { (async () => { try { setLoadingEvents(true); const isAdmin = role === "admin"; const isSupervisor = role === "supervisor"; const evPath = (isAdmin ? "/api/events/admin/all" : "/api/events"); const evs = await apiFetch(evPath, (isAdmin || isSupervisor) ? { authToken: token || undefined } : 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); // Filters per report // Payments const [payFrom, setPayFrom] = useState(""); const [payTo, setPayTo] = useState(""); const [payEvents, setPayEvents] = useState([]); // Attendees const [attEventId, setAttEventId] = useState(""); const [attIncludeCancelled, setAttIncludeCancelled] = useState(false); // Registration type counts const [regTypeEvents, setRegTypeEvents] = useState([]); // Ticket usage summary const [usageEvents, setUsageEvents] = useState([]); // Revenue summary const [revEvents, setRevEvents] = useState([]); // Revenue detailed const [revDetEvents, setRevDetEvents] = useState([]); // Registration status breakdown const [statusEvents, setStatusEvents] = useState([]); const [statusIncludeCancelled, setStatusIncludeCancelled] = useState(true); // Data stores const [paymentsByEvent, setPaymentsByEvent] = useState>({}); const [registrationsByEvent, setRegistrationsByEvent] = useState>({}); const [ticketUsage, setTicketUsage] = useState>({}); // Initialize default selections when events load useEffect(() => { if (filteredEvents.length === 0) return; const first1 = events.slice(0, 1).map((e: any) => e.id); if (payEvents.length === 0) setPayEvents(first1); if (regTypeEvents.length === 0) setRegTypeEvents(first1); if (usageEvents.length === 0) setUsageEvents(first1); if (revEvents.length === 0) setRevEvents(first1); if (revDetEvents.length === 0) setRevDetEvents(first1); if (statusEvents.length === 0) setStatusEvents(first1); if (!attEventId) setAttEventId(events[0].id); }, [filteredEvents]); // 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; }; // View handler according to selected report const onView = async () => { setBusy(true); setReady(false); try { if (report === "payments") { const byEv = await loadPayments(payEvents); setPaymentsByEvent(byEv); } else if (report === "attendees") { const byEv = await loadRegistrations([attEventId]); setRegistrationsByEvent(byEv); } else if (report === "regTypes") { const byEv = await loadRegistrations(regTypeEvents); setRegistrationsByEvent(byEv); } else if (report === "usage") { const usage = await loadTicketUsage(usageEvents); setTicketUsage(usage); } else if (report === "revenue") { const byEv = await loadPayments(revEvents); setPaymentsByEvent(byEv); } else if (report === "revenueDetailed") { const [byEvP, byEvR] = await Promise.all([ loadPayments(revDetEvents), loadRegistrations(revDetEvents) ]); setPaymentsByEvent(byEvP); setRegistrationsByEvent(byEvR); } else if (report === "regStatus") { const byEv = await loadRegistrations(statusEvents); setRegistrationsByEvent(byEv); } 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 }[] = []; 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] || []) { 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) || {}); 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, }); } } 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)); } }); }); // Early-bird helper (reference = lastPaymentAt if any; else now) const effectiveUnit = (eo: any, referenceTime: any, atTime: Date) => { const base = Number(eo?.price || 0); const tiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : []; 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; }; 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 + effectiveUnit(ro.eventOption, 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 + effectiveUnit(ro.eventOption, lastAt, lastAt) * (ro.quantity || 0), 0); if (paid >= dueAtLast) outstanding = 0; } map.set(r.id, outstanding); }); }); return map; }, [paymentsByEvent, registrationsByEvent]); // 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]); 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 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 totalPaid = paidByReg.get(r.id) || 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, status: r.status, registrationId: r.id, outstanding, }); }); }); rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || ''))); return rows; }, [report, registrationsByEvent, filteredEvents, payFrom, payTo, paidByReg, outstandingByReg]); // 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]); // Export/print/email handlers per report const doExportCsv = () => { if (report === "payments") { return downloadCsv(`payments_${new Date().toISOString().slice(0,10)}`, paymentRows.map(r => ({ Event: r.eventTitle, User: r.userName, Email: r.userEmail || "", Amount: r.amount, Method: r.method, Donation: r.isDonation ? "Yes" : "No", Date: new Date(r.createdAt).toLocaleString(), }))); } if (report === "attendees") { const rows: any[] = []; attendeesLayer.options.forEach(opt => { opt.users.forEach((u: any) => rows.push({ Event: attendeesLayer.eventTitle, Option: opt.optionName, Name: u.name, Email: u.email || "", Status: u.status, Qty: u.Qty })); }); return downloadCsv(`attendees_${attEventId}_${new Date().toISOString().slice(0,10)}`, rows); } if (report === "regTypes") { return downloadCsv(`registration_types_${new Date().toISOString().slice(0,10)}`, regTypeRows.map(r => ({ Event: r.eventTitle, Type: r.type, Quantity: r.qty }))); } if (report === "usage") { const rows: any[] = []; Object.keys(ticketUsage).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); rows.push({ Event: ev?.title || evId, Used: ticketUsage[evId].used, Unused: ticketUsage[evId].unused }); }); return downloadCsv(`ticket_usage_${new Date().toISOString().slice(0,10)}`, rows); } if (report === "revenue") { const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; const rows: any[] = []; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const list = (paymentsByEvent[evId] || []).filter((p: any) => { 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); }); if (list.length === 0) rows.push({ Event: ev?.title || evId, Method: '-', Amount: 0, Total: 0 }); Object.keys(byMethod).forEach(m => rows.push({ Event: ev?.title || evId, Method: m, Amount: byMethod[m], Total: total })); }); return downloadCsv(`revenue_${new Date().toISOString().slice(0,10)}`, rows); } if (report === "revenueDetailed") { return downloadCsv(`revenue_detailed_${new Date().toISOString().slice(0,10)}`, revDetRows.map((r: any) => ({ Event: r.eventTitle, Name: r.userName, Email: r.userEmail || "", Status: r.status || "", TotalPaid: r.totalPaid, Outstanding: r.outstanding, RegistrationId: r.registrationId }))); } if (report === "regStatus") { const rows: any[] = []; Object.keys(registrationsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const regs = registrationsByEvent[evId] || []; const counts: Record = {}; const statuses = ["pending","partial_paid","paid","cancelled"]; statuses.forEach(s => counts[s] = 0); regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; }); rows.push({ Event: ev?.title || evId, Pending: counts["pending"] || 0, Partial: counts["partial_paid"] || 0, Paid: counts["paid"] || 0, Cancelled: counts["cancelled"] || 0 }); }); return downloadCsv(`registration_status_${new Date().toISOString().slice(0,10)}`, rows); } }; const doPrintPdf = async () => { if (!token) { alert('Please login to export PDF'); return; } try { if (report === "payments") { await downloadReportPdf(API_BASE, token, { title: 'Payments Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","User","Email","Amount","Method","Donation","Date"], rows: paymentRows.map(r => [r.eventTitle, r.userName, r.userEmail || "", `R ${Number(r.amount).toFixed(2)}`, r.method, r.isDonation ? "Yes" : "No", new Date(r.createdAt).toLocaleString()]) } }); return; } if (report === "attendees") { // Build layered sections: each option -> list of strings (user line) 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}` : ''}`) })); await downloadReportPdf(API_BASE, token, { title: 'Attendees Report', kind: 'layered', orientation: 'portrait', layered: { header: `Event: ${attendeesLayer.eventTitle}`, sections } }); return; } if (report === "regTypes") { await downloadReportPdf(API_BASE, token, { title: 'Registration Types Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Type","Quantity"], rows: regTypeRows.map(r => [r.eventTitle, r.type, String(r.qty)]) } }); return; } if (report === "usage") { const rows: (string|number)[][] = []; 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]); }); await downloadReportPdf(API_BASE, token, { title: 'Ticket Usage Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Used","Unused"], rows } }); return; } if (report === "revenue") { const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; const rows: (string|number)[][] = []; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const list = (paymentsByEvent[evId] || []).filter((p: any) => { 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); }); 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))])); }); await downloadReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows } }); return; } 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 || "", ]); await downloadReportPdf(API_BASE, token, { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows } }); return; } if (report === "regStatus") { const rows: (string|number)[][] = []; 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) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); }); await downloadReportPdf(API_BASE, token, { title: 'Registration Status Breakdown', kind: 'table', table: { columns: ["Event","Pending","Partial","Paid","Cancelled"], rows } }); return; } } catch (e: any) { alert(e?.message || 'Failed to generate PDF'); } }; const doEmail = async () => { if (!token) { alert('Please login to email PDF'); return; } const subject = { 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", }[report]; const body = "Report generated on " + new Date().toLocaleString(); try { if (report === 'payments') { await emailReportPdf(API_BASE, token, { title: 'Payments Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","User","Email","Amount","Method","Donation","Date"], rows: paymentRows.map(r => [r.eventTitle, r.userName, r.userEmail || "", `R ${Number(r.amount).toFixed(2)}`, r.method, r.isDonation ? "Yes" : "No", new Date(r.createdAt).toLocaleString()]) }, subject, body }); } else 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}` : ''}`) })); await emailReportPdf(API_BASE, token, { title: 'Attendees Report', kind: 'layered', orientation: 'portrait', layered: { header: `Event: ${attendeesLayer.eventTitle}`, sections }, subject, body }); } else if (report === 'regTypes') { await emailReportPdf(API_BASE, token, { title: 'Registration Types Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Type","Quantity"], rows: regTypeRows.map(r => [r.eventTitle, r.type, String(r.qty)]) }, subject, body }); } else if (report === 'usage') { const rows: (string|number)[][] = []; 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]); }); await emailReportPdf(API_BASE, token, { title: 'Ticket Usage Report', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Used","Unused"], rows }, subject, body }); } else if (report === 'revenue') { const df = payFrom ? new Date(payFrom).getTime() : null; const dt = payTo ? new Date(payTo).getTime() : null; const rows: (string|number)[][] = []; Object.keys(paymentsByEvent).forEach(evId => { const ev = filteredEvents.find(e => e.id === evId); const list = (paymentsByEvent[evId] || []).filter((p: any) => { 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); }); 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))])); }); await emailReportPdf(API_BASE, token, { title: 'Revenue Summary', kind: 'table', orientation: 'portrait', table: { columns: ["Event","Method","Amount","Event Total"], rows }, subject, body }); } else 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 || "" ]); await emailReportPdf(API_BASE, token, { title: 'Revenue Detailed', kind: 'table', orientation: 'landscape', table: { columns: ["Event","Name","Email","Status","Total paid","Outstanding","RegistrationId"], rows }, subject, body }); } else if (report === 'regStatus') { const rows: (string|number)[][] = []; 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) + 1; }); rows.push([ev?.title || evId, counts.pending||0, counts.partial_paid||0, counts.paid||0, counts.cancelled||0]); }); await emailReportPdf(API_BASE, token, { title: 'Registration Status Breakdown', kind: 'table', table: { columns: ["Event","Pending","Partial","Paid","Cancelled"], rows }, subject, body }); } alert('Email sent with PDF attachment'); } catch (e: any) { alert(e?.message || 'Failed to email PDF'); } }; // 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)); } // UI return (
{!canView && (
You need supervisor or admin access to view reports.
)} {error &&
{error}
} {/* Step 1: select report */}
Select a report
{REPORTS.map(r => ( ))}
{/* Step 2: filters per report */}
Filters
{report === "payments" && (
setPayFrom(e.target.value)} />
setPayTo(e.target.value)} />
({ value: ev.id, label: ev.title }))} value={payEvents} onChange={setPayEvents} />
)} {report === "attendees" && (
)} {report === "regTypes" && (
({ value: ev.id, label: ev.title }))} value={regTypeEvents} onChange={setRegTypeEvents} />
)} {report === "usage" && (
({ value: ev.id, label: ev.title }))} value={usageEvents} onChange={setUsageEvents} />
)} {report === "revenue" && (
setPayFrom(e.target.value)} />
setPayTo(e.target.value)} />
({ value: ev.id, label: ev.title }))} value={revEvents} onChange={setRevEvents} />
)} {report === "revenueDetailed" && (
setPayFrom(e.target.value)} />
setPayTo(e.target.value)} />
({ value: ev.id, label: ev.title }))} value={revDetEvents} onChange={setRevDetEvents} />
)} {report === "regStatus" && (
({ value: ev.id, label: ev.title }))} value={statusEvents} onChange={setStatusEvents} />
)}
{/* Step 3: results + actions */} {ready && (
Results
{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 {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.
) : ( {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]; return ( ); })}
Event Used Unused
{ev?.title || evId} {tu.used} {tu.unused}
)}
)} {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) => { 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); }); return (
{ev?.title || evId}
Total: R {total.toFixed(2)}
{Object.keys(byMethod).length === 0 ? (
No payments in date range.
) : (
    {Object.keys(byMethod).sort().map(m => (
  • {m}R {byMethod[m].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}
Total: 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.
) : ( {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) + 1; }); return ( ); })}
Event Pending Partial Paid Cancelled
{ev?.title || evId} {counts.pending || 0} {counts.partial_paid || 0} {counts.paid || 0} {counts.cancelled || 0}
)}
)}
)}
); }