3d381944d2
Next.js + Express event management app for Hope Family Church.
534 lines
22 KiB
TypeScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
|
}
|