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>
507 lines
24 KiB
TypeScript
507 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|