Full site redesign, help system, and dashboard stats fixes

Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:00:10 +02:00
co-authored by Claude Sonnet 5
parent d74fec3a5c
commit 8e6cb542d9
119 changed files with 5116 additions and 4316 deletions
@@ -26,8 +26,8 @@ export default function ReportViewerModal({
<div className="flex items-start justify-between gap-3 sm:gap-4 px-5 pt-4 pb-4">
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<div className="w-11 h-11 rounded-xl bg-indigo-50 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-indigo-600" />
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-brand-600" />
</div>
)}
<div className="min-w-0">
@@ -52,16 +52,16 @@ export default function ReportViewerModal({
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-indigo-50/50">
<div className="flex items-center gap-2 text-sm text-indigo-900 flex-1 min-w-0">
<Info className="w-4 h-4 text-indigo-400 shrink-0" />
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-3 border-b bg-brand-50/50">
<div className="flex items-center gap-2 text-sm text-brand-900 flex-1 min-w-0">
<Info className="w-4 h-4 text-brand-400 shrink-0" />
{filters || <span>This report has no extra filters beyond Events and Date range in the sidebar.</span>}
</div>
{onRefresh && (
<button
onClick={onRefresh}
disabled={busy}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
>
<RefreshCw className={"w-3.5 h-3.5 " + (busy ? "animate-spin" : "")} /> {busy ? "Loading…" : "Refresh"}
</button>
@@ -1,221 +0,0 @@
"use client";
import React, { useState } from "react";
import {
X, Home, Filter, ListFilter, Download, BarChart2, MessageCircleQuestion,
Calendar, CalendarClock, EyeOff, Printer, Mail, FileSpreadsheet, MessageCircle,
CreditCard, Gift, Clock, HandHeart, RefreshCw, type LucideIcon,
} from "lucide-react";
type GuideTab = "overview" | "universal" | "specific" | "exporting" | "fields" | "help";
const TABS: { key: GuideTab; label: string; icon: LucideIcon }[] = [
{ key: "overview", label: "Overview", icon: Home },
{ key: "universal", label: "Universal filters", icon: Filter },
{ key: "specific", label: "Report-specific filters", icon: ListFilter },
{ key: "exporting", label: "Exporting reports", icon: Download },
{ key: "fields", label: "Fields & metrics", icon: BarChart2 },
{ key: "help", label: "Need more help?", icon: MessageCircleQuestion },
];
const TONES = {
indigo: { bg: "bg-indigo-50", icon: "text-indigo-600" },
emerald: { bg: "bg-emerald-50", icon: "text-emerald-600" },
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
} as const;
type Tone = keyof typeof TONES;
function GuideItem({ icon: Icon, title, children, tone = "gray" }: { icon: LucideIcon; title: string; children: React.ReactNode; tone?: Tone }) {
const t = TONES[tone] || TONES.gray;
return (
<div className="flex items-start gap-3">
<div className={"w-8 h-8 rounded-full flex items-center justify-center shrink-0 " + t.bg}>
<Icon className={"w-4 h-4 " + t.icon} />
</div>
<div>
<div className="font-medium text-gray-800">{title}</div>
<div className="text-xs text-gray-500 mt-0.5">{children}</div>
</div>
</div>
);
}
export const GUIDE_DISMISSED_KEY = "hope_events_reports_guide_dismissed";
const ADMIN_EMAIL = "admin@crosscode.co.za";
export default function ReportingGuideModal({ onClose }: { onClose: (dontShowAgain: boolean) => void }) {
const [tab, setTab] = useState<GuideTab>("overview");
const [dontShowAgain, setDontShowAgain] = useState(false);
return (
// Above the site header (Navbar is `sticky top-0 z-50`) and above the report popup
// (z-[60]), since the guide can be opened while a report is showing.
<div className="fixed inset-0 z-[70]">
<div className="absolute inset-0 bg-black/40" onClick={() => onClose(dontShowAgain)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-3xl bg-white rounded-xl shadow-xl" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between px-5 py-4 border-b">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
<MessageCircleQuestion className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-base font-semibold">Reporting guide</h2>
<p className="text-xs text-gray-500">This guide explains how reports work and how to use the available filters.</p>
</div>
</div>
<button className="p-1.5 rounded hover:bg-gray-100" onClick={() => onClose(dontShowAgain)} aria-label="Close">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col sm:flex-row">
<nav className="sm:w-56 shrink-0 border-b sm:border-b-0 sm:border-r p-3 space-y-1">
{TABS.map(t => {
const Icon = t.icon;
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={"w-full flex items-center gap-2 text-left text-sm px-3 py-2 rounded-lg " + (active ? "bg-indigo-50 text-indigo-700 font-medium" : "text-gray-600 hover:bg-gray-50")}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</nav>
<div className="flex-1 min-w-0 p-5 text-sm text-gray-700 max-h-[60vh] overflow-auto">
{tab === "overview" && (
<div className="space-y-4">
<p>Reports help you view key data about your events. You can filter the data, preview it on screen, and export or email it.</p>
<div className="space-y-4">
<GuideItem icon={Filter} title="Use filters" tone="indigo">
Apply universal filters (like events and date range) that affect all reports, and report-specific filters for more detailed results.
</GuideItem>
<GuideItem icon={BarChart2} title="Preview & customize" tone="emerald">
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp.
</GuideItem>
</div>
</div>
)}
{tab === "universal" && (
<div className="space-y-4">
<p>Universal filters live in the sidebar on the left and apply to whichever report you open you only set them once, not per report.</p>
<div className="space-y-4">
<GuideItem icon={Calendar} title="Events" tone="indigo">
Pick one or more events. Every report loads data for exactly these events.
</GuideItem>
<GuideItem icon={EyeOff} title="Include past / inactive / closed events" tone="gray">
Controls which events even appear in the Events list to pick from.
</GuideItem>
<GuideItem icon={CalendarClock} title="Date range" tone="blue">
A preset (This month, Last month, This year) or a custom range. Only applies to reports that are inherently date-based (e.g. Payments between dates, Revenue reports, Cashup audit trail) reports like Attendees or Ticket usage show a live snapshot and ignore the date range.
</GuideItem>
</div>
</div>
)}
{tab === "specific" && (
<div className="space-y-4">
<p>Some reports have extra options that only make sense for that report these appear at the top of the report popup once it&apos;s open, separate from the universal filters.</p>
<div className="space-y-4">
<GuideItem icon={ListFilter} title="Attendees" tone="violet">
Which single event to show (defaults to the first selected event) and whether to include cancelled registrations.
</GuideItem>
<GuideItem icon={BarChart2} title="Registration status breakdown" tone="emerald">
Whether to include cancelled registrations in the counts, and whether to count by number of registrations or by ticket quantity (so someone with 3 tickets counts as 3).
</GuideItem>
<GuideItem icon={RefreshCw} title="Refresh" tone="indigo">
Adjust a report-specific filter, then use the &quot;Refresh&quot; button inside the popup to re-run the report without closing it.
</GuideItem>
</div>
</div>
)}
{tab === "exporting" && (
<div className="space-y-4">
<p>Every report can be exported straight from its popup:</p>
<div className="space-y-4">
<GuideItem icon={Printer} title="Print" tone="gray">
Opens a print-ready PDF in a new tab; use your browser&apos;s print button from there.
</GuideItem>
<GuideItem icon={Mail} title="Email" tone="blue">
Sends the PDF to your own account email.
</GuideItem>
<GuideItem icon={FileSpreadsheet} title="Excel" tone="emerald">
Downloads a styled .xlsx workbook colored header, key totals, and a chart section where available matching the on-screen report.
</GuideItem>
<GuideItem icon={MessageCircle} title="WhatsApp" tone="violet">
Sends the PDF to your own account&apos;s WhatsApp number (needs a valid phone number on file).
</GuideItem>
</div>
</div>
)}
{tab === "fields" && (
<div className="space-y-4">
<p>A few terms come up across several financial reports and are easy to misread here&apos;s what each one actually means:</p>
<div className="space-y-4">
<GuideItem icon={CreditCard} title="Paid" tone="blue">
Money the person paid themselves directly (cash/card/eft/online). Never includes money that reached their order via someone else&apos;s donation.
</GuideItem>
<GuideItem icon={Gift} title="Paid via donation" tone="violet">
The portion of an order that was covered by an assigned donation. This is part of what&apos;s &quot;settled&quot; on the order, but it&apos;s the donor&apos;s money, not the registrant&apos;s so it&apos;s broken out separately and attributed to the donor elsewhere in the report.
</GuideItem>
<GuideItem icon={Clock} title="Outstanding" tone="amber">
What&apos;s still owed on an order, after direct payments and any donation cover.
</GuideItem>
<GuideItem icon={HandHeart} title="Unassigned donations" tone="rose">
Real money already received as a donation that hasn&apos;t been applied to any order yet.
</GuideItem>
<GuideItem icon={BarChart2} title="Donations: Used / Unused" tone="emerald">
How much of a given donation has been assigned to orders (Used) versus what&apos;s still available to assign (Unused). A donation is never overwritten when assigned the original donation record always keeps its full original amount.
</GuideItem>
</div>
</div>
)}
{tab === "help" && (
<div className="space-y-4">
<p>Still stuck? Reach out to the site administrator they can check the underlying data with you or flag anything that looks wrong.</p>
<div className="flex items-start gap-3 border border-gray-100 rounded-xl p-4 bg-gray-50">
<div className="w-9 h-9 rounded-full bg-indigo-50 flex items-center justify-center shrink-0">
<Mail className="w-4 h-4 text-indigo-600" />
</div>
<div>
<div className="font-medium text-gray-800">Site administrator</div>
<a href={`mailto:${ADMIN_EMAIL}`} className="text-sm text-indigo-600 hover:underline">{ADMIN_EMAIL}</a>
</div>
</div>
<p className="text-xs text-gray-500">Financial figures matter if a number in a report doesn&apos;t look right, it&apos;s always worth asking rather than assuming.</p>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between px-5 py-3 border-t">
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} />
Don&apos;t show this again
</label>
<button className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => onClose(dontShowAgain)}>
Got it
</button>
</div>
</div>
</div>
</div>
);
}
-533
View File
@@ -1,533 +0,0 @@
"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));
}
@@ -1,7 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
import { ArrowLeft, HelpCircle, Search } from "lucide-react";
import { ArrowLeft, Search } from "lucide-react";
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
import EventsDropdown from "./EventsDropdown";
@@ -17,7 +17,6 @@ export default function ReportsShell({
dateFrom, setDateFrom, dateTo, setDateTo,
report, setReport,
onViewReport, busy,
onOpenGuide,
onBack,
}: {
events: EventLite[]; isAdmin: boolean;
@@ -28,7 +27,6 @@ export default function ReportsShell({
dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void;
report: ReportKey; setReport: (r: ReportKey) => void;
onViewReport: () => void; busy: boolean;
onOpenGuide: () => void;
onBack?: () => void;
}) {
const [search, setSearch] = useState("");
@@ -141,14 +139,6 @@ export default function ReportsShell({
)}
</div>
</div>
<button type="button" className="w-full flex items-start gap-3 text-left text-sm border rounded-xl p-4 bg-white shadow-sm hover:bg-gray-50" onClick={onOpenGuide}>
<HelpCircle className="w-5 h-5 text-gray-500 shrink-0" />
<span>
<span className="block font-medium text-gray-800">Need help?</span>
<span className="block text-xs text-gray-500">View our reporting guide</span>
</span>
</button>
</aside>
{/* Main: categories, report grid */}
@@ -158,7 +148,7 @@ export default function ReportsShell({
<button
key={c}
type="button"
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
className={"px-3 py-1.5 text-sm rounded-lg border " + (category === c ? "bg-brand-600 text-white border-brand-600" : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50")}
onClick={() => setCategory(c)}
>
{c}
@@ -175,7 +165,7 @@ export default function ReportsShell({
key={r.key}
type="button"
onClick={() => setReport(r.key)}
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-indigo-500 ring-2 ring-indigo-100 bg-indigo-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
className={"text-left border rounded-xl p-4 transition " + (selected ? "border-brand-500 ring-2 ring-brand-100 bg-brand-50/40" : "border-gray-200 hover:border-gray-300 bg-white")}
>
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center mb-3">
<Icon className="w-5 h-5 text-gray-600" />
@@ -199,7 +189,7 @@ export default function ReportsShell({
<button
disabled={busy}
onClick={onViewReport}
className="px-4 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
>
{busy ? "Loading…" : "View report"}
</button>
+43 -24
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
@@ -8,7 +9,6 @@ import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
import ReportingGuideModal, { GUIDE_DISMISSED_KEY } from "./ReportingGuideModal";
import { StatTile, StatTileRow } from "./StatTile";
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
@@ -91,6 +91,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const { token, user } = useAuth();
const role = user?.role || "user";
const canView = role === "admin" || role === "supervisor";
const searchParams = useSearchParams();
// Events list
const [events, setEvents] = useState<any[]>([]);
@@ -135,23 +136,6 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [ready, setReady] = useState(false); // toggled by View button
const [busy, setBusy] = useState(false);
const [popupOpen, setPopupOpen] = useState(false);
const [guideOpen, setGuideOpen] = useState(false);
// Show the reporting guide automatically on first visit, unless the user dismissed it for good.
useEffect(() => {
try {
if (typeof window !== "undefined" && window.localStorage.getItem(GUIDE_DISMISSED_KEY) !== "1") {
setGuideOpen(true);
}
} catch {}
}, []);
const closeGuide = (dontShowAgain: boolean) => {
setGuideOpen(false);
if (dontShowAgain) {
try { window.localStorage.setItem(GUIDE_DISMISSED_KEY, "1"); } catch {}
}
};
// Universal filters — apply to every report's onView loader (per-report filters below stay
// report-specific). A single events selection replaces what used to be ~10 separate
@@ -190,10 +174,48 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
const [auditRows, setAuditRows] = useState<any[]>([]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
// trailing month (today back one month, matching the dashboard KPI window — see
// trailingMonthRanges in backend/src/controllers/statsController.js), instead of landing
// on the plain report grid. deepLinkHandledRef is a ref (not state) so it updates
// synchronously — the "Initialize default selection" effect right below reads it in the
// same commit to skip its own single-event default.
const deepLinkHandledRef = useRef(false);
const [autoViewPending, setAutoViewPending] = useState(false);
useEffect(() => {
if (deepLinkHandledRef.current) return;
if (filteredEvents.length === 0) return;
const reportParam = searchParams.get("report");
if (!reportParam || !REPORTS.some(r => r.key === reportParam)) return;
deepLinkHandledRef.current = true;
setReport(reportParam as ReportKey);
setSelectedEventIds(filteredEvents.map((e: any) => e.id));
if (searchParams.get("range") === "trailing_month") {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30);
const iso = (d: Date) => d.toISOString().slice(0, 10);
setDateFrom(iso(start));
setDateTo(iso(now));
}
setPopupOpen(true);
setAutoViewPending(true);
}, [filteredEvents, searchParams]);
// Fires once the state set above has actually committed (selectedEventIds reflects the
// deep-link's full event list), so onView() below closes over the updated values instead
// of the stale defaults from the render that scheduled it.
useEffect(() => {
if (!autoViewPending || selectedEventIds.length === 0) return;
setAutoViewPending(false);
onView();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoViewPending, selectedEventIds, report]);
// Initialize default selection when events load
useEffect(() => {
if (filteredEvents.length === 0) return;
if (selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id));
if (!deepLinkHandledRef.current && selectedEventIds.length === 0) setSelectedEventIds(filteredEvents.slice(0, 1).map((e: any) => e.id));
if (!attEventId) setAttEventId(filteredEvents[0].id);
}, [filteredEvents]);
@@ -1213,12 +1235,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
report={report} setReport={(r: ReportKey) => { setReport(r); setReady(false); }}
onViewReport={() => { setPopupOpen(true); onView(); }}
busy={busy}
onOpenGuide={() => setGuideOpen(true)}
onBack={onBack}
/>
{guideOpen && <ReportingGuideModal onClose={closeGuide} />}
{popupOpen && (
<ReportViewerModal
title={activeReportMeta?.label || "Report"}
@@ -1697,7 +1716,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<td className="py-2 px-3 text-right">R {masterTotals?.paidViaDonation.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {masterTotals?.outstanding.toFixed(2)}</td>
</tr>
<tr className="text-xs bg-blue-50/60">
<tr className="text-xs bg-brand-50/60">
<td className="py-1.5 px-3" colSpan={3}>Revenue per Ticket</td>
{masterOptions.map(opt => (
File diff suppressed because it is too large Load Diff