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:
@@ -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'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 "Refresh" 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'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'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'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'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's "settled" on the order, but it's the donor's money, not the registrant's — so it's broken out separately and attributed to the donor elsewhere in the report.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Outstanding" tone="amber">
|
||||
What'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'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'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't look right, it'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user