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
@@ -26,7 +26,7 @@ export default function EventCashupPage() {
const router = useRouter();
const { token } = useAuth();
const [tab, setTab] = useState<"costs" | "reconciliation">("costs");
const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs");
const [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
@@ -72,6 +72,7 @@ export default function EventCashupPage() {
<div className="flex gap-2 border-b">
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
<button className={"px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}>Report</button>
</div>
{loading && <div className="text-sm text-gray-400">Loading</div>}
@@ -89,8 +90,13 @@ export default function EventCashupPage() {
setBusy={setBusy}
setError={setError}
onChanged={load}
onClosed={() => setTab("report")}
/>
)}
{!loading && data && tab === "report" && (
<ReportTab eventId={eventId} token={token || ""} data={data} isClosed={isClosed} />
)}
</div>
);
}
@@ -266,9 +272,349 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
// ─── Reconciliation tab ─────────────────────────────────────────────────────
function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged }: {
type PersonDenom = { value: number; count: number };
type MethodTotals = { total: number; count: number };
type CashMethodTotals = MethodTotals & {
actual: number | null; variance: number | null; denominations: PersonDenom[];
enteredBy: { id: string; name: string } | null; countUpdatedAt: string | null; notes: string | null;
};
type AccountabilityRow = {
userId: string | null; name: string; email: string | null;
cash: CashMethodTotals; card: MethodTotals; eft: MethodTotals; other: MethodTotals;
};
type AccountabilityResponse = { rows: AccountabilityRow[]; cashActualTotal: number | null; cashDenominations: PersonDenom[] };
// Payment accountability, per staff member who recorded the payment, broken down by method —
// separate from the overall cash reconciliation above, which only totals the float without
// saying who's responsible for it. For cash, staff can optionally enter what was physically
// counted for each person, any time (not required to close the event); the event's cash actual
// is the live sum of these per-person counts, shown here and used by the reconciliation table.
function CashByUserSection({ eventId, token, onCashSummaryChange }: {
eventId: string; token: string;
onCashSummaryChange?: (summary: { actualTotal: number | null; denominations: PersonDenom[] }) => void;
}) {
const [data, setData] = useState<AccountabilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(true);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const load = () => {
if (!token || !eventId) return;
setLoading(true);
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
.finally(() => setLoading(false));
};
useEffect(load, [eventId, token]);
const rows = data?.rows || [];
const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") =>
rows.reduce((s, r) => s + (r[m][key] || 0), 0);
const totalCash = sumOf("cash", "total");
const totalCashActual = rows.reduce((s, r) => s + (r.cash.actual || 0), 0);
const grandTotal = rows.reduce((s, r) => s + r.cash.total + r.card.total + r.eft.total + r.other.total, 0);
return (
<div className="bg-white border rounded-lg p-4 space-y-2">
<button className="text-sm font-medium flex items-center gap-1" onClick={() => setOpen(o => !o)}>
<span>{open ? "▾" : "▸"}</span> Payment accountability by staff member
</button>
{open && (
loading ? (
<div className="text-sm text-gray-400">Loading</div>
) : rows.length === 0 ? (
<div className="text-sm text-gray-500">No payments recorded for this event.</div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm min-w-[760px] border-separate border-spacing-0">
<thead>
<tr className="text-gray-400 text-[10px] uppercase tracking-wide">
<th rowSpan={2} className="text-left align-bottom pb-1 pr-3">Staff member</th>
<th colSpan={3} className="text-center pb-1 border-l border-gray-100 px-2">Cash</th>
<th rowSpan={2} className="text-right align-bottom pb-1 border-l border-gray-100 px-2">Card</th>
<th rowSpan={2} className="text-right align-bottom pb-1 px-2">EFT</th>
<th rowSpan={2} className="text-right align-bottom pb-1 px-2">Other</th>
<th rowSpan={2} className="text-right align-bottom pb-1 border-l border-gray-100 px-2">Total</th>
<th rowSpan={2}></th>
</tr>
<tr className="text-left text-gray-500 border-b text-xs">
<th className="text-right pb-1.5 border-l border-gray-100 px-2 font-normal">Expected</th>
<th className="text-right pb-1.5 px-2 font-normal">Actual</th>
<th className="text-right pb-1.5 px-2 font-normal">Variance</th>
<th className="border-l border-gray-100"></th>
<th></th>
<th></th>
<th className="border-l border-gray-100"></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<React.Fragment key={r.userId || "unknown"}>
<tr className="border-b border-gray-100 last:border-0">
<td className="py-2.5 pr-3">
<div className="font-medium text-gray-800">{r.name}</div>
{r.email && <div className="text-xs text-gray-400">{r.email}</div>}
</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(r.cash.total)}</td>
<td className="py-2.5 text-right px-2">{r.cash.actual != null ? money(r.cash.actual) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right px-2">
{r.cash.variance != null ? (
Math.abs(r.cash.variance) > 0.01 ? (
<span className={"inline-block px-1.5 py-0.5 rounded text-xs font-medium " + (r.cash.variance < 0 ? "bg-rose-50 text-rose-700" : "bg-amber-50 text-amber-700")}>
{money(r.cash.variance)}
</span>
) : <span className="inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-50 text-emerald-700">Matches</span>
) : <span className="text-gray-300"></span>}
</td>
<td className="py-2.5 text-right text-gray-600 border-l border-gray-100 px-2">{r.card.total > 0 ? money(r.card.total) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right text-gray-600 px-2">{r.eft.total > 0 ? money(r.eft.total) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right text-gray-600 px-2">{r.other.total > 0 ? money(r.other.total) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right font-medium border-l border-gray-100 px-2">{money(r.cash.total + r.card.total + r.eft.total + r.other.total)}</td>
<td className="py-2.5 text-right pl-2">
{r.userId && (
<button
className="text-xs text-indigo-600 hover:underline whitespace-nowrap"
onClick={() => setEditingUserId(editingUserId === r.userId ? null : r.userId)}
>
{r.cash.actual != null ? "Edit count" : "Enter count"}
</button>
)}
</td>
</tr>
{r.userId && editingUserId === r.userId && (
<tr>
<td colSpan={9} className="pb-3">
<PersonCashCountEditor
eventId={eventId}
token={token}
userId={r.userId}
initialDenominations={r.cash.denominations}
initialNotes={r.cash.notes}
enteredBy={r.cash.enteredBy}
countUpdatedAt={r.cash.countUpdatedAt}
onSaved={() => { setEditingUserId(null); load(); }}
onCancel={() => setEditingUserId(null)}
/>
</td>
</tr>
)}
</React.Fragment>
))}
<tr className="font-semibold border-t border-gray-200">
<td className="py-2.5 pr-3">Total</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(totalCash)}</td>
<td className="py-2.5 text-right px-2">{money(totalCashActual)}</td>
<td className="py-2.5 text-right px-2">{money(totalCashActual - totalCash)}</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(sumOf("card", "total"))}</td>
<td className="py-2.5 text-right px-2">{money(sumOf("eft", "total"))}</td>
<td className="py-2.5 text-right px-2">{money(sumOf("other", "total"))}</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(grandTotal)}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
)
)}
</div>
);
}
// Denomination entry for one person's actual cash count — pick a denomination from the dropdown,
// enter how many, add it to the list. Repeatable, editable, removable before saving.
function PersonCashCountEditor({ eventId, token, userId, initialDenominations, initialNotes, enteredBy, countUpdatedAt, onSaved, onCancel }: {
eventId: string; token: string; userId: string;
initialDenominations: PersonDenom[]; initialNotes: string | null;
enteredBy: { id: string; name: string } | null; countUpdatedAt: string | null;
onSaved: () => void; onCancel: () => void;
}) {
const initialCounts: Record<number, string> = {};
for (const d of initialDenominations) initialCounts[d.value] = String(d.count);
const [counts, setCounts] = useState<Record<number, string>>(initialCounts);
const [notes, setNotes] = useState(initialNotes || "");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const setCount = (value: number, v: string) => setCounts(prev => ({ ...prev, [value]: v }));
const lines: PersonDenom[] = ZAR_DENOMINATIONS
.map(value => ({ value, count: parseInt(counts[value] || "0", 10) || 0 }))
.filter(d => d.count > 0);
const runningTotal = lines.reduce((s, l) => s + l.value * l.count, 0);
const save = async () => {
setSaving(true); setErr(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, {
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
});
onSaved();
} catch (e: any) {
setErr(e?.message || "Failed to save count");
} finally {
setSaving(false);
}
};
return (
<div className="border rounded-lg p-3 bg-gray-50 space-y-3">
{err && <div className="text-xs text-red-600">{err}</div>}
{enteredBy && countUpdatedAt && (
<div className="text-[11px] text-gray-500">Last entered by {enteredBy.name} on {new Date(countUpdatedAt).toLocaleString()}</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{ZAR_DENOMINATIONS.map(v => (
<div key={v} className="flex items-center gap-2">
<span className="text-sm w-14">{denomLabel(v)}</span>
<span className="text-xs text-gray-400">×</span>
<input
type="number"
min={0}
step={1}
className="w-16 border rounded px-2 py-1 text-sm"
value={counts[v] || ""}
onChange={e => setCount(v, e.target.value)}
/>
</div>
))}
</div>
<div className="text-sm font-medium">Total: {money(runningTotal)}</div>
<div>
<label className="block text-[11px] text-gray-600 mb-1">Notes (optional)</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={notes} onChange={e => setNotes(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<button className="text-xs px-3 py-1.5 rounded border" onClick={onCancel} disabled={saving}>Cancel</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
</div>
</div>
);
}
// ─── Report tab ──────────────────────────────────────────────────────────
// A clean, read-only summary of the cashup — opened automatically once the event is closed, so
// staff land straight on "here's what happened" instead of the editable Reconciliation tab.
function ReportTab({ eventId, token, data, isClosed }: {
eventId: string; token: string; data: EventFinancials; isClosed: boolean;
}) {
const reconciled = data.reconciled;
const latestClose = data.history.find(h => h.action === "closed" || h.action === "quick_closed");
return (
<div className="space-y-4">
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-medium">Cashup report</div>
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
{isClosed ? "Closed" : "Open (live preview)"}
</span>
</div>
{latestClose ? (
<div className="text-xs text-gray-500">
{latestClose.action === "closed" ? "Closed (full cashup)" : "Quick closed"} by {latestClose.performedBy?.name || "Unknown"} on {new Date(latestClose.createdAt).toLocaleString()}
</div>
) : (
<div className="text-xs text-gray-500">This event hasn&apos;t been closed yet figures below are a live preview and will change as more payments come in.</div>
)}
</div>
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
<div className="text-sm font-medium">Revenue by method</div>
<table className="w-full text-sm min-w-[560px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Method</th>
<th className="py-1 text-right">Income</th>
<th className="py-1 text-right">Expected cash</th>
<th className="py-1 text-right">Actual</th>
<th className="py-1 text-right">Variance</th>
</tr>
</thead>
<tbody>
{METHODS.map(m => {
const r = reconciled?.byMethod?.[m];
return (
<tr key={m} className="border-b last:border-0">
<td className="py-1.5">{METHOD_LABELS[m]}</td>
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
<td className="py-1.5 text-right">{r?.actual != null ? money(r.actual) : "—"}</td>
<td className="py-1.5 text-right">{r?.variance != null ? money(r.variance) : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{reconciled?.byMethod?.cash?.denominations?.length ? (
<div className="bg-white border rounded-lg p-4 space-y-2">
<div className="text-sm font-medium">Cash denomination count</div>
<table className="text-sm">
<tbody>
{reconciled.byMethod.cash.denominations.map((d: any) => (
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
))}
</tbody>
</table>
</div>
) : null}
<CashByUserSection eventId={eventId} token={token} />
{data.costs.length > 0 && (
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
<div className="text-sm font-medium">Costs</div>
<table className="w-full text-sm min-w-[480px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Label</th>
<th className="py-1">Paid from</th>
<th className="py-1 text-right">Total</th>
</tr>
</thead>
<tbody>
{data.costs.map((c: any) => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5">{c.label}</td>
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
<td className="py-1.5 text-right">{money(c.total ?? c.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div>
<div className="text-gray-500 text-xs">Donations counted as profit</div>
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Total costs</div>
<div className="font-semibold">{money(data.totalCosts)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Net profit</div>
<div className="font-semibold">{money(data.netProfit)}</div>
</div>
</div>
</div>
);
}
function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged, onClosed }: {
eventId: string; token: string; data: EventFinancials; busy: boolean;
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void;
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void;
}) {
const isClosed = data.event.cashupStatus === "closed";
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
@@ -281,32 +627,23 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
return base;
}, [draftLines]);
const initialDenomCounts: Record<number, string> = useMemo(() => {
const cashLine = (draftLines || []).find(l => l.method === "cash");
const out: Record<number, string> = {};
for (const d of cashLine?.denominations || []) out[d.value] = String(d.count);
return out;
}, [draftLines]);
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
const [denomCounts, setDenomCounts] = useState<Record<number, string>>(initialDenomCounts);
const [closeNotes, setCloseNotes] = useState("");
const [reopenNotes, setReopenNotes] = useState("");
// Cash no longer has its own manual entry — it's the live sum of every staff member's
// per-person count, reported up from CashByUserSection below.
const [cashSummary, setCashSummary] = useState<{ actualTotal: number | null; denominations: PersonDenom[] }>({ actualTotal: null, denominations: [] });
useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]);
useEffect(() => { setLines(initialLines); }, [initialLines]);
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
};
const cashDenominationsPayload = () => ZAR_DENOMINATIONS
.map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 }))
.filter(d => d.count > 0);
const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0);
// Cash is included with notes only — its actual/denominations are always sourced server-side
// from per-person counts (see cashupController.closeEvent), never from this payload.
const buildLinesPayload = () => METHODS.map(m => m === "cash"
? { method: "cash", denominations: cashDenominationsPayload(), notes: lines.cash.notes || null }
? { method: "cash", notes: lines.cash.notes || null }
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
const saveDraft = async () => {
@@ -327,6 +664,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
@@ -340,6 +678,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
@@ -391,7 +730,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed ? (
money(r?.actual ?? null)
) : m === "cash" ? (
money(cashActualFromDenoms)
cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : <span className="text-gray-400">not counted</span>
) : (
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
)}
@@ -400,7 +739,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
: (m === "cash"
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "")
? (cashSummary.actualTotal != null ? money(cashSummary.actualTotal - data.expectedCashByMethod[m]) : "—")
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
</td>
<td className="py-1.5">
@@ -413,38 +752,31 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
})}
</tbody>
</table>
{!isClosed && (
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below there's no separate event-wide entry.</div>
)}
</div>
<CashByUserSection eventId={eventId} token={token} onCashSummaryChange={setCashSummary} />
<div className="bg-white border rounded-lg p-4 space-y-2">
<div className="text-sm font-medium">Cash denomination count</div>
{isClosed ? (
reconciled?.byMethod?.cash?.denominations?.length ? (
{(() => {
const denoms = isClosed ? (reconciled?.byMethod?.cash?.denominations || []) : cashSummary.denominations;
return denoms.length > 0 ? (
<table className="text-sm">
<tbody>
{reconciled.byMethod.cash.denominations.map(d => (
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
{denoms.map((d: any) => (
<tr key={d.id || d.value}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
))}
</tbody>
</table>
) : <div className="text-xs text-gray-400">No denomination breakdown recorded for this cashup.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{ZAR_DENOMINATIONS.map(v => (
<div key={v} className="flex items-center gap-2">
<span className="text-sm w-14">{denomLabel(v)}</span>
<span className="text-xs text-gray-400">×</span>
<input
type="number"
min={0}
step={1}
className="w-16 border rounded px-2 py-1 text-sm"
value={denomCounts[v] || ""}
onChange={e => setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))}
/>
</div>
))}
</div>
)}
) : (
<div className="text-xs text-gray-400">
{isClosed ? "No denomination breakdown recorded for this cashup." : "No per-staff-member cash counts entered yet — see “Payment accountability by staff member” above."}
</div>
);
})()}
</div>
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
@@ -157,6 +157,32 @@ export default function AdminRegistrationsPage() {
return "text-gray-700 bg-gray-50";
};
const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
// Aggregate stats across the currently filtered registrations — counts by status, plus
// revenue/outstanding totals (cancelled registrations are excluded from the money totals
// since they're not expected to be paid).
const stats = useMemo(() => {
const counts: Record<string, number> = {};
let totalRevenue = 0;
let totalOutstanding = 0;
filtered.forEach((r: any) => {
counts[r.status] = (counts[r.status] || 0) + 1;
if (r.status === "cancelled") return;
const due = totalDueFor(r);
const paid = totalPaidFor(r);
totalRevenue += paid;
totalOutstanding += Math.max(due - paid, 0);
});
return { counts, totalRevenue, totalOutstanding };
}, [filtered]);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
@@ -173,6 +199,24 @@ export default function AdminRegistrationsPage() {
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
{/* Aggregate stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2 mb-4">
{STATUS_OPTIONS.map(s => (
<div key={s} className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500 capitalize">{s.replace("_", " ")}</div>
<div className="text-lg font-semibold">{stats.counts[s] || 0}</div>
</div>
))}
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500">Total revenue</div>
<div className="text-lg font-semibold text-green-700">R {stats.totalRevenue.toFixed(2)}</div>
</div>
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500">Total outstanding</div>
<div className="text-lg font-semibold text-amber-700">R {stats.totalOutstanding.toFixed(2)}</div>
</div>
</div>
{/* Filters */}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<div className="flex flex-wrap gap-3 items-end">
@@ -221,12 +265,9 @@ export default function AdminRegistrationsPage() {
<div className="border rounded-xl bg-white shadow-sm">
<ul className="divide-y text-sm">
{filtered.map((r: any) => {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const totalDue = totalDueFor(r);
const totalPaid = totalPaidFor(r);
const outstanding = Math.max(totalDue - totalPaid, 0);
const isExpanded = expanded.has(r.id);
const responses = formResponses[r.id];
const loadingResponse = loadingForms.has(r.id);
@@ -245,7 +286,9 @@ export default function AdminRegistrationsPage() {
<div className="text-xs text-gray-500 mt-0.5">
{r.user?.email && <span className="mr-2">{r.user.email}</span>}
{r.user?.phoneNumber && <span className="mr-2">{r.user.phoneNumber}</span>}
<span>R {totalDue.toFixed(2)}</span>
<span>R {totalPaid.toFixed(2)} paid</span>
{outstanding > 0.000001 && <span className="ml-2 text-amber-700">R {outstanding.toFixed(2)} owing</span>}
<span className="ml-2 text-gray-400">(R {totalDue.toFixed(2)} total)</span>
<span className="ml-2 text-gray-400">#{String(r.id).slice(0, 8)}</span>
</div>
</div>
@@ -306,6 +349,31 @@ export default function AdminRegistrationsPage() {
</div>
)}
{/* Payments */}
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Payments</div>
{(r.payments || []).length === 0 ? (
<div className="text-xs text-gray-400">No payments recorded.</div>
) : (
<div className="grid sm:grid-cols-2 gap-2">
{r.payments.map((p: any) => (
<div key={p.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
</div>
<div className="text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
<div className="text-gray-500">Recorded by: {p.recordedBy.name}</div>
)}
</div>
))}
</div>
)}
<div className="text-xs text-gray-700 mt-1 font-medium">
Paid: R {totalPaid.toFixed(2)}{outstanding > 0.000001 && <span className="text-amber-700"> · Owing: R {outstanding.toFixed(2)}</span>}
</div>
</div>
{/* Form responses */}
<div>
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
+83 -47
View File
@@ -12,6 +12,7 @@ interface UserItem {
email: string;
role: string;
phoneNumber?: string | null;
notificationPreference?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
@@ -20,6 +21,8 @@ interface UserItem {
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
type Role = typeof roleOptions[number];
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
@@ -71,10 +74,11 @@ export default function AdminUsersPage() {
const [cRole, setCRole] = useState<Role>("user");
const [creating, setCreating] = useState(false);
// Inline edit state
// Edit modal state
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
const [saving, setSaving] = useState(false);
const editingUser = useMemo(() => users.find(u => u.id === editingId) || null, [users, editingId]);
const buildQuery = useCallback((p: number, ps = pageSize) => {
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
@@ -152,6 +156,7 @@ export default function AdminUsersPage() {
email: editData.email,
role: editData.role,
phoneNumber: editData.phoneNumber || null,
notificationPreference: editData.notificationPreference,
isActive: editData.isActive,
};
if (editData.password && editData.password.trim().length > 0) {
@@ -305,8 +310,8 @@ export default function AdminUsersPage() {
<th className="p-2">Email</th>
<th className="p-2">Role</th>
<th className="p-2">Phone</th>
<th className="p-2">Notify</th>
<th className="p-2">Active</th>
<th className="p-2">Password</th>
<th className="p-2">Actions</th>
</tr>
</thead>
@@ -314,63 +319,30 @@ export default function AdminUsersPage() {
{users.map(u => (
<tr key={u.id} className="border-t hover:bg-gray-50">
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-44" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
) : (
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
)}
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-60" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
) : (
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
)}
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<select className="border rounded px-2 py-1" value={(editData.role as Role) || (u.role as Role)} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
) : (
<span className="capitalize">{u.role}</span>
)}
<span className="capitalize">{u.role}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-36" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
) : (
<span>{u.phoneNumber || ""}</span>
)}
<span>{u.phoneNumber || ""}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
) : (
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
)}
<span className="capitalize">{u.notificationPreference || "email"}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="password" placeholder="Set new password" className="border rounded px-2 py-1 w-44" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
) : (
<span className="text-gray-400"></span>
)}
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<div className="flex gap-2">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-2 py-1 text-xs rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
) : (
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Sign out all devices">Sessions</button>
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
)}
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
</td>
</tr>
))}
@@ -431,6 +403,70 @@ export default function AdminUsersPage() {
</div>
)}
</div>
{editingUser && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => !saving && cancelEdit()} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold">Edit user</h2>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Close</button>
</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Name</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Email</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Role</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={(editData.role as Role) || "user"} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Phone</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
</div>
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Notification preference</label>
<select
className="w-full border rounded px-3 py-2 text-sm"
value={editData.notificationPreference || "email"}
onChange={e => setEditData(d => ({ ...d, notificationPreference: e.target.value }))}
>
{notificationPreferenceOptions.map(p => (
<option key={p} value={p} disabled={p !== "email" && !editData.phoneNumber}>{p}</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
Active
</label>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">New password (leave blank to keep current)</label>
<input type="password" placeholder="Set new password" className="w-full border rounded px-3 py-2 text-sm" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+15 -6
View File
@@ -63,16 +63,25 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
// The Reports page is a full-width, self-contained workspace (its own header, filters, and
// navigation) — the dashboard sidebar's section links (My Events, Profile, Admin, etc.) would
// just crowd it, so it's hidden there specifically, not app-wide.
const hideSidebar = pathname === "/dashboard/supervisor/reports";
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 flex flex-col md:flex-row">
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
{!hideSidebar && (
<>
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
</>
)}
<main className="flex-1 p-6 bg-gray-50">{children}</main>
</div>
<Footer />
@@ -466,8 +466,8 @@ function EmailAttendeesPageInner() {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const sent = res?.sent ?? 0; const matched = res?.matched ?? 0;
setInfo(`Sent ${sent} out of ${matched} recipient(s).`);
const queued = res?.queued ?? res?.matched ?? 0;
setInfo(`Queued ${queued} recipient(s) for sending.`);
// Reset form to default state
resetAttendeesForm();
} catch (e: any) {
@@ -552,7 +552,7 @@ function EmailAttendeesPageInner() {
</select>
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'} <button type="button" className="ml-2 underline hover:no-underline" onClick={() => setShowInfo(true)}>Learn more</button></div>
<div className="text-[11px] text-gray-500 mt-6">Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}, {'{{payment.link}}'} <button type="button" className="ml-2 underline hover:no-underline" onClick={() => setShowInfo(true)}>Learn more</button></div>
</div>
</div>
@@ -572,9 +572,10 @@ function EmailAttendeesPageInner() {
<li><code>{'{{event.title}}'}</code> the event title.</li>
<li><code>{'{{event.start}}'}</code> the event start date/time (local).</li>
<li><code>{'{{balance}}'}</code> outstanding amount across the attendees registrations for the selected event.</li>
<li><code>{'{{payment.link}}'}</code> a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>.</p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend the replacement logic in <span className="font-mono">eventController.emailEventAttendees</span> (replacePlaceholders function) and update this help.</p>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
</div>
</div>
</div>
@@ -7,6 +7,15 @@ import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
// excluded — the money was already counted via the original donation row.
function isDonationLeg(p: any): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
@@ -439,7 +448,8 @@ function PaymentsContent() {
// Stats
const todayTotals = useMemo(() => {
const start = new Date(); start.setHours(0,0,0,0);
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime());
// Exclude donation-application legs — that money was already counted once, as the donation.
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
const donations = today.filter(p => p.isDonation).length;
return { revenue, donations, count: today.length };
@@ -805,7 +815,7 @@ function PaymentsContent() {
{loadingList && <span className="text-xs text-gray-500">Loading</span>}
</div>
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
{payments.slice(0, 25).map(p => {
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
return (
@@ -818,6 +828,9 @@ function PaymentsContent() {
{(p.registration?.user?.name || p.user?.name) && <div className="text-xs text-gray-600">Name: {p.registration?.user?.name || p.user?.name}</div>}
{p.registrationId && <div className="text-xs text-gray-600">Registration: #{String(p.registrationId).slice(0,8)}</div>}
{p.eventId && <div className="text-xs text-gray-600">Event: {p.event?.title || p.eventId}</div>}
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
<div className="text-xs text-gray-500">Recorded by: {p.recordedBy.name}</div>
)}
</li>
);
})}
@@ -1025,27 +1038,50 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
[registrations, registrationId]
);
// Donations "relevant to that event" — unassigned donations logged against the same event
// as the chosen registration.
// A donation is never mutated once assigned — each assignment creates a separate "leg"
// Payment row (isDonation:false, originalPaymentId -> the donation). A donation's remaining
// balance is its original amount minus every leg that references it, so it stays offerable
// (and its registrationId stays null forever) until fully used up.
const legsById = useMemo(() => {
const m = new Map<string, number>();
payments.forEach((p: any) => {
if (p.originalPaymentId && !p.isDonation) {
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + (p.amount || 0));
}
});
return m;
}, [payments]);
// Donations "relevant to that event" — donations logged against the same event as the
// chosen registration that still have a remaining, unused balance.
const donationsForEvent = useMemo(() => {
if (!selectedRegistration) return [] as any[];
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
return payments.filter((p: any) => p.isDonation && !p.registrationId && String(p.eventId) === String(eventId));
}, [payments, selectedRegistration]);
return payments.filter((p: any) => {
if (!p.isDonation || p.registrationId) return false;
if (String(p.eventId) !== String(eventId)) return false;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
return remaining > 0.000001;
});
}, [payments, selectedRegistration, legsById]);
const selectedDonation = useMemo(
() => payments.find((p: any) => String(p.id) === String(paymentId)),
[payments, paymentId]
);
const donationRemaining = selectedDonation
? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0)
: 0;
const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 0) : 0;
// The most that can be allocated: never more than the donation itself, never more than
// what's actually owed. Staff can type a smaller amount to leave a balance outstanding.
// The most that can be allocated: never more than the donation's remaining balance, never
// more than what's actually owed. Staff can type a smaller amount to leave a balance owing.
const maxAllocatable = useMemo(() => {
if (!selectedDonation) return 0;
return Math.min(selectedDonation.amount || 0, outstanding);
}, [selectedDonation, outstanding]);
return Math.min(donationRemaining, outstanding);
}, [selectedDonation, donationRemaining, outstanding]);
// Default to "apply as much as needed" whenever a new donation is picked — the common
// case needs no typing, but the field stays editable for a deliberate partial allocation.
@@ -1055,7 +1091,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [paymentId]);
const amountNum = parseFloat(amountStr || "0");
const leftover = selectedDonation ? Math.max(0, (selectedDonation.amount || 0) - amountNum) : 0;
const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0;
const assign = async () => {
if (!token) return;
@@ -1110,12 +1146,13 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
>
<option value="">Select donation</option>
{donationsForEvent.map((p: any) => {
const label = `R ${(p.amount || 0).toFixed(2)}${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
return <option key={p.id} value={p.id} title={label}>{label}</option>;
})}
</select>
{registrationId && donationsForEvent.length === 0 && (
<div className="text-xs text-gray-500">No unassigned donations for this event.</div>
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
)}
{selectedDonation && (
@@ -1131,8 +1168,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
onChange={e => setAmountStr(e.target.value)}
/>
<div className="text-xs text-gray-500">
Donation is R {(selectedDonation.amount || 0).toFixed(2)}; outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay unassigned as a donation.</>}
Donation has R {donationRemaining.toFixed(2)} remaining (of R {(selectedDonation.amount || 0).toFixed(2)} total); outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.</>}
</div>
</>
)}
@@ -7,13 +7,8 @@ import ReportsV2 from "@/components/reports/ReportsV2";
export default function SupervisorReportsPage() {
const router = useRouter();
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Reports</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
<p className="text-sm text-gray-600 mb-4">View, export, or email operational reports for events.</p>
<ReportsV2 />
<div className="w-full p-6">
<ReportsV2 onBack={() => router.push('/dashboard')} />
</div>
);
}
@@ -476,7 +476,7 @@ function WhatsAppAttendeesPageInner() {
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
};
@@ -655,7 +655,7 @@ function WhatsAppAttendeesPageInner() {
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}, {"{{payment.link}}"}
</div>
</div>
</div>
@@ -676,6 +676,7 @@ function WhatsAppAttendeesPageInner() {
<li><code>{"{{event.title}}"}</code> the event title.</li>
<li><code>{"{{event.start}}"}</code> the event start date/time.</li>
<li><code>{"{{balance}}"}</code> outstanding balance for the event.</li>
<li><code>{"{{payment.link}}"}</code> a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="text-[11px] text-gray-500 mt-2">
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
@@ -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>
);
}
+81 -1
View File
@@ -31,13 +31,26 @@ function valueToString(v: any): string {
return String(v);
}
// New: server-side PDF generation and email helpers
// New: server-side PDF/Excel generation and email helpers
export type ReportStat = { label: string; value: string; tone?: 'green' | 'blue' | 'violet' | 'amber' | 'rose' | 'gray' };
export type ReportChartDatum = { label: string; value: number; displayValue?: string };
export type ReportPdfPayload = {
title: string;
subtitle?: string;
kind: 'table' | 'layered';
orientation?: 'portrait' | 'landscape';
// Optional visual sections rendered above the table/layered body — mirrors the on-screen
// report's stat tiles / bar chart / explanatory note, so exported PDF/Excel/email/WhatsApp
// all look like the same report the web UI shows, not a plain data dump.
stats?: ReportStat[];
chart?: { title?: string; data: ReportChartDatum[] };
note?: string;
table?: { columns: string[]; rows: (string | number)[][] };
layered?: { header?: string; sections: { title: string; items: string[] }[] };
// Additional titled tables rendered below the main table/layered body — e.g. Master Orders'
// separate "Donations made" breakdown, which isn't part of the Orders table itself.
extraTables?: { title: string; columns: string[]; rows: (string | number)[][] }[];
};
export async function downloadReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) {
@@ -64,6 +77,32 @@ export async function downloadReportPdf(apiBase: string, authToken: string, payl
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
}
// Downloads a styled .xlsx mirroring the same branded look as the PDF (colored header,
// stat rows, a data-bar chart, banded table with a highlighted total row).
export async function downloadReportExcel(apiBase: string, authToken: string, payload: ReportPdfPayload) {
const url = `${apiBase}/api/reports/excel`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to generate Excel file (${res.status})`);
}
const blob = await res.blob();
const dl = document.createElement('a');
const objUrl = URL.createObjectURL(blob);
dl.href = objUrl;
const safe = (payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase();
dl.download = `${safe}.xlsx`;
dl.click();
setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
}
export async function emailReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { subject?: string; body?: string }) {
const url = `${apiBase}/api/reports/email`;
const res = await fetch(url, {
@@ -81,6 +120,47 @@ export async function emailReportPdf(apiBase: string, authToken: string, payload
return res.json();
}
// Sends the report PDF to the current user's own WhatsApp number (same self-service pattern as
// emailReportPdf — no recipient picker needed).
export async function whatsappReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload & { caption?: string }) {
const url = `${apiBase}/api/reports/whatsapp`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to send PDF via WhatsApp (${res.status})`);
}
return res.json();
}
// "Print": fetches the same PDF as downloadReportPdf but opens it in a new tab instead of
// downloading, so the browser's built-in PDF viewer's print button handles printing.
export async function viewReportPdf(apiBase: string, authToken: string, payload: ReportPdfPayload) {
const url = `${apiBase}/api/reports/pdf`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Failed to generate PDF (${res.status})`);
}
const blob = await res.blob();
const objUrl = URL.createObjectURL(blob);
window.open(objUrl, '_blank');
setTimeout(() => URL.revokeObjectURL(objUrl), 60000);
}
// Legacy (used elsewhere). Kept in case other code paths still rely on print flow.
export function openPrintWindow(title: string, htmlContent: string) {
const w = window.open('', '_blank');