- /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>
266 lines
19 KiB
TypeScript
266 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|