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>
222 lines
12 KiB
TypeScript
222 lines
12 KiB
TypeScript
"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>
|
|
);
|
|
}
|