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.
Renames the existing /income/invoices page to "Internal Invoices" and adds a
sibling /income/external-invoices page, splitting ONE `invoices` collection by
the debtor printed on the invoice: a Mega group company is internal, anything
else is external.
Never two collections. An invoice must not be able to exist on neither page, and
the halves must stay reconcilable against /income. Both routes render through the
single components/InvoiceRegisterPage.tsx so they cannot drift apart.
lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts
does the matching. Matching is on debtor name OR debtor_no, never name alone:
all three external invoices (PHI 178/186/192) carry an empty debtor name and only
a third-party number, so a name-only match would misfile every one of them. An
invoice naming neither is never internal - it lands on External and is reported
as unclassified rather than silently filed.
The split is 16 internal / 3 external across the 19 invoices in the register.
Also fixes three things the split would otherwise break:
- Booking a line, importing a PDF and retrying an intake can all land on either
side depending on the debtor, so the five write paths only revalidated
/income/invoices and would have left the other page stale. They now call
revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in
lib/invoice-routes.ts, which imports no next/* so a client component may import
it; the revalidate helper imports next/cache and is server-only.
- A site's "Go and assign them" link hard-coded the internal path, so a site whose
outstanding lines sit on external invoices would dead-end. SiteSummary now
carries the outstanding lines split by party.
- verify-auth-routes.mjs now covers the new route.
The two routes are siblings by design: isGroupActive matches a child by path
prefix, so a nested /income/invoices/external would highlight both leaves at once.
Verified: typecheck clean, 41 tests pass (7 new), production build green,
anonymous access to both routes 307s to /login, and the mobile checklist passes at
375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no
text under 12px. The nested line-item table still scrolls inside its own container
without stretching the outer table.
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.
Selecting a plant item in the Booked to dropdown silently did nothing. The menu
opened, the option highlighted, and clicking it produced no request at all — the
menu just closed. Reproduced with a real browser click: no POST to /book.
The menu is rendered with createPortal(..., document.body) because the line table
lives in an overflow-x-auto container that would clip an in-place dropdown. A
portal is not a DOM descendant of the component that made it, so the click-away
handler's `boxRef.contains(target)` reported every option as an outside click:
mousedown closed the menu, React unmounted the button, and the click that would
have booked it never landed. The menu now has its own ref and is treated as part
of the control. Verified with a real click — the booking posts, the site clears,
and the unbooked badge goes.
The site dropdown was never affected; it is a native <select>, outside the portal.
Also on this screen:
- The Booked to column needs min-w-[240px] with the two pickers stacked inside
it. At 768px the column is only ~166px, so two 200px controls could not sit
side by side; as flex children they compressed into each other by ~12px, and
once they were not allowed to shrink they spilled past the edge of the table.
- Dropped the "Booked to income / VAT to SARS / Billed to customer / Still
unbooked" money block, its component and the variables feeding it. The status
line stays: "1 line not yet booked to a machine".
Typecheck and build green. Checked at 768x1024 and 375x812: no page overflow, no
control past the table edge, no tap target under 44px.
On /income/invoices at tablet width the expanded invoice detail was unreadable
and the booking control was effectively unreachable.
- The line-item table is rendered inside a <td> of the invoice list's own
scrollable table, so its 1200px width stretched the OUTER table to 1234px and
the whole list scrolled sideways whenever a row was open. The detail is now
measured with a ResizeObserver and given that pixel width, so it can no longer
influence the outer table.
- Both tables' first columns are sticky left-0, and two nested stickies at the
same offset stack on top of one another: the invoice number, the site and the
period printed over each other. The detail wrapper is now sticky left-0 z-20,
above the outer cell's z-10, so the inner table scrolls in its own box.
- The unbooked row tint was bg-[#D1495B]/[0.04] — 96% transparent — so every
column a sticky cell covered bled through it on scroll. The tint is now the
solid #FDF3F4 (dark #292934), repeated exactly on the sticky cells.
- The KPI row was flex-wrap, which sizes tiles by CONTENT, so the wide "booked
to income" tile pushed the third one onto a second row below ~644px. It is a
grid-cols-3 now, with the figures sized to the column.
- The machine picker and the site dropdown in the Booked to cell were flex
children and compressed into each other by ~12px instead of sitting side by
side. Both are shrink-0 now.
- Booking a line always saved on selection with no Save button, which read as
"nothing happened". Unmatched rows now say so.
The booking API was already correct: a machine booking clears the site and posts
income, a site booking posts none. Verified at 768x1024 and 375x812.
Three things in here cost real time to work out, so they are written down
rather than left to be re-derived:
- The Recharts constraints behind the profit line (a Line is one <Curve>,
so <Cell> cannot recolour a segment; a Line with its own `data` corrupts
the shared categorical axis; profit Lines must be direct children or they
vanish silently; the tooltip needs custom `content`).
- globals.css hard-codes the axis tick font size, which beats Recharts'
tick.fontSize prop — so the 12px mobile bump has to happen in that CSS
rule or nowhere.
- /sites now defaults to Cards and must be kept in step with IncomeView.
Plus the two deliberate palette exceptions (--color-chart-loss,
--color-chart-profit) and the client rulings behind them.
Two things this question turned up.
The upload DID store the PDF, but on the intake record, never on the invoice
— so "Open invoice PDF" 404'd for every invoice imported through the app
while working for the older ones. The file now lives on invoices.source_pdf
and the intake record keeps only the provenance (who sent it, what was
parsed), rather than a second copy of the file. The document endpoint also
falls back to the intake record, which makes the seven already-stored
documents reachable again without a migration. All 10 now open.
The machine combobox searched only the register's own words, so an invoice
line reading "CB22 2.2 TON Roller Vibration" could never be matched to the
asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The
line's own invoice code and description are now searchable too, so the two
sides of the same machine can actually be found from either direction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.
Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
one file
Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
read by both the app and the CLI so they cannot disagree
Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
VAT stays visible rather than hidden.
Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
so a direct PocketBase /api/files link 404s in a browser
Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.