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.
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.
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>
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.
- 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