"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 = { 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("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(null); const [amount, setAmount] = useState(""); const [comment, setComment] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [deleting, setDeleting] = useState(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 (

Cash Floats

The cash-float card. Slips photographed in the Plant Clock app, or added here by hand, come off the balance.

{started && (
)}
{!started ? (

The card has no opening balance yet

Enter what is on the card now. Every slip will come off this amount.

) : ( <>

Balance on card

{rand(balance)}

Opening balance

{rand(opening)}

Topped up

{rand(toppedUp)}

Spent on slips

{rand(spent)}

{PRESETS.map((p) => ( ))}
{preset === "range" && (
setFrom(e.target.value)} aria-label="From" /> to setTo(e.target.value)} aria-label="To" />
)} Spent in this period: {rand(shownSpent)}
{shown.length === 0 ? (

No slips in this period. Office staff photograph slips in the Plant Clock app, on the Slips tab.

) : (
{shown.map((r) => ( ))}
When Type Supplier Cost code Comments Amount By Slip
{when(r.at)} {KIND[r.kind] ?? r.kind} {r.supplier || "—"} {r.costCode || "—"}{r.asset && ●} {r.comment || "—"} {sign(r.kind) < 0 ? "−" : "+"}{rand(r.amount)} {r.by || "—"}
{r.photo && View slip}
)}
)} {manual && (
!busy && setManual(false)}>
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">

Add an entry by hand

For a slip that was not photographed. It comes off the card balance like any other slip.

setMSupplier(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} /> {suppliers.map((s) => setMAmount(e.target.value)} className={`${field} w-full mt-1 text-lg tabular-nums`} placeholder="0.00" /> setMDate(e.target.value)} className={`${field} w-full mt-1`} /> setMCode(e.target.value)} maxLength={100} className={`${field} w-full mt-1`} placeholder="e.g. TLB04" /> {costCodes.map((c) =>