473 lines
27 KiB
TypeScript
473 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Store, ChevronRight, Plus, Trash2, X, Loader2, Pencil } from "lucide-react";
|
|
import { Sparkline } from "@/components/Sparkline";
|
|
import { MONTH_NAMES } from "@/lib/data";
|
|
import { ZAR, ZAR2 } from "@/lib/utils";
|
|
import { termsLabel, isAccount, TERMS } from "@/lib/supplier";
|
|
import { STATUS_STYLE } from "@/lib/purchase-invoice-status";
|
|
import type { SupplierSpendRow } from "@/lib/supplier-spend";
|
|
|
|
const SPARK = "#30638E";
|
|
|
|
const monthLabel = (ym: string) => {
|
|
const m = /^(\d{4})-(\d{2})$/.exec(ym);
|
|
return m ? `${MONTH_NAMES[Number(m[2]) - 1]} ${m[1]}` : "—";
|
|
};
|
|
|
|
export function Suppliers2View({
|
|
rows, year, month,
|
|
}: {
|
|
rows: SupplierSpendRow[];
|
|
year: number;
|
|
month: number;
|
|
}) {
|
|
// Cash suppliers are paid on the spot — nothing to chase — so they stay out of the way until asked for.
|
|
const [show, setShow] = useState<"account" | "all" | "cash">("account");
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
const [paying, setPaying] = useState(false);
|
|
const [termsFor, setTermsFor] = useState<SupplierSpendRow | null>(null);
|
|
// Deleting a payment is hidden until this is switched on, and it resets on reload.
|
|
const [editMode, setEditMode] = useState(false);
|
|
|
|
const cashN = rows.filter((r) => r.isCash).length;
|
|
const visible = useMemo(
|
|
() => rows.filter((r) => (show === "all" ? true : show === "cash" ? r.isCash : !r.isCash))
|
|
.sort((a, b) => b.thisMonth - a.thisMonth || b.ytd - a.ytd),
|
|
[rows, show],
|
|
);
|
|
|
|
// Summed over what is VISIBLE, so the headline never disagrees with the list under it.
|
|
const spendNow = visible.reduce((s, r) => s + r.thisMonth, 0);
|
|
const payableNow = visible.reduce((s, r) => s + r.payableNow, 0);
|
|
// Remaining = what is still owed this month; surplus = paid above what fell due. Per supplier,
|
|
// so one supplier's overpayment never quietly hides another's shortfall.
|
|
const remainingNow = visible.reduce((s, r) => s + r.payableNet, 0);
|
|
const surplusNow = visible.reduce((s, r) => s + r.surplus, 0);
|
|
const broughtFwd = visible.reduce((s, r) => s + r.broughtForward, 0);
|
|
const paidNowTotal = visible.reduce((s, r) => s + r.paidNow, 0);
|
|
const payableNext = visible.reduce((s, r) => s + r.payableNext, 0);
|
|
const payableLater = visible.reduce((s, r) => s + r.payableLater, 0);
|
|
const unsetInvoiced = visible.reduce((s, r) => s + r.termsUnsetInvoiced, 0);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#00798C]/15 grid place-items-center text-[#30638E] dark:text-[#8DB8E0]"><Store className="w-5 h-5" /></div>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Suppliers</h1>
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">Spend and payables per supplier</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<div className="bg-gradient-to-br from-[#30638E] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-blue-900/20">
|
|
<div className="text-xs text-white/80">Spend in {MONTH_NAMES[month - 1]} {year}</div>
|
|
<div className="text-2xl font-semibold tabular-nums">{ZAR(spendNow)}</div>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg">
|
|
<div className="text-xs text-white/80">Payable in {MONTH_NAMES[month - 1]}</div>
|
|
<div className="text-2xl font-semibold tabular-nums">{ZAR(remainingNow)}</div>
|
|
<div className="text-xs text-white/80">
|
|
{ZAR(paidNowTotal)} paid this month{broughtFwd > 0 && ` · ${ZAR(broughtFwd)} brought forward`}{surplusNow > 0 && ` · ${ZAR(surplusNow)} surplus`}
|
|
</div>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-[#00798C] to-[#30638E] rounded-2xl px-5 py-3 text-white shadow-lg">
|
|
<div className="text-xs text-white/80">Payable in {MONTH_NAMES[month % 12]}{month === 12 ? ` ${year + 1}` : ""}</div>
|
|
<div className="text-2xl font-semibold tabular-nums">{ZAR(payableNext)}</div>
|
|
<div className="text-xs text-white/80">{ZAR(payableLater)} after that</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-5">
|
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
|
<span className="px-2.5 py-1 rounded-md bg-[#30638E]/10 text-[#30638E] dark:text-[#8DB8E0] font-medium">{visible.length} supplier{visible.length !== 1 && "s"}</span>
|
|
{show === "account" && cashN > 0 && (
|
|
<span className="px-2.5 py-1 rounded-md bg-gray-100 dark:bg-white/5 font-medium">{cashN} cash supplier{cashN !== 1 && "s"} hidden</span>
|
|
)}
|
|
{unsetInvoiced > 0 && (
|
|
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49] font-medium">
|
|
{ZAR(unsetInvoiced)} invoiced with terms not set — not counted as payable
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<button onClick={() => setEditMode(!editMode)} aria-pressed={editMode}
|
|
className={`min-h-[44px] px-3 rounded-lg text-xs font-medium inline-flex items-center gap-1.5 border transition ${
|
|
editMode ? "bg-[#EDAE49] border-[#EDAE49] text-white" : "border-gray-200 dark:border-border text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5"
|
|
}`}>
|
|
<Pencil className="w-4 h-4" /> {editMode ? "Editing — delete enabled" : "Edit mode"}
|
|
</button>
|
|
<button onClick={() => setPaying(true)}
|
|
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 bg-[#00798C]">
|
|
<Plus className="w-4 h-4" /> Add payment
|
|
</button>
|
|
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-white/5 self-start">
|
|
{([
|
|
{ id: "account", label: "Accounts", n: rows.length - cashN },
|
|
{ id: "all", label: "All", n: rows.length },
|
|
{ id: "cash", label: "Cash", n: cashN },
|
|
] as const).map((o) => (
|
|
<button
|
|
key={o.id}
|
|
onClick={() => { setShow(o.id); setExpanded(null); }}
|
|
aria-pressed={show === o.id}
|
|
className={`min-h-[44px] px-3 rounded-md text-xs font-medium transition ${
|
|
show === o.id ? "bg-[#003D5B] text-white dark:bg-[#00798C]" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
|
|
}`}
|
|
>
|
|
{o.label} <span className="opacity-60">({o.n})</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{termsFor && <TermsModal row={termsFor} onClose={() => setTermsFor(null)} />}
|
|
{paying && <AddPaymentModal rows={rows} month={month} onClose={() => setPaying(false)} />}
|
|
|
|
<div className="flex items-center gap-3 flex-wrap text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
{(["none", "partial", "complete", "over"] as const).map((k) => (
|
|
<span key={k} className="inline-flex items-center gap-1.5">
|
|
<span className="w-3 h-1.5 rounded" style={{ background: STATUS_STYLE[k].color }} /> {STATUS_STYLE[k].label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{visible.length === 0 ? (
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 text-center text-sm text-gray-500">No suppliers in this view.</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{visible.map((r) => {
|
|
const isOpen = expanded === r.id;
|
|
const stripe = r.status === "n/a" ? "#d1d5db" : STATUS_STYLE[r.status].color;
|
|
return (
|
|
<div key={r.id} className="relative bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
|
|
<span className="absolute left-0 inset-y-0 w-1.5" style={{ background: stripe }} aria-hidden />
|
|
<button onClick={() => setExpanded(isOpen ? null : r.id)} aria-expanded={isOpen} className="w-full text-left p-4 pl-5">
|
|
<div className="flex items-center gap-3 sm:gap-4 min-h-[44px]">
|
|
{r.code && (
|
|
<span className="hidden sm:inline font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] dark:text-[#00A6BE] font-semibold shrink-0">{r.code}</span>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div
|
|
className="text-sm font-medium text-[#003D5B] dark:text-white truncate"
|
|
onDoubleClick={(e) => { e.stopPropagation(); setTermsFor(r); }}
|
|
title="Double-click to set the payment terms"
|
|
>{r.name}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{r.terms ? termsLabel(r.terms) : <span className="text-[#EDAE49]">Terms not set</span>}
|
|
</div>
|
|
</div>
|
|
<div className="hidden sm:block shrink-0"><Sparkline data={r.months} color={SPARK} /></div>
|
|
<div className="text-right shrink-0 w-32 hidden md:block">
|
|
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">Spend this month</div>
|
|
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.thisMonth)}</div>
|
|
</div>
|
|
<div className="text-right shrink-0 w-32 hidden md:block">
|
|
<div className="text-xs uppercase tracking-wider text-[#EDAE49]">Payable now</div>
|
|
<div className={`text-sm font-semibold tabular-nums ${r.payableNet > 0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
|
|
{r.payableNet > 0.005 ? ZAR2(r.payableNet) : "—"}
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0 w-32 hidden md:block">
|
|
<div className="text-xs uppercase tracking-wider text-[#00798C] dark:text-[#00A6BE]">Paid</div>
|
|
<div className={`text-sm font-semibold tabular-nums ${r.paidNow > 0 ? "text-[#00798C] dark:text-[#00A6BE]" : "text-gray-300 dark:text-text-soft"}`}>
|
|
{r.paidNow > 0 ? ZAR2(r.paidNow) : "—"}
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0 w-32 hidden md:block">
|
|
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">YTD spend</div>
|
|
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.ytd)}</div>
|
|
</div>
|
|
<ChevronRight className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`} />
|
|
</div>
|
|
<div className="flex md:hidden items-center justify-between gap-3 pt-2 text-xs">
|
|
<Sparkline data={r.months} color={SPARK} width={72} height={28} />
|
|
<div className="text-right min-w-0">
|
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Spend this month</div>
|
|
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.thisMonth)}</div>
|
|
</div>
|
|
<div className="text-right min-w-0">
|
|
<div className="text-xs uppercase tracking-wider text-[#EDAE49]">Payable</div>
|
|
<div className={`text-sm font-semibold tabular-nums ${r.payableNet > 0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
|
|
{r.payableNet > 0.005 ? ZAR2(r.payableNet) : "—"}
|
|
</div>
|
|
{r.paidNow > 0 && <div className="text-xs text-[#00798C] dark:text-[#00A6BE] tabular-nums">paid {ZAR(r.paidNow)}</div>}
|
|
</div>
|
|
<div className="text-right min-w-0">
|
|
<div className="text-xs text-gray-400 uppercase tracking-wider">YTD</div>
|
|
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.ytd)}</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="border-t border-gray-100 dark:border-border px-4 py-4 pl-5 bg-gray-50/50 dark:bg-white/[0.04]">
|
|
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-xs">
|
|
{r.months.map((v, i) => (
|
|
<div key={i} className="text-center">
|
|
<div className="text-gray-400 dark:text-text-soft text-xs mb-1">{MONTH_NAMES[i]}</div>
|
|
<div className={`py-1.5 rounded tabular-nums ${v ? "bg-[#30638E]/5 text-[#30638E] dark:bg-[#30638E]/25 dark:text-gray-100" : "text-gray-300 dark:text-text-soft"}`}>
|
|
{v ? ZAR2(v) : "—"}
|
|
</div>
|
|
<MonthPayLine due={r.dueByMonth[i]} paid={r.paidByMonth[i]} past={i < month} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{isAccount(r.terms) && (
|
|
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border">
|
|
<div className="flex items-baseline justify-between gap-3 mb-2">
|
|
<div className="text-xs uppercase tracking-wider text-gray-400">Payments</div>
|
|
<div className={`text-xs font-semibold tabular-nums ${r.owingToDate > 0.005 ? "text-[#D1495B] dark:text-[#D94F60]" : "text-[#00798C] dark:text-[#00A6BE]"}`}>
|
|
{r.owingToDate > 0.005 ? `${ZAR2(r.owingToDate)} still owing to date` : r.owingToDate < -0.005 ? `${ZAR2(-r.owingToDate)} paid ahead` : "Settled to date"}
|
|
</div>
|
|
</div>
|
|
{r.payments.length === 0 ? (
|
|
<div className="text-xs text-gray-400">No payments entered yet.</div>
|
|
) : (
|
|
<ul className="divide-y divide-gray-100 dark:divide-border">
|
|
{r.payments.map((p) => <PaymentRow key={p.id} p={p} editMode={editMode} />)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border">
|
|
<div className="text-xs uppercase tracking-wider text-gray-400 mb-2">Purchase orders</div>
|
|
{r.pos.length === 0 ? (
|
|
<div className="text-xs text-gray-400">No purchase orders — only till slips{r.isCash ? "" : " so far"}.</div>
|
|
) : (
|
|
<ul className="space-y-1">
|
|
{r.pos.map((p) => (
|
|
<li key={p.id} className="relative flex items-center gap-3 rounded-lg bg-white dark:bg-surface border border-gray-100 dark:border-border pl-4 pr-3 py-2 overflow-hidden text-xs">
|
|
<span className="absolute left-0 inset-y-0 w-1.5" style={{ background: STATUS_STYLE[p.status].color }} title={STATUS_STYLE[p.status].label} />
|
|
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{p.po_no}</span>
|
|
<span className="flex-1 min-w-0 truncate text-gray-500 dark:text-gray-400">
|
|
{p.invoiceCount === 0 ? "no invoice yet" : p.status === "over"
|
|
? <span className="font-semibold text-[#8B5CF6] dark:text-[#A78BFA]">{ZAR2(p.invoiced)} invoiced — {ZAR2(p.invoiced - p.total)} OVER the PO</span>
|
|
: `${ZAR2(p.invoiced)} of ${ZAR2(p.total)} invoiced`}
|
|
{p.dueMonth && ` · pay ${monthLabel(p.dueMonth)}`}
|
|
</span>
|
|
<span className="tabular-nums font-semibold shrink-0">{ZAR2(p.total)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
/** Under a month's spend: what fell due, what was paid (green) and the surplus / shortfall. */
|
|
function MonthPayLine({ due, paid, past }: { due: number; paid: number; past: boolean }) {
|
|
if (!due && !paid) return null;
|
|
const diff = paid - due;
|
|
return (
|
|
<div className="mt-1 space-y-0.5 text-xs tabular-nums">
|
|
{due > 0 && <div className="text-[#EDAE49]" title="Fell due this month">due {ZAR(due)}</div>}
|
|
{paid > 0 && <div className="text-[#00798C] dark:text-[#00A6BE] font-medium" title="Paid this month">paid {ZAR(paid)}</div>}
|
|
{past && Math.abs(diff) > 0.005 && (
|
|
diff > 0
|
|
? <div className="text-[#00798C] dark:text-[#00A6BE] font-semibold">+{ZAR(diff)} surplus</div>
|
|
: <div className="text-[#D1495B] dark:text-[#D94F60] font-semibold">{ZAR(-diff)} short</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PaymentRow({ p, editMode }: { p: { id: string; paid_on: string; description: string; amount: number }; editMode: boolean }) {
|
|
const router = useRouter();
|
|
const [busy, setBusy] = useState(false);
|
|
async function remove() {
|
|
if (!confirm(`Remove the ${ZAR2(p.amount)} payment of ${p.paid_on}?`)) return;
|
|
setBusy(true);
|
|
const res = await fetch(`/api/supplier-payments/${p.id}`, { method: "DELETE" });
|
|
const body = await res.json().catch(() => ({ ok: false, error: "Could not remove" }));
|
|
setBusy(false);
|
|
if (!body.ok) alert(body.error);
|
|
else router.refresh();
|
|
}
|
|
return (
|
|
<li className="flex items-center gap-3 py-1 text-xs">
|
|
<span className="tabular-nums text-gray-500 dark:text-gray-400 shrink-0">{p.paid_on}</span>
|
|
<span className="flex-1 min-w-0 truncate text-gray-600 dark:text-gray-300">{p.description || "—"}</span>
|
|
<span className="font-semibold tabular-nums text-[#00798C] dark:text-[#00A6BE] shrink-0">{ZAR2(p.amount)}</span>
|
|
{editMode && (
|
|
<button onClick={remove} disabled={busy} aria-label="Remove payment"
|
|
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#D1495B] hover:bg-gray-50 dark:hover:bg-white/5 disabled:opacity-50">
|
|
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
|
</button>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
const inputCls =
|
|
"min-h-[44px] w-full rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm text-[#003D5B] dark:text-white";
|
|
|
|
/** Global "Add payment": pick an ACCOUNT supplier, then date, description and amount. */
|
|
function AddPaymentModal({ rows, month, onClose }: { rows: SupplierSpendRow[]; month: number; onClose: () => void }) {
|
|
const router = useRouter();
|
|
const accounts = useMemo(() => rows.filter((r) => isAccount(r.terms)).sort((a, b) => a.name.localeCompare(b.name)), [rows]);
|
|
const [supplierId, setSupplierId] = useState("");
|
|
const [paidOn, setPaidOn] = useState(() => new Date().toISOString().slice(0, 10));
|
|
const [description, setDescription] = useState("");
|
|
const [amount, setAmount] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const sup = accounts.find((a) => a.id === supplierId) || null;
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch("/api/supplier-payments", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ supplier_id: supplierId, paid_on: paidOn, description, amount }),
|
|
});
|
|
const body = await res.json();
|
|
if (!body.ok) { setError(body.error); return; }
|
|
router.refresh();
|
|
onClose();
|
|
} catch {
|
|
setError("Could not save — check your connection");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center p-4 overflow-y-auto" onClick={onClose}>
|
|
<form onSubmit={submit} onClick={(e) => e.stopPropagation()} role="dialog" aria-label="Add payment"
|
|
className="w-full max-w-md bg-white dark:bg-surface rounded-2xl p-5 shadow-xl space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Add payment</h2>
|
|
<button type="button" onClick={onClose} aria-label="Close"
|
|
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-50 dark:hover:bg-white/5"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
|
|
Supplier (accounts only)
|
|
<select value={supplierId} onChange={(e) => setSupplierId(e.target.value)} required className={inputCls}>
|
|
<option value="">Choose a supplier…</option>
|
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} · {termsLabel(a.terms)}</option>)}
|
|
</select>
|
|
</label>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
|
|
Date paid
|
|
<input type="date" value={paidOn} onChange={(e) => setPaidOn(e.target.value)} required className={inputCls} />
|
|
</label>
|
|
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
|
|
Amount
|
|
<input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="0.00" required className={inputCls} />
|
|
</label>
|
|
</div>
|
|
{sup && (
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{MONTH_NAMES[month - 1]}: <span className="text-[#EDAE49] font-medium">{ZAR2(sup.payableNow)} due</span>
|
|
{" · "}<span className="text-[#00798C] dark:text-[#00A6BE] font-medium">{ZAR2(sup.paidNow)} paid</span>
|
|
{" · "}
|
|
{sup.owingToDate > 0.005
|
|
? <span className="text-[#D1495B] dark:text-[#D94F60] font-medium">{ZAR2(sup.owingToDate)} owing to date</span>
|
|
: <span className="text-[#00798C] dark:text-[#00A6BE] font-medium">settled to date</span>}
|
|
</div>
|
|
)}
|
|
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
|
|
Description
|
|
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="e.g. EFT — September invoices" className={inputCls} />
|
|
</label>
|
|
{accounts.length === 0 && <div className="text-xs text-[#EDAE49]">No supplier is on 30 or 60 day terms yet — set terms on the Suppliers page first.</div>}
|
|
{error && <div className="text-xs text-[#D1495B] dark:text-[#D94F60]">{error}</div>}
|
|
<div className="flex gap-2 justify-end">
|
|
<button type="button" onClick={onClose} className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5">Cancel</button>
|
|
<button type="submit" disabled={busy || !supplierId}
|
|
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 bg-[#00798C] disabled:opacity-50">
|
|
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save payment
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/** Double-click a supplier name: set how the supplier is paid. Cash suppliers owe nothing; 30 / 60 are accounts. */
|
|
function TermsModal({ row, onClose }: { row: SupplierSpendRow; onClose: () => void }) {
|
|
const router = useRouter();
|
|
const [terms, setTerms] = useState(row.terms);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function save() {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`/api/suppliers/${row.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ terms }),
|
|
});
|
|
const body = await res.json();
|
|
if (!body.ok) { setError(body.error); return; }
|
|
router.refresh();
|
|
onClose();
|
|
} catch {
|
|
setError("Could not save — check your connection");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center p-4" onClick={onClose}>
|
|
<div onClick={(e) => e.stopPropagation()} role="dialog" aria-label="Payment terms"
|
|
className="w-full max-w-sm bg-white dark:bg-surface rounded-2xl p-5 shadow-xl space-y-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Payment terms</h2>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">{row.name}</div>
|
|
</div>
|
|
<button onClick={onClose} aria-label="Close"
|
|
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-50 dark:hover:bg-white/5"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{TERMS.map((t) => (
|
|
<button key={t.id} onClick={() => setTerms(t.id)} aria-pressed={terms === t.id}
|
|
className={`min-h-[44px] rounded-lg text-xs font-medium border transition ${
|
|
terms === t.id ? "bg-[#003D5B] border-[#003D5B] text-white dark:bg-[#00798C] dark:border-[#00798C]" : "border-gray-200 dark:border-border text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/5"
|
|
}`}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{terms === "cash" ? "Paid on the spot — nothing is ever payable, and payments are not tracked."
|
|
: terms ? `Invoices are paid ${Number(terms) / 30} month${Number(terms) > 30 ? "s" : ""} after the invoice month.`
|
|
: "Not set yet — invoices from this supplier are not counted as payable."}
|
|
</p>
|
|
{error && <div className="text-xs text-[#D1495B] dark:text-[#D94F60]">{error}</div>}
|
|
<div className="flex gap-2 justify-end">
|
|
<button onClick={onClose} className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5">Cancel</button>
|
|
<button onClick={save} disabled={busy || terms === row.terms}
|
|
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 bg-[#00798C] disabled:opacity-50">
|
|
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save terms
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|