Files
hope-events/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

507 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<CashupMethod, string> = { 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<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useDismissingState<string | null>(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<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
apiFetch<any>(`/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 (
<div className="max-w-4xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<button className="text-xs text-indigo-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}> Back to cashup</button>
<h1 className="text-xl font-semibold mt-1">{data?.event?.title || "Event"} Cashup</h1>
</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"}
</span>
</div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
<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>
</div>
{loading && <div className="text-sm text-gray-400">Loading</div>}
{!loading && data && tab === "costs" && (
<CostsTab eventId={eventId} token={token || ""} costs={data.costs} eventOptions={eventOptions} isClosed={isClosed} onChanged={load} />
)}
{!loading && data && tab === "reconciliation" && (
<ReconciliationTab
eventId={eventId}
token={token || ""}
data={data}
busy={busy}
setBusy={setBusy}
setError={setError}
onChanged={load}
/>
)}
</div>
);
}
// ─── 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<string | "new" | null>(null);
const [label, setLabel] = useState("");
const [costType, setCostType] = useState<EventCostType>("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<string | null>(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 (
<div className="bg-white border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Event costs</div>
{!isClosed && editingId === null && (
<button className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={startNew}>+ Add cost</button>
)}
</div>
{isClosed && <div className="text-xs text-gray-500">This event is closed costs can't be changed until it's reopened.</div>}
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Label</th>
<th className="py-1">Type</th>
<th className="py-1">Ticket type</th>
<th className="py-1">Paid from</th>
<th className="py-1 text-right">Amount</th>
<th className="py-1 text-right">Total</th>
{!isClosed && <th className="py-1"></th>}
</tr>
</thead>
<tbody>
{costs.map(c => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5">{c.label}</td>
<td className="py-1.5">{c.costType === "once_off" ? "Once-off" : "Per item"}</td>
<td className="py-1.5">{c.eventOption?.name || "—"}</td>
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
<td className="py-1.5 text-right">{money(c.amount)}</td>
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
{!isClosed && (
<td className="py-1.5 text-right whitespace-nowrap">
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
</td>
)}
</tr>
))}
{costs.length === 0 && (
<tr><td colSpan={7} className="py-3 text-gray-400 text-center">No costs added yet.</td></tr>
)}
</tbody>
{costs.length > 0 && (
<tfoot>
<tr>
<td colSpan={5} className="pt-2 text-right text-gray-500">Total costs</td>
<td className="pt-2 text-right font-semibold">{money(totalCosts)}</td>
{!isClosed && <td />}
</tr>
</tfoot>
)}
</table>
{editingId !== null && (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{err && <div className="text-xs text-red-600">{err}</div>}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Label</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Venue hire" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Type</label>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={costType} onChange={e => setCostType(e.target.value as EventCostType)}>
<option value="once_off">Once-off / overall</option>
<option value="per_item">Per item (ticket type)</option>
</select>
</div>
{costType === "per_item" && (
<div>
<label className="block text-xs text-gray-600 mb-1">Ticket type</label>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={eventOptionId} onChange={e => setEventOptionId(e.target.value)}>
<option value="">Select</option>
{eventOptions.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Amount {costType === "per_item" ? "(per ticket)" : "(flat total)"}</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 text-sm" value={amount} onChange={e => setAmount(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Paid from (optional)</label>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as any)}>
<option value="">Not from event takings</option>
{METHODS.map(m => <option key={m} value={m}>{METHOD_LABELS[m]}</option>)}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">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.</p>
</div>
<div className="col-span-2">
<label className="block text-xs 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>
<div className="flex gap-2 justify-end">
<button className="text-xs px-3 py-1.5 rounded border" onClick={cancel} 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"}</button>
</div>
</div>
)}
</div>
);
}
// ─── 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<CashupMethod, LineInput> = useMemo(() => {
const base: Record<CashupMethod, LineInput> = { 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<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("");
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 (
<div className="space-y-4">
<div className="bg-white border rounded-lg p-4 space-y-3 overflow-auto">
<div className="text-sm font-medium">Cash-up reconciliation</div>
<table className="w-full text-sm min-w-[640px]">
<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">Costs from method</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>
<th className="py-1">Notes</th>
</tr>
</thead>
<tbody>
{METHODS.map(m => {
const r = reconciled?.byMethod?.[m];
return (
<tr key={m} className="border-b last:border-0 align-top">
<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.costsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
<td className="py-1.5 text-right">
{isClosed ? (
money(r?.actual ?? null)
) : m === "cash" ? (
money(cashActualFromDenoms)
) : (
<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)} />
)}
</td>
<td className="py-1.5 text-right">
{isClosed
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
: (m === "cash"
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "")
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : ""))}
</td>
<td className="py-1.5">
{isClosed ? (r?.notes || "") : (
<input className="w-full border rounded px-2 py-1 text-sm" value={lines[m].notes} onChange={e => setLine(m, "notes", e.target.value)} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<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 ? (
<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>
))}
</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>
<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>
{!isClosed && (
<div className="bg-white border rounded-lg p-4 space-y-2">
<label className="block text-xs text-gray-600 mb-1">Notes for closing (optional)</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={closeNotes} onChange={e => setCloseNotes(e.target.value)} />
<div className="flex flex-wrap gap-2 justify-end pt-1">
<button className="text-xs px-3 py-1.5 rounded border" disabled={busy} onClick={saveDraft}>Save draft</button>
<button className="text-xs px-3 py-1.5 rounded bg-amber-600 text-white hover:bg-amber-700" disabled={busy} onClick={quickClose}>Quick close (skip cashup)</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
</div>
</div>
)}
{isClosed && (
<div className="bg-white border rounded-lg p-4 space-y-2">
<label className="block text-xs text-gray-600 mb-1">Notes for reopening (optional)</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={reopenNotes} onChange={e => setReopenNotes(e.target.value)} />
<div className="flex justify-end">
<button className="text-xs px-3 py-1.5 rounded bg-rose-600 text-white hover:bg-rose-700" disabled={busy} onClick={reopen}>Reopen event</button>
</div>
</div>
)}
<div className="bg-white border rounded-lg p-4 space-y-2">
<div className="text-sm font-medium">History</div>
{data.history.length === 0 && <div className="text-xs text-gray-400">No close/reopen actions yet.</div>}
<ul className="space-y-1.5">
{data.history.map(h => (
<li key={h.id} className="text-xs border-b last:border-0 pb-1.5 flex items-center justify-between gap-3">
<span>
<span className="font-medium">{h.action === "closed" ? "Closed (full cashup)" : h.action === "quick_closed" ? "Quick closed" : "Reopened"}</span>
{" "}{h.performedBy?.name || "Unknown"} — {new Date(h.createdAt).toLocaleString()}
</span>
{(h.action === "closed" || h.action === "quick_closed") && (
<span className="text-gray-500 shrink-0">Donations to profit {money(h.unallocatedDonationsTotal)} · Costs {money(h.totalCosts)}</span>
)}
</li>
))}
</ul>
</div>
</div>
);
}