1119 lines
52 KiB
TypeScript
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 => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[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>
|
|
);
|
|
}
|