Files
hope-events/frontend/src/components/reports/ReportsV2_backup.tsx
T
joshua 3d381944d2 Initial commit
Next.js + Express event management app for Hope Family Church.
2026-07-23 15:26:47 +02:00

1119 lines
52 KiB
TypeScript

"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 (
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<label key={opt.value} className={"px-2 py-1 text-xs rounded border cursor-pointer " + (value.includes(opt.value) ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-800 border-gray-200") }>
<input type="checkbox" className="hidden" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt.label}
</label>
))}
</div>
);
}
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<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(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<any[]>(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<ReportKey>("payments");
const [ready, setReady] = useState(false); // toggled by View button
const [busy, setBusy] = useState(false);
// Filters per report
// Payments
const [payFrom, setPayFrom] = useState<string>("");
const [payTo, setPayTo] = useState<string>("");
const [payEvents, setPayEvents] = useState<string[]>([]);
// Attendees
const [attEventId, setAttEventId] = useState<string>("");
const [attIncludeCancelled, setAttIncludeCancelled] = useState(false);
// Registration type counts
const [regTypeEvents, setRegTypeEvents] = useState<string[]>([]);
// Ticket usage summary
const [usageEvents, setUsageEvents] = useState<string[]>([]);
// Revenue summary
const [revEvents, setRevEvents] = useState<string[]>([]);
// Revenue detailed
const [revDetEvents, setRevDetEvents] = useState<string[]>([]);
// Registration status breakdown
const [statusEvents, setStatusEvents] = useState<string[]>([]);
const [statusIncludeCancelled, setStatusIncludeCancelled] = useState<boolean>(true);
// Data stores
const [paymentsByEvent, setPaymentsByEvent] = useState<Record<string, any[]>>({});
const [registrationsByEvent, setRegistrationsByEvent] = useState<Record<string, any[]>>({});
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
// 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<string, any[]>;
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
return byEv;
};
const loadRegistrations = async (eventIds: string[]) => {
if (!token || !eventIds.length) return {} as Record<string, any[]>;
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
return byEv;
};
const loadTicketUsage = async (eventIds: string[]) => {
if (!token || !eventIds.length) return {} as Record<string, { used: number; unused: number }>;
const result: Record<string, { used: number; unused: number }> = {};
for (const id of eventIds) {
try {
const tickets = await apiFetch<any[]>(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token });
result[id] = { used: tickets.filter(t => t.isUsed).length, unused: tickets.filter(t => !t.isUsed).length };
} catch (e) {
result[id] = { used: 0, unused: 0 };
}
}
return result;
};
// 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<string, number>();
// Build quick index of payments per registration from all payments loaded per event
const paidByReg = new Map<string, number>();
const lastPaymentAtByReg = new Map<string, Date | null>();
Object.keys(paymentsByEvent).forEach(evId => {
(paymentsByEvent[evId] || []).forEach((p: any) => {
if (p.registrationId) {
const regId = p.registrationId;
paidByReg.set(regId, (paidByReg.get(regId) || 0) + (p.amount || 0));
const t = new Date(p.createdAt).getTime();
const prev = lastPaymentAtByReg.get(regId)?.getTime() || -Infinity;
if (t > prev) lastPaymentAtByReg.set(regId, new Date(t));
}
});
});
// 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<string, number>();
Object.keys(paymentsByEvent).forEach(evId => {
(paymentsByEvent[evId] || []).forEach((p: any) => {
if (p.registrationId) {
map.set(p.registrationId, (map.get(p.registrationId) || 0) + (p.amount || 0));
}
});
});
return map;
}, [paymentsByEvent]);
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<string, { optionName: string; users: { name: string; email?: string; status: string; qty: number }[] }>();
for (const r of regs) {
if (!attIncludeCancelled && r.status === "cancelled") continue;
const baseUser = {
name: r.user?.name || r.userId,
email: r.user?.email,
status: r.status,
};
const options = (r.registrationOptions || []);
if (options.length === 0) {
const key = "(No option)";
if (!byOption.has(key)) byOption.set(key, { optionName: key, users: [] });
byOption.get(key)!.users.push({
...baseUser,
qty: 0
});
}
else {
for (const ro of options) {
const key = ro.eventOption?.name || "Option";
if (!byOption.has(key)) {
byOption.set(key, { optionName: key, users: [] });
}
byOption.get(key)!.users.push({
...baseUser,
qty: ro.quantity || 0
});
}
}
}
const options = Array.from(byOption.values()).sort((a,b) => a.optionName.localeCompare(b.optionName));
options.forEach(o => o.users.sort((a,b) => (a.name || '').localeCompare(b.name || '')));
return { eventTitle: ev?.title || attEventId, options };
}, [report, registrationsByEvent, attEventId, attIncludeCancelled, filteredEvents]);
// Derived for reg type counts
const regTypeRows = useMemo(() => {
if (report !== "regTypes") return [] as { eventTitle: string; type: string; qty: number }[];
const rows: { eventTitle: string; type: string; qty: number }[] = [];
for (const evId of Object.keys(registrationsByEvent)) {
const ev = filteredEvents.find(e => e.id === evId);
const title = ev?.title || evId;
const regs = registrationsByEvent[evId] || [];
const counts: Record<string, number> = {};
for (const r of regs) {
if (r.status === "cancelled") continue;
for (const ro of (r.registrationOptions || [])) {
const name = ro.eventOption?.name || "Option";
counts[name] = (counts[name] || 0) + (ro.quantity || 0);
}
}
for (const type of Object.keys(counts)) rows.push({ eventTitle: title, type, qty: counts[type] });
}
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || a.type.localeCompare(b.type)));
return rows;
}, [report, registrationsByEvent, filteredEvents]);
// 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<string, number> = {};
let total = 0;
list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); });
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<string, number> = {};
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<string, number> = {};
let total = 0;
list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); });
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<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 };
regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 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<string, number> = {}; let total = 0;
list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); });
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<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 }; regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 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 = `<tr>${headers.map(h => `<th>${escapeHtml(h)}</th>`).join("")}</tr>`;
const tbody = rows.map(r => `<tr>${r.map(c => `<td>${escapeHtml(String(c ?? ""))}</td>`).join("")}</tr>`).join("");
return `<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`;
}
function htmlLayered(eventTitle: string, options: { optionName: string; users: { name: string; email?: string; status: string; qty?: number }[] }[]) {
const optHtml = options.map(opt => `
<div class="layer-option">
<div class="layer-option-title">${escapeHtml(opt.optionName)}</div>
<ul class="layer-users">
${opt.users.map(u => `<li><span class="name">${escapeHtml(u.name || "")}</span>${u.email ? ` <span class="email">(${escapeHtml(u.email)})</span>` : ""} <span class="status">— ${escapeHtml(u.status)}</span>${typeof u.qty === 'number' ? ` <span class="status"> • Qty: ${u.qty}</span>` : ''}</li>`).join("")}
</ul>
</div>
`).join("");
return `
<style>
.layer-event{font-size:16px;font-weight:700;margin:4px 0 10px 0}
.layer-option{margin:8px 0 12px 16px;page-break-inside:avoid}
.layer-option-title{font-weight:600;margin-bottom:6px}
.layer-users{list-style:disc;margin-left:20px}
.layer-users li{margin:3px 0;font-size:12px}
.layer-users .name{font-weight:500}
.layer-users .email{color:#555}
.layer-users .status{color:#666}
</style>
<div class="layer-event">${escapeHtml(eventTitle)}</div>
${optHtml}
`;
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c] as string));
}
// UI
return (
<div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor or admin access to view reports.</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{/* Step 1: select report */}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6 space-y-3">
<div className="text-sm font-medium">Select a report</div>
<div className="flex flex-wrap gap-2">
{REPORTS.map(r => (
<label key={r.key} className={`px-3 py-2 text-sm rounded border cursor-pointer ${report===r.key? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'}`}>
<input type="radio" className="hidden" name="report" value={r.key} checked={report===r.key} onChange={() => { setReport(r.key); setReady(false); }} />
{r.label}
</label>
))}
</div>
</div>
{/* Step 2: filters per report */}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6 space-y-4">
<div className="text-sm font-medium">Filters</div>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showPastEvents}
onChange={e => setShowPastEvents(e.target.checked)}
/>
Show past events
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showCancelledEvents}
onChange={e => setShowCancelledEvents(e.target.checked)}
/>
Show cancelled events
</label>
</div>
{report === "payments" && (
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={payFrom} onChange={e => setPayFrom(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={payTo} onChange={e => setPayTo(e.target.value)} />
</div>
<div className="min-w-64 flex-1">
<label className="block text-xs text-gray-600 mb-1">Events</label>
<MultiSelect options={filteredEvents.map(ev => ({ value: ev.id, label: ev.title }))} value={payEvents} onChange={setPayEvents} />
</div>
</div>
)}
{report === "attendees" && (
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm">
<span className="text-gray-600 mr-2">Event</span>
<select className="border rounded px-3 py-2 text-sm" value={attEventId} onChange={e => setAttEventId(e.target.value)}>
{filteredEvents.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={attIncludeCancelled} onChange={e => setAttIncludeCancelled(e.target.checked)} /> Include cancelled registrations
</label>
</div>
)}
{report === "regTypes" && (
<div>
<label className="block text-xs text-gray-600 mb-1">Events</label>
<MultiSelect options={filteredEvents.map(ev => ({ value: ev.id, label: ev.title }))} value={regTypeEvents} onChange={setRegTypeEvents} />
</div>
)}
{report === "usage" && (
<div>
<label className="block text-xs text-gray-600 mb-1">Events</label>
<MultiSelect options={filteredEvents.map(ev => ({ value: ev.id, label: ev.title }))} value={usageEvents} onChange={setUsageEvents} />
</div>
)}
{report === "revenue" && (
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={payFrom} onChange={e => setPayFrom(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={payTo} onChange={e => setPayTo(e.target.value)} />
</div>
<div className="min-w-64 flex-1">
<label className="block text-xs text-gray-600 mb-1">Events</label>
<MultiSelect options={filteredEvents.map(ev => ({ value: ev.id, label: ev.title }))} value={revEvents} onChange={setRevEvents} />
</div>
</div>
)}
{report === "revenueDetailed" && (
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={payFrom} onChange={e => setPayFrom(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={payTo} onChange={e => setPayTo(e.target.value)} />
</div>
<div className="min-w-64 flex-1">
<label className="block text-xs text-gray-600 mb-1">Events</label>
<MultiSelect options={filteredEvents.map(ev => ({ value: ev.id, label: ev.title }))} value={revDetEvents} onChange={setRevDetEvents} />
</div>
</div>
)}
{report === "regStatus" && (
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-64 flex-1">
<label className="block text-xs text-gray-600 mb-1">Events</label>
<MultiSelect options={filteredEvents.map(ev => ({ value: ev.id, label: ev.title }))} value={statusEvents} onChange={setStatusEvents} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={statusIncludeCancelled} onChange={e => setStatusIncludeCancelled(e.target.checked)} /> Include cancelled in counts
</label>
</div>
)}
<div>
<button disabled={busy} onClick={onView} className="px-3 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50">{busy ? 'Loading…' : 'View report'}</button>
</div>
</div>
{/* Step 3: results + actions */}
{ready && (
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-4">
<div className="text-lg font-semibold">Results</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded border" onClick={doExportCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={doPrintPdf}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={doEmail}>Email</button>
</div>
</div>
{report === "payments" && (
<div>
{paymentRows.length === 0 ? (
<div className="text-sm text-gray-500">No payments match the selected filters.</div>
) : (
<div className="space-y-5">
{Object.entries(paymentRows.reduce((acc: any, r: any) => {
acc[r.eventTitle] = acc[r.eventTitle] || [];
acc[r.eventTitle].push(r);
return acc;
}, {})).map(([eventTitle, rows]: any) => {
// group by user
const byUser: Record<string, any[]> = {};
rows.forEach((r: any) => {
const key = r.userName || 'User';
byUser[key] = byUser[key] || [];
byUser[key].push(r);
});
const eventTotal = rows.reduce((s: number, r: any) => s + (r.amount || 0), 0);
return (
<div key={eventTitle} className="border rounded-xl p-4 bg-gray-50">
<div className="flex items-center justify-between mb-2">
<div className="text-base font-semibold">{eventTitle}</div>
<div className="text-sm font-medium">Total: R {eventTotal.toFixed(2)}</div>
</div>
<div className="space-y-3">
{Object.keys(byUser).sort().map(userName => {
const list = byUser[userName];
const userTotal = list.reduce((s: number, r: any) => s + (r.amount || 0), 0);
const email = list.find((r: any) => r.userEmail)?.userEmail || '';
return (
<div key={userName} className="border rounded-lg p-3 bg-white">
<div className="flex items-center justify-between mb-2">
<div className="font-medium">{userName} {email ? <span className="text-gray-600 font-normal">({email})</span> : null}</div>
<div className="text-sm">Subtotal: R {userTotal.toFixed(2)}</div>
</div>
<ul className="text-sm text-gray-700 space-y-1">
{list.map((r: any, i: number) => (
<li key={i} className="flex items-center justify-between">
<span>{r.method}{r.isDonation ? ' • Donation' : ''}</span>
<span>R {Number(r.amount).toFixed(2)} {new Date(r.createdAt).toLocaleString()}</span>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{report === "attendees" && (
<div>
<div className="text-sm text-gray-700 mb-3">Event: <span className="font-medium">{attendeesLayer.eventTitle}</span></div>
{attendeesLayer.options.length === 0 ? (
<div className="text-sm text-gray-500">No attendees for selected filters.</div>
) : (
<div className="space-y-4">
{attendeesLayer.options.map(opt => (
<div key={opt.optionName} className="border rounded-lg p-3">
<div className="font-medium mb-2">{opt.optionName}</div>
<ul className="list-disc ml-5 space-y-1">
{opt.users.map((u: any, i: number) => (
<li key={opt.optionName + i} className="text-sm">
<span className="font-medium">{u.name}</span>
{u.email && <span className="text-gray-600"> ({u.email})</span>}
<span className="text-gray-600"> {u.status}</span>
<span className="text-gray-700"> Qty {u.qty}</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
)}
{report === "regTypes" && (
<div className="overflow-auto">
{regTypeRows.length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
<table className="min-w-[520px]">
<thead>
<tr>
<th>Event</th>
<th>Registration Type</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{regTypeRows.map((r, idx) => (
<tr key={idx}>
<td>{r.eventTitle}</td>
<td>{r.type}</td>
<td>{r.qty}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{report === "usage" && (
<div className="overflow-auto">
{Object.keys(ticketUsage).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
<table className="min-w-[420px]">
<thead>
<tr>
<th>Event</th>
<th>Used</th>
<th>Unused</th>
</tr>
</thead>
<tbody>
{Object.keys(ticketUsage).map(evId => {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
return (
<tr key={evId}>
<td>{ev?.title || evId}</td>
<td>{tu.used}</td>
<td>{tu.unused}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
)}
{report === "revenue" && (
<div className="space-y-4">
{Object.keys(paymentsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
Object.keys(paymentsByEvent).map(evId => {
const ev = events.find(e => e.id === evId);
const df = payFrom ? new Date(payFrom).getTime() : null;
const dt = payTo ? new Date(payTo).getTime() : null;
const list = (paymentsByEvent[evId] || []).filter((p: any) => {
const t = new Date(p.createdAt).getTime();
return !(df && t < df) && !(dt && t > dt);
});
const byMethod: Record<string, number> = {};
let total = 0;
list.forEach((p: any) => { const m = getPaymentMethod(p); byMethod[m] = (byMethod[m] || 0) + (p.amount || 0); total += (p.amount || 0); });
return (
<div key={evId} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="font-medium">{ev?.title || evId}</div>
<div className="text-sm">Total: R {total.toFixed(2)}</div>
</div>
{Object.keys(byMethod).length === 0 ? (
<div className="text-sm text-gray-500">No payments in date range.</div>
) : (
<ul className="text-sm text-gray-700 space-y-1">
{Object.keys(byMethod).sort().map(m => (
<li key={m} className="flex items-center justify-between"><span>{m}</span><span>R {byMethod[m].toFixed(2)}</span></li>
))}
</ul>
)}
</div>
);
})
)}
</div>
)}
{report === "revenueDetailed" && (
<div className="space-y-4">
{Object.keys(registrationsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
Object.keys(registrationsByEvent).map(evId => {
const ev = events.find(e => e.id === evId);
const rows = (revDetRows as any[]).filter(r => r.eventId === evId);
const total = rows.reduce((s, r) => s + (r.totalPaid || 0), 0);
const regIds = rows.map(r => r.registrationId);
const outstandingTotal = regIds.reduce((s, id) => s + (outstandingByReg.get(id) || 0), 0);
return (
<div key={evId} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="font-medium">{ev?.title || evId}</div>
<div className="text-sm">Total: R {total.toFixed(2)}{` • Outstanding: R ${outstandingTotal.toFixed(2)}`}</div>
</div>
{rows.length === 0 ? (
<div className="text-sm text-gray-500">No registrations in date range.</div>
) : (
<div className="overflow-auto">
<table className="min-w-[760px] text-sm">
<thead>
<tr>
<th className="text-left">Name</th>
<th className="text-left">Email</th>
<th className="text-left">Status</th>
<th className="text-left">Total paid</th>
<th className="text-left">Outstanding</th>
<th className="text-left">Registration</th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => (
<tr key={evId + idx}>
<td>{r.userName}</td>
<td>{r.userEmail || ''}</td>
<td className="capitalize">{r.status || ''}</td>
<td>R {Number(r.totalPaid || 0).toFixed(2)}</td>
<td>R {Number(r.outstanding || 0).toFixed(2)}</td>
<td className="font-mono text-xs">{r.registrationId || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})
)}
</div>
)}
{report === "regStatus" && (
<div className="overflow-auto">
{Object.keys(registrationsByEvent).length === 0 ? (
<div className="text-sm text-gray-500">No data available. Select events and view.</div>
) : (
<table className="min-w-[560px]">
<thead>
<tr>
<th>Event</th>
<th>Pending</th>
<th>Partial</th>
<th>Paid</th>
<th>Cancelled</th>
</tr>
</thead>
<tbody>
{Object.keys(registrationsByEvent).map(evId => {
const ev = events.find(e => e.id === evId);
const regs = registrationsByEvent[evId] || [];
const counts: Record<string, number> = { pending: 0, partial_paid: 0, paid: 0, cancelled: 0 };
regs.forEach((r: any) => { if (!statusIncludeCancelled && r.status === 'cancelled') return; counts[r.status] = (counts[r.status] || 0) + 1; });
return (
<tr key={evId}>
<td>{ev?.title || evId}</td>
<td>{counts.pending || 0}</td>
<td>{counts.partial_paid || 0}</td>
<td>{counts.paid || 0}</td>
<td>{counts.cancelled || 0}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
)}
</div>
)}
</div>
);
}