"use client"; import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; import { downloadCsv, mailtoReport, openPrintWindow } from "@/lib/export"; // Common small UI controls function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) { return (
{title}
{actions}
{children}
); } function MultiSelect({ options, value, onChange, className }: { options: { value: string; label: string }[]; value: string[]; onChange: (v: string[]) => void; className?: string }) { 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 Reports() { const { token, user } = useAuth(); const role = user?.role || 'user'; const canView = role === 'admin' || role === 'supervisor'; const [events, setEvents] = useState([]); const [loadingEvents, setLoadingEvents] = useState(false); const [error, setError] = useState(null); useEffect(() => { (async () => { try { setLoadingEvents(true); const evs = await apiFetch("/api/events/all?includePast=true", { authToken: token || undefined }); setEvents(Array.isArray(evs) ? evs : []); } catch (e: any) { setError(e?.message || 'Failed to load events'); } finally { setLoadingEvents(false); } })(); }, [token, role]); // Filters state const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); const [selectedEvents, setSelectedEvents] = useState([]); const [attendeesEventId, setAttendeesEventId] = useState(""); const [includeCancelled, setIncludeCancelled] = useState(false); const [includePastEvents, setIncludePastEvents] = useState(false); // DATA const [paymentsByEvent, setPaymentsByEvent] = useState>({}); const [registrationsByEvent, setRegistrationsByEvent] = useState>({}); const [loading, setLoading] = useState(false); // Load initial selected events useEffect(() => { if (events.length > 0 && selectedEvents.length === 0) { const nowIso = new Date().toISOString(); const filtered = events.filter(ev => includePastEvents || !ev.endDate || new Date(ev.endDate).toISOString() >= nowIso); const ids = filtered.map(ev => ev.id); setSelectedEvents(ids.slice(0, Math.min(ids.length, 3))); // pick first few by default if (!attendeesEventId && ids.length > 0) setAttendeesEventId(ids[0]); } }, [events, includePastEvents, attendeesEventId]); // Fetch payments per selected event (works for staff via /event/:id, and for supervisor/admin we could also use this) const loadPayments = async (eventIds: string[]) => { if (!token) return; setLoading(true); try { 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] = []; } } setPaymentsByEvent(byEv); } finally { setLoading(false); } }; // Fetch registrations for selected/attendees events const loadRegistrations = async (eventIds: string[]) => { if (!token) return; setLoading(true); try { 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] = []; } } setRegistrationsByEvent(byEv); } finally { setLoading(false); } }; useEffect(() => { if (selectedEvents.length > 0) { loadPayments(selectedEvents); loadRegistrations(selectedEvents); } }, [token, selectedEvents]); // Helpers const paymentsRows = useMemo(() => { // Flatten to rows applying date filter, grouped by event then user in presentation const df = dateFrom ? new Date(dateFrom).getTime() : null; const dt = dateTo ? new Date(dateTo).getTime() : null; const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string; registrationId?: string }[] = []; for (const evId of Object.keys(paymentsByEvent)) { const ev = events.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; // For reporting: if it's a donation, show the payer; otherwise show the user assigned to the registration const user = p.isDonation ? (p.user || {}) : ((p.registration?.user || p.user) || {}); rows.push({ eventId: evId, eventTitle: evTitle, userName: user?.name || user?.email || p.userId || 'User', userEmail: user?.email, amount: p.amount, method: p.method, isDonation: p.isDonation, createdAt: p.createdAt, registrationId: p.registrationId || undefined, }); } } // Sort by event, then user, then date 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; }, [paymentsByEvent, dateFrom, dateTo, events]); const attendeesRows = useMemo(() => { const evId = attendeesEventId; const regs = registrationsByEvent[evId] || []; const filtered = regs.filter((r: any) => includeCancelled ? true : (r.status !== 'cancelled')); const rows = filtered.map((r: any) => ({ name: r.user?.name || r.userId, email: r.user?.email, status: r.status, options: (r.registrationOptions || []).map((ro: any) => `${ro.eventOption?.name || 'Option'} x${ro.quantity}`).join('; '), registeredAt: r.createdAt, })); rows.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')); return rows; }, [registrationsByEvent, attendeesEventId, includeCancelled]); const regTypeCounts = useMemo(() => { // For selectedEvents gather counts of eventOption.name quantities const counts: Record> = {}; // eventId -> optionName -> qty for (const evId of selectedEvents) { const regs = registrationsByEvent[evId] || []; counts[evId] = counts[evId] || {}; for (const r of regs) { if (r.status === 'cancelled') continue; // default exclude for (const ro of (r.registrationOptions || [])) { const name = ro.eventOption?.name || 'Option'; const qty = ro.quantity || 0; counts[evId][name] = (counts[evId][name] || 0) + qty; } } } return counts; }, [registrationsByEvent, selectedEvents]); // Actions for each report const exportPaymentsCsv = () => { downloadCsv(`payments_${new Date().toISOString().slice(0,10)}`, paymentsRows.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() }))); }; const printPayments = () => { const html = tableHtml(['Event','User','Email','Amount','Method','Donation','Date'], paymentsRows.map(r => [ r.eventTitle, r.userName, r.userEmail || '', String(r.amount), r.method, r.isDonation ? 'Yes' : 'No', new Date(r.createdAt).toLocaleString() ])); openPrintWindow('Payments Report', html); }; const emailPayments = () => { const summary = `Payments report generated on ${new Date().toLocaleString()}\nFilters: from ${dateFrom || '-'} to ${dateTo || '-'}; Events: ${selectedEvents.length}`; mailtoReport('Payments Report', summary + '\n\nPlease find the CSV/PDF attached.'); }; const exportAttendeesCsv = () => { downloadCsv(`attendees_${attendeesEventId}_${new Date().toISOString().slice(0,10)}`, attendeesRows.map(r => ({ Name: r.name, Email: r.email || '', Status: r.status, Options: r.options, RegisteredAt: new Date(r.registeredAt).toLocaleString() }))); }; const printAttendees = () => { const html = tableHtml(['Name','Email','Status','Options','Registered At'], attendeesRows.map(r => [r.name, r.email || '', r.status, r.options, new Date(r.registeredAt).toLocaleString()])); openPrintWindow('Attendees Report', html); }; const emailAttendees = () => { const ev = events.find(e => e.id === attendeesEventId); const summary = `Attendees for ${ev?.title || attendeesEventId} generated on ${new Date().toLocaleString()}\nInclude cancelled: ${includeCancelled ? 'Yes' : 'No'}`; mailtoReport('Attendees Report', summary + '\n\nPlease find the CSV/PDF attached.'); }; const exportRegTypesCsv = () => { const rows: any[] = []; for (const evId of Object.keys(regTypeCounts)) { const ev = events.find(e => e.id === evId); const byType = regTypeCounts[evId]; for (const type of Object.keys(byType)) rows.push({ Event: ev?.title || evId, Type: type, Quantity: byType[type] }); } downloadCsv(`registration_types_${new Date().toISOString().slice(0,10)}`, rows); }; const printRegTypes = () => { const rows: string[][] = []; for (const evId of Object.keys(regTypeCounts)) { const ev = events.find(e => e.id === evId); const byType = regTypeCounts[evId]; for (const type of Object.keys(byType)) rows.push([ev?.title || evId, type, String(byType[type])]); } const html = tableHtml(['Event','Type','Quantity'], rows); openPrintWindow('Registration Types Report', html); }; const emailRegTypes = () => { const summary = `Registration types report generated on ${new Date().toLocaleString()} for ${Object.keys(regTypeCounts).length} event(s).`; mailtoReport('Registration Types Report', summary + '\n\nPlease find the CSV/PDF attached.'); }; // Extra useful report: Ticket usage summary per event (Used vs Unused, requires ticket scans info already available via tickets API on staff pages) const [ticketUsage, setTicketUsage] = useState>({}); const loadTicketUsage = async (eventIds: string[]) => { if (!token) return; const result: Record = {}; for (const id of eventIds) { try { const tickets = await apiFetch(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token }); const used = tickets.filter(t => t.isUsed).length; const unused = tickets.filter(t => !t.isUsed).length; result[id] = { used, unused }; } catch (e) { result[id] = { used: 0, unused: 0 }; } } setTicketUsage(result); }; useEffect(() => { if (selectedEvents.length) loadTicketUsage(selectedEvents); }, [token, selectedEvents]); const exportUsageCsv = () => { const rows: any[] = []; for (const evId of Object.keys(ticketUsage)) { const ev = events.find(e => e.id === evId); rows.push({ Event: ev?.title || evId, Used: ticketUsage[evId].used, Unused: ticketUsage[evId].unused }); } downloadCsv(`ticket_usage_${new Date().toISOString().slice(0,10)}`, rows); }; const printUsage = () => { const rows: string[][] = []; for (const evId of Object.keys(ticketUsage)) { const ev = events.find(e => e.id === evId); const tu = ticketUsage[evId]; rows.push([ev?.title || evId, String(tu.used), String(tu.unused)]); } const html = tableHtml(['Event','Used','Unused'], rows); openPrintWindow('Ticket Usage Report', html); }; const emailUsage = () => { const summary = `Ticket usage report generated on ${new Date().toLocaleString()} for ${Object.keys(ticketUsage).length} event(s).`; mailtoReport('Ticket Usage Report', summary + '\n\nPlease find the CSV/PDF attached.'); }; // UI return (
{!canView && (
You need supervisor or admin access to view reports.
)} {error &&
{error}
}
Global filters
setDateFrom(e.target.value)} />
setDateTo(e.target.value)} />
({ value: ev.id, label: ev.title }))} value={selectedEvents} onChange={setSelectedEvents} />
} > {loading &&
Loading…
}
{paymentsRows.length === 0 ? ( ) : paymentsRows.map((r, idx) => ( ))}
Event User Email Amount Method Donation Date
No payments match the selected filters.
{r.eventTitle} {r.userName} {r.userEmail || ''} R {Number(r.amount).toFixed(2)} {r.method} {r.isDonation ? 'Yes' : 'No'} {new Date(r.createdAt).toLocaleString()}
} >
{attendeesRows.length === 0 ? ( ) : attendeesRows.map((r, idx) => ( ))}
Name Email Status Options Registered at
No attendees for selected filters.
{r.name} {r.email || ''} {r.status} {r.options} {new Date(r.registeredAt).toLocaleString()}
} >
{Object.keys(regTypeCounts).length === 0 ? ( ) : ( Object.keys(regTypeCounts).flatMap(evId => { const ev = events.find(e => e.id === evId); const byType = regTypeCounts[evId] || {}; const rows = Object.keys(byType); if (rows.length === 0) return []; return rows.map(type => ( )); }) )}
Event Registration Type Quantity
No data available. Select events and refresh.
{ev?.title || evId}No registrations
{ev?.title || evId} {type} {byType[type]}
} >
{Object.keys(ticketUsage).length === 0 ? ( ) : Object.keys(ticketUsage).map(evId => { const ev = events.find(e => e.id === evId); const tu = ticketUsage[evId]; return ( ); })}
Event Used Unused
No data available. Select events and refresh.
{ev?.title || evId} {tu.used} {tu.unused}
); } function tableHtml(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 escapeHtml(s: string) { return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); }