Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page

Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:52:54 +02:00
co-authored by Claude Sonnet 5
parent 56f2a9f7fc
commit 0de3f4be7d
42 changed files with 4297 additions and 1586 deletions
@@ -0,0 +1,61 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
export default function EventsDropdown({ options, value, onChange }: {
options: { value: string; label: string }[];
value: string[];
onChange: (v: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
const toggle = (v: string) => {
onChange(value.includes(v) ? value.filter(x => x !== v) : [...value, v]);
};
const summary = value.length === 0
? "No events selected"
: options.length > 0 && value.length === options.length
? "All events"
: value.length === 1
? (options.find(o => o.value === value[0])?.label || "1 event selected")
: `${value.length} events selected`;
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between gap-2 border rounded-lg px-3 py-2 text-sm bg-white hover:bg-gray-50"
>
<span className="truncate text-left">{summary}</span>
<ChevronDown className={"w-4 h-4 text-gray-400 shrink-0 transition-transform " + (open ? "rotate-180" : "")} />
</button>
{open && (
<div className="absolute z-20 mt-1 w-full min-w-[240px] bg-white border rounded-lg shadow-lg max-h-64 overflow-auto p-1">
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-gray-500 border-b mb-1">
<button type="button" className="hover:underline" onClick={() => onChange(options.map(o => o.value))}>Select all</button>
<button type="button" className="hover:underline" onClick={() => onChange([])}>Clear</button>
</div>
{options.map(opt => (
<label key={opt.value} className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
<span className="truncate">{opt.label}</span>
</label>
))}
{options.length === 0 && <div className="px-2 py-1.5 text-xs text-gray-400">No events available.</div>}
</div>
)}
</div>
);
}
@@ -0,0 +1,45 @@
import type { LucideIcon } from "lucide-react";
import {
Calendar,
Users,
PieChart,
Ticket,
TrendingUp,
FileText,
Box,
ListChecks,
Heart,
Camera,
FileBarChart,
BarChart3,
ClipboardList,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
export const REPORT_CATEGORIES: ReportCategory[] = ["Financial", "Registration", "Ticketing", "Donations", "Cashup", "Other"];
export const REPORTS = [
{ key: "payments", label: "Payments between dates", description: "View payments within a date range", category: "Financial", icon: Calendar, fields: 4 },
{ key: "attendees", label: "Attendees per event (grouped by option)", description: "Grouped by option", category: "Registration", icon: Users, fields: 3 },
{ key: "regTypes", label: "Registration type counts", description: "Count by registration type", category: "Registration", icon: PieChart, fields: 2 },
{ key: "usage", label: "Ticket usage summary", description: "Summary of ticket usage", category: "Ticketing", icon: Ticket, fields: 3 },
{ key: "revenue", label: "Revenue summary (by method)", description: "Summary of revenue by payment method", category: "Financial", icon: TrendingUp, fields: 5 },
{ key: "revenueDetailed", label: "Revenue detailed", description: "Detailed revenue breakdown", category: "Financial", icon: FileText, fields: 6 },
{ key: "masterOrders", label: "Master orders breakdown", description: "Overview of orders, payments, and donations for the selected filters", category: "Registration", icon: Box, fields: 4 },
{ key: "regStatus", label: "Registration status breakdown", description: "Registration status overview", category: "Registration", icon: ListChecks, fields: 3 },
{ key: "donations", label: "Donations breakdown", description: "Breakdown of donations", category: "Donations", icon: Heart, fields: 3 },
{ key: "cashup", label: "Cashup reconciliation", description: "Reconcile cashup totals", category: "Cashup", icon: Camera, fields: 4 },
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
] as const satisfies ReadonlyArray<{
key: string;
label: string;
description: string;
category: ReportCategory;
icon: LucideIcon;
fields: number;
}>;
export type ReportKey = typeof REPORTS[number]["key"];
@@ -0,0 +1,81 @@
"use client";
import React from "react";
import { X, Info, RefreshCw, type LucideIcon } from "lucide-react";
export default function ReportViewerModal({
title, description, icon: Icon, onClose, actions, filters, onRefresh, busy, children,
}: {
title: string;
description?: string;
icon?: LucideIcon;
onClose: () => void;
actions?: React.ReactNode;
filters?: React.ReactNode;
onRefresh?: () => void;
busy?: boolean;
children: React.ReactNode;
}) {
return (
// Above the site header (Navbar is `sticky top-0 z-50`) so the popup never sits behind it.
<div className="fixed inset-0 z-[60]">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="absolute inset-0 flex items-start justify-center p-4 overflow-auto">
<div className="w-full max-w-6xl bg-white rounded-xl shadow-xl my-8" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b">
<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>
)}
<div className="min-w-0">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{description && <p className="text-sm text-gray-500 mt-0.5">{description}</p>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{actions}
<button className="p-2 rounded-lg hover:bg-gray-100 ml-1" onClick={onClose} aria-label="Close">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
</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" />
{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"
>
<RefreshCw className={"w-3.5 h-3.5 " + (busy ? "animate-spin" : "")} /> {busy ? "Loading…" : "Refresh"}
</button>
)}
</div>
<div className="p-5">
{children}
</div>
</div>
</div>
</div>
);
}
export function ReportActionButton({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50 whitespace-nowrap"
>
<Icon className="w-4 h-4 text-gray-500" />
{label}
</button>
);
}
@@ -0,0 +1,221 @@
"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>
);
}
@@ -0,0 +1,205 @@
"use client";
import React, { useMemo, useState } from "react";
import { ArrowLeft, HelpCircle, Search } from "lucide-react";
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
import EventsDropdown from "./EventsDropdown";
type EventLite = { id: string; title: string };
type DatePreset = "all_time" | "this_month" | "last_month" | "this_year" | "custom";
export default function ReportsShell({
events, isAdmin,
showPastEvents, setShowPastEvents,
showInactiveEvents, setShowInactiveEvents,
showClosedEvents, setShowClosedEvents,
selectedEventIds, setSelectedEventIds,
dateFrom, setDateFrom, dateTo, setDateTo,
report, setReport,
onViewReport, busy,
onOpenGuide,
onBack,
}: {
events: EventLite[]; isAdmin: boolean;
showPastEvents: boolean; setShowPastEvents: (v: boolean) => void;
showInactiveEvents: boolean; setShowInactiveEvents: (v: boolean) => void;
showClosedEvents: boolean; setShowClosedEvents: (v: boolean) => void;
selectedEventIds: string[]; setSelectedEventIds: (v: string[]) => void;
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("");
const [category, setCategory] = useState<"All" | ReportCategory>("All");
const [datePreset, setDatePreset] = useState<DatePreset>("all_time");
const iso = (d: Date) => d.toISOString().slice(0, 10);
const applyPreset = (preset: DatePreset) => {
setDatePreset(preset);
const now = new Date();
if (preset === "this_month") {
setDateFrom(iso(new Date(now.getFullYear(), now.getMonth(), 1)));
setDateTo(iso(new Date(now.getFullYear(), now.getMonth() + 1, 0)));
} else if (preset === "last_month") {
setDateFrom(iso(new Date(now.getFullYear(), now.getMonth() - 1, 1)));
setDateTo(iso(new Date(now.getFullYear(), now.getMonth(), 0)));
} else if (preset === "this_year") {
setDateFrom(iso(new Date(now.getFullYear(), 0, 1)));
setDateTo(iso(new Date(now.getFullYear(), 11, 31)));
} else if (preset === "all_time") {
setDateFrom(""); setDateTo("");
}
// 'custom' leaves dateFrom/dateTo as whatever's typed in the fields below
};
const filteredReports = useMemo(() => {
return REPORTS.filter(r => {
if (category !== "All" && r.category !== category) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
if (!r.label.toLowerCase().includes(q) && !r.description.toLowerCase().includes(q)) return false;
}
return true;
});
}, [search, category]);
return (
<div>
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Reports</h1>
<p className="text-sm text-gray-500 mt-0.5">View, export, or email operational reports for events.</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="w-56 border rounded-lg pl-8 pr-3 py-2 text-sm"
placeholder="Search reports…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{onBack && (
<button type="button" className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
<ArrowLeft className="w-4 h-4" /> Back
</button>
)}
</div>
</div>
<div className="flex flex-col lg:flex-row gap-6">
{/* Sidebar: universal filters */}
<aside className="lg:w-72 shrink-0 space-y-5">
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
<div className="text-sm font-semibold">Filters</div>
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Events</div>
<EventsDropdown options={events.map(ev => ({ value: ev.id, label: ev.title }))} value={selectedEventIds} onChange={setSelectedEventIds} />
</div>
<div className="space-y-1.5 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showPastEvents} onChange={e => setShowPastEvents(e.target.checked)} />
Include past events
</label>
{isAdmin && (
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showInactiveEvents} onChange={e => setShowInactiveEvents(e.target.checked)} />
Include inactive events
</label>
)}
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showClosedEvents} onChange={e => setShowClosedEvents(e.target.checked)} />
Include closed (cashed-up) events
</label>
</div>
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Date range (where applicable)</div>
<select className="w-full border rounded px-2 py-1.5 text-sm mb-2" value={datePreset} onChange={e => applyPreset(e.target.value as DatePreset)}>
<option value="all_time">All time</option>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
<option value="this_year">This year</option>
<option value="custom">Custom</option>
</select>
{datePreset === "custom" && (
<div className="flex gap-2">
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
<input type="date" className="w-full border rounded px-2 py-1.5 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
</div>
)}
</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 */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap gap-2 mb-4">
{(["All", ...REPORT_CATEGORIES] as const).map(c => (
<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")}
onClick={() => setCategory(c)}
>
{c}
</button>
))}
</div>
<div className="grid sm:grid-cols-2 xl:grid-cols-4 gap-3">
{filteredReports.map(r => {
const Icon = r.icon;
const selected = report === r.key;
return (
<button
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")}
>
<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" />
</div>
<div className="text-sm font-semibold text-gray-900 mb-1">{r.label}</div>
<div className="text-xs text-gray-500 mb-3">{r.description}</div>
<div className="flex items-center justify-between text-[11px] text-gray-400">
<span>{r.category}</span>
<span>{r.fields} fields</span>
</div>
</button>
);
})}
{filteredReports.length === 0 && (
<div className="col-span-full text-sm text-gray-500 py-8 text-center">No reports match your search.</div>
)}
</div>
<div className="mt-6 flex items-center justify-between bg-gray-50 border rounded-xl px-4 py-3">
<div className="text-xs text-gray-500">Filters will be applied to the selected report where relevant.</div>
<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"
>
{busy ? "Loading…" : "View report"}
</button>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
"use client";
import React from "react";
import type { LucideIcon } from "lucide-react";
const TONES = {
green: { bg: "bg-emerald-50", icon: "text-emerald-600" },
blue: { bg: "bg-blue-50", icon: "text-blue-600" },
violet: { bg: "bg-violet-50", icon: "text-violet-600" },
amber: { bg: "bg-amber-50", icon: "text-amber-600" },
rose: { bg: "bg-rose-50", icon: "text-rose-600" },
gray: { bg: "bg-gray-100", icon: "text-gray-600" },
} as const;
export type StatTileTone = keyof typeof TONES;
export function StatTile({ icon: Icon, label, value, tone = "gray" }: { icon: LucideIcon; label: string; value: string; tone?: StatTileTone }) {
const t = TONES[tone] || TONES.gray;
return (
<div className={"flex items-center gap-3 rounded-xl p-3 " + t.bg}>
<div className="w-9 h-9 rounded-lg bg-white/70 flex items-center justify-center shrink-0">
<Icon className={"w-5 h-5 " + t.icon} />
</div>
<div className="min-w-0">
<div className="text-xs text-gray-500 truncate">{label}</div>
<div className="text-sm font-semibold text-gray-900 truncate">{value}</div>
</div>
</div>
);
}
export function StatTileRow({ children }: { children: React.ReactNode }) {
return <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">{children}</div>;
}
@@ -0,0 +1,41 @@
"use client";
import React from "react";
// Fixed categorical order from the validated reference palette (dataviz skill,
// references/palette.md) — never cycled or reassigned per-render, so the same category
// always gets the same color across a session.
export const CATEGORICAL_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#4a3aa7", "#e34948"];
export type BarDatum = { label: string; value: number };
// A simple, dependency-free horizontal bar list: thin rounded track, filled bar, direct value
// label. Suited to comparing a handful of categories' magnitude (the job most reports need) —
// per the dataviz skill's form heuristic, magnitude-by-category is exactly a bar chart's job.
export function HorizontalBarChart({
data, valueFormatter, labelWidthClass = "w-28",
}: {
data: BarDatum[];
valueFormatter?: (v: number) => string;
labelWidthClass?: string;
}) {
const max = Math.max(1, ...data.map(d => Math.abs(d.value)));
const fmt = valueFormatter || ((v: number) => String(v));
return (
<div className="space-y-2.5">
{data.map((d, i) => (
<div key={d.label} className="flex items-center gap-3">
<div className={labelWidthClass + " text-xs text-gray-600 truncate shrink-0"} title={d.label}>{d.label}</div>
<div className="flex-1 h-3 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{ width: `${Math.max(d.value > 0 ? 2 : 0, (Math.abs(d.value) / max) * 100)}%`, backgroundColor: CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length] }}
/>
</div>
<div className="w-24 text-xs text-gray-700 text-right shrink-0 tabular-nums">{fmt(d.value)}</div>
</div>
))}
{data.length === 0 && <div className="text-xs text-gray-400">No data to chart.</div>}
</div>
);
}