mega-plant/components/CashFloatCard.tsx
David cfd54385b3 Add Suppliers list; book purchase orders and petty-cash slips against it
- /expenses/suppliers: terms (cash / 30 / 60 days), PO + petty cash totals per supplier,
  period filters, add/edit/delete, due dates
- POs and slips resolve to a supplier by code then folded name; new PO suppliers start
  with terms unset, new slip suppliers as cash
- Full-user guard on cash-float and job-card write routes

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

266 lines
19 KiB
TypeScript
Raw Permalink 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 = [], suppliers = [] }: { rows: CashRow[]; costCodes?: string[]; suppliers?: 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 list="cf-suppliers" value={mSupplier} onChange={(e) => setMSupplier(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} />
<datalist id="cf-suppliers">{suppliers.map((s) => <option key={s} value={s} />)}</datalist>
<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>
);
}