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:
parent
642d23dd24
commit
56305fc599
3 changed files with 110 additions and 10 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue