"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(null); const [pending, startTransition] = useTransition(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [open, setOpen] = useState(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(); 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 (
{dragging && (
Drop purchase order PDFs to upload
)}
{purchases.length} purchase order{purchases.length !== 1 && "s"} {ZAR(totalSpend)} total {unallocatedLines > 0 && ( {unallocatedLines} line{unallocatedLines !== 1 && "s"} with no machine )}
upload(e.target.files)} />
{invoiceModal && setInvoiceModal(false)} />}
{error && (
Could not import
{error}
)} {notice && (
{notice}
)}
{([["all", "All"], ["week", "Week"], ["month", "Month"]] as const).map(([id, label]) => ( ))}
{win && (
{win.label}
)}
{undated > 0 && (
{undated} purchase order{undated !== 1 && "s"} with no readable date {undated !== 1 ? "are" : "is"} hidden while a range is chosen.
)} {allPurchases.length > 0 && purchases.length === 0 ? (
No purchase orders dated in this {range}.
) : purchases.length === 0 ? (
No purchase orders yet.
Upload a PO PDF above, or drag it onto this page. Each line is allocated to a machine by its cost code.
) : (
{groups.map((g) => (
{groupBySupplier && (

{g.name}

{g.items.length} PO{g.items.length !== 1 && "s"} · {ZAR2(g.items.reduce((t, x) => t + x.total, 0))}
)}
{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 (
{isOpen && (
{p.lines.map((l) => ( ))}
# Description Cost code Machine Amount Incl VAT
{l.line_no} {l.description || no description} {l.cost_code || "—"} {l.assetCode ? ( {l.assetCode} {l.assetName} ) : ( no matching machine )} {ZAR2(l.amount_excl)} {l.total_incl == null ? "—" : ZAR2(l.total_incl)}
)}
); })}
))}
)}
); } function Meta({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } 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(null); const [amt, setAmt] = useState(""); const [editId, setEditId] = useState(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) { 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 (
Supplier invoices {p.invoices.length > 0 && ( {ZAR2(invoiced)} invoiced · {ZAR2(Math.abs(outstanding))} of PO {outstanding < 0 ? "over" : "not yet invoiced"} {unpriced > 0 && ` · ${unpriced} with no amount`} )}
{!adding && ( )}
{p.invoices.length === 0 && !adding && (
No invoices attached to this PO yet.
)} {p.invoices.length > 0 && (
    {p.invoices.map((i) => editId === i.id ? (
  • Correct invoice {i.invoice_no}
  • ) : (
  • {i.invoice_no} {" "}· {i.invoice_date || "no date"} {i.note && ` · ${i.note}`}
    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 ? no amount : ZAR2(i.amount_incl)}
    {editMode && ( )} {i.hasPdf ? ( ) : ( no PDF )} {editMode && ( )}
  • ))}
)} {error &&
{error}
} {adding && (
)}
); } 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(null); const [query, setQuery] = useState(""); const [poId, setPoId] = useState(""); const [file, setFile] = useState(null); const [amt, setAmt] = useState(""); const [drag, setDrag] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(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) { 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 (
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" >

Upload invoice

{ setQuery(e.target.value); setPoId(""); }} placeholder="e.g. MEGPPO49676, tyres, 12500" autoComplete="off" className={`${inputCls} pl-9`} />
{!po && (
    {matches.length === 0 &&
  • No purchase order matches.
  • } {matches.map((p) => (
  • ))}
)} {po && st && (
{po.po_no} · {po.supplier}
)}
{ 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" }`} > pick(e.target.files?.[0])} /> {file ? (
{file.name}
) : ( <>
Drag the invoice PDF here, or
)}
{error &&
{error}
}
); } 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 ( This is {ZAR2(over)} MORE than the PO ({ZAR2(po.total)}). Check for a typo before saving. ); }