mega-plant/components/CashFloatCard.tsx
David 1ec52961ee Cash float card, Office role + Slips tab, job card PDF, manual close/delete
- Cash Floats: ledger card (opening balance, top-ups, slips), manual entries, slip photos
- Office access level; clock app Slips tab (photograph a slip, popup for supplier/cost
  code/comments/amount); only Office sees it; per-role route table in lib/clock-access
- Job cards: Machine Service PDF (with Oil block), JC numbers, manual Close job, Delete
- Hide Expenses > Vehicle Costs and Finance (Bastion) from the menu

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:09:57 +02:00

265 lines
19 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 { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { CreditCard, Plus } from "lucide-react";
import { cashBalance, hasOpening, rand, sign } from "@/lib/cash-float";
import { inBounds, presetBounds, saDay, type LogPreset } from "@/lib/oil-log";
export type CashRow = {
id: string; kind: string; amount: number; at: string; supplier: string; costCode: string;
asset: string; comment: string; photo: boolean; by: string;
};
const PRESETS: { id: LogPreset; label: string }[] = [
{ id: "today", label: "Today" }, { id: "week", label: "This week" }, { id: "month", label: "This month" },
{ id: "all", label: "Everything" }, { id: "range", label: "Date range" },
];
const when = (s: string) => (s ? new Date(s.replace(" ", "T")).toLocaleString("en-ZA", {
timeZone: "Africa/Johannesburg", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) : "—");
const KIND: Record<string, string> = { opening: "Opening balance", topup: "Top-up", expense: "Slip" };
/**
* The cash-float card: opening balance, then every slip (photographed in the Plant Clock app)
* comes off it. Shown as a ledger so any rand can be traced to a slip.
*/
export function CashFloatCard({ rows, costCodes = [] }: { rows: CashRow[]; costCodes?: string[] }) {
const router = useRouter();
const [preset, setPreset] = useState<LogPreset>("month");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [dialog, setDialog] = useState<"opening" | "topup" | "correct" | null>(null);
const [manual, setManual] = useState(false);
const [mSupplier, setMSupplier] = useState("");
const [mCode, setMCode] = useState("");
const [mComment, setMComment] = useState("");
const [mAmount, setMAmount] = useState("");
const [mDate, setMDate] = useState("");
const [mPhoto, setMPhoto] = useState<File | null>(null);
const [amount, setAmount] = useState("");
const [comment, setComment] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [deleting, setDeleting] = useState<CashRow | null>(null);
const started = hasOpening(rows);
const balance = cashBalance(rows);
const opening = rows.find((r) => r.kind === "opening")?.amount ?? 0;
const toppedUp = rows.filter((r) => r.kind === "topup").reduce((s, r) => s + r.amount, 0);
const spent = rows.filter((r) => r.kind === "expense").reduce((s, r) => s + r.amount, 0);
const shown = useMemo(() => {
const b = presetBounds(preset, saDay(new Date().toISOString()), from, to);
return rows.filter((r) => r.kind !== "opening" && inBounds(saDay(r.at), b));
}, [rows, preset, from, to]);
const shownSpent = shown.filter((r) => r.kind === "expense").reduce((s, r) => s + r.amount, 0);
function open(kind: "opening" | "topup" | "correct") {
setDialog(kind); setError("");
setAmount(kind === "correct" ? String(opening) : ""); setComment("");
}
async function send(url: string, method: string, body: object) {
setBusy(true); setError("");
try {
const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const j = await res.json().catch(() => ({}));
if (!res.ok || j.ok === false || j.error) throw new Error(j.error || "Could not save");
setDialog(null); setDeleting(null); router.refresh();
} catch (e: any) { setError(e.message); } finally { setBusy(false); }
}
function openManual() {
setManual(true); setError(""); setMSupplier(""); setMCode(""); setMComment(""); setMAmount(""); setMPhoto(null);
const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
setMDate(d.toISOString().slice(0, 16));
}
async function submitManual(e: React.FormEvent) {
e.preventDefault();
setBusy(true); setError("");
try {
const body = new FormData();
body.set("supplier", mSupplier); body.set("cost_code", mCode); body.set("comment", mComment); body.set("amount", mAmount);
if (mDate) body.set("occurred_on", new Date(mDate).toISOString());
if (mPhoto) body.set("slip_photo", mPhoto);
const res = await fetch("/api/cash-float/entries", { method: "POST", body });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || "Could not save");
setManual(false); router.refresh();
} catch (err: any) { setError(err.message); } finally { setBusy(false); }
}
function submit(e: React.FormEvent) {
e.preventDefault();
if (dialog === "opening") send("/api/clock/cash-float/opening", "POST", { amount });
else if (dialog === "correct") send("/api/cash-float/opening", "PUT", { amount });
else if (dialog === "topup") send("/api/cash-float/topup", "POST", { amount, comment });
}
const field = "min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm";
const card = "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm";
return (
<section className="mb-8">
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#00798C]/15 to-[#30638E]/15 grid place-items-center text-[#00798C]"><CreditCard className="w-5 h-5" /></div>
<div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Cash Floats</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">The cash-float card. Slips photographed in the Plant Clock app, or added here by hand, come off the balance.</p>
</div>
</div>
{started && (
<div className="flex gap-2">
<button onClick={() => open("correct")} className="min-h-[44px] px-3 rounded-lg border border-gray-200 dark:border-border text-xs font-medium">Correct opening balance</button>
<button onClick={openManual} className="min-h-[44px] px-4 rounded-lg bg-[#00798C] text-white text-xs font-medium inline-flex items-center gap-1.5"><Plus className="w-4 h-4" />Add entry</button>
<button onClick={() => open("topup")} className="min-h-[44px] px-4 rounded-lg bg-[#30638E] text-white text-xs font-medium inline-flex items-center gap-1.5"><Plus className="w-4 h-4" />Top up card</button>
</div>
)}
</div>
{!started ? (
<div className={`${card} p-6 text-center`}>
<p className="text-sm font-medium text-[#003D5B] dark:text-white">The card has no opening balance yet</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Enter what is on the card now. Every slip will come off this amount.</p>
<button onClick={() => open("opening")} className="mt-4 min-h-[44px] px-5 rounded-lg bg-[#30638E] text-white text-sm font-medium">Set opening balance</button>
</div>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Balance on card</p><p className={`text-2xl font-semibold tabular-nums mt-1 ${balance < 0 ? "text-[#D1495B]" : "text-[#00798C]"}`}>{rand(balance)}</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Opening balance</p><p className="text-xl font-semibold tabular-nums mt-1">{rand(opening)}</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Topped up</p><p className="text-xl font-semibold tabular-nums mt-1">{rand(toppedUp)}</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Spent on slips</p><p className="text-xl font-semibold tabular-nums mt-1">{rand(spent)}</p></div>
</div>
<div className={`${card} p-4 sm:p-5`}>
<div className="flex flex-col lg:flex-row lg:items-center gap-3 mb-3">
<div className="hidden lg:flex gap-1.5" role="group" aria-label="Period">
{PRESETS.map((p) => (
<button key={p.id} onClick={() => setPreset(p.id)} aria-pressed={preset === p.id}
className={`min-h-[44px] px-4 rounded-lg text-xs font-medium border ${preset === p.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border text-gray-600 dark:text-gray-300"}`}>{p.label}</button>
))}
</div>
<select className={`${field} lg:hidden`} value={preset} onChange={(e) => setPreset(e.target.value as LogPreset)} aria-label="Period">
{PRESETS.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</select>
{preset === "range" && (
<div className="flex items-center gap-2">
<input type="date" className={field} value={from} max={to || undefined} onChange={(e) => setFrom(e.target.value)} aria-label="From" />
<span className="text-xs text-gray-500">to</span>
<input type="date" className={field} value={to} min={from || undefined} onChange={(e) => setTo(e.target.value)} aria-label="To" />
</div>
)}
<span className="lg:ml-auto text-xs font-medium px-3 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B]">Spent in this period: {rand(shownSpent)}</span>
</div>
{shown.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400 py-4">No slips in this period. Office staff photograph slips in the Plant Clock app, on the Slips tab.</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400">
<th className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium">When</th>
<th className="py-2 pr-3 font-medium">Type</th>
<th className="py-2 pr-3 font-medium">Supplier</th>
<th className="py-2 pr-3 font-medium">Cost code</th>
<th className="py-2 pr-3 font-medium">Comments</th>
<th className="py-2 pr-3 font-medium text-right">Amount</th>
<th className="py-2 pr-3 font-medium">By</th>
<th className="py-2 font-medium">Slip</th>
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.id} className="border-t border-gray-100 dark:border-border align-top">
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 whitespace-nowrap tabular-nums">{when(r.at)}</td>
<td className="py-2 pr-3 whitespace-nowrap">{KIND[r.kind] ?? r.kind}</td>
<td className="py-2 pr-3">{r.supplier || "—"}</td>
<td className="py-2 pr-3 font-mono">{r.costCode || "—"}{r.asset && <span className="ml-1 text-[10px] text-[#00798C]" title="Linked to a vehicle">●</span>}</td>
<td className="py-2 pr-3 min-w-[200px] whitespace-pre-wrap break-words">{r.comment || "—"}</td>
<td className={`py-2 pr-3 text-right tabular-nums whitespace-nowrap ${sign(r.kind) < 0 ? "text-[#D1495B]" : "text-[#00798C]"}`}>{sign(r.kind) < 0 ? "−" : "+"}{rand(r.amount)}</td>
<td className="py-2 pr-3">{r.by || "—"}</td>
<td className="py-2 whitespace-nowrap">
<div className="flex items-center gap-1">
{r.photo && <a href={`/api/cash-float/entries/${r.id}/photo`} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[44px] items-center px-2 text-[#30638E] underline">View slip</a>}
<button onClick={() => { setError(""); setDeleting(r); }} className="min-h-[44px] px-2 text-xs text-[#D1495B] hover:bg-[#D1495B]/10 rounded-lg">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
{manual && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => !busy && setManual(false)}>
<form onClick={(e) => e.stopPropagation()} onSubmit={submitManual} role="dialog" aria-modal="true" aria-label="Add entry"
className="w-full max-w-md max-h-[92svh] overflow-y-auto rounded-2xl bg-white dark:bg-surface p-5 shadow-xl">
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">Add an entry by hand</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">For a slip that was not photographed. It comes off the card balance like any other slip.</p>
<label htmlFor="m-supplier" className="block text-sm font-medium mt-4">Supplier</label>
<input id="m-supplier" autoFocus value={mSupplier} onChange={(e) => setMSupplier(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} />
<label htmlFor="m-amount" className="block text-sm font-medium mt-3">Amount (R)</label>
<input id="m-amount" type="text" inputMode="decimal" value={mAmount} onChange={(e) => setMAmount(e.target.value)} className={`${field} w-full mt-1 text-lg tabular-nums`} placeholder="0.00" />
<label htmlFor="m-date" className="block text-sm font-medium mt-3">Date and time of the slip</label>
<input id="m-date" type="datetime-local" value={mDate} onChange={(e) => setMDate(e.target.value)} className={`${field} w-full mt-1`} />
<label htmlFor="m-code" className="block text-sm font-medium mt-3">Cost code <span className="font-normal text-gray-500">(optional)</span></label>
<input id="m-code" list="cf-codes" value={mCode} onChange={(e) => setMCode(e.target.value)} maxLength={100} className={`${field} w-full mt-1`} placeholder="e.g. TLB04" />
<datalist id="cf-codes">{costCodes.map((c) => <option key={c} value={c} />)}</datalist>
<label htmlFor="m-comment" className="block text-sm font-medium mt-3">Comments <span className="font-normal text-gray-500">(optional)</span></label>
<textarea id="m-comment" value={mComment} onChange={(e) => setMComment(e.target.value)} maxLength={1000} className="mt-1 w-full min-h-[70px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface p-2 text-sm" />
<label htmlFor="m-photo" className="block text-sm font-medium mt-3">Photo of the slip <span className="font-normal text-gray-500">(optional)</span></label>
<input id="m-photo" type="file" accept="image/*" onChange={(e) => setMPhoto(e.target.files?.[0] ?? null)} className="mt-1 w-full text-sm" />
{error && <p role="alert" className="text-xs text-[#D1495B] mt-3">{error}</p>}
<div className="flex justify-end gap-2 mt-5">
<button type="button" disabled={busy} onClick={() => setManual(false)} className="min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border">Cancel</button>
<button type="submit" disabled={busy || !mSupplier.trim() || !mAmount.trim()} className="min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#00798C] disabled:opacity-60">{busy ? "Saving…" : "Add entry"}</button>
</div>
</form>
</div>
)}
{dialog && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => !busy && setDialog(null)}>
<form onClick={(e) => e.stopPropagation()} onSubmit={submit} role="dialog" aria-modal="true" className="w-full max-w-sm rounded-2xl bg-white dark:bg-surface p-5 shadow-xl">
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">
{dialog === "opening" ? "Set the opening balance" : dialog === "correct" ? "Correct the opening balance" : "Top up the card"}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{dialog === "topup" ? "Money loaded onto the card. It is added to the balance." : "The balance on the card when tracking started. Slips and top-ups are not changed."}
</p>
<label htmlFor="cf-amount" className="block text-sm font-medium mt-4">Amount (R)</label>
<input id="cf-amount" autoFocus type="text" inputMode="decimal" value={amount} onChange={(e) => setAmount(e.target.value)} className={`${field} w-full text-lg tabular-nums mt-1`} placeholder="0.00" />
{dialog === "topup" && (<>
<label htmlFor="cf-comment" className="block text-sm font-medium mt-3">Note <span className="font-normal text-gray-500">(optional)</span></label>
<input id="cf-comment" value={comment} onChange={(e) => setComment(e.target.value)} className={`${field} w-full mt-1`} maxLength={1000} />
</>)}
{error && <p role="alert" className="text-xs text-[#D1495B] mt-3">{error}</p>}
<div className="flex justify-end gap-2 mt-5">
<button type="button" disabled={busy} onClick={() => setDialog(null)} className="min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border">Cancel</button>
<button type="submit" disabled={busy || !amount.trim()} className="min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#30638E] disabled:opacity-60">{busy ? "Saving…" : "Save"}</button>
</div>
</form>
</div>
)}
{deleting && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => !busy && setDeleting(null)}>
<div onClick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true" className="w-full max-w-sm rounded-2xl bg-white dark:bg-surface p-5 shadow-xl">
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">Delete this entry?</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">{KIND[deleting.kind]} · {deleting.supplier || "—"} · {rand(deleting.amount)}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">The balance is recalculated without it. The slip photo is deleted too. This cannot be undone.</p>
{error && <p role="alert" className="text-xs text-[#D1495B] mt-3">{error}</p>}
<div className="flex justify-end gap-2 mt-5">
<button disabled={busy} onClick={() => setDeleting(null)} className="min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border">Cancel</button>
<button disabled={busy} onClick={() => send(`/api/cash-float/entries/${deleting.id}`, "DELETE", {})} className="min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#D1495B] disabled:opacity-60">{busy ? "Deleting…" : "Delete"}</button>
</div>
</div>
</div>
)}
</section>
);
}