mega-plant/components/PurchaseIntake.tsx
David 56305fc599 Suppliers: double-click name to set terms; Edit mode gates deletes and invoice corrections
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 20:54:34 +02:00

880 lines
42 KiB
TypeScript
Raw Permalink 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";
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);
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)} />}
</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] 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} />
<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">
<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">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 className="py-2 px-3 text-[#003D5B] dark:text-gray-300">
{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>
)}
</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.
*/
function SupplierInvoices({ purchase: p, editMode }: { purchase: PurchaseView; editMode: boolean }) {
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 [editId, setEditId] = useState<string | null>(null);
const [editAmt, setEditAmt] = useState("");
const [editReason, setEditReason] = 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;
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);
}
}
function startEdit(i: PurchaseInvoiceView) {
setEditId(i.id);
setEditAmt(i.amount_incl == null ? "" : String(i.amount_incl));
setEditReason("");
setError(null);
}
async function saveEdit(i: PurchaseInvoiceView) {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount_incl: editAmt, reason: editReason }),
});
const body = await res.json();
if (!body.ok) { setError(body.error); return; }
setEditId(null);
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 && ` · ${unpriced} with no amount`}
</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) => editId === i.id ? (
<li key={i.id} className="py-2 space-y-2">
<div className="text-xs font-semibold text-[#003D5B] dark:text-white">Correct invoice {i.invoice_no}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<label className="text-[12px] text-gray-500 dark:text-gray-400">
Amount incl VAT (PO is {ZAR2(p.total)})
<input value={editAmt} onChange={(e) => setEditAmt(e.target.value)} inputMode="decimal" className={inputCls} autoFocus />
</label>
<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={editReason} onChange={(e) => setEditReason(e.target.value)} placeholder="e.g. decimal point missed when captured" className={inputCls} />
</label>
</div>
<div className="flex gap-2">
<button onClick={() => saveEdit(i)} disabled={busy || editReason.trim().length < 3}
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: ACCENT }}>
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save correction
</button>
<button onClick={() => setEditId(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>
</li>
) : (
<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>
<div
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}`}
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
>
<Pencil className="w-4 h-4" />
</button>
)}
{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
<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>
);
}
function invoiceStatus(p: PurchaseView) {
return invoiceStatusOf(p.total, p.invoices.map((i) => i.amount_incl));
}
const BRAND_ORANGE = "#EDAE49";
/**
* Global "Upload invoice": find the PO by number, supplier, amount or line description,
* then give the invoice number, date, amount and PDF (drag and drop or browse).
*/
function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[]; onClose: () => void }) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [poId, setPoId] = useState("");
const [file, setFile] = useState<File | null>(null);
const [amt, setAmt] = useState("");
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
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 === poId) || null;
const st = po ? invoiceStatus(po) : null;
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;
}
const fd = new FormData(e.currentTarget);
if (file) fd.set("file", file);
else fd.delete("file");
setBusy(true);
setError(null);
try {
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);
}
}
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="Upload invoice"
className="w-full max-w-lg 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">Upload invoice</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>
<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); setPoId(""); }}
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={() => setPoId(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={() => setPoId("")}
className="min-h-[44px] px-2 text-[#30638E] font-medium">Change</button>
</div>
)}
</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 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 sm:col-span-2">
Amount incl VAT
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
{po && <OverWarning po={po} typed={amt} />}
{po && st && (
<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>
{" · "}already invoiced {ZAR2(st.invoiced)}
{" · "}left <span className="font-semibold tabular-nums">{ZAR2(Math.max(po.total - st.invoiced, 0))}</span>
</span>
)}
</label>
</div>
<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 || !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" />} 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. */
function OverWarning({ po, typed }: { po: PurchaseView; typed: string }) {
const n = Number(typed.trim().replace(/\s/g, "").replace(",", "."));
if (!typed.trim() || !Number.isFinite(n)) return null;
const over = po.invoices.reduce((t, i) => t + (i.amount_incl ?? 0), 0) + 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>
);
}