"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"; 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">("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" && ( )}
); } // ─── 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 ───────────────────────────────────────────────────── function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged }: { eventId: string; token: string; data: EventFinancials; busy: boolean; setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => 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 initialDenomCounts: Record = useMemo(() => { const cashLine = (draftLines || []).find(l => l.method === "cash"); const out: Record = {}; for (const d of cashLine?.denominations || []) out[d.value] = String(d.count); return out; }, [draftLines]); const [lines, setLines] = useState>(initialLines); const [denomCounts, setDenomCounts] = useState>(initialDenomCounts); const [closeNotes, setCloseNotes] = useState(""); const [reopenNotes, setReopenNotes] = useState(""); useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]); 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); const buildLinesPayload = () => METHODS.map(m => m === "cash" ? { method: "cash", denominations: cashDenominationsPayload(), 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(); } 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(); } 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" ? ( money(cashActualFromDenoms) ) : ( setLine(m, "actualAmount", e.target.value)} /> )} {isClosed ? (r?.variance != null ? money(r.variance) : not reconciled) : (m === "cash" ? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—") : (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))} {isClosed ? (r?.notes || "—") : ( setLine(m, "notes", e.target.value)} /> )}
Cash denomination count
{isClosed ? ( reconciled?.byMethod?.cash?.denominations?.length ? ( {reconciled.byMethod.cash.denominations.map(d => ( ))}
{denomLabel(d.value)}× {d.count}{money(d.value * d.count)}
) :
No denomination breakdown recorded for this cashup.
) : (
{ZAR_DENOMINATIONS.map(v => (
{denomLabel(v)} × setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))} />
))}
)}
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)} )}
  • ))}
); }