Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
"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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||
}
|
||||
Reference in New Issue
Block a user