Suppliers: double-click name to set terms; Edit mode gates deletes and invoice corrections

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
David 2026-10-29 20:54:34 +02:00
parent 642d23dd24
commit 56305fc599
3 changed files with 110 additions and 10 deletions

View file

@ -29,6 +29,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
if (!Object.keys(patch).length) return Response.json({ ok: false, error: "Nothing to change." }, { status: 400 });
await pb.collection("suppliers").update(id, patch);
revalidatePath("/expenses/suppliers");
revalidatePath("/expenses/suppliers-2");
return Response.json({ ok: true });
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not save" }, { status: 500 });

View file

@ -66,6 +66,8 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
const [offset, setOffset] = useState(0);
const [groupBySupplier, setGroupBySupplier] = useState(true);
const [dragging, setDragging] = useState(false);
// Deleting and correcting are hidden until this is switched on, and it resets on reload.
const [editMode, setEditMode] = useState(false);
// Drop a PO anywhere on the page. Not while the invoice window is open: that has its own
// drop area, and an invoice PDF must never be read as a purchase order.
const dropProps = {
@ -237,7 +239,16 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 disabled:opacity-30"><ChevronRight className="w-4 h-4" /></button>
</div>
)}
<label className="inline-flex items-center gap-2 min-h-[44px] text-xs text-gray-600 dark:text-gray-300 cursor-pointer sm:ml-auto">
<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 sm:ml-auto ${
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>
<label className="inline-flex items-center gap-2 min-h-[44px] text-xs text-gray-600 dark:text-gray-300 cursor-pointer">
<input type="checkbox" checked={groupBySupplier} onChange={(e) => setGroupBySupplier(e.target.checked)} className="w-4 h-4" />
Group by supplier
</label>
@ -333,7 +344,7 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
<Meta label="VAT" value={ZAR2(p.vat)} />
</dl>
<SupplierInvoices purchase={p} />
<SupplierInvoices purchase={p} editMode={editMode} />
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface">
<table className="w-full text-xs">
@ -409,7 +420,7 @@ const inputCls =
* with the PDF attached; the "invoiced" figure sums only the amounts actually entered, and
* an invoice with no amount is listed but never counted as R0.
*/
function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; editMode: boolean }) {
const router = useRouter();
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
@ -543,12 +554,13 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
</span>
</div>
<div
onDoubleClick={() => startEdit(i)}
title="Double-click to correct this amount"
onDoubleClick={() => editMode && startEdit(i)}
title={editMode ? "Double-click to correct this amount" : "Switch on Edit mode to correct this amount"}
className="text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0 cursor-text select-none"
>
{i.amount_incl == null ? <span className="font-normal italic text-gray-400">no amount</span> : ZAR2(i.amount_incl)}
</div>
{editMode && (
<button
onClick={() => startEdit(i)}
aria-label={`Correct the amount of ${i.invoice_no}`}
@ -556,6 +568,7 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
>
<Pencil className="w-4 h-4" />
</button>
)}
{i.hasPdf ? (
<a
href={`/api/purchases/${p.id}/invoices/${i.id}/document`}
@ -569,6 +582,7 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
) : (
<span className="min-w-[44px] text-center text-[12px] text-gray-400">no PDF</span>
)}
{editMode && (
<button
onClick={() => remove(i)}
aria-label={`Remove invoice ${i.invoice_no}`}
@ -576,6 +590,7 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
>
<Trash2 className="w-4 h-4" />
</button>
)}
</li>
))}
</ul>

View file

@ -2,11 +2,11 @@
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Plus, Trash2, X, Loader2 } from "lucide-react";
import { 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 } from "@/lib/supplier";
import { termsLabel, isAccount, TERMS } from "@/lib/supplier";
import { STATUS_STYLE } from "@/lib/purchase-invoice-status";
import type { SupplierSpendRow } from "@/lib/supplier-spend";
@ -29,6 +29,9 @@ export function Suppliers2View({
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(
@ -87,6 +90,12 @@ export function Suppliers2View({
)}
</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
@ -111,6 +120,7 @@ export function Suppliers2View({
</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">
@ -137,7 +147,11 @@ export function Suppliers2View({
<span className="hidden sm:inline font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] 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">{r.name}</div>
<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>
@ -211,7 +225,7 @@ export function Suppliers2View({
<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} />)}
{r.payments.map((p) => <PaymentRow key={p.id} p={p} editMode={editMode} />)}
</ul>
)}
</div>
@ -268,7 +282,7 @@ function MonthPayLine({ due, paid, past }: { due: number; paid: number; past: bo
);
}
function PaymentRow({ p }: { p: { id: string; paid_on: string; description: string; amount: number } }) {
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() {
@ -285,10 +299,12 @@ function PaymentRow({ p }: { p: { id: string; paid_on: string; description: stri
<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] 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>
);
}
@ -382,3 +398,71 @@ function AddPaymentModal({ rows, month, onClose }: { rows: SupplierSpendRow[]; m
</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>
);
}