mega-plant/components/InvoiceIntake.tsx
David 5caa923375 Invoices: the app ingests PDFs itself, with a problem-invoice inbox
Extraction was previously manual: an agent read each PDF, mapped the columns
by hand and wrote data/source/invoice-*.json. That was the only way anything
got in, and the upload button just filed the document without reading it.

That supplier's report is one fixed template — the columns sit at identical
x-positions on every invoice, and only the line count and page count vary —
so the figures are now read by coordinate instead. Deliberately not an AI
extractor: this input does not need one, and a deterministic parse that fails
loudly is worth more here than a flexible one that can hallucinate a total.

lib/invoice-parser.ts reads the header, the machine lines across all pages,
the "Plant Hire Total" row and the SUB TOTAL / Escalation / PLUS VAT /
INVOICE TOTAL block, then applies the same arithmetic gates the seed does:
lines must sum to the subtotal, each line must satisfy qty x rate = amount
and amount + VAT = total, and VAT must match the stated rate. Anything that
does not reconcile is refused with the reason — never partially imported.

Validated by parsing all five existing invoices and comparing every field
against the hand-extracted JSON: identical, including the R24,205.13-VAT
column that a plain text read gets wrong. PHI 169 was then deleted and
re-ingested through the HTTP endpoint alone and came back field-for-field
identical, with no agent involved.

- "Add Invoice" drops a PDF in and imports it; lines are matched to plant
  items by the code the invoice prints, and an unmatched line (the
  container codes MPLANT04/MPLANT05) is left unassigned rather than guessed.
- A "Problem invoices" inbox lists every PDF that failed, with the reason,
  and Retry re-reads the stored document and re-runs the parse. Retry
  re-reports rather than claiming success, and only files imported data.
- invoice_intake collection records each attempt.

pdfjs-dist is in serverExternalPackages: bundled, it rewrites its own lazy
worker import to a .next path absent from the standalone image and every
parse dies with "Setting up fake worker failed". The parser also copies the
buffer before handing it over, since pdfjs transfers and detaches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:30:17 +02:00

308 lines
No EOL
11 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
FileUp, Loader2, AlertTriangle, CheckCircle2, RotateCw, Inbox, Trash2, X,
} from "lucide-react";
import { ZAR2 } from "@/lib/utils";
// "Add Invoice" — drop a PDF and the app parses it itself. No agent, no AI: the
// figures are read by coordinate from a fixed report template and checked
// arithmetically before anything is written.
export function AddInvoiceButton() {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<null | { ok: boolean; title: string; body: string }>(null);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const send = useCallback(
async (file: File) => {
setBusy(true);
setResult(null);
const fd = new FormData();
fd.append("file", file);
try {
const res = await fetch("/api/invoices/intake", { method: "POST", body: fd });
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
if (data.ok) {
const unassigned = (data.unassigned ?? []) as string[];
setResult({
ok: true,
title: `${data.invoice_no} imported`,
body:
`${data.lines} line${data.lines > 1 ? "s" : ""} · ${ZAR2(data.total_incl)} · ` +
`${data.booked} booked` +
(unassigned.length
? `. Needs a plant item: ${unassigned.join(", ")}`
: data.fullyBooked
? ". All lines matched a plant item."
: "."),
});
} else {
setResult({
ok: false,
title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import",
body: data.error ?? "Unknown error",
});
}
router.refresh();
} catch {
setResult({ ok: false, title: "Could not reach the server", body: "Check the app is running." });
} finally {
setBusy(false);
}
},
[router],
);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-xl bg-gradient-to-br from-[#00798C] to-[#003D5B] text-white text-sm font-semibold shadow-lg shadow-teal-500/20 hover:opacity-90 transition"
>
<FileUp className="w-4 h-4" />
Add Invoice
</button>
);
}
return (
<div className="mb-5 bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Add invoice</h2>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="grid place-items-center w-11 h-11 -mr-2 -mt-1 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<div
role="button"
tabIndex={0}
aria-label="Upload an invoice PDF"
onClick={() => !busy && inputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !busy) {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) send(f);
}}
className={`flex flex-col sm:flex-row sm:items-center gap-3 px-4 py-4 rounded-xl border-2 border-dashed cursor-pointer transition ${
dragging
? "border-[#00798C] bg-[#00798C]/10"
: "border-gray-200 dark:border-white/10 hover:border-[#00798C]/50 hover:bg-gray-50 dark:hover:bg-white/[0.02]"
} ${busy ? "opacity-60 pointer-events-none" : ""}`}
>
{busy ? (
<Loader2 className="w-5 h-5 animate-spin text-[#00798C] shrink-0" />
) : (
<FileUp className="w-5 h-5 text-[#00798C] shrink-0" />
)}
<div className="min-w-0">
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
{busy ? "Reading the invoice…" : "Drop the invoice PDF here, or tap to browse"}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
PDF only · up to 20 MB · figures are read from the invoice and checked before saving
</div>
</div>
</div>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) send(f);
e.target.value = "";
}}
/>
{result && (
<div
className={`mt-3 flex items-start gap-2.5 px-3 py-2.5 rounded-xl text-sm ${
result.ok ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
}`}
>
{result.ok ? (
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
) : (
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
)}
<div className="min-w-0">
<div className="font-semibold">{result.title}</div>
<div className="mt-0.5 break-words">{result.body}</div>
</div>
</div>
)}
</div>
);
}
type IntakeItem = {
id: string;
file_name: string;
file_size: number;
invoice_no: string;
status: "imported" | "failed";
error: string;
created: string;
};
// The problem-invoice inbox. Anything that could not be parsed or imported lands here
// with the reason, so it can be inspected and retried instead of disappearing.
export function ProblemInbox() {
const [items, setItems] = useState<IntakeItem[] | null>(null);
const [retrying, setRetrying] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const router = useRouter();
const load = useCallback(async () => {
try {
const res = await fetch("/api/invoices/intake?status=failed", { cache: "no-store" });
const data = await res.json().catch(() => ({ ok: false, items: [] }));
setItems(data.ok ? data.items : []);
} catch {
setItems([]);
}
}, []);
useEffect(() => {
load();
}, [load]);
const retry = async (id: string) => {
setRetrying(id);
setMsg(null);
try {
const res = await fetch(`/api/invoices/intake/${id}/retry`, { method: "POST" });
const data = await res.json().catch(() => ({ ok: false }));
setMsg(
data.ok
? `${data.invoice_no} imported on retry.`
: `Still failing: ${data.error ?? "unknown reason"}`,
);
await load();
router.refresh();
} catch {
setMsg("Could not reach the server");
} finally {
setRetrying(null);
}
};
const discard = async (id: string) => {
setRetrying(id);
try {
await fetch(`/api/invoices/intake/${id}`, { method: "DELETE" });
await load();
} catch {
setMsg("Could not reach the server");
} finally {
setRetrying(null);
}
};
return (
<div className="bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl shadow-sm overflow-hidden mb-6">
<div className="flex items-center gap-2.5 px-5 py-4 border-b border-gray-100 dark:border-white/5">
<Inbox className="w-4 h-4 text-[#D1495B] shrink-0" />
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Problem invoices</h2>
<span className="text-xs px-2 py-0.5 rounded-md bg-[#D1495B]/10 text-[#D1495B] font-semibold">
{items?.length ?? 0}
</span>
</div>
{msg && (
<div className="px-5 py-2.5 bg-[#EDAE49]/10 text-[#8a6420] dark:text-[#EDAE49] text-sm">{msg}</div>
)}
{items === null ? (
<p className="px-5 py-6 text-sm text-gray-500 dark:text-gray-400">Checking…</p>
) : items.length === 0 ? (
<p className="px-5 py-6 text-sm text-gray-500 dark:text-gray-400">
Nothing in the inbox — every PDF offered so far imported cleanly.
</p>
) : (
<ul className="divide-y divide-gray-100 dark:border-white/5">
{items.map((it) => (
<li key={it.id} className="px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-semibold text-[#003D5B] dark:text-white">
{it.file_name}
</span>
{it.invoice_no && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-white/5 text-gray-600 dark:text-gray-300">
reads as {it.invoice_no}
</span>
)}
</div>
<p className="text-sm text-[#D1495B] mt-1.5 break-words">{it.error}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{(it.file_size / 1024).toFixed(0)} KB
{it.created ? ` · ${new Date(it.created).toLocaleString("en-ZA")}` : ""}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => retry(it.id)}
disabled={retrying === it.id}
className="inline-flex items-center gap-1.5 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#003D5B] dark:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
{retrying === it.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCw className="w-4 h-4" />
)}
Retry
</button>
<button
type="button"
onClick={() => discard(it.id)}
disabled={retrying === it.id}
aria-label={`Discard ${it.file_name}`}
className="inline-flex items-center justify-center w-11 h-11 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
);
}