Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.
The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.
The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.
It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS"
header row while the data under LINES was money. The header and its data cell
had drifted apart: the earlier pass hid the Lines <td> but not the matching
<th>, because the loop that added the hide classes only matched text-left
headers and Lines is text-right. So the Total values rendered under a heading
that named a column which no longer existed.
The same screenshot showed the totals clipped — "41,710.50" reading as
"710,50". With only three columns left, the 520px min-width was still forcing
a sideways scroll, and the opaque sticky first column then sat on top of the
left of the money values. The mobile floor is gone, so the three remaining
columns size to their content, and the sticky and status gutters are trimmed
at mobile widths to let them fit 375px.
Below sm the table is now exactly Invoice, Total, Status — and every header
matches a visible cell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
An import that worked left the modal sitting there with a green message,
blocking the table it had just added a row to. Success now resets the dialog
and closes it, and the refreshed table is the confirmation.
A failure deliberately keeps it open: the reason is the whole point, and the
problem inbox entry alone does not stop the user from wondering what the
modal was trying to tell them. Anything still needing attention after a
successful import (an unassigned line) is already badged on the invoice's
row, so nothing is lost by closing.
With success no longer rendering in the dialog, the result state was carrying
a boolean that could only ever be false, so it collapsed to the error itself.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The upload panel expanded inline above the table, which pushed the list down
the page every time it opened. It is now a standalone modal: backdrop,
Escape to close, body scroll locked, sheet on small screens and a centred
dialog from sm up. The file is chosen or dropped into the modal and nothing
is sent until OK — a mis-drop can be cleared without leaving a stray record
in the inbox. Cancel, a busy state and inline size/type errors round it out.
The status column carried two extra lines on every single row — a multi-site
note and a "booked R X · R Y VAT" figure — which is repetition: the figures
are already in the Revenue/VAT/Total columns, and the note restated something
the client ruled on. Both are gone, leaving just the booked/unbooked badge,
which is the only thing that needs acting on. The per-row booking figures
stay available in the expanded detail.
The plumbing those two lines used — the multi-site cross-invoice lookup and
the per-row booked/VAT totals — is now unused and removed with them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>