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

534 lines
22 KiB
TypeScript

"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 (
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">{title}</div>
<div className="flex gap-2">{actions}</div>
</div>
{children}
</div>
);
}
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 (
<div className={"flex flex-wrap gap-2 " + (className || '')}>
{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 Reports() {
const { token, user } = useAuth();
const role = user?.role || 'user';
const canView = role === 'admin' || role === 'supervisor';
const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/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<string>("");
const [dateTo, setDateTo] = useState<string>("");
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
const [attendeesEventId, setAttendeesEventId] = useState<string>("");
const [includeCancelled, setIncludeCancelled] = useState<boolean>(false);
const [includePastEvents, setIncludePastEvents] = useState<boolean>(false);
// DATA
const [paymentsByEvent, setPaymentsByEvent] = useState<Record<string, any[]>>({});
const [registrationsByEvent, setRegistrationsByEvent] = useState<Record<string, any[]>>({});
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<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] = [];
}
}
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<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] = [];
}
}
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<string, Record<string, number>> = {}; // 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<Record<string, { used: number; unused: number }>>({});
const loadTicketUsage = async (eventIds: string[]) => {
if (!token) return;
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 });
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 (
<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>}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-sm font-medium mb-2">Global filters</div>
<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={dateFrom} onChange={e => setDateFrom(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={dateTo} onChange={e => setDateTo(e.target.value)} />
</div>
<div className="flex-1 min-w-64">
<label className="block text-xs text-gray-600 mb-1">Select events</label>
<MultiSelect
options={events.map(ev => ({ value: ev.id, label: ev.title }))}
value={selectedEvents}
onChange={setSelectedEvents}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} /> Include past events in list
</label>
<button onClick={() => { loadPayments(selectedEvents); loadRegistrations(selectedEvents); }} className="px-3 py-2 text-sm rounded bg-gray-100 hover:bg-gray-200">Refresh data</button>
</div>
</div>
<Section
title="Payments between dates (grouped by event then user)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportPaymentsCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printPayments}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailPayments}>Email</button>
</>
}
>
{loading && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Event</th>
<th>User</th>
<th>Email</th>
<th>Amount</th>
<th>Method</th>
<th>Donation</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{paymentsRows.length === 0 ? (
<tr><td colSpan={7} className="text-sm text-gray-500">No payments match the selected filters.</td></tr>
) : paymentsRows.map((r, idx) => (
<tr key={idx}>
<td>{r.eventTitle}</td>
<td>{r.userName}</td>
<td>{r.userEmail || ''}</td>
<td>R {Number(r.amount).toFixed(2)}</td>
<td>{r.method}</td>
<td>{r.isDonation ? 'Yes' : 'No'}</td>
<td>{new Date(r.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Attendees per event and registration status"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportAttendeesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printAttendees}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailAttendees}>Email</button>
</>
}
>
<div className="flex flex-wrap items-center gap-3 mb-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={attendeesEventId} onChange={e => setAttendeesEventId(e.target.value)}>
{events.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={includeCancelled} onChange={e => setIncludeCancelled(e.target.checked)} /> Include cancelled registrations
</label>
<button className="px-3 py-1.5 text-sm rounded border" onClick={() => loadRegistrations([attendeesEventId])}>Refresh</button>
</div>
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Options</th>
<th>Registered at</th>
</tr>
</thead>
<tbody>
{attendeesRows.length === 0 ? (
<tr><td colSpan={5} className="text-sm text-gray-500">No attendees for selected filters.</td></tr>
) : attendeesRows.map((r, idx) => (
<tr key={idx}>
<td>{r.name}</td>
<td>{r.email || ''}</td>
<td className="capitalize">{r.status}</td>
<td>{r.options}</td>
<td>{new Date(r.registeredAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Registration type counts per event"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportRegTypesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printRegTypes}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailRegTypes}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[480px]">
<thead>
<tr>
<th>Event</th>
<th>Registration Type</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{Object.keys(regTypeCounts).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : (
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 [<tr key={evId}><td>{ev?.title || evId}</td><td colSpan={2} className="text-sm text-gray-500">No registrations</td></tr>];
return rows.map(type => (
<tr key={evId + type}>
<td>{ev?.title || evId}</td>
<td>{type}</td>
<td>{byType[type]}</td>
</tr>
));
})
)}
</tbody>
</table>
</div>
</Section>
<Section
title="Ticket usage summary (extra)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportUsageCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printUsage}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailUsage}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[420px]">
<thead>
<tr>
<th>Event</th>
<th>Used</th>
<th>Unused</th>
</tr>
</thead>
<tbody>
{Object.keys(ticketUsage).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : 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>
</Section>
</div>
);
}
function tableHtml(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 escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
}