mega-plant/components/InvoiceIntake.tsx

448 lines
18 KiB
TypeScript
Raw Normal View History

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 12:30:17 +00:00
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
FileUp, Loader2, AlertTriangle, RotateCw, Inbox, Trash2, X, UploadCloud, FileText,
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 12:30:17 +00:00
} from "lucide-react";
import { Toast } from "@/components/Toast";
import { ZAR2 } from "@/lib/utils";
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 12:30:17 +00:00
// "Add Invoice" — opens a standalone dialog. The file is chosen or dropped into the
// modal and nothing is uploaded until OK is pressed, so a mis-drop can be cleared
// without leaving a stray record in the inbox.
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 12:30:17 +00:00
export function AddInvoiceButton() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
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 12:30:17 +00:00
const [dragging, setDragging] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<null | { title: string; body: string }>(null);
// The dialog closes on success, which would otherwise leave no feedback at all — so
// a toast confirms it landed. Anything still needing attention is named in it AND
// badged on the invoice row.
const [toast, setToast] = useState<null | { message: string; detail: string; tone: "success" | "notice" }>(null);
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 12:30:17 +00:00
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const reset = useCallback(() => {
setFile(null);
setError(null);
setDragging(false);
}, []);
const close = useCallback(() => {
if (busy) return;
setOpen(false);
reset();
}, [busy, reset]);
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 12:30:17 +00:00
// Escape closes, and the page behind the dialog must not scroll under it.
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 12:30:17 +00:00
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
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 12:30:17 +00:00
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, close]);
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 12:30:17 +00:00
const accept = (f: File | undefined) => {
if (!f) return;
if (f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf")) {
setError({ title: "Not a PDF", body: `${f.name} — only PDF invoices are accepted.` });
setFile(null);
return;
}
if (f.size > 20 * 1024 * 1024) {
setError({
title: "Too large",
body: `${(f.size / 1048576).toFixed(1)} MB — the limit is 20 MB.`,
});
setFile(null);
return;
}
setError(null);
setFile(f);
};
const upload = async () => {
if (!file) return;
setBusy(true);
setError(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) {
// Success closes the dialog and confirms with a toast, so the click is never
// silent. A FAILURE stays open, because the reason is the whole point.
const unassigned = (data.unassigned ?? []) as string[];
setToast({
tone: "success",
message: `${data.invoice_no} imported`,
detail:
`${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"
Give external invoices a supplier name; keep them off the sites page The name on an invoice means two different things. On an internal invoice it is a SITE, transcribed from the invoice's own description. On an external invoice it is the SUPPLIER - and the PDF does not print the supplier's company name at all, so there is nothing to transcribe and a human has to type it. `invoices.site` stays the single field; the column header follows the page (Site on Internal, Supplier on External) and only the external one is editable, via components/SupplierCell.tsx and PATCH /api/invoices/:id/site. It saves on blur or Enter, never per keystroke, and an empty value clears the name so a stray description fragment can be removed rather than displayed as a supplier. Sites stop taking their names from external invoices, in both places: - syncSitesFromInvoices only creates sites from internal invoices, so a job description can never be registered as a yard. - buildSiteSummaries only counts internal invoice lines towards a site, which is what /sites and the dashboard's per-site chart read. This is the "Move containers" case. PHI 192's description is "Crane Truck Hire 17 September 2026 Move containers", which had registered a site literally called "Move containers" - a job, not a yard - on /sites, on the dashboard's per-site chart and in the plant register's site filter. The record stays in the collection (sites are never deleted) but is no longer surfaced; the count went 13 -> 12. External income is not lost by this - it is booked to a machine and still appears on /income and /charts. It is simply not a site. The intake route stops resolving an external invoice's text against the site list, which would have invented a site out of a description, and reports needsSupplier so the import toast says the name is outstanding. npm run seed now captures existing invoice names before it wipes the collection and re-applies them where data/source does not specify one - the same data-loss trap the uploaded PDFs already had. A JSON name always wins, so internal sites are unchanged. Verified: typecheck clean, 45 tests pass (4 new, covering the "Move containers" case directly), production build green, the new PATCH route 401s when anonymous, and /sites plus both invoice pages pass the mobile checklist at 375x812 and 768x1024. The supplier edit was exercised end to end and the test value reverted. Still open: the three external invoices (PHI 178/186/192) have no supplier name yet and none can be derived from the data. They show "Set supplier" until the client supplies them. Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 10:04:42 +00:00
: "") +
// An external invoice has no supplier name until someone types it. Naming
// that here beats leaving the reader to find an empty column.
(data.needsSupplier ? " · add the supplier on External Invoices" : ""),
});
reset();
setOpen(false);
router.refresh();
return;
} else if (data.status === "duplicate") {
// Already in the register. Nothing was added and nothing is wrong, so this closes
// the dialog and reports it — it is not a problem invoice and does not go to the
// retry inbox. The detail names the copy you already have so it is obvious which
// one this matched.
const e = data.existing ?? {};
setToast({
tone: "notice",
message: `${data.invoice_no} is already imported`,
detail:
`${e.site ?? "—"} · ${e.lines ?? 0} line${e.lines === 1 ? "" : "s"} · ` +
`${ZAR2(e.total_incl ?? 0)} — nothing was added.`,
});
reset();
setOpen(false);
router.refresh();
return;
} else {
setError({
title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import",
body: data.error ?? "Unknown error",
});
router.refresh();
}
} catch {
setError({ title: "Could not reach the server", body: "Check the app is running." });
} finally {
setBusy(false);
}
};
return (
<>
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 12:30:17 +00:00
<button
type="button"
onClick={() => {
setToast(null);
setOpen(true);
}}
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 12:30:17 +00:00
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>
{toast && (
<Toast
message={toast.message}
detail={toast.detail}
tone={toast.tone}
onDismiss={() => setToast(null)}
/>
)}
{open && (
<div className="fixed inset-0 z-[1250] flex items-end sm:items-center justify-center p-0 sm:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={close}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-label="Add invoice"
className="relative w-full sm:max-w-lg bg-white dark:bg-surface rounded-t-2xl sm:rounded-2xl shadow-2xl border border-gray-100 dark:border-white/10 max-h-[90vh] overflow-y-auto"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-border">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Add invoice</h2>
<button
type="button"
onClick={close}
disabled={busy}
aria-label="Close"
className="grid place-items-center w-11 h-11 -mr-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5">
<div
role="button"
tabIndex={0}
aria-label="Choose or drop 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);
accept(e.dataTransfer.files?.[0]);
}}
className={`flex flex-col items-center justify-center gap-2 text-center px-4 py-8 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" : ""}`}
>
<UploadCloud className="w-7 h-7 text-[#00798C]" />
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
Drop the invoice PDF here
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
or tap to browse your files
</div>
</div>
<input
ref={inputRef}
type="file"
accept="application/pdf,.pdf"
className="sr-only"
onChange={(e) => {
accept(e.target.files?.[0]);
e.target.value = "";
}}
/>
{file && (
<div className="mt-3 flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#00798C]/10">
<FileText className="w-4 h-4 text-[#00798C] shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-[#003D5B] dark:text-white truncate">
{file.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{(file.size / 1024).toFixed(0)} KB · ready to upload
</div>
</div>
{!busy && (
<button
type="button"
onClick={reset}
aria-label="Remove the selected file"
className="grid place-items-center w-11 h-11 -mr-1 rounded-lg text-gray-500 hover:bg-white/60 dark:hover:bg-white/10 transition"
>
<X className="w-4 h-4" />
</button>
)}
</div>
)}
{error && (
<div className="mt-3 flex items-start gap-2.5 px-3 py-2.5 rounded-xl text-sm bg-[#D1495B]/10 text-[#D1495B]">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="font-semibold">{error.title}</div>
<div className="mt-0.5 break-words">{error.body}</div>
</div>
</div>
)}
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
Figures are read from the invoice and checked before anything is saved. If it
does not reconcile it is kept in the problem inbox instead of imported.
</p>
</div>
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-100 dark:border-border bg-gray-50/60 dark:bg-white/[0.04] rounded-b-2xl">
<button
type="button"
onClick={close}
disabled={busy}
className="inline-flex items-center justify-center min-h-[44px] px-4 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={upload}
disabled={!file || busy}
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-lg text-sm font-semibold text-white bg-gradient-to-br from-[#00798C] to-[#003D5B] hover:opacity-90 transition disabled:opacity-40 disabled:pointer-events-none"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <FileUp className="w-4 h-4" />}
{busy ? "Importing…" : "OK — Import"}
</button>
</div>
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 12:30:17 +00:00
</div>
</div>
)}
</>
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 12:30:17 +00:00
);
}
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-surface border border-gray-100 dark:border-border 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-border">
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 12:30:17 +00:00
<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-border">
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 12:30:17 +00:00
{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-text-soft mt-1">
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 12:30:17 +00:00
{(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">
Invoices: one line per charge, and fold an abbreviated site PHI 179 was refused: "Machine lines total R5265 but the invoice subtotal is R13730". Three separate faults, each of which only this invoice exposed. 1. The parser read ONE line per machine. A machine hired over more than one period is printed as one heading followed by a repeating block per charge, and PHI 179 is a single CT01 crane truck billed on 4, 16 and 18 Sep at R5 265 + R3 200 + R5 265. The parser only ever looked at the first block, so two thirds of the invoice went missing and the arithmetic check — which did its job — rejected it. extractLines() now walks y-banded rows and emits one line per money row, all carrying the machine's code. A machine billed once still yields exactly one line, which is what PHI 168/169 do. The banding matters: pdfjs splits one printed row across two y positions (the TOTAL sits ~2pt above the rest), so the fixed +-12pt window the old code used was never a row, it was a coincidence. 2. siteFromDescription required the description to START with "Hire Plant" or "Plant Hire". PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" — it leads with the machine type — so the regex returned "" and the invoice linked to no site at all. The month/year pair is the part every description shares, so that is the anchor now. 3. Even read correctly the site is "Cosmo", where every other invoice says "Cosmo City". Sites are DERIVED from the invoice wording, so saving the abbreviation would create a second "Cosmo" and split this customer across two rows on /sites. resolveSiteName() folds a shorter whole-word prefix onto a site already in the register, one direction only, so a genuinely new site is still recorded as new. Also: a problem invoice stored its PDF with no way to open it. The inbox reported the failure but not the document behind it, and /api/invoices/:id/ document only serves imported invoices — the one case you most want to inspect. New GET /api/invoices/intake/:id/document, plus a PDF link on each inbox row. Verified: PHI 179 reconciles in full (R13 730 + R2 059.50 VAT = R15 789.50, three lines, each balancing). PHI 168 and PHI 169 still reproduce their hand extraction field for field, including line count. 40 tests, typecheck clean.
2026-10-06 09:25:10 +00:00
<a
href={`/api/invoices/intake/${it.id}/document`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${it.file_name} in a new tab`}
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"
>
<FileText className="w-4 h-4" />
PDF
</a>
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 12:30:17 +00:00
<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>
);
}