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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user