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
+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 => (