- /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>
248 lines
18 KiB
TypeScript
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'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>
|
|
);
|
|
}
|