Importing a PDF built the invoice from that file's figures but stored the file
only on the intake row, never on `invoices.source_pdf`. Every invoice therefore
opened on a "drop the PDF here to attach it" prompt — asking for the very file
the person had just uploaded to create it, and leaving every figure on the page
without its source one click away.
The intake now writes the document onto the invoice as part of the import, so
"Open invoice PDF" is there from the moment the invoice exists. The manual
uploader stays as a Replace.
Verified end to end: the document endpoint returns 200, application/pdf, 49,085
bytes starting %PDF-, and the drop prompt is gone from the expanded detail.
PHI 180 prints "Westpark" where the register says "West Park", so a
spacing-insensitive fold looks like an obvious typo fix. Tried it, and it is wrong:
the register ALSO carries both "Autumns Gate" and "Autumnsgate", and no rule can
tell a missing space from two genuinely different places — it merged that pair.
Reverted lib/sites.ts to its previous conservative behaviour and pinned the
decision with a test. PHI 180's stored name was corrected to "West Park" by hand
instead. Merging the two Autumn sites is the client's call, not a side effect of a
re-import.
buildSiteSummaries counts only internal invoice lines towards a site, but this
page built its line-item table from every line whose invoice named the site. On a
site carrying both, the table would have listed lines the totals excluded — the
two halves of one page disagreeing.
Both now filter on the internal party, so the table and the totals are always the
same set. A site left with no internal invoices has no summary and notFound()
fires, which is correct: "Move containers" now returns a 404 rather than being
presented as a yard.
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.
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.
def147b moved the YTD tiles into IncomeView so they would follow the
active/inactive filter, and in doing so moved the whole component into the
page's `lg:flex-row` header. IncomeView returns a FRAGMENT, so its three
sections — the toolbar, the tiles and the entire machine list — became flex
siblings of the title and competed for one line.
Measured at 1440px before the fix: the tile grid was 124px wide, two 56px
columns holding text-2xl money that needs 138px. The "YTD Income" label was
smeared across the full 1440px viewport, and the machine list was pushed right
and clipped off the edge. After: the tile grid is 369px, the money fits, and
the last card ends inside the viewport.
It hid from the mobile checklist because the header only becomes a row at lg —
below that it is a column and stacks correctly, so 375 and 768 both passed
while every desktop width was broken.
The heading now renders inside IncomeView, in the same row as the tiles. The
tiles stay in the client component, so they still follow the filter, which is
what def147b was after. /sites uses the same header pattern and was always
correct: its tiles are a plain div, not a fragment-returning component.
Every PDF import failed in production with "Setting up fake worker failed:
Cannot find module '/app/node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'".
output: "standalone" ships only the files Next's output tracer can see. It
traced pdf.mjs, because lib/invoice-parser.ts imports it by literal path,
but the worker's sole reference is a createRequire(...).resolve() call,
which the tracer does not follow. So the image contained pdf.mjs alone,
pdfjs fell back to its default "./pdf.worker.mjs" relative to pdf.mjs, and
the import hit a file that was never copied.
Copy the whole package in the runner stage: the worker has to sit next to
pdf.mjs, and so do the standard fonts, which the parser needs because it
passes useSystemFonts. Verified against the reconstructed runner layout —
the old layout reproduces the error verbatim, the new one parses and
extracts text.
Also in lib/invoice-parser.ts: workerPinned was set before the resolve, so
a failed resolve was never retried; and the empty catch swallowed the
MODULE_NOT_FOUND that named the real cause. Both fixed.
Add PocketBase login, first-sign-in password changes, account settings,
and user activation, reactivation and deletion with self-removal guards.
Protect application pages and APIs and keep private pages out of PWA
caches.
Resolve plant locations from site assignments and improve map controls,
profit chart views, navigation and mobile table layouts.
Validation: production build and TypeScript passed; 28 unit tests passed.
Backend access integration tests and mobile verification are documented.
The profit line already drawn on Income vs Fixed vs Running, isolated into its
own full-width card above the monthly trend, with the marker the eye actually
wants: a green dot on a month that made money, a red dot on one that did not.
A line rather than bars, so it is the same series the card above draws and the
two cannot drift.
The dots are NOT on the run lines. A run of a single month has no segment, so a
lone profitable month would have had no dot at all and vanished from the chart.
A second, transparent Line carries `profit` and draws one marker per month
coloured from that month's own sign.
September is a HOLLOW dot, which is the honest part: monthly_running_costs has
rows for Jan-Aug only, so September's profit has had no running cost subtracted
and is optimistic. /charts already marks such a month the same way. The chart
also says so in words, and the KPI is unaffected.
The tooltip shows the hovered MONTH's profit, not a running total, and names it
Profit or Loss by sign rather than surfacing the profitRun0/profitCross0 keys.
Answers "which site earned what this month" without leaving the overview.
Horizontal bars, so the site NAME is the y axis and the card grows downward
as sites are added rather than squeezing the bars narrower.
The month defaults to the latest month that actually carries billing, not the
calendar month: the September invoices only land in October, so a "current
month" default opened on an empty card that read as broken. A month picker
covers the rest of the year, and a month with no invoice says so in words
rather than drawing five zero-height bars.
Figures come from buildSiteSummaries, so this card and /sites are the same
numbers by construction — West Park Sep R 185 573, total R 780 003.61 on both.
The site's own fetches are wrapped: the dashboard must still render its own
numbers if the invoices collections are missing.
Also p-4 sm:p-8 on the page shell, which was p-8 at every width.
The field is `assets.is_active`. All 7 inactive machines have no income at all
— the three generators, two porta camps, AEB Plant Versekering and the Diesel
Truck — so on the income list they were a flat row of zeros and nothing else,
reading as "idle" when they are actually out of the fleet.
They are now hidden by default. The rows are not dropped from the data: the
filter lives in the view, so Active / All / Inactive is a client-side toggle
with no refetch, and each shows its count so a filter that silently hides rows
is obvious. A machine hidden by the filter also collapses if it was expanded.
The YTD income and profit tiles moved into the view so they sum only what is
visible — otherwise the headline would report the whole fleet while the list
below it showed a subset.
/charts is unchanged: it passes no filter and still sees everything.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three separate layout faults in the header rows, all reported from a phone.
/income scrolled sideways by 158px. Three causes stacked, none of them responsive:
the wrapper was p-8 (32px each side at 375px), the header row was
`flex items-end justify-between` with no breakpoint, and the KPI row was
`flex items-center gap-3` with no wrap, so the two tiles physically could not
stack. Measured: main sat at x=36 w=339 at 375px, and the YTD Profit tile began
at x=355 in a 375px viewport — about 20px of its 178px was on screen.
- Wrapper is p-4 sm:p-8, and the header row stacks until lg, not sm. This one
matters: at 768px a side-by-side row leaves the tile grid only 317px of the
704px available (the title block takes 371px), so two columns of 152px are too
narrow for text-2xl money and the figures spilled out over their own tiles.
Note the DOM check said pageHScroll:false at 768 — the page did not report
overflow even though the text was visibly broken, and only the screenshot
caught it. The tiles may not go 2-up until the title is above them.
- The KPI row is grid grid-cols-1 sm:grid-cols-2, so the two tiles fill the row
instead of being sized by their content.
The machine card's P/L pill hung 32px outside its card (pill ended at x=374, the
card at x=342), jammed against the viewport edge. IncomeView's mobile row 2 is
sparkline + YTD + pill, and both ends are shrink-0 with nothing allowed to give:
Sparkline's root is a fixed 120px box and the pill is shrink-0. /sites already
passed width={72} for exactly this reason (see the note in Sparkline.tsx);
/income never did. It does now, with min-w-0 on the YTD block. The pill now sits
16px inside the card.
/sites was not broken — it never overflowed — it just looked wrong. `flex
flex-wrap items-stretch` wraps, so nothing broke, but flex-wrap sizes tiles by
CONTENT: "Revenue (ex VAT)" measured 197px inside a 343px row and the second
tile wrapped to a line narrower still, both left-aligned, leaving a ragged edge
and dead space. That is the same anti-pattern AGENTS.md already records as fixed
on /income/invoices, never carried over here. It is a grid now.
/income/invoices showed "Booked to income" and "Billed (incl VAT)" as two tiles
with the same figure, which read as a duplicate. Not a bug: one sums booked
lines, the other sums every invoice header, and all 10 invoices are currently
fully booked (0 unbooked, problem inbox empty), so they are legitimately equal.
They separate the moment a line is left unbooked — which is the whole point of
the screen. Merged into one tile that says what the pair actually means,
"R 780 003,61 of R 780 003,61 billed", with the VAT breakdown kept underneath
because AGENTS.md requires VAT to stay visible. The gradient moved onto the
merged tile so the money figure keeps its emphasis.
Verified in the browser at 375, 768 and 1280: no page overflow, no element past
the viewport edge outside the table's own scroller, nothing clipped inside the
merged tile, and both desktop layouts unchanged. Typecheck and build green.
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
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.
The register is a working list of the machines currently in the fleet, so
retired rows were noise until someone asked for them. Rows with an unsaved
toggle staged stay visible, so an edit is never hidden mid-change.
(Change made in the working tree alongside the invoice layout work; committed
separately so it keeps its own message.)
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.
The machine dropdown was still hard-coding one of four dark surface hexes
that globals.css already defines as a token.
Also ignore .verify/, where the Round 4 mobile checklist leaves its
screenshots. They are regenerated on every pass, so they were never meant
to be committed.
CARTO now answers every keyless basemap request with HTTP 200 and a valid
256x256 PNG that is just a dark square watermarked "API KEY REQUIRED".
Leaflet accepts it as a tile and paints it, so the map silently filled with
the warning — no console error, nothing in the network tab to catch it.
Esri's World Dark Gray Canvas is a genuine dark basemap and needs no key.
Its ArcGIS REST service orders the tile path {z}/{y}/{x}, the reverse of
what Leaflet knows how to substitute, so a getTileUrl override is the whole
fix. react-leaflet always instantiates Leaflet's own TileLayer, so the
custom class is added to the map directly and torn down on unmount rather
than passed as a prop.
The client likes the vehicles overview and asked for the site list to work
the same way, so /sites now defaults to Cards — same toolbar, same card
shape, same 12-month strip on expand, same three-up footer as IncomeView.
The old table is kept as a Grid toggle rather than deleted.
The detail link lives inside the expanded panel only: a card is a <button>,
so a nested <a> there would be invalid HTML.
Profit was a single always-teal line, so a month that lost money looked
like a month that made a little. It is now drawn per sign: one <Line> per
same-sign run, plus a gradient <Line> across each crossing. Recharts draws
a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate
series are the only way. Every variant is a key on the ONE shared dataset
rather than a sliced `data` prop, because Recharts builds a categorical axis
by concatenating every series' data and a short slice re-adds the month
labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content`
component to fold those per-run keys back into a single profit row.
Two honesty rules: profit stays null in a month with no income figure
(treating it as 0 drew a green dot for a month that was never billed), and
a month with no running-cost row gets a hollow dot, because its profit is
optimistic.
The toolbar's Series button toggles which lines a card draws, remembered in
localStorage. A hidden series takes its legend entry and its Y axis with it,
so there is never an axis for a line that is not drawn, and the last visible
series cannot be switched off.
Palette: Running moves off red to the project's existing brand purple,
because red is now reserved for a loss and a red Running line next to a red
loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit`
are separate tokens on purpose — loss used to read the Running token, so
recolouring Running silently dragged the red with it.
/charts alone now reads profit including running cost, so the card badge,
the profit line and the Profit bars view cannot disagree. Scoped to the
page: lib/profit.ts keeps its workbook default for every other surface.
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>
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>
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.
Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.
The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.
Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.
The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.
A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.
Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`assets` already carried latitude/longitude, but `sites` had nowhere to put a
pin — so there was no way to locate a site. Both fields added, matching the
plant collection's shape so the same map component can plot either.
Coordinates are entered as two plain number fields with an "open in maps" link
to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is
still being worked on, and a coordinate is usually copied off some other map
anyway. Clearing removes the pin rather than leaving half a coordinate, which
would plot in the Gulf of Guinea and read as "located".
Bounds are checked server-side, not just in the form: a point outside South Africa
is refused with the numbers echoed back, because a swapped pair or a dropped
digit is far likelier than a machine abroad. 0,0 is rejected by the same rule.
The sites list marks each row as pinned or "no pin", with a banner counting the
gaps, so an unlocated site is visible without opening it.
While checking this, found that all 44 assets carry the SAME coordinate
(-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin
while reporting "44 located on the map". Left alone, but worth knowing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /sites table showed seven columns and needed roughly 493px of a 343px
viewport once the page padding is taken off, so at phone width the row was a
sideways scroll with the sticky Site column sitting on top of the figures —
the same trap the invoice table had.
Below sm it now shows Site, Total incl VAT and Unbooked. The ex-VAT and VAT
columns hide, but their split is not dropped: it moves under the total as two
stacked lines, "ex 161 367,50" and "VAT 24 205,13", which is the same
information the columns carried without the width.
The split is stacked rather than on one line because "161 367,50 + 24 205,13"
is wider than the total it annotates, which pushed the row back over the
viewport. The unbooked badge drops its word on mobile for the same reason — the
row is already tinted, and the count plus icon carry the meaning.
Width budget for the three visible columns: 154 + 102 + 60 = 316px of 343px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.
`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.
/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.
Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.
One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.
The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.
Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.
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>
Audited the invoice UI against the Round 4 checklist and fixed four things.
The summary table carried eight columns at a fixed 820px min-width, which at
375px is a long sideways scroll through three figures before reaching the
status badge — the one thing that needs acting on. Below sm it now keeps only
Invoice, Site, Total and Status, and drops to a 520px min-width, so the useful
columns are readable without scrolling. Period, line count and the ex-VAT/VAT
split are one tap away in the expanded detail, and the full eight columns
return from sm up where there is room.
The summary table also had no right-edge fade. Rule 4 asks for a visible
affordance on any wide table and it was simply missing, so scrolling sideways
looked like the table ended. Same for the month grid on /income, which was also
missing -webkit-overflow-scrolling. Both now share components/ScrollFade.
Two 10px labels in the app header ("Overheads 2026", "FY") were invisible at
375px but visible from 640/768px — and 768x1024 is one of the two sizes the
checklist tests, so they were a rule-2 violation at tablet width. Bumped to
12px. PlantRegisterTable's 10px filter count was checked and left alone: it
sits inside hidden lg:flex, so it never renders on mobile.
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>
The confirmation now arrives at the top of the screen instead of the bottom,
and is a solid teal panel rather than a white card with a green icon — at a
glance it reads as a system success message rather than a quiet note.
Teal rather than a literal green: #00798C is already this app's success
colour (the "All booked" badge, the income tiles, the import button), so a new
hue would have been off-palette. Solid fill with white text reads unambiguously
as confirmation, where the previous white card could be missed.
Slides down from above rather than up from below. Honours the notch on mobile
via env(safe-area-inset-top) and keeps a 44px dismiss target on the new fill.
The unused tone prop went with it — failures stay in the dialog, so the toast
is only ever a success.
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>
Single line, TLB07-CASE 570 ST at 87.00 Hr x R280.00 = R24,360.00 ex VAT,
plus R3,654.00 VAT (15%) for R28,014.00. Escalation is zero, as on every
invoice in this run.
Extracted by coordinate and confirmed against the rendered page.
TLB07 is also on PHI 171 (Cosmo City) over the same period, which the
multi-site ruling covers, so both book to TLB07.
Verified through the real buildIncomeRows: September now totals R369,055.13
across 11 machines, matching the sum of all five invoice totals, with zero
unbooked lines and every machine's month cells equal to its own YTD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fifth line set from the September 2026 plant-hire run. All five codes map
cleanly to register assets, so nothing is left unbooked this time:
MPLANT05 to SC01, RV-01-CAT to RV01, TLB06-CASE to TLB06, TP601-TATA to
TP601, TP602-TATA to TP602.
Extracted by coordinate and confirmed against both rendered pages. The
R14,548.50 column is PLUS VAT (15%), not escalation - escalation is R0.00.
SC01 and TP601 each pick up a second site this period, which the multi-site
ruling covers, so those lines book to the same machine as before.
Verified through the real buildIncomeRows: September now totals R341,041.13
across 11 machines, matching the sum of all four invoice totals, and every
machine's month cells still equal its own YTD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WIP carried over from the working tree, committed separately so it does not
sit inside the invoice work.
- inline Edit cells for code / name / type and an active toggle
- GET /api/assets/:id/impact returns the cascade counts so the client can
confirm before a delete
- DELETE /api/assets/:id hard delete; PocketBase cascade clears the dependent
income / running / cost-line rows
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>
- public/brand/logo.png: 117×115 transparent PNG (source)
- public/icons/icon-192.png, icon-512.png, icon-maskable-512.png: PWA icons derived from logo
- public/icons/favicon.png (64), favicon-32.png (32): favicon sizes
- icon-maskable-512: logo on #003D5B indigo background, 80% safe zone
- layout.tsx: header logo + mobile drawer logo both use the new image
- Sidebar.tsx: drawer header logo uses the new image
- metadata.icons updated to include favicon sizes
- <link rel='icon'> added for 32×32 and 64×64 favicons
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.
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed