mega-plant/components/PurchaseIntake.tsx
Dave 696c157e2e Purchase invoices: price an invoice that has no amount
An invoice saved with no amount landed as amount_incl = 0 (PocketBase
stores a number field as zero, not null), so the PO read "R 0,00 invoiced"
and the money never booked. There were two ways to give it an amount and
neither worked: the Upload-invoice modal let a blank amount save silently,
while the double-click correction form refused to save without a value AND
a written reason, and sat behind Edit mode.

- lib/purchase-invoice-amount.ts holds the rules, shared by the screen and
  the API so they cannot disagree. R0 is not an amount: a blank amount and
  a figure of zero are both refused, so an invoice can never land as zero.
- The API now separates PRICING an invoice that never had an amount (no
  reason owed, still logged) from CORRECTING one that was booked (Full user
  + a reason, as before).
- Double-clicking an amount on a PO opens the ordinary invoice modal on
  that invoice, with the amount field waiting. The inline correction form is
  gone — one window, one rule. There is no pencil button: a booked figure
  must take a deliberate second gesture, so a single stray tap never opens
  a form that can rewrite money.
- The PO-lines table freezes its Description column with an opaque
  background and fades its right edge, so it passes the mobile checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-11-04 18:06:02 +02:00

1040 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useRef, useTransition, useMemo } from "react";
import { useRouter } from "next/navigation";
import {
Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert,
Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft, Pencil,
} from "lucide-react";
import { ZAR, ZAR2 } from "@/lib/utils";
import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status";
import { checkAmountValue, checkNewAmount, checkReason, isUnpriced, AMOUNT_REQUIRED_ON_NEW_INVOICE } from "@/lib/purchase-invoice-amount";
const ACCENT = "#30638E";
export type PurchaseLineView = {
id: string;
line_no: number;
material_code: string;
cost_code: string;
description: string;
qty: number | null;
rate: number | null;
amount_excl: number;
vat: number | null;
total_incl: number | null;
assetCode: string;
assetName: string;
};
export type PurchaseView = {
id: string;
po_no: string;
supplier: string;
supplier_code: string;
po_date: string;
required_by: string;
req_no: string;
currency: string;
subtotal: number;
vat: number;
total: number;
source_file: string;
invoices: PurchaseInvoiceView[];
lines: PurchaseLineView[];
};
export type PurchaseInvoiceView = {
id: string;
invoice_no: string;
invoice_date: string;
amount_incl: number | null;
note: string;
hasPdf: boolean;
};
export function PurchaseIntake({ purchases: allPurchases }: { purchases: PurchaseView[] }) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [pending, startTransition] = useTransition();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [invoiceModal, setInvoiceModal] = useState(false);
// An invoice already on a PO whose amount still has to be put in. It opens the SAME modal
// that adds an invoice — one place to type an amount, not two that can drift apart.
const [pricing, setPricing] = useState<{ purchaseId: string; invoice: PurchaseInvoiceView } | null>(null);
const [range, setRange] = useState<"all" | "week" | "month">("all");
// 0 = the current week/month, -1 = the one before, …
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 = {
onDragOver: (e: React.DragEvent) => {
if (invoiceModal || busy || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragging(true);
},
onDragLeave: (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false);
},
onDrop: (e: React.DragEvent) => {
if (invoiceModal || busy) return;
e.preventDefault();
setDragging(false);
upload(e.dataTransfer.files);
},
};
const win = useMemo(() => (range === "all" ? null : rangeWindow(range, offset)), [range, offset]);
// A PO with no readable date cannot be placed in a week or month, so a range hides it —
// and says so, rather than letting it quietly vanish.
const purchases = useMemo(
() => (win ? allPurchases.filter((p) => /^\d{4}-\d{2}-\d{2}/.test(p.po_date) && p.po_date.slice(0, 10) >= win.from && p.po_date.slice(0, 10) <= win.to) : allPurchases),
[allPurchases, win],
);
const undated = win ? allPurchases.filter((p) => !/^\d{4}-\d{2}-\d{2}/.test(p.po_date)).length : 0;
const groups = useMemo(() => {
if (!groupBySupplier) return [{ key: "", name: "", items: purchases }];
const m = new Map<string, PurchaseView[]>();
for (const p of purchases) m.set(p.supplier || "No supplier", [...(m.get(p.supplier || "No supplier") ?? []), p]);
return [...m.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([name, items]) => ({ key: name, name, items }));
}, [purchases, groupBySupplier]);
async function upload(files: FileList | null) {
if (!files || !files.length) return;
setBusy(true);
setError(null);
setNotice(null);
try {
for (const f of Array.from(files)) {
const fd = new FormData();
fd.append("file", f);
const res = await fetch("/api/purchases/intake", { method: "POST", body: fd });
const body = await res.json();
if (!body.ok) {
setError(`${f.name}: ${body.error}`);
break;
}
// A line that found no machine is money that exists but is not yet on one.
// Named, not buried — that is the whole point of reading the cost code.
setNotice(
body.unallocated?.length
? `${body.purchase.po_no}: ${body.lines} line(s) imported, but no machine matched ${body.unallocated.join(", ")} — left unallocated.`
: `${body.purchase.po_no}: ${body.lines} line(s) imported and allocated.`,
);
}
startTransition(() => router.refresh());
} catch {
setError("Upload failed — check your connection");
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
}
}
const totalSpend = purchases.reduce((s, p) => s + p.total, 0);
const unallocatedLines = purchases.reduce(
(s, p) => s + p.lines.filter((l) => !l.assetCode).length,
0,
);
return (
<div {...dropProps} className={`relative rounded-2xl transition ${dragging ? "ring-2 ring-dashed ring-[#30638E] ring-offset-4 bg-[#30638E]/5" : ""}`}>
{dragging && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<div className="rounded-xl bg-[#30638E] text-white text-sm font-medium px-5 py-3 shadow-lg inline-flex items-center gap-2">
<Upload className="w-4 h-4" /> Drop purchase order PDFs to upload
</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 font-medium" style={{ background: `${ACCENT}1a`, color: ACCENT }}>
{purchases.length} purchase order{purchases.length !== 1 && "s"}
</span>
<span className="px-2.5 py-1 rounded-md bg-gray-100 text-gray-500 dark:bg-white/5 dark:text-gray-400 font-medium">
{ZAR(totalSpend)} total
</span>
{unallocatedLines > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium">
{unallocatedLines} line{unallocatedLines !== 1 && "s"} with no machine
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setInvoiceModal(true)}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white"
style={{ background: BRAND_ORANGE }}
>
<Receipt className="w-4 h-4" /> Upload invoice
</button>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
multiple
// The input stays sr-only and the LABEL is the 44px control. It is still
// focusable, so `peer-focus-visible` puts the focus ring on the label —
// otherwise a keyboard user tabs onto an invisible 1px target.
className="sr-only peer"
id="po-upload"
onChange={(e) => upload(e.target.files)}
/>
<label
htmlFor="po-upload"
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white cursor-pointer peer-focus-visible:ring-2 peer-focus-visible:ring-[#30638E] peer-focus-visible:ring-offset-2"
style={{ background: ACCENT }}
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{busy ? "Reading…" : "Upload purchase orders"}
</label>
</div>
{invoiceModal && <InvoiceUploadModal purchases={allPurchases} onClose={() => setInvoiceModal(false)} />}
{pricing && (
<InvoiceUploadModal
purchases={allPurchases}
purchaseId={pricing.purchaseId}
invoice={pricing.invoice}
onClose={() => setPricing(null)}
/>
)}
</div>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#D1495B]/30 bg-[#D1495B]/[0.06] p-4">
<TriangleAlert className="w-4 h-4 mt-0.5 shrink-0 text-[#D1495B] dark:text-[#D94F60]" />
<div className="text-xs text-gray-600 dark:text-gray-400">
<span className="font-semibold text-[#003D5B] dark:text-white">Could not import</span>
<div className="mt-1">{error}</div>
</div>
<button onClick={() => setError(null)} aria-label="Dismiss"
className="ml-auto min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-white dark:hover:bg-white/5">
<X className="w-4 h-4" />
</button>
</div>
)}
{notice && (
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#00798C]/30 bg-[#00798C]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
<Check className="w-4 h-4 mt-0.5 shrink-0 text-[#00798C]" />
<span>{notice}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3 mb-4">
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border self-start">
{([["all", "All"], ["week", "Week"], ["month", "Month"]] as const).map(([id, label]) => (
<button key={id} onClick={() => { setRange(id); setOffset(0); }} aria-pressed={range === id}
className={`min-h-[44px] min-w-[44px] px-3 rounded-md text-xs font-medium transition ${
range === id ? "bg-[#003D5B] text-white dark:bg-[#00798C]" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}>
{label}
</button>
))}
</div>
{win && (
<div className="flex items-center gap-1">
<button onClick={() => setOffset(offset - 1)} aria-label="Previous"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5"><ChevronLeft className="w-4 h-4" /></button>
<span className="text-xs font-medium text-[#003D5B] dark:text-white min-w-[150px] text-center">{win.label}</span>
<button onClick={() => setOffset(offset + 1)} aria-label="Next" disabled={offset >= 0}
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>
)}
<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>
</div>
{undated > 0 && (
<div className="mb-3 text-xs text-[#8a6420] dark:text-[#EDAE49]">
{undated} purchase order{undated !== 1 && "s"} with no readable date {undated !== 1 ? "are" : "is"} hidden while a range is chosen.
</div>
)}
{allPurchases.length > 0 && purchases.length === 0 ? (
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-10 shadow-sm text-center text-sm text-gray-500 dark:text-gray-400">
No purchase orders dated in this {range}.
</section>
) : purchases.length === 0 ? (
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 shadow-sm text-center">
<ShoppingCart className="w-8 h-8 mx-auto mb-3 text-gray-300" />
<div className="text-sm text-gray-500 dark:text-gray-400">No purchase orders yet.</div>
<div className="text-xs text-gray-400 dark:text-text-soft mt-1">
Upload a PO PDF above, or drag it onto this page. Each line is allocated to a machine by its cost code.
</div>
</section>
) : (
<div className="space-y-5">
{groups.map((g) => (
<div key={g.key || "all"}>
{groupBySupplier && (
<div className="flex items-baseline justify-between gap-3 mb-2 px-1">
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white truncate">{g.name}</h3>
<span className="text-xs text-gray-500 dark:text-gray-400 shrink-0 tabular-nums">
{g.items.length} PO{g.items.length !== 1 && "s"} · {ZAR2(g.items.reduce((t, x) => t + x.total, 0))}
</span>
</div>
)}
<div className="space-y-2">
{g.items.map((p) => {
const isOpen = open === p.id;
const unalloc = p.lines.filter((l) => !l.assetCode).length;
const inv = invoiceStatus(p);
const sty = STATUS_STYLE[inv.status];
return (
<div key={p.id} className="relative bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm">
<span
className="absolute left-0 inset-y-0 w-1.5"
style={{ background: sty.color }}
title={sty.label}
role="img"
aria-label={sty.label}
/>
<button
onClick={() => setOpen(isOpen ? null : p.id)}
aria-expanded={isOpen}
className="w-full flex items-center gap-3 sm:gap-4 p-4 text-left min-h-[44px]"
>
<FileText className="w-4 h-4 shrink-0" style={{ color: ACCENT }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
{p.po_no}
</span>
<span className="text-sm font-medium text-[#003D5B] dark:text-white truncate">{p.supplier}</span>
{unalloc > 0 && (
<TriangleAlert className="w-3.5 h-3.5 text-[#EDAE49]" aria-label="Some lines have no machine" />
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{p.po_date || "no date"} · {p.lines.length} line{p.lines.length !== 1 && "s"} ·{" "}
{p.lines.filter((l) => l.assetCode).length} allocated ·{" "}
<span style={{ color: sty.color }} className="font-medium">
{inv.status === "over" ? `invoiced ${ZAR2(inv.invoiced - p.total)} OVER the PO — check the amount` : inv.status === "complete" ? "fully invoiced — can be closed" : inv.status === "partial" ? `${ZAR2(inv.invoiced)} of ${ZAR2(p.total)} invoiced` : "no invoice yet"}
</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-[12px] text-gray-400 uppercase tracking-wider">Total</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR(p.total)}
</div>
</div>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-400 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />}
</button>
{isOpen && (
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-xs mb-4">
<Meta label="Supplier" value={p.supplier} />
<Meta label="Supplier code" value={p.supplier_code || "—"} />
<Meta label="PO date" value={p.po_date || "—"} />
<Meta label="Required by" value={p.required_by || "—"} />
<Meta label="Requisition" value={p.req_no || "—"} />
<Meta label="Currency" value={p.currency || "—"} />
<Meta label="Sub-total" value={ZAR2(p.subtotal)} />
<Meta label="VAT" value={ZAR2(p.vat)} />
</dl>
<SupplierInvoices
purchase={p}
editMode={editMode}
onPrice={(invoice) => setPricing({ purchaseId: p.id, invoice })}
/>
{/* Wide table: it scrolls inside its own box, the description stays put
so a line is still identifiable when the money columns come into view,
and the right edge fades to show there is more to scroll to. */}
<div className="relative">
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface [-webkit-overflow-scrolling:touch]">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-surface-muted">
<th className="text-left py-2 px-3 font-medium">#</th>
<th className="text-left py-2 px-3 font-medium sticky left-0 z-20 bg-gray-50 dark:bg-surface-muted shadow-[1px_0_0_0_rgba(0,0,0,0.07)] dark:shadow-[1px_0_0_0_rgba(255,255,255,0.07)]">Description</th>
<th className="text-left py-2 px-3 font-medium">Cost code</th>
<th className="text-left py-2 px-3 font-medium">Machine</th>
<th className="text-right py-2 px-3 font-medium">Amount</th>
<th className="text-right py-2 px-3 font-medium">Incl VAT</th>
</tr>
</thead>
<tbody>
{p.lines.map((l) => (
<tr key={l.id} className="border-t border-gray-100 dark:border-border">
<td className="py-2 px-3 text-gray-400">{l.line_no}</td>
<td
title={l.description || undefined}
className="py-2 px-3 text-[#003D5B] dark:text-gray-300 sticky left-0 z-10 bg-white dark:bg-surface max-w-[190px] truncate shadow-[1px_0_0_0_rgba(0,0,0,0.07)] dark:shadow-[1px_0_0_0_rgba(255,255,255,0.07)]"
>
{l.description || <span className="italic text-gray-400">no description</span>}
</td>
<td className="py-2 px-3 font-mono text-gray-500 dark:text-gray-400">{l.cost_code || "—"}</td>
<td className="py-2 px-3">
{l.assetCode ? (
<span className="inline-flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
<span className="font-mono text-[12px] px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C]">
{l.assetCode}
</span>
<span className="truncate">{l.assetName}</span>
</span>
) : (
<span className="text-[12px] text-[#EDAE49] italic">no matching machine</span>
)}
</td>
<td className="py-2 px-3 text-right tabular-nums text-gray-600 dark:text-gray-400">
{ZAR2(l.amount_excl)}
</td>
<td className="py-2 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
{l.total_incl == null ? "—" : ZAR2(l.total_incl)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-6 rounded-r-lg bg-gradient-to-l from-gray-100/90 dark:from-border/70 to-transparent" />
</div>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-gray-400 uppercase tracking-wider text-[12px]">{label}</dt>
<dd className="text-gray-700 dark:text-gray-300 mt-0.5 break-words">{value}</dd>
</div>
);
}
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";
/**
* Supplier invoices received against one PO. A PO can have several. Typed in by a person
* 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.
*
* An invoice with no amount is not a dead end: double-click its amount (or tap the pencil)
* and the ordinary invoice modal opens on that invoice, with the amount field waiting.
*/
function SupplierInvoices({
purchase: p,
editMode,
onPrice,
}: {
purchase: PurchaseView;
editMode: boolean;
onPrice: (invoice: PurchaseInvoiceView) => void;
}) {
const router = useRouter();
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [amt, setAmt] = useState("");
const invoiced = p.invoices.reduce((s, i) => s + (i.amount_incl ?? 0), 0);
const unpriced = p.invoices.filter((i) => i.amount_incl == null).length;
const outstanding = p.total - invoiced;
async function add(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
// The same rule the route applies, so the message lands on the field instead of coming
// back as a failed request.
const amount = checkNewAmount(amt);
if (!amount.ok) {
setError(amount.error);
return;
}
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${p.id}/invoices`, { method: "POST", body: new FormData(form) });
const body = await res.json();
if (!body.ok) {
setError(body.error);
return;
}
if (body.over > 0) setError(`Saved — but this takes the invoices ${ZAR2(body.over)} OVER the PO total. Check the amount.`);
else if (body.warning) setError(body.warning);
form.reset();
setAmt("");
setAdding(false);
router.refresh();
} catch {
setError("Could not save — check your connection");
} finally {
setBusy(false);
}
}
async function remove(i: PurchaseInvoiceView) {
if (!confirm(`Remove invoice ${i.invoice_no} from ${p.po_no}?`)) return;
const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, { method: "DELETE" });
const body = await res.json().catch(() => ({ ok: false, error: "Could not remove" }));
if (!body.ok) setError(body.error);
else router.refresh();
}
return (
<div className="mb-4 rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface p-3">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div className="text-xs font-semibold text-[#003D5B] dark:text-white">
Supplier invoices
{p.invoices.length > 0 && (
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
{ZAR2(invoiced)} invoiced · {ZAR2(Math.abs(outstanding))} of PO {outstanding < 0 ? "over" : "not yet invoiced"}
{unpriced > 0 && (
<span className="text-[#EDAE49]"> · {unpriced} with no amount — double-click it to set one</span>
)}
</span>
)}
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="min-h-[44px] px-3 rounded-lg text-xs font-medium text-white inline-flex items-center gap-1.5"
style={{ background: ACCENT }}
>
<Plus className="w-4 h-4" /> Add invoice
</button>
)}
</div>
{p.invoices.length === 0 && !adding && (
<div className="text-xs text-gray-400">No invoices attached to this PO yet.</div>
)}
{p.invoices.length > 0 && (
<ul className="divide-y divide-gray-100 dark:divide-border">
{p.invoices.map((i) => (
<li key={i.id} className="flex items-center gap-3 py-1">
<div className="flex-1 min-w-0 text-xs">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{i.invoice_no}</span>
<span className="text-gray-500 dark:text-gray-400">
{" "}· {i.invoice_date || "no date"}
{i.note && ` · ${i.note}`}
</span>
</div>
<AmountButton invoice={i} onPrice={() => onPrice(i)} />
{i.hasPdf ? (
<a
href={`/api/purchases/${p.id}/invoices/${i.id}/document`}
target="_blank"
rel="noreferrer"
aria-label={`Open PDF for ${i.invoice_no}`}
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
>
<FileText className="w-4 h-4" />
</a>
) : (
<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}`}
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"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</li>
))}
</ul>
)}
{error && <div className="mt-2 text-xs text-[#D1495B] dark:text-[#D94F60]">{error}</div>}
{adding && (
<form onSubmit={add} className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice number *
<input name="invoice_no" required className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice date
<input name="invoice_date" type="date" className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Amount incl VAT {AMOUNT_REQUIRED_ON_NEW_INVOICE ? "*" : ""}
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
<OverWarning po={p} typed={amt} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Note
<input name="note" placeholder="e.g. part delivery" className={inputCls} />
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Invoice PDF
<input name="file" type="file" accept="application/pdf,.pdf" className={`${inputCls} py-2`} />
</label>
<div className="sm:col-span-2 flex gap-2">
<button
type="submit"
disabled={busy}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-60"
style={{ background: ACCENT }}
>
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save invoice
</button>
<button
type="button"
onClick={() => { setAdding(false); setError(null); }}
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>
</div>
</form>
)}
</div>
);
}
/**
* The amount on one supplier invoice.
*
* ⚠️ **It opens on a DOUBLE-CLICK and on nothing else.** There is no pencil button beside it.
* A figure that has already been booked reaches the supplier's payables and the client's
* invoice status, so changing one must take a deliberate second gesture — a single stray tap
* must never open a form that can rewrite money.
*
* A long press does the same job on a phone, where a double tap is not reliably delivered,
* and it is just as hard to do by accident. Enter or Space does it from the keyboard.
*/
function AmountButton({ invoice, onPrice }: { invoice: PurchaseInvoiceView; onPrice: () => void }) {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set once the window has opened, so a long press that ends in a double tap cannot open it twice.
const opened = useRef(false);
function open() {
if (opened.current) return;
opened.current = true;
onPrice();
}
function cancel() {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
}
return (
<button
type="button"
onDoubleClick={open}
onPointerDown={() => {
cancel();
timer.current = setTimeout(open, 500);
}}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onContextMenu={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
}}
title="Double-click to set the amount"
aria-label={`Amount for invoice ${invoice.invoice_no}. Double-click to set it.`}
style={{ touchAction: "manipulation" }}
className="min-h-[44px] px-2 -my-1 rounded text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0 select-none hover:bg-[#30638E]/10 focus-visible:ring-2 focus-visible:ring-[#30638E] underline decoration-dotted decoration-[#30638E]/60 underline-offset-4"
>
{invoice.amount_incl == null ? <span className="font-normal italic text-[#EDAE49]">no amount</span> : ZAR2(invoice.amount_incl)}
</button>
);
}
function invoiceStatus(p: PurchaseView) {
return invoiceStatusOf(p.total, p.invoices.map((i) => i.amount_incl));
}
const BRAND_ORANGE = "#EDAE49";
/**
* The invoice window — ONE place to put an amount on an invoice.
*
* - No `invoice`: it ADDS a new invoice to a PO (the global "Upload invoice").
* - With `invoice`: it PRICES an invoice already on a PO. Double-clicking that invoice's
* amount opens this same window on it, with the amount field waiting. One window, one rule,
* one place to type a figure — instead of a second form that refused to save.
*
* Pricing an invoice that never had an amount is not a correction: nothing was booked, so
* there is nothing to justify and no reason is demanded. Changing a figure that WAS booked,
* or the invoice's number or date, is a correction — a reason is compulsory and it is logged.
*/
function InvoiceUploadModal({
purchases,
onClose,
purchaseId,
invoice,
}: {
purchases: PurchaseView[];
onClose: () => void;
/** The PO the invoice belongs to. Fixed when `invoice` is given. */
purchaseId?: string;
/** An invoice already on file: the modal prices it instead of creating a new one. */
invoice?: PurchaseInvoiceView;
}) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [chosenId, setChosenId] = useState(purchaseId ?? "");
const [file, setFile] = useState<File | null>(null);
const [amt, setAmt] = useState(invoice ? (invoice.amount_incl == null ? "" : String(invoice.amount_incl)) : "");
const [invoiceNo, setInvoiceNo] = useState(invoice?.invoice_no ?? "");
const [invoiceDate, setInvoiceDate] = useState(invoice?.invoice_date ?? "");
const [reason, setReason] = useState("");
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const editing = !!invoice;
const q = query.trim().toLowerCase().replace(/[\s,]/g, "");
const matches = purchases
.filter((p) => {
if (!q) return true;
const hay = [
p.po_no, p.supplier, p.supplier_code,
p.total.toFixed(2), String(Math.round(p.total)),
...p.lines.map((l) => `${l.description} ${l.cost_code}`),
].join(" ").toLowerCase().replace(/[\s,]/g, "");
return hay.includes(q);
})
.slice(0, 8);
const po = purchases.find((p) => p.id === (editing ? purchaseId : chosenId)) || null;
const st = po ? invoiceStatus(po) : null;
// The invoice already carries a figure → correcting it. Changing its number or date is
// also a correction. Neither is true of a figure that was never entered.
const wasPriced = invoice ? !isUnpriced(invoice.amount_incl) : false;
const identityChanged = !!invoice && (invoiceDate !== invoice.invoice_date || invoiceNo !== invoice.invoice_no);
const needsReason = wasPriced || identityChanged;
function pick(f: File | undefined) {
if (!f) return;
if (!f.name.toLowerCase().endsWith(".pdf")) {
setError("Invoices must be PDF files");
return;
}
setError(null);
setFile(f);
}
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!po) {
setError("Choose the purchase order first");
return;
}
setBusy(true);
setError(null);
try {
if (editing && invoice) {
// The same rules the route applies, checked here so the message lands on the field.
const value = checkAmountValue(amt);
if (!value.ok) { setError(value.error); setBusy(false); return; }
const stated = checkReason({ pricingOnly: !needsReason, reason });
if (!stated.ok) { setError(stated.error); setBusy(false); return; }
const res = await fetch(`/api/purchases/${po.id}/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount_incl: amt,
invoice_date: invoiceDate,
invoice_no: invoiceNo,
reason: stated.reason,
}),
});
const body = await res.json();
if (!body.ok) { setError(body.error); return; }
router.refresh();
onClose();
return;
}
const amount = checkNewAmount(amt);
if (!amount.ok) { setError(amount.error); setBusy(false); return; }
const fd = new FormData();
fd.append("invoice_no", invoiceNo);
fd.append("invoice_date", invoiceDate);
fd.append("amount_incl", amount.amount == null ? "" : String(amount.amount));
if (file) fd.set("file", file);
const res = await fetch(`/api/purchases/${po.id}/invoices`, { method: "POST", body: fd });
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);
}
}
const title = editing ? (wasPriced ? "Correct invoice" : "Set the amount") : "Upload invoice";
const otherInvoiced = po && editing
? po.invoices.reduce((t, i) => (i.id === invoice!.id ? t : t + (i.amount_incl ?? 0)), 0)
: (po ? po.invoices.reduce((t, i) => t + (i.amount_incl ?? 0), 0) : 0);
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={title}
className="w-full max-w-lg bg-white dark:bg-surface rounded-2xl p-5 shadow-xl space-y-3 my-auto"
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">{title}</h2>
{editing && invoice && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{invoice.invoice_no}{po ? ` · ${po.po_no}` : ""}
</div>
)}
</div>
<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>
{!editing && (
<div>
<label className="text-[12px] text-gray-500 dark:text-gray-400" htmlFor="inv-search">
Find the purchase order — number, supplier, amount or description
</label>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input id="inv-search" value={query} onChange={(e) => { setQuery(e.target.value); setChosenId(""); }}
placeholder="e.g. MEGPPO49676, tyres, 12500" autoComplete="off"
className={`${inputCls} pl-9`} />
</div>
{!po && (
<ul className="mt-1 max-h-56 overflow-y-auto rounded-lg border border-gray-100 dark:border-border divide-y divide-gray-100 dark:divide-border">
{matches.length === 0 && <li className="p-3 text-xs text-gray-400">No purchase order matches.</li>}
{matches.map((p) => (
<li key={p.id}>
<button type="button" onClick={() => setChosenId(p.id)}
className="w-full min-h-[44px] flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-white/5">
<span className="w-1 self-stretch rounded" style={{ background: STATUS_STYLE[invoiceStatus(p).status].color }} />
<span className="flex-1 min-w-0 text-xs">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{p.po_no}</span>
<span className="text-gray-500 dark:text-gray-400"> · {p.supplier}</span>
<span className="block truncate text-gray-400">{p.lines[0]?.description}</span>
</span>
<span className="text-xs tabular-nums font-semibold shrink-0">{ZAR2(p.total)}</span>
</button>
</li>
))}
</ul>
)}
{po && st && (
<div className="mt-1 flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-white/5 p-2 text-xs">
<span className="w-1 self-stretch rounded" style={{ background: STATUS_STYLE[st.status].color }} />
<span className="flex-1 min-w-0">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{po.po_no}</span>
<span className="text-gray-500 dark:text-gray-400"> · {po.supplier}</span>
</span>
<button type="button" onClick={() => setChosenId("")}
className="min-h-[44px] px-2 text-[#30638E] font-medium">Change</button>
</div>
)}
</div>
)}
{editing && po && (
<div className="flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-white/5 p-2 text-xs">
<span className="w-1 self-stretch rounded" style={{ background: STATUS_STYLE[st!.status].color }} />
<span className="flex-1 min-w-0">
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{po.po_no}</span>
<span className="text-gray-500 dark:text-gray-400"> · {po.supplier}</span>
</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice number *
<input
value={invoiceNo}
onChange={(e) => setInvoiceNo(e.target.value)}
required
className={inputCls}
// An invoice already on file keeps its number unless somebody corrects it.
readOnly={editing}
/>
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Invoice date
<input
value={invoiceDate}
onChange={(e) => setInvoiceDate(e.target.value)}
type="date"
className={inputCls}
/>
</label>
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Amount incl VAT {editing ? "" : "*"}
<input
name="amount_incl"
value={amt}
onChange={(e) => setAmt(e.target.value)}
inputMode="decimal"
placeholder="0.00"
autoFocus
className={inputCls}
/>
{po && <OverWarning po={po} typed={amt} excludeId={editing ? invoice?.id : undefined} />}
{po && (
<span className="block mt-1 text-xs text-gray-500 dark:text-gray-400">
PO value <span className="font-semibold text-[#003D5B] dark:text-white tabular-nums">{ZAR2(po.total)}</span>
{/* On an invoice already on file the figure being edited is excluded, so
"already invoiced" would read as nothing and mislead. */}
{!editing && <>{" · "}already invoiced {ZAR2(otherInvoiced)}</>}
{" · "}left <span className="font-semibold tabular-nums">{ZAR2(Math.max(po.total - otherInvoiced - (Number(amt.replace(/\s/g, "").replace(",", ".")) || 0), 0))}</span>
</span>
)}
</label>
{needsReason && (
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
Reason for the change (kept in the log) *
<input
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. decimal point missed when captured"
className={inputCls}
/>
</label>
)}
</div>
{!editing && (
<div
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); pick(e.dataTransfer.files[0]); }}
className={`rounded-xl border-2 border-dashed p-4 text-center text-xs transition-colors ${
drag ? "border-[#EDAE49] bg-[#EDAE49]/10" : "border-gray-200 dark:border-border"
}`}
>
<input ref={fileRef} type="file" accept="application/pdf,.pdf" className="sr-only"
onChange={(e) => pick(e.target.files?.[0])} />
{file ? (
<div className="flex items-center justify-center gap-2 text-[#003D5B] dark:text-white">
<FileText className="w-4 h-4" /> {file.name}
<button type="button" onClick={() => setFile(null)} aria-label="Remove file"
className="min-h-[44px] min-w-[44px] grid place-items-center"><X className="w-4 h-4" /></button>
</div>
) : (
<>
<Upload className="w-5 h-5 mx-auto mb-1 text-gray-400" />
<div className="text-gray-500 dark:text-gray-400">Drag the invoice PDF here, or</div>
<button type="button" onClick={() => fileRef.current?.click()}
className="mt-1 min-h-[44px] px-4 rounded-lg text-xs font-medium text-white"
style={{ background: BRAND_ORANGE }}>
Choose file
</button>
</>
)}
</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 || (!editing && !po)}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-50"
style={{ background: BRAND_ORANGE }}>
{busy && <Loader2 className="w-4 h-4 animate-spin" />}
{editing ? (wasPriced ? "Save correction" : "Save amount") : "Save invoice"}
</button>
</div>
</form>
</div>
);
}
const isoDay = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const fmtDay = (d: Date) => d.toLocaleDateString("en-ZA", { day: "numeric", month: "short" });
/** The week (Monday–Sunday) or calendar month `offset` periods from the current one. */
function rangeWindow(range: "week" | "month", offset: number): { from: string; to: string; label: string } {
const now = new Date();
if (range === "month") {
const first = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const last = new Date(first.getFullYear(), first.getMonth() + 1, 0);
return { from: isoDay(first), to: isoDay(last), label: first.toLocaleDateString("en-ZA", { month: "long", year: "numeric" }) };
}
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - ((now.getDay() + 6) % 7) + offset * 7);
const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6);
return { from: isoDay(monday), to: isoDay(sunday), label: `${fmtDay(monday)} – ${fmtDay(sunday)}` };
}
/**
* Live check while typing an invoice amount: anything that takes the PO over its total is
* flagged before it is saved.
*
* `excludeId` leaves out the invoice being edited — its own figure is being replaced, so
* counting it as well would report a false over-spend.
*/
function OverWarning({ po, typed, excludeId }: { po: PurchaseView; typed: string; excludeId?: string }) {
const n = Number(typed.trim().replace(/\s/g, "").replace(",", "."));
if (!typed.trim() || !Number.isFinite(n)) return null;
const others = po.invoices.reduce((t, i) => (i.id === excludeId ? t : t + (i.amount_incl ?? 0)), 0);
const over = others + n - po.total;
if (over <= 1) return null;
return (
<span className="mt-1 flex items-start gap-1.5 text-xs font-medium text-[#8B5CF6]">
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0" />
This is {ZAR2(over)} MORE than the PO ({ZAR2(po.total)}). Check for a typo before saving.
</span>
);
}