"use client"; import React, { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; import { apiFetch } from "@/lib/api"; import { useDismissingState } from "@/hooks/useDismissingState"; import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types"; import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react"; const METHOD_LABELS: Record = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" }; const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"]; const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1]; function money(n: number | null | undefined): string { return `R${Number(n || 0).toFixed(2)}`; } function denomLabel(v: number): string { return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`; } type LineInput = { actualAmount: string; notes: string }; export default function EventCashupPage() { const params = useParams<{ id: string }>(); const eventId = params.id; const router = useRouter(); const { token } = useAuth(); const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs"); const [data, setData] = useState(null); const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useDismissingState(null); const [busy, setBusy] = useState(false); const isClosed = data?.event?.cashupStatus === "closed"; const load = async () => { if (!token || !eventId) return; setLoading(true); setError(null); try { const [financials, event] = await Promise.all([ apiFetch(`/api/cashups/event/${eventId}`, { authToken: token }), apiFetch(`/api/events/${eventId}`, { authToken: token }) ]); setData(financials); setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name }))); } catch (e: any) { setError(e?.message || "Failed to load cashup data"); } finally { setLoading(false); } }; useEffect(() => { load(); }, [token, eventId]); return (

{data?.event?.title || "Event"} — Cashup

{isClosed ? "Closed" : "Open"}
{error &&
{error}
}
{loading &&
Loading…
} {!loading && data && tab === "costs" && ( )} {!loading && data && tab === "reconciliation" && ( setTab("report")} /> )} {!loading && data && tab === "report" && ( )}
); } // ─── Costs tab ───────────────────────────────────────────────────────────── function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }: { eventId: string; token: string; costs: EventCost[]; eventOptions: { id: string; name: string }[]; isClosed: boolean; onChanged: () => void; }) { const [editingId, setEditingId] = useState(null); const [label, setLabel] = useState(""); const [costType, setCostType] = useState("once_off"); const [amount, setAmount] = useState(""); const [eventOptionId, setEventOptionId] = useState(""); const [paidFromMethod, setPaidFromMethod] = useState<"" | CashupMethod>(""); const [notes, setNotes] = useState(""); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); const startNew = () => { setEditingId("new"); setLabel(""); setCostType("once_off"); setAmount(""); setEventOptionId(""); setPaidFromMethod(""); setNotes(""); setErr(null); }; const startEdit = (c: EventCost) => { setEditingId(c.id); setLabel(c.label); setCostType(c.costType); setAmount(String(c.amount)); setEventOptionId(c.eventOptionId || ""); setPaidFromMethod(c.paidFromMethod || ""); setNotes(c.notes || ""); setErr(null); }; const cancel = () => setEditingId(null); const save = async () => { if (!label.trim()) { setErr("Label is required"); return; } if (!amount || isNaN(parseFloat(amount))) { setErr("Amount is required"); return; } if (costType === "per_item" && !eventOptionId) { setErr("Select a ticket type for per-item costs"); return; } setSaving(true); setErr(null); try { const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: paidFromMethod || null, notes: notes || null }; if (editingId === "new") { await apiFetch(`/api/events/${eventId}/costs`, { method: "POST", authToken: token, body }); } else { await apiFetch(`/api/costs/${editingId}`, { method: "PUT", authToken: token, body }); } setEditingId(null); onChanged(); } catch (e: any) { setErr(e?.message || "Failed to save cost"); } finally { setSaving(false); } }; const remove = async (id: string) => { if (!confirm("Delete this cost?")) return; try { await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token }); onChanged(); } catch (e: any) { alert(e?.message || "Failed to delete cost"); } }; const totalCosts = costs.reduce((sum, c) => sum + (c.total ?? c.amount), 0); return (
Event costs
{!isClosed && editingId === null && ( )}
{isClosed &&
This event is closed — costs can't be changed until it's reopened.
}
{!isClosed && } {costs.map(c => ( {!isClosed && ( )} ))} {costs.length === 0 && ( )} {costs.length > 0 && ( {!isClosed && )}
Label Type Ticket type Paid from Amount Total
{c.label} {c.costType === "once_off" ? "Once-off" : "Per item"} {c.eventOption?.name || "—"} {c.paidFromMethod || "—"} {money(c.amount)} {money(c.total ?? c.amount)}
No costs added yet.
Total costs {money(totalCosts)}}
{editingId !== null && (
{err &&
{err}
}
setLabel(e.target.value)} placeholder="e.g. Venue hire" />
{costType === "per_item" && (
)}
setAmount(e.target.value)} />

If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.

setNotes(e.target.value)} />
)}
); } // ─── Reconciliation tab ───────────────────────────────────────────────────── 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(null); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(true); const [editingUserId, setEditingUserId] = useState(null); const load = () => { if (!token || !eventId) return; setLoading(true); apiFetch(`/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 (
{open && ( loading ? (
Loading…
) : rows.length === 0 ? (
No payments recorded for this event.
) : (
{rows.map(r => ( {r.userId && editingUserId === r.userId && ( )} ))}
Staff member Cash Card EFT Other Total
Expected Actual Variance
{r.name}
{r.email &&
{r.email}
}
{money(r.cash.total)} {r.cash.actual != null ? money(r.cash.actual) : } {r.cash.variance != null ? ( Math.abs(r.cash.variance) > 0.01 ? ( {money(r.cash.variance)} ) : Matches ) : } {r.card.total > 0 ? money(r.card.total) : } {r.eft.total > 0 ? money(r.eft.total) : } {r.other.total > 0 ? money(r.other.total) : } {money(r.cash.total + r.card.total + r.eft.total + r.other.total)} {r.userId && ( )}
{ setEditingUserId(null); load(); }} onCancel={() => setEditingUserId(null)} />
Total {money(totalCash)} {money(totalCashActual)} {money(totalCashActual - totalCash)} {money(sumOf("card", "total"))} {money(sumOf("eft", "total"))} {money(sumOf("other", "total"))} {money(grandTotal)}
) )}
); } // 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 = {}; for (const d of initialDenominations) initialCounts[d.value] = String(d.count); const [counts, setCounts] = useState>(initialCounts); const [notes, setNotes] = useState(initialNotes || ""); const [saving, setSaving] = useState(false); const [err, setErr] = useState(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 (
{err &&
{err}
} {enteredBy && countUpdatedAt && (
Last entered by {enteredBy.name} on {new Date(countUpdatedAt).toLocaleString()}
)}
{ZAR_DENOMINATIONS.map(v => (
{denomLabel(v)} × setCount(v, e.target.value)} />
))}
Total: {money(runningTotal)}
setNotes(e.target.value)} />
); } // ─── 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 (
Cashup report
{isClosed ? "Closed" : "Open (live preview)"}
{latestClose ? (
{latestClose.action === "closed" ? "Closed (full cashup)" : "Quick closed"} by {latestClose.performedBy?.name || "Unknown"} on {new Date(latestClose.createdAt).toLocaleString()}
) : (
This event hasn't been closed yet — figures below are a live preview and will change as more payments come in.
)}
Revenue by method
{METHODS.map(m => { const r = reconciled?.byMethod?.[m]; return ( ); })}
Method Income Expected cash Actual Variance
{METHOD_LABELS[m]} {money(data.paymentsByMethod[m])} {money(data.expectedCashByMethod[m])} {r?.actual != null ? money(r.actual) : "—"} {r?.variance != null ? money(r.variance) : "—"}
{reconciled?.byMethod?.cash?.denominations?.length ? (
Cash denomination count
{reconciled.byMethod.cash.denominations.map((d: any) => ( ))}
{denomLabel(d.value)}× {d.count}{money(d.value * d.count)}
) : null} {data.costs.length > 0 && (
Costs
{data.costs.map((c: any) => ( ))}
Label Paid from Total
{c.label} {c.paidFromMethod || "—"} {money(c.total ?? c.amount)}
)}
Donations counted as profit
{money(data.unallocatedDonationsTotal)}
Total costs
{money(data.totalCosts)}
Net profit
{money(data.netProfit)}
); } 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; 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; const initialLines: Record = useMemo(() => { const base: Record = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } }; for (const l of draftLines || []) { if (l.method in base) base[l.method as CashupMethod] = { actualAmount: l.actualAmount != null ? String(l.actualAmount) : "", notes: l.notes || "" }; } return base; }, [draftLines]); const [lines, setLines] = useState>(initialLines); 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); }, [initialLines]); const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => { setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } })); }; // 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", notes: lines.cash.notes || null } : { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null }); const saveDraft = async () => { setBusy(true); setError(null); try { await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } }); onChanged(); } catch (e: any) { setError(e?.message || "Failed to save draft"); } finally { setBusy(false); } }; const closeWithCashup = async () => { if (!confirm("Close this event with the entered reconciliation? This will count unallocated donations as profit and fully lock the event until it's reopened.")) return; setBusy(true); setError(null); 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 { setBusy(false); } }; const quickClose = async () => { if (!confirm("Quick close this event without a per-method cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return; setBusy(true); setError(null); 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 { setBusy(false); } }; const reopen = async () => { if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return; setBusy(true); setError(null); try { await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } }); onChanged(); } catch (e: any) { setError(e?.message || "Failed to reopen event"); } finally { setBusy(false); } }; const reconciled = data.reconciled; return (
Cash-up reconciliation
{METHODS.map(m => { const r = reconciled?.byMethod?.[m]; return ( ); })}
Method Income Costs from method Expected cash Actual Variance Notes
{METHOD_LABELS[m]} {money(data.paymentsByMethod[m])} {money(data.costsByMethod[m])} {money(data.expectedCashByMethod[m])} {isClosed ? ( money(r?.actual ?? null) ) : m === "cash" ? ( cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : not counted ) : ( setLine(m, "actualAmount", e.target.value)} /> )} {isClosed ? (r?.variance != null ? money(r.variance) : not reconciled) : (m === "cash" ? (cashSummary.actualTotal != null ? money(cashSummary.actualTotal - data.expectedCashByMethod[m]) : "—") : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))} {isClosed ? (r?.notes || "—") : ( setLine(m, "notes", e.target.value)} /> )}
{!isClosed && (
Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.
)}
Cash denomination count
{(() => { const denoms = isClosed ? (reconciled?.byMethod?.cash?.denominations || []) : cashSummary.denominations; return denoms.length > 0 ? ( {denoms.map((d: any) => ( ))}
{denomLabel(d.value)}× {d.count}{money(d.value * d.count)}
) : (
{isClosed ? "No denomination breakdown recorded for this cashup." : "No per-staff-member cash counts entered yet — see “Payment accountability by staff member” above."}
); })()}
Donations counted as profit
{money(data.unallocatedDonationsTotal)}
Total costs
{money(data.totalCosts)}
Net profit
{money(data.netProfit)}
{!isClosed && (
setCloseNotes(e.target.value)} />
)} {isClosed && (
setReopenNotes(e.target.value)} />
)}
History
{data.history.length === 0 &&
No close/reopen actions yet.
}
    {data.history.map(h => (
  • {h.action === "closed" ? "Closed (full cashup)" : h.action === "quick_closed" ? "Quick closed" : "Reopened"} {" — "}{h.performedBy?.name || "Unknown"} — {new Date(h.createdAt).toLocaleString()} {(h.action === "closed" || h.action === "quick_closed") && ( Donations to profit {money(h.unallocatedDonationsTotal)} · Costs {money(h.totalCosts)} )}
  • ))}
); }