Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
"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 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] = useState<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user