Files
hope-events/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx
T
joshua 8f69e58aa2 Add tab icons and fix mobile dashboard styling issues
- Add matching lucide-react icons to tab/mode switcher buttons on
  payments, at-the-door, manual, email-attendees, whatsapp-attendees,
  and admin cashup pages, mirroring icons already used in their help menus
- Fix navbar Logout button sitting lower than other nav links (missing
  border/padding classes that other links use for their active-underline)
- Fix stat card labels getting truncated on mobile by removing the
  ellipsis-cut label and widening the mobile grid to one column
- Fix Revenue trend / Top performing events rendering outside the
  viewport on mobile by containing horizontal overflow and truncating
  long event titles in the table
2026-08-07 00:41:52 +02:00

847 lines
42 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";
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
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" | "report">("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 gap-3 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<div>
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}> Back to cashup</button>
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">{data?.event?.title || "Event"} Cashup</h1>
</div>
</div>
<span className={"text-xs px-2 py-1 rounded-full font-medium " + (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={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}><DollarSign className="w-4 h-4" />Costs</button>
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}><ClipboardCheck className="w-4 h-4" />Reconciliation</button>
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}><FileBarChart className="w-4 h-4" />Report</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}
onClosed={() => setTab("report")}
/>
)}
{!loading && data && tab === "report" && (
<ReportTab eventId={eventId} token={token || ""} data={data} isClosed={isClosed} />
)}
</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-brand-600 text-white hover:bg-brand-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>}
<div className="overflow-auto">
<table className="w-full text-sm min-w-[600px]">
<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-brand-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>
</div>
{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-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
)}
</div>
);
}
// ─── 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<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-brand-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-brand-600 text-white hover:bg-brand-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; 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<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 [lines, setLines] = useState<Record<CashupMethod, LineInput>>(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 (
<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" ? (
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)} />
)}
</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"
? (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">
{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>
{!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>
{(() => {
const denoms = isClosed ? (reconciled?.byMethod?.cash?.denominations || []) : cashSummary.denominations;
return denoms.length > 0 ? (
<table className="text-sm">
<tbody>
{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">
{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">
<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-brand-600 text-white hover:bg-brand-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>
);
}