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 {
|
2026-10-02 12:43:32 +00:00
|
|
|
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";
|
Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.
It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.
Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.
No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:46:55 +00:00
|
|
|
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
|
|
|
|
2026-10-02 12:35:27 +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);
|
2026-10-02 12:35:27 +00:00
|
|
|
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);
|
2026-10-02 12:35:27 +00:00
|
|
|
const [busy, setBusy] = useState(false);
|
2026-10-02 12:43:32 +00:00
|
|
|
const [error, setError] = useState<null | { title: string; body: string }>(null);
|
Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.
It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.
Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.
No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:46:55 +00:00
|
|
|
// 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.
|
Invoices: a duplicate is a notice, not a broken invoice
Re-offering a PDF that is already in the register did the safe thing — nothing
was written, no income double-counted — but reported it as a failure, which
was wrong twice over. No toast fired, so if the dialog were not in front of
you the click looked unacknowledged; and it filed an entry in the retry inbox,
where a duplicate is a trap: re-offering it can never succeed unless the
original is deleted first, so it sat in a list of things to fix that could not
be fixed.
Duplicates now get their own status. Nothing is written, no inbox entry is
created, the dialog closes and a notice toast names the copy already held —
site, line count and total — so it is obvious which one it matched. Amber
rather than teal, because nothing was imported and a success-coloured toast
would misread.
Verified by re-uffering PHI 169: invoices 10->10, lines 31->31, inbox 7->7,
failed 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:59:08 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-10-02 12:35:27 +00:00
|
|
|
const reset = useCallback(() => {
|
|
|
|
|
setFile(null);
|
2026-10-02 12:43:32 +00:00
|
|
|
setError(null);
|
2026-10-02 12:35:27 +00:00
|
|
|
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
|
|
|
|
2026-10-02 12:35:27 +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;
|
2026-10-02 12:35:27 +00:00
|
|
|
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);
|
2026-10-02 12:35:27 +00:00
|
|
|
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
|
|
|
|
2026-10-02 12:35:27 +00:00
|
|
|
const accept = (f: File | undefined) => {
|
|
|
|
|
if (!f) return;
|
|
|
|
|
if (f.type !== "application/pdf" && !f.name.toLowerCase().endsWith(".pdf")) {
|
2026-10-02 12:43:32 +00:00
|
|
|
setError({ title: "Not a PDF", body: `${f.name} — only PDF invoices are accepted.` });
|
2026-10-02 12:35:27 +00:00
|
|
|
setFile(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (f.size > 20 * 1024 * 1024) {
|
2026-10-02 12:43:32 +00:00
|
|
|
setError({
|
2026-10-02 12:35:27 +00:00
|
|
|
title: "Too large",
|
|
|
|
|
body: `${(f.size / 1048576).toFixed(1)} MB — the limit is 20 MB.`,
|
|
|
|
|
});
|
|
|
|
|
setFile(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-02 12:43:32 +00:00
|
|
|
setError(null);
|
2026-10-02 12:35:27 +00:00
|
|
|
setFile(f);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const upload = async () => {
|
|
|
|
|
if (!file) return;
|
|
|
|
|
setBusy(true);
|
2026-10-02 12:43:32 +00:00
|
|
|
setError(null);
|
2026-10-02 12:35:27 +00:00
|
|
|
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) {
|
Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.
It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.
Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.
No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:46:55 +00:00
|
|
|
// 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({
|
Invoices: a duplicate is a notice, not a broken invoice
Re-offering a PDF that is already in the register did the safe thing — nothing
was written, no income double-counted — but reported it as a failure, which
was wrong twice over. No toast fired, so if the dialog were not in front of
you the click looked unacknowledged; and it filed an entry in the retry inbox,
where a duplicate is a trap: re-offering it can never succeed unless the
original is deleted first, so it sat in a list of things to fix that could not
be fixed.
Duplicates now get their own status. Nothing is written, no inbox entry is
created, the dialog closes and a notice toast names the copy already held —
site, line count and total — so it is obvious which one it matched. Amber
rather than teal, because nothing was imported and a success-coloured toast
would misread.
Verified by re-uffering PHI 169: invoices 10->10, lines 31->31, inbox 7->7,
failed 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:59:08 +00:00
|
|
|
tone: "success",
|
Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.
It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.
Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.
No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:46:55 +00:00
|
|
|
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"
|
|
|
|
|
: ""),
|
|
|
|
|
});
|
2026-10-02 12:43:32 +00:00
|
|
|
reset();
|
|
|
|
|
setOpen(false);
|
2026-10-02 12:35:27 +00:00
|
|
|
router.refresh();
|
2026-10-02 12:43:32 +00:00
|
|
|
return;
|
Invoices: a duplicate is a notice, not a broken invoice
Re-offering a PDF that is already in the register did the safe thing — nothing
was written, no income double-counted — but reported it as a failure, which
was wrong twice over. No toast fired, so if the dialog were not in front of
you the click looked unacknowledged; and it filed an entry in the retry inbox,
where a duplicate is a trap: re-offering it can never succeed unless the
original is deleted first, so it sat in a list of things to fix that could not
be fixed.
Duplicates now get their own status. Nothing is written, no inbox entry is
created, the dialog closes and a notice toast names the copy already held —
site, line count and total — so it is obvious which one it matched. Amber
rather than teal, because nothing was imported and a success-coloured toast
would misread.
Verified by re-uffering PHI 169: invoices 10->10, lines 31->31, inbox 7->7,
failed 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:59:08 +00:00
|
|
|
} 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;
|
2026-10-02 12:35:27 +00:00
|
|
|
} else {
|
2026-10-02 12:43:32 +00:00
|
|
|
setError({
|
2026-10-02 12:35:27 +00:00
|
|
|
title: data.status === "failed" ? "Saved to the problem inbox" : "Could not import",
|
|
|
|
|
body: data.error ?? "Unknown error",
|
|
|
|
|
});
|
|
|
|
|
router.refresh();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
2026-10-02 12:43:32 +00:00
|
|
|
setError({ title: "Could not reach the server", body: "Check the app is running." });
|
2026-10-02 12:35:27 +00:00
|
|
|
} 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"
|
Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.
It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.
Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.
No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:46:55 +00:00
|
|
|
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>
|
|
|
|
|
|
Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.
It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.
Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.
No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:46:55 +00:00
|
|
|
{toast && (
|
Invoices: a duplicate is a notice, not a broken invoice
Re-offering a PDF that is already in the register did the safe thing — nothing
was written, no income double-counted — but reported it as a failure, which
was wrong twice over. No toast fired, so if the dialog were not in front of
you the click looked unacknowledged; and it filed an entry in the retry inbox,
where a duplicate is a trap: re-offering it can never succeed unless the
original is deleted first, so it sat in a list of things to fix that could not
be fixed.
Duplicates now get their own status. Nothing is written, no inbox entry is
created, the dialog closes and a notice toast names the copy already held —
site, line count and total — so it is obvious which one it matched. Amber
rather than teal, because nothing was imported and a success-coloured toast
would misread.
Verified by re-uffering PHI 169: invoices 10->10, lines 31->31, inbox 7->7,
failed 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:59:08 +00:00
|
|
|
<Toast
|
|
|
|
|
message={toast.message}
|
|
|
|
|
detail={toast.detail}
|
|
|
|
|
tone={toast.tone}
|
|
|
|
|
onDismiss={() => setToast(null)}
|
|
|
|
|
/>
|
Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.
It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.
Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.
No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:46:55 +00:00
|
|
|
)}
|
|
|
|
|
|
2026-10-02 12:35:27 +00:00
|
|
|
{open && (
|
|
|
|
|
<div className="fixed inset-0 z-50 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"
|
2026-10-02 13:34:19 +00:00
|
|
|
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"
|
2026-10-02 12:35:27 +00:00
|
|
|
>
|
2026-10-02 13:34:19 +00:00
|
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-border">
|
2026-10-02 12:35:27 +00:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-10-02 12:43:32 +00:00
|
|
|
{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" />
|
2026-10-02 12:35:27 +00:00
|
|
|
<div className="min-w-0">
|
2026-10-02 12:43:32 +00:00
|
|
|
<div className="font-semibold">{error.title}</div>
|
|
|
|
|
<div className="mt-0.5 break-words">{error.body}</div>
|
2026-10-02 12:35:27 +00:00
|
|
|
</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>
|
|
|
|
|
|
2026-10-02 13:34:19 +00:00
|
|
|
<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">
|
2026-10-02 12:35:27 +00:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-10-02 12:35:27 +00:00
|
|
|
</>
|
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 (
|
2026-10-02 13:34:19 +00:00
|
|
|
<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>
|
|
|
|
|
) : (
|
2026-10-02 13:34:19 +00:00
|
|
|
<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>
|
2026-10-02 13:34:19 +00:00
|
|
|
<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">
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|