mega-plant/components/SuppliersView.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

248 lines
18 KiB
TypeScript

"use client";
import { Fragment, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
import { dueDate, isAccount, TERMS, termsLabel } from "@/lib/supplier";
import { rand } from "@/lib/cash-float";
import { inBounds, presetBounds, saDay, type LogPreset } from "@/lib/oil-log";
export type SupplierTxn = { id: string; kind: "po" | "cash"; at: string; ref: string; amount: number };
export type SupplierRow = { id: string; name: string; code: string; terms: string; notes: string; source: string; txns: SupplierTxn[] };
const PRESETS: { id: LogPreset; label: string }[] = [
{ id: "all", label: "Everything" }, { id: "month", label: "This month" }, { id: "week", label: "This week" },
{ id: "today", label: "Today" }, { id: "range", label: "Date range" },
];
const dmy = (s: string) => {
const d = new Date(s.replace(" ", "T"));
return s && !Number.isNaN(d.getTime()) ? d.toLocaleDateString("en-ZA", { timeZone: "Africa/Johannesburg", day: "numeric", month: "short", year: "numeric" }) : "—";
};
const TERMS_FILTERS = [
{ id: "", label: "All suppliers" }, { id: "account", label: "Account (30 / 60 days)" }, { id: "30", label: "30 days" },
{ id: "60", label: "60 days" }, { id: "cash", label: "Cash" }, { id: "unset", label: "Terms not set" },
];
export function SuppliersView({ rows }: { rows: SupplierRow[] }) {
const router = useRouter();
const [preset, setPreset] = useState<LogPreset>("all");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [termsFilter, setTermsFilter] = useState("");
const [query, setQuery] = useState("");
const [open, setOpen] = useState<string | null>(null);
const [dialog, setDialog] = useState<null | "add" | SupplierRow>(null);
const [deleting, setDeleting] = useState<SupplierRow | null>(null);
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [terms, setTerms] = useState("");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const computed = useMemo(() => {
const bounds = presetBounds(preset, saDay(new Date().toISOString()), from, to);
return rows.map((r) => {
const t = r.txns.filter((x) => inBounds(saDay(x.at), bounds));
const po = t.filter((x) => x.kind === "po"), cash = t.filter((x) => x.kind === "cash");
const poTotal = po.reduce((s, x) => s + x.amount, 0), cashTotal = cash.reduce((s, x) => s + x.amount, 0);
return { r, t, poCount: po.length, poTotal, cashCount: cash.length, cashTotal, total: poTotal + cashTotal, last: t[0]?.at ?? "" };
});
}, [rows, preset, from, to]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
return computed
.filter(({ r }) => {
if (q && !`${r.name} ${r.code}`.toLowerCase().includes(q)) return false;
if (termsFilter === "account") return isAccount(r.terms);
if (termsFilter === "unset") return !r.terms;
return !termsFilter || r.terms === termsFilter;
})
.sort((a, b) => b.total - a.total || a.r.name.localeCompare(b.r.name));
}, [computed, query, termsFilter]);
const grandPo = shown.reduce((s, x) => s + x.poTotal, 0);
const grandCash = shown.reduce((s, x) => s + x.cashTotal, 0);
const needTerms = rows.filter((r) => !r.terms).length;
function begin(target: "add" | SupplierRow) {
setDialog(target); setError("");
if (target === "add") { setName(""); setCode(""); setTerms(""); setNotes(""); }
else { setName(target.name); setCode(target.code); setTerms(target.terms); setNotes(target.notes); }
}
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: body ? JSON.stringify(body) : undefined });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || "Could not save");
setDialog(null); setDeleting(null); router.refresh();
} catch (e: any) { setError(e.message); } finally { setBusy(false); }
}
function submit(e: React.FormEvent) {
e.preventDefault();
const body = { name, code, terms, notes };
if (dialog === "add") send("/api/suppliers", "POST", body);
else if (dialog) send(`/api/suppliers/${dialog.id}`, "PATCH", body);
}
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 (
<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">Suppliers</p><p className="text-2xl font-semibold mt-1">{rows.length}</p>
<p className="text-xs text-gray-500 mt-1">{rows.filter((r) => isAccount(r.terms)).length} on account · {rows.filter((r) => r.terms === "cash").length} cash</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Purchase orders</p><p className="text-xl font-semibold tabular-nums mt-1">{rand(grandPo)}</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Petty cash</p><p className="text-xl font-semibold tabular-nums mt-1">{rand(grandCash)}</p></div>
<div className={`${card} p-4`}><p className="text-xs text-gray-500">Total spent</p><p className="text-xl font-semibold tabular-nums mt-1 text-[#30638E]">{rand(grandPo + grandCash)}</p></div>
</div>
{needTerms > 0 && (
<p className="mb-4 rounded-xl border border-[#EDAE49]/40 bg-[#EDAE49]/[0.08] p-3 text-xs text-gray-700 dark:text-gray-300">
<strong>{needTerms} supplier{needTerms !== 1 && "s"}</strong> came in from purchase orders and still need payment terms (cash, 30 or 60 days). Filter by “Terms not set” and tap Edit.
</p>
)}
<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>
)}
<div className="flex flex-wrap gap-2 lg:ml-auto">
<input className={`${field} w-44`} placeholder="Search supplier…" value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search suppliers" />
<select className={field} value={termsFilter} onChange={(e) => setTermsFilter(e.target.value)} aria-label="Payment terms">
{TERMS_FILTERS.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
<button onClick={() => begin("add")} 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 supplier</button>
</div>
</div>
{shown.length === 0 ? (
<p className="text-xs text-gray-500 py-4">{rows.length === 0 ? "No suppliers yet." : "No suppliers match these filters."}</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<table className="w-full text-sm min-w-[920px]">
<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">Supplier</th>
<th className="py-2 pr-3 font-medium">Terms</th>
<th className="py-2 pr-3 font-medium text-right">Purchase orders</th>
<th className="py-2 pr-3 font-medium text-right">Petty cash</th>
<th className="py-2 pr-3 font-medium text-right">Total spent</th>
<th className="py-2 pr-3 font-medium">Last purchase</th>
<th className="py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{shown.map(({ r, t, poCount, poTotal, cashCount, cashTotal, total, last }) => (
<Fragment key={r.id}>
<tr 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">
<button onClick={() => setOpen(open === r.id ? null : r.id)} aria-expanded={open === r.id} className="min-h-[44px] flex items-center gap-1.5 text-left font-medium">
{open === r.id ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronRight className="w-4 h-4 shrink-0" />}
<span>{r.name}{r.code && <span className="block text-xs font-mono font-normal text-gray-500">{r.code}</span>}</span>
</button>
</td>
<td className="py-2 pr-3 whitespace-nowrap">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${!r.terms ? "bg-[#EDAE49]/20 text-[#8a5d00]" : r.terms === "cash" ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#30638E]/10 text-[#30638E]"}`}>
{r.terms === "cash" ? "Cash" : isAccount(r.terms) ? `Account · ${r.terms} days` : "Not set"}</span>
</td>
<td className="py-2 pr-3 text-right tabular-nums">{poCount ? <>{rand(poTotal)}<span className="block text-xs text-gray-500">{poCount} order{poCount !== 1 && "s"}</span></> : "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums">{cashCount ? <>{rand(cashTotal)}<span className="block text-xs text-gray-500">{cashCount} slip{cashCount !== 1 && "s"}</span></> : "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums font-semibold">{total ? rand(total) : "—"}</td>
<td className="py-2 pr-3 whitespace-nowrap">{last ? dmy(last) : "—"}</td>
<td className="py-2">
<div className="flex items-center gap-1 whitespace-nowrap">
<button onClick={() => begin(r)} className="min-h-[44px] px-3 rounded-lg border border-gray-200 dark:border-border text-xs font-medium">Edit</button>
<button onClick={() => { setError(""); setDeleting(r); }} className="min-h-[44px] px-3 rounded-lg text-xs text-[#D1495B] hover:bg-[#D1495B]/10">Delete</button>
</div>
</td>
</tr>
{open === r.id && (
<tr className="bg-gray-50/60 dark:bg-white/[0.03]">
<td colSpan={7} className="px-4 py-3">
{r.notes && <p className="text-xs text-gray-600 dark:text-gray-300 mb-2">{r.notes}</p>}
{t.length === 0 ? <p className="text-xs text-gray-500">Nothing booked against this supplier in this period.</p> : (
<table className="w-full text-xs">
<thead><tr className="text-left text-gray-500"><th className="py-1 pr-3 font-medium">Date</th><th className="py-1 pr-3 font-medium">Type</th><th className="py-1 pr-3 font-medium">Reference</th><th className="py-1 pr-3 font-medium text-right">Amount</th><th className="py-1 font-medium">Due</th></tr></thead>
<tbody>{t.map((x) => (
<tr key={x.id} className="border-t border-gray-100 dark:border-border">
<td className="py-1.5 pr-3 whitespace-nowrap">{dmy(x.at)}</td>
<td className="py-1.5 pr-3">{x.kind === "po" ? "Purchase order" : "Petty cash"}</td>
<td className="py-1.5 pr-3 font-mono">{x.ref || "—"}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{rand(x.amount)}</td>
<td className="py-1.5 whitespace-nowrap">{x.kind === "po" && isAccount(r.terms) ? dmy(dueDate(x.at, r.terms)) : x.kind === "cash" ? "Paid" : "—"}</td>
</tr>))}</tbody>
</table>
)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">Purchase orders are VAT-inclusive totals. Due dates are the PO date plus the supplier&apos;s terms; payments are not tracked here yet.</p>
</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-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">{dialog === "add" ? "Add a supplier" : "Edit supplier"}</h3>
<label htmlFor="s-name" className="block text-sm font-medium mt-4">Name</label>
<input id="s-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={200} className={`${field} w-full mt-1`} />
<label htmlFor="s-code" className="block text-sm font-medium mt-3">Supplier code <span className="font-normal text-gray-500">(as on purchase orders, optional)</span></label>
<input id="s-code" value={code} onChange={(e) => setCode(e.target.value)} maxLength={40} className={`${field} w-full mt-1 font-mono`} placeholder="e.g. 09YTS001" />
<p className="text-sm font-medium mt-3">How do we pay them?</p>
<div className="grid grid-cols-3 gap-2 mt-1" role="radiogroup" aria-label="Payment terms">
{TERMS.map((t) => (
<button key={t.id} type="button" role="radio" aria-checked={terms === t.id} onClick={() => setTerms(t.id)}
className={`min-h-[48px] rounded-lg text-xs font-medium border ${terms === t.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border"}`}>{t.id === "cash" ? "Cash" : `Account · ${t.label}`}</button>
))}
</div>
{!terms && <p className="text-xs text-gray-500 mt-1">Not set yet — choose one when you know.</p>}
<label htmlFor="s-notes" className="block text-sm font-medium mt-3">Notes <span className="font-normal text-gray-500">(optional)</span></label>
<textarea id="s-notes" value={notes} onChange={(e) => setNotes(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" />
{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 || !name.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 {deleting.name}?</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Only a supplier with no purchase orders or slips booked against it can be deleted.</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/suppliers/${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>
)}
</div>
);
}