Round 4: regressions fixed + mobile standard
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.
2026-09-12 10:05:43 +00:00
# AGENTS.md — Mega Plant
## Mobile checklist (Round 4 standing rule)
Every page must pass the mobile checklist before it is considered complete:
1. **No horizontal page scroll at 375px.** Individual tables may scroll inside their own container; the page itself may not.
2. **No text under 12px on mobile.** Bump `text-[10px]` labels up.
3. **No tap target under 44× 44px.** Keep the visual size if you like — add padding to the hit area.
4. **Wide tables scroll inside their own container** with a sticky first column and `-webkit-overflow-scrolling: touch` . Add a visible scroll affordance (e.g. right-edge fade).
5. **Filter controls collapse** below `lg` — pills become a `<select>` dropdown. Don't stack 13 pills into 13 rows.
6. **Verify at 375× 812 and 768× 1024.** Run `document.documentElement.scrollWidth > document.documentElement.clientWidth` and confirm it returns `false` . Count tap targets and text sizes. Screenshot every route.
If a route fails any of the above, it is not done.
## Build / dev
- `npm run dev` → port 6007 (nohup-detached: `nohup npm run dev > /tmp/plant-dev.log 2>&1 & disown` )
- `npm run build` → 15 routes, webpack mode (PWA is webpack-only)
- `npm run typecheck` → `npx tsc --noEmit`
- `npm run seed` → wipe + re-import `data/source/*.json` into PocketBase. Fails loudly on any unmatched row or broken VAT.
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
- `node scripts/setup-invoice-collections.mjs` → create the `invoices` / `invoice_lines` collections (idempotent).
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
- `node scripts/setup-insurance-collections.mjs` → create the `insurance_rates` collection (idempotent).
- `node scripts/setup-financing-collections.mjs` → create the `financing_terms` collection (idempotent).
- `node scripts/setup-operators-collections.mjs` → create the `operators` collection (idempotent).
- `node scripts/setup-mechanics-collections.mjs` → create the `mechanics` collection (idempotent).
- `node scripts/setup-purchase-collections.mjs` → create the `purchases` / `purchase_lines` collections (idempotent).
- `node scripts/setup-oil-collections.mjs` → create the `oil_items` / `oil_stock_movements` collections (idempotent).
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
- `node scripts/book-invoice.mjs` → post invoice lines into `monthly_income` as additional rows.
## Invoices — adding another one
`scripts/seed.mjs` imports **every** `data/source/invoice-*.json` automatically, so a new
invoice is one file in that folder — no script edit. Each needs `invoice_no` , the period,
`vat_pct` , the header totals and a `lines` array. The seed re-checks the arithmetic on every
invoice and refuses any one that does not balance, while still seeding the others.
⚠️ `npm run seed` **deletes and recreates the `invoices` rows** , so any PDF uploaded through
`/income/invoices` is lost on the next seed and must be re-attached. Copy the PDFs somewhere
durable first — they are not in the repo.
`invoices.site` is a plain text label transcribed from the invoice's own description
(`PHI 166` → "West Park", `PHI 167` → "Halfway Gardens"). There is **no `sites` collection
yet** — build one when the client has sites to list, and back-fill `site` from this field.
Split the invoice register into Internal and External Invoices
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.
2026-10-08 09:49:48 +00:00
## Internal vs external invoices — one collection, two pages
The register is split in two by the **debtor printed on the invoice** :
- **`/income/invoices` — Internal Invoices.** The debtor is a Mega group company.
- **`/income/external-invoices` — External Invoices.** The debtor is a third party.
**This is one `invoices` collection filtered two ways, never two collections.** Do not
"fix" it by giving each page its own collection — that would let a row exist on neither
page and make the totals unable to reconcile against `/income` . The split lives in
`lib/invoice-party-policy.json` (the entity list) and `lib/invoice-party.ts` (the matching),
and both pages render through the single `components/InvoiceRegisterPage.tsx` , so they
cannot drift apart the way two hand-written pages would.
As of 2026-10-08 this splits the 19 invoices in the register **16 internal / 3 external** :
- Internal — debtor `Mega Infrastructure` , no `09MIS001` . All the seeded `data/source/invoice-*.json` files.
- External — `PHI 178` (`09CIG001`), `PHI 186` (`09DTE001`), `PHI 192` (`09DAS001`).
⚠️ **A third-party invoice may carry an empty `debtor` name and only a `debtor_no`.** All three
external invoices do exactly that. So the policy matches on **name OR debtor number** , never
name alone — matching by name only would misfile every one of them. An invoice naming neither is
never internal: it lands on External and the page reports it as unclassified.
Three consequences that are easy to get wrong:
- **Every write must revalidate BOTH pages.** Booking a line, importing a PDF and retrying an
intake can land on either side depending on the debtor, so a bare
`revalidatePath("/income/invoices")` leaves the other page showing stale rows. Use
`revalidateInvoicePages(...)` from `lib/invoice-revalidate.ts` — never a bare
`revalidatePath` for anything touching `invoices` or `invoice_lines` .
- **Route paths are client-safe; the revalidate helper is not.** `lib/invoice-routes.ts` holds
the path constants and imports no `next/*` , so a client component may import it.
`lib/invoice-revalidate.ts` imports `next/cache` and is server-only.
- **The two routes are siblings, never nested.** `isGroupActive` in `Sidebar.tsx` matches a
child by path prefix, so `/income/invoices/external` would satisfy the prefix test for both
leaves and highlight "Internal" and "External" together.
`invoices.category` is `"External Plant Hire"` on **every** invoice, internal ones included —
it is the supplier's own wording transcribed from the PDF, **not** this internal/external
classification. Never derive the split from it.
Give external invoices a supplier name; keep them off the sites page
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.
2026-10-08 10:04:42 +00:00
### The name on an invoice means two different things
`invoices.site` is a **SITE** on an internal invoice and the **SUPPLIER** on an external
one. Same field, different meaning — so the column header follows the page (`Site` on
Internal, `Supplier` on External) and only the external one is editable.
- **Internal** — transcribed from the invoice's description and folded against the existing
sites by `resolveSiteName` . Read-only in the UI; changing it would move real site data.
- **External** — **the PDF does not print the supplier's company name** , so there is nothing
to transcribe. A human types it on `/income/external-invoices` (`components/SupplierCell.tsx`,
`PATCH /api/invoices/:id/site` ). Saves on blur or Enter, never per keystroke; an empty
value **clears** the name.
The three external invoices are seeded that way and their names are **not yet known** — ask
the client rather than inventing them. Until then the page shows `Set supplier` .
⚠️ ** `syncSitesFromInvoices` and `buildSiteSummaries` only ever consider INTERNAL invoices.**
This is what keeps the sites page honest. PHI 192's description is "Crane Truck Hire 17
September 2026 Move containers", which had registered a site literally called ** "Move
containers"** — a job description, not a yard — on `/sites` , on the dashboard's per-site
chart and in the plant register's site filter. The site record is left in the collection
(never delete — see above); it is simply no longer surfaced, and 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 plant register's site dropdown still reads the `sites` collection directly, so "Move
containers" remains selectable there. That is deliberate — a container genuinely can be
parked for a move job — but change it if the client disagrees.
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
## Invoice booking — run order matters
`npm run seed` **wipes `monthly_income`** and re-imports only the workbook, so invoice-derived
income is destroyed on every seed. Always:
```bash
npm run seed & & node scripts/book-invoice.mjs
```
Booking is idempotent: each posted row is stamped with `monthly_income.invoice_line` , and
re-running removes the prior invoice-derived rows before re-inserting. Rows with an empty
`invoice_line` are workbook data and are never touched.
An invoice line with no `booked_asset` is **left unbooked and reported loudly** — never guessed
at. Assign it from `/income/invoices` (the **Booked to** column), or set `asset_code` in the
relevant `data/source/invoice-*.json` and re-run the pair above.
## One machine, many sites — not a duplicate
A machine hired to more than one site in the same month is **normal use, not a duplicate
billing**. Client ruling, 2026-10-01. Such a line is booked to the **same machine** as income,
and every hire accumulates on that one asset.
- `MPLANT05` (SC01) → PHI 166 West Park **and** PHI 171 Cosmo City, same period.
- `SS-01-CASE` (SS01) → PHI 167 Halfway Gardens **and** PHI 171 Cosmo City, same period.
`findDuplicateMachineCodes` still finds these overlaps, but the result is **informational, not
a problem**: it must never tint a row red, badge it as unbooked, or block booking. The income
figure's hover/tap breakdown shows each machine's full hire history across sites, which is
where a multi-site machine should be read. Only an **unbooked** line is a real problem.
The register has no `MPLANT04` / `MPLANT05` codes, so those map to `OC01` / `SC01` as the
consistent target — chosen, not derived. Confirm with the client if either should differ.
2026-10-02 17:09:16 +00:00
## Site monthly chart — which month an invoice lands in
`/sites` plots one sparkline per site (billed per month) and a site's row expands to the
invoices behind each month. `buildSiteSummaries` (`lib/sites.ts`) produces both.
**The month is always the invoice's `booked_month` , never `invoice_date` and never
`period_from` .** `seed.mjs` sets `booked_month` /`booked_year` from the month of
`period_to` , which is the same rule that posts the income into `monthly_income` . So a
site's September total on `/sites` is the same rand as the September column on `/income`
— the two screens cannot disagree. PHI 166 (period 21 Aug → 18 Sep) therefore books to
**September**. Re-deriving the month any other way is the one way to break this.
Other rules worth not re-learning:
- A month total is the **sum of that site's lines** , not the invoice header total. One
invoice carries many lines to a site (PHI 166 has five), so an invoice is listed once
per month with its lines accumulated — never one card per line.
- `months[]` is 12 slots of `null` for "no invoice", never `0` . A site whose billing
starts in September is not eight months of zero.
- The series covers ONE year — `latestInvoiceYear(invoices)` , the max `booked_year` .
Months from other years are dropped, not folded in, and the panel says so.
- `SiteSummary.pinned` exists because the table is a client component and cannot look
the site up in the `sites` collection itself.
2026-10-03 07:20:29 +00:00
- **`/sites` defaults to the Cards view, deliberately mirroring `IncomeView` ** — same
toolbar, same card shape, same 12-month strip on expand, same three-up footer. The
client likes the vehicles overview and asked for the sites to read the same way, so
keep the two in step when either changes. Grid is the old table, kept as a toggle.
The site detail link lives **inside the expanded panel only** — a card is a
`<button>` , so a nested `<a>` would be invalid HTML.
2026-10-02 17:09:16 +00:00
Invoices: fix the tablet layout and make booking an unbooked line obvious
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.
2026-10-03 07:59:05 +00:00
## Nested wide tables — the sticky-column trap
`/income/invoices` expands an invoice row into a line-item table **inside a `<td>` of the
invoice list's own scrollable table**. Two things break if this is not pinned down, and
both look like a rendering glitch rather than a layout bug:
- The inner table's `min-width` propagates up and **stretches the outer table** (820px
became 1234px). The whole list then scrolled sideways whenever a row was open. The
detail is now measured with a `ResizeObserver` and given that pixel width, so it
cannot influence the outer table.
- Both tables' first columns are `sticky left-0` , and **two nested stickies at the same
offset stack on top of each other** — the invoice number, the site and the period all
printed over one another. The detail wrapper is `sticky left-0 z-20` (above the outer
cell's `z-10` ) and holds a fixed width, so the inner table scrolls inside its own box
and is the only sticky in it.
**A sticky cell must have an OPAQUE background.** The unbooked rows were tinted
`bg-[#D1495B]/[0.04]` — 96% transparent — so every column they covered bled through them
on scroll. The row tint is now the solid `#FDF3F4` (dark: `#292934` ) and the sticky
cells repeat it exactly.
Two more traps on this screen: a `flex-wrap` KPI row sizes tiles by CONTENT, so the wide
"booked to income" tile pushed the third one onto a second row below ~644px — it is a
Invoices: a machine chosen in the dropdown was never actually booked
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.
2026-10-03 13:43:46 +00:00
`grid-cols-3` now. And 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, and when they were not allowed to shrink they spilled past
the edge of the table.
## The machine dropdown — a portal is NOT inside its trigger
`MachineCombobox` renders its menu 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.** The click-away
handler tested `boxRef.contains(target)` , so every option in the menu read as an outside
click: `mousedown` closed the menu, React unmounted the button, and the `click` that
would have booked it never fired. Selecting a machine silently did nothing — the menu
just closed. The menu now has its own ref and is treated as part of the control.
Symptom to recognise: a dropdown opens, options highlight, and choosing one does nothing
at all — no request, no error, no change. Suspect the outside-click test before the API.
Invoices: fix the tablet layout and make booking an unbooked line obvious
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.
2026-10-03 07:59:05 +00:00
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
## Money on invoices — VAT is NOT revenue
On `PHI 166` the totals block reads `SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT
24,205.13 / INVOICE TOTAL 185,572.63`. The R24,205.13 sits under the column headed **PLUS
VAT** — it is 15% VAT, *not* an escalation, and escalation is R0.00 on every line.
- The **invoice total (`total_incl`) is VAT-inclusive** — that is the headline figure.
- `total_excl` (hire) + `vat` = `total_incl` , checked per line and on the header by the seed.
- **Booked amount follows `lib/invoice-booking-policy.json` → `bookInclusiveVat` .** It is
currently `true` : amounts are booked VAT-**inclusive**, per the client's ruling of
2026-10-01. That means SARS output VAT of R29,935.13 (Sept 2026 set) sits **inside** the
R229,502.63 shown as income on `/income` , so those figures are a cash/billing view, not a
VAT-excl. P& L. Flip the flag to `false` and re-run `npm run seed && node scripts/book-invoice.mjs`
to book ex-VAT instead. Both the app and the CLI script read that one file, so they
cannot disagree.
- The VAT stays visible rather than hidden: `/income` shows "includes R 29 935 VAT" and each
machine card shows "incl R X VAT" (`IncomeRow.invoiceVat`). Do not collapse the two.
Extract these by coordinate (`pdfplumber`, x-positions) or read the rendered page visually.
Plain text extraction reorders these columns and silently turns VAT into escalation.
## Manual invoice actions
- `POST /api/invoice-lines/:id/book` body `{ assetId }` (`""` clears) — sets `booked_asset`
and **moves** the `monthly_income` row to the new machine rather than adding a second one.
- `POST /api/invoices/upload` multipart `file` + `invoiceNo` — PDF only, 20 MB cap, stored on
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
`invoices.source_pdf` . **This is only a REPLACE** , not the way an invoice gets its document:
`POST /api/invoices/intake` already attaches the uploaded PDF to `invoices.source_pdf` as
part of the import. Every figure on an invoice is transcribed from that PDF, so it belongs
on the invoice row; a reader must be able to check any number against its source without
asking anyone for the file again.
⚠️ **An import that runs while `assets` is empty books nothing.** Intake matches each line's
machine code against the plant register to decide which machine the income posts to. It reads
`assets` once at the start; if a seed is running concurrently the register is briefly empty,
the map has nothing in it, and every line is saved unbooked with a valid `asset_code` but an
empty `booked_asset` . Nothing retries it — the page just shows "unbooked" forever. Run
`node scripts/book-invoice.mjs` after seeding, and check the register is populated before
importing.
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
- `GET /api/invoices/:id/document` — proxies the PDF. Do **not** link to
`NEXT_PUBLIC_PB_URL/api/files/<filename>` : the `invoices` collection is rule-locked and
that path 404s in a browser. PocketBase also resolves protected files by the full
`/api/files/<collectionId>/<recordId>/<filename>` path — build it with `pb.files.getURL()` .
Round 4: regressions fixed + mobile standard
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.
2026-09-12 10:05:43 +00:00
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
## Insurance — a premium is a PERIOD, not twelve rows
`/running/insurance` shows each plant vehicle's insurance. **Client ruling, 2026-10-08: a
premium can move every month or every three months**, so it is stored once per change in
`insurance_rates` , and each month's cost is derived by looking up the rate in force *that
month*.
Three collections are in play and they are not interchangeable:
- **`insurance_rates`** — the source of truth. One row per machine per rate PERIOD:
`month_from` (inclusive, required), `month_to` (inclusive, ** `0` = open = still current**),
`rate` . Derived from `data/source/insurance-rates.json` .
- **`cost_lines` where `kind = "insurance"` ** — **derived** by the seed from those periods.
These rows were removed from `data/source/cost_lines.json` so a premium is keyed in
exactly once; re-adding them by hand double-books the insurance.
- **`monthly_running_costs`** — repairs & parts, which the /running Overview totals. A
completely different figure. The Insurance YTD and the Overview YTD are **meant** not to
reconcile; never present one as if it explained the other.
⚠️ ** `month_to` is `0` for open, never `null` .** Null reads as "unknown end date", and an
unknown end date must not be mistaken for a premium that never expires. The seed
normalises a missing `month_to` to `0` .
### An absent rate is `null`, never `0`
`monthlyCost()` returns `null` for a month no period covers. That is **unknown** , not free.
Two things follow, and both are load-bearing:
- The seed **skips the `cost_lines` row** for such a month. Booking `0` would read as
"insured for free" and silently pull the total down.
- The page prints a dashed ** "no rate"** cell and lists the vehicle under
"not covered for all 12 months". `ytd` sums only the months actually known, so it is
never a full year quietly divided by twelve.
### Re-rating a vehicle
Close the open period and open a new one in `data/source/insurance-rates.json` , then run
`npm run seed && node scripts/book-invoice.mjs` :
```json
{ "asset_code": "WB01", "year": 2026, "month_from": 1, "month_to": 6, "rate": 1137.83 },
{ "asset_code": "WB01", "year": 2026, "month_from": 7, "month_to": 0, "rate": 1310.00 }
```
Jan– Jun then cost R1 137,83 and Jul– Dec R1 310,00, and the card shows both periods.
`validatePeriods()` (`lib/insurance.ts`) rejects **overlaps** , **two open periods** , and
**holes between periods**; the seed exits non-zero rather than writing a year it cannot
cost honestly, and the page renders any surviving problem in a banner instead of a blank cell.
**`scripts/seed.mjs` imports `lib/insurance.ts` directly** (Node strips the types), so the
CLI and the page cannot disagree about what a rate costs. That import works only because
`lib/insurance.ts` uses erasable syntax and type-only imports — adding a value import of
`./profit` (extensionless, fine for webpack) would break `npm run seed` .
### The rates are INCL VAT
**Client note, 2026-10-08: every rate on the "Vehicle Monthly Insurance" sheet includes
VAT.** The figure quoted is what the insurer invoices and what Mega actually pays, so it is
booked to the plant vehicle **whole** — it is a cash cost, and it belongs in fixed expenses
at the amount actually paid. Do not add VAT again on the way in.
This does **not** distort profit. Booked income is VAT-inclusive too (see
`invoice-booking-policy.json` ), so both sides of `income - fixed` are on the same basis.
The ruling lives in `lib/insurance-rate-policy.json` (`ratesIncludeVat`) and is surfaced as
`VAT_NOTE` in the page and cards, because the same rands sit next to an **Excl VAT** column
on `/overheads` and a reader who assumes the wrong basis will misread the comparison.
⚠️ **No VAT rate is stored, and none is assumed.** A split is shown only once the client
confirms the rate their policy carries — short-term insurance is not automatically the
standard 15%, so dividing by 1.15 would print a SARS figure nobody has confirmed.
⚠️ ** `lib/insurance.ts` must NOT import a `.json` file.** `scripts/seed.mjs` imports that
module directly, and bare Node rejects a JSON import without an import attribute
(`ERR_IMPORT_ATTRIBUTE_MISSING`), taking `npm run seed` down with it. Webpack tolerates it,
Node does not. App-side policy constants live in `lib/insurance-rate-policy.ts` instead —
the same split `lib/invoice-booking.ts` has against `book-invoice.mjs` 's `readFileSync` .
### TB01 is not insured
The Toyota Bakkie carried R2 131,99/mo in `cost_lines` and is **absent from the rate sheet**
— client confirmed 2026-10-08 that it is no longer insured, so it has no rate period and no
derived cost. Do not re-add it without a new rate. Note its premium still sits inside its
`monthly_income.fixed_expenses` ; restating that is a financial decision, not a seeding one.
### The seed currently fails on two Subscriptions rows
`npm run seed` exits non-zero on **pre-existing** VAT errors in `data/source/overheads.json`
("Charl - CCS", "Buildsmart Yearly update") — unrelated to insurance, present in git since
before this work. Because the seed wipes `monthly_income` before it validates, a failure
leaves invoice-derived income missing until `node scripts/book-invoice.mjs` is re-run. Fix the
overhead VAT figures or the seed cannot complete.
## Financing — an agreement is a row a human owns
`/running/financing` is the vehicle-finance register. **Deliberately independent of
`/running/insurance` **: they are separate commitments that happen to share one rate
sheet, with separate collections and separate maths. Never merge them, and never let
one corrupt the other.
- **`financing_terms`** — one row per financed vehicle. `installment` comes from
`data/source/financing-terms.json` (the "Monthly Installments" column, **incl VAT** ).
`start_*` , `end_*` and `is_settled` are typed in **by a human in the app** , because
no sheet records them.
- `WB02` and `DT01` are **paid off** , not unfunded — the sheet leaves their instalment
cell blank because the agreement is closed (client ruling 2026-10-08). They carry
`installment: 0` and `settled: true` in the JSON and DO appear on the register, so
the fleet total accounts for them. Their instalment cell reads "paid off", never
R 0, and they are excluded from the monthly run-rate.
- `TB01` is absent from the sheet entirely (same as insurance) and drops out with it.
### The seed PRESERVES the human-entered fields
⚠️ This is the whole reason the seed captures `financing_terms` before it deletes rows.
`npm run seed` wipes and rebuilds every collection, but the start date, paid-off date
and settled flag exist **nowhere on disk** . The capture is keyed by asset code and
re-applied to the fresh row, exactly as `preservedNames` does for invoice suppliers.
**Deleting that capture block silently destroys months of the client's bookkeeping on
the next seed**, and the page would look fine — just wrong. If you touch the wipe loop,
keep `FINANCING_COLL` in it AND keep the capture.
### `0` means "not entered", never January
A year or month of `0` is the sentinel for *nobody has entered this* . A month with a
year but no month (or the reverse) is rejected by `PATCH /api/financing/:id` rather
than stored — a half-set date later reads as a real, wrong one.
⚠️ ** `financing_terms.installment` is deliberately NOT `required` .** PocketBase treats
a 0 value as blank, so a required number rejects BOTH `0` and `null` — the seed could
never write a paid-off agreement, aborted mid-run, and left the database half-built
(income, running costs and cost lines all at zero). The rule that an ACTIVE agreement
must carry an instalment lives in `scripts/seed.mjs` , where it belongs, and it fails
loudly. Do not re-add `required` to that field.
### A missing date is `—`, never R 0
This matters more here than anywhere else in the app. Until a paid-off date is entered,
**"months left" and "still owing" print a dash**, not R0. A zero balance would tell the
client a live debt is cleared when really nobody has entered the date — the most
expensive possible wrong on a page whose whole job is *how much do I still owe* .
`0` is reserved for exactly one meaning: the agreement's end date has passed.
### The four states
`lib/financing.ts` resolves each row to one: `financed` (no end date yet), `closing`
(end date ahead, instalments still falling), `finished` (end date has passed but the
settled toggle is still on) and `settled` (the client says so). ** `finished` raises a
banner** — it is the one state that means the data is waiting on a decision. A
contradictory term (end before start) reports every figure as unknown and also raises
the banner; nothing is silently "corrected".
### Not wired to `cost_lines` — on purpose
Unlike insurance, the instalments here are **display only** . `cost_lines` keeps its
hand-written `instalment` rows for the four machines that had them. Booking the other
14 would change their `fixed_expense` reconciliation, and term 3 (operator wages) is
still unknown — so that is a financial decision to make explicitly, not a side effect
of building a page. Ask before wiring it.
⚠️ **TLB06 is R0,20 apart between sources.** The rate sheet and the Bastion overheads
both say **R29 036,08** (which is what this page uses); `assets.TLB06.monthly_incl`
and the `cost_lines` row say **R29 036,28** . One of them is a typo — ask which.
## Purchases — a cost code is a machine, not a guess
`/running/purchases` takes purchase orders as PDFs, exactly the way `/income/invoices`
takes invoices. Parts, repairs and servicing bought **against a machine** .
- **`purchases`** — PO number, supplier, PO/required-by dates, sub-total, VAT, total,
and the uploaded PDF on the record so any figure can be checked against its source.
- **`purchase_lines`** — one row per printed line, carrying `cost_code` **exactly as
printed** and the `asset` it was allocated to.
- Re-uploading a PO number already on file is **refused** (409): the same spend booked
twice is worse than an inconvenience.
### The COST CODE is the join, and it is normalised
`normaliseCostCode()` strips everything non-alphanumeric, so the PO's `SS-02` finds
`SS02` in the register and `TLB06` matches directly. **Never fuzzy-match or guess a
machine.** A line whose code matches nothing is still saved, with an EMPTY `asset` ,
and is named in the response and in the page's "no machine matched" count — money that
exists but is not yet on a plant item. That is a different statement from R0 and from
"not bought".
### The parser reads COORDINATES, not text — and why
`lib/purchase-parser.ts` follows the same rule as `lib/invoice-parser.ts` . Plain text
extraction **reorders the columns** : on MEGPPO49676 the trailing `1 / 2 / 3` reads as if
it were a VAT% column when it is the line No. Values are therefore read from x-position
bands, then checked arithmetically — the lines must sum to the sub-total, and
sub-total + VAT must equal the total. A PO that does not balance is **reported and never
written**; a line with no cost code is refused too, because it cannot be allocated.
Three traps this file has already hit, all in AGENTS.md terms of "do not regress":
1. ** `createRequire` must be a STATIC import of `node:module` .** A dynamic
`await import("node:module")` parses fine under bare Node and hands webpack's ESM
interop a namespace whose `createRequire` is not a function — every upload dies with
"createRequire is not a function".
2. **pdfjs DETACHES the buffer it parses.** `getDocument` takes ownership and the
ArrayBuffer is unusable afterwards, so the file upload failed with "Cannot perform
ArrayBuffer.prototype.slice on a detached ArrayBuffer". Parse a `bytes.slice()` copy
and keep the original for storage.
3. **Attach the PDF AFTER the lines, never before.** Storing it first meant a storage
failure returned early and left a PO header with NO lines at all — the most
important data skipped because of the least important step.
4. **A purchase-line allocation is destroyed by every re-seed.** `assets` is rebuilt with
new ids and PocketBase clears a relation whose target was deleted, so every line's
`asset` empties. The seed captures (PO number, line number) → machine code before the
delete and re-points afterwards, exactly as it does for operators. Two traps hit while
building that: a per-line PocketBase filter silently returning nothing (index the lines
from ONE query instead), and a Map holding the po_no *string* being read as `po.po_no` ,
which stored `undefined` and made every restore miss with no error to show for it.
Collections created by the collections API do **not** get `created` /`updated` for free —
declare them explicitly or a `sort: "-created"` fails with an opaque server error.
Running cost = everything linked to a vehicle: repairs, purchases, insurance, finance
A machine with no repairs and no purchase order still carries an insurance premium
and, while it is financed, a monthly instalment. So a blank row was never "this
vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a
figure. The Overview now totals all four kinds per machine per month.
Everything is read from POCKETBASE, the registers the client maintains in the app.
The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug);
it is not a source here. September having no workbook data is expected and correct —
the figures there now come from the app.
Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old
workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current
sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and
TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms
row, so its old instalment no longer counts — consistent with it being absent from the
current sheet. Insurance has one source and no such problem.
Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a
premium that moves mid-year is charged at the price that applied that month.
Also: settled agreements contribute nothing (a paid-off machine is not a running cost),
purchase lines are filed under the PO date, and the bottom row keeps showing incl above
excl. The page carries one line saying this is a re-cut of costs already inside fixed
expenses — adding it to them would count the same premium twice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:28:29 +00:00
### Running Costs means EVERYTHING linked to a vehicle
Client ruling, 2026-10-09: running cost is **insurance, finance, purchases, repairs —
anything linked to a machine**. A vehicle with no repairs and no purchase order still
has an insurance premium and, while it is running, a monthly instalment, so it still
belongs on this page. A blank row is not "no running cost"; it is "nothing recorded".
**⚠️ The Overview is a RE-CUT, not an addition.** Insurance and instalments are ALREADY
inside `monthly_income.fixed_expenses` (workbook term 1 + term 2). Putting them on this
page as well does not create new cost — and **nobody may add the Overview total to their
fixed expenses**, because they would then count the same premium twice. Any figure here
must be labelled as a re-cut.
**⚠️ Two sources of finance data exist and they OVERLAP.** `cost_lines` instalment rows
are the stale workbook subset (MG02, RV04, TLB06, TLB07, TB01 — R115 576,14/mo);
`financing_terms` is the client's current sheet (18 agreements — R428 585,04/mo). Using
both double-counts the four shared machines. **Use `financing_terms` ; treat
`cost_lines` instalment as superseded** — with one exception to settle: TB01 carries an
instalment in `cost_lines` but has no `financing_terms` row, so it would drop off.
Insurance has one source and no such problem: `cost_lines` insurance rows are DERIVED
from `insurance_rates` , so there is nothing to double count.
Fold purchase orders into the Running Costs Overview, with incl/excl totals
A purchase order buys parts and repairs against a plant item, so its money is
running cost and belongs in the same per-machine, per-month series the Overview
already reads. Without it September showed nothing at all.
Merged at READ time, never copied into monthly_running_costs: that collection is
deleted and rebuilt by every seed, so a purchase written there would vanish on the
next one and have to be re-uploaded. purchase_lines is never deleted, so the money
lives in one place and the Overview adds it up.
- Month comes from the PO date (when it was ordered), not "Required By":
MEGPPO50532 is dated 07/10 but required 29/08 and those must not be confused.
- Only lines allocated to a machine are counted. An unallocated line is real money
not yet on a plant item; it stays on /purchases, reported, rather than being
hidden or added to the fleet total.
- A PO with no readable date is excluded and named, never guessed into January.
- Bottom total row shows incl ABOVE excl per month and for the YTD. Workbook
figures carry no VAT column, so RUNNING_VAT_PCT (15, confirmed by every PO line)
is the single constant to revisit if they are ever restated ex-VAT.
September now reads R 5 842 incl / R 5 080 excl from the two September POs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:10:13 +00:00
## Running Costs Overview — purchases ARE running cost, merged at read time
The Overview adds two sources per machine per month, both **VAT-INCLUSIVE** (client
confirmed 2026-10-09 that the workbook's figures are the amount paid):
1. the workbook's "Plant Monthly Running Cost" (Jan– Aug 2026), and
2. uploaded **purchase orders** allocated to a machine, filed under the **PO date**
(client ruling: the month it was *ordered* , not the "Required By" month —
MEGPPO50532 is dated 07/10 but was "required" 29/08, and those must not be confused).
**They are merged at READ time, never copied into `monthly_running_costs` .** That
collection is deleted and rebuilt by every seed, so a purchase written into it would
vanish on the next one and have to be re-uploaded. `purchase_lines` is never deleted, so
the money lives in one place and the Overview adds it up.
- Only purchase lines with an `asset` are counted. An unallocated line is real money not
yet on a plant item — adding it to no machine hides it, adding it to the fleet total
overstates one. It stays on /purchases, reported as unallocated.
- A purchase with **no readable PO date** is excluded and named in a banner, never
guessed into January.
2026-10-09 10:35:52 +00:00
### Never show a month ahead
**The grid stops at the month we are IN (client ruling, 2026-10-09).** Insurance and
finance genuinely recur, so left alone they would quietly fill November and December
with ~R470k a month and make the sheet look like the year is known when those months
have not started — "it will confuse the people".
Two things enforce it, and both are needed:
1. `machineRunningCosts(..., lastMonth)` caps every source at the horizon, so no
phantom figure is even computed. The **YTD is therefore year-to-date, not
twelve months** — it moves as the year goes on.
2. `RunningView` takes `visibleMonths` and renders only that many columns. The future
months are NOT rendered empty — an empty column still reads as "we have a figure of
zero", which is the same confusion in a quieter form.
The current month IS shown and is expected to **grow** as repairs and purchases are
entered against a vehicle; that is the point of showing it live.
⚠️ A PO dated beyond the horizon is skipped silently, which is NOT the same as an
undated PO. Only a genuinely unreadable date is reported as undated.
Fold purchase orders into the Running Costs Overview, with incl/excl totals
A purchase order buys parts and repairs against a plant item, so its money is
running cost and belongs in the same per-machine, per-month series the Overview
already reads. Without it September showed nothing at all.
Merged at READ time, never copied into monthly_running_costs: that collection is
deleted and rebuilt by every seed, so a purchase written there would vanish on the
next one and have to be re-uploaded. purchase_lines is never deleted, so the money
lives in one place and the Overview adds it up.
- Month comes from the PO date (when it was ordered), not "Required By":
MEGPPO50532 is dated 07/10 but required 29/08 and those must not be confused.
- Only lines allocated to a machine are counted. An unallocated line is real money
not yet on a plant item; it stays on /purchases, reported, rather than being
hidden or added to the fleet total.
- A PO with no readable date is excluded and named, never guessed into January.
- Bottom total row shows incl ABOVE excl per month and for the YTD. Workbook
figures carry no VAT column, so RUNNING_VAT_PCT (15, confirmed by every PO line)
is the single constant to revisit if they are ever restated ex-VAT.
September now reads R 5 842 incl / R 5 080 excl from the two September POs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 10:10:13 +00:00
### The incl / excl split
`lib/purchase-booking.ts` holds `RUNNING_VAT_PCT = 15` . Purchase lines state their own
VAT% (all 14 read 15.00, and the arithmetic reconciles to exactly 15.000%), and the same
rate is applied to the workbook side because that sheet records ONE amount with no VAT
column at all. The bottom total row shows **incl above excl** for every month and for the
YTD, so nobody has to back the VAT out by hand.
**If the workbook figures are ever restated ex-VAT, `RUNNING_VAT_PCT` is the one constant
to revisit** — every excl figure on the Overview derives from it.
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
## Oil Stock — a cost code can name a STORE, not a machine
`/running/oils` tracks the two bulk oils in stock and prints their drum labels.
⚠️ **An oil purchase order is NOT a machine purchase order, and the cost code is what
tells them apart.** On MEGPPO50027 the lines read `MPLANT, ENGOIL` , `MPLANT, HYDOIL` and
`73 Plant Workshop, JC#:` — those are STOCK STORES. `storeCodeOf()` resolves them against
`oil_items` ; a code matching neither a store nor `assets` is left unallocated.
- **`oil_items`** — the tracked oils, with drum size, colour and the store cost code a PO
prints for them. Reconciled by `code` and NOT deleted by the seed, so an oil added in the
app survives (same rule as operators and mechanics).
- **`oil_stock_movements`** — `received` when a PO delivers oil, `used` when a drum is
drawn down. **Stock is the sum of movements, not a running balance** , so the first usage
is a subtraction from real history rather than a guess about what it removed.
- ⚠️ **Usage is NOT wired up yet.** Nothing has been scanned, so stock today is deliveries
only. The `used` kind already exists for when it arrives.
### A drum is FULL at its capacity and fills below it
`oilStock()` clamps `fillRatio` to 1, so 420 L against a 210 L drum reads **FULL** with
"2 full drums" beside it. `known` is separate from the litres: an oil with **no movements
at all** has never had a delivery entered, so the page says that instead of drawing a full
drum for something nobody has recorded.
### The QR goes on the drum, NOT on Purchases
One QR per oil, printed as a dispenser/drum label, encoding name and item code
("15W-40 CI-4 | OIL-ENG"). A purchase order is not a consumable — a QR that only means
"this PO exists" is no use to someone standing at a drum, while a QR that says *which oil*
is what usage scanning will need.
### normaliseCostCode lives in lib/cost-code.ts, NOT in the parser
`lib/purchase-parser.ts` imports pdfjs and `node:module` , so a client component importing it
fails the build outright (`UnhandledSchemeError: Reading from "node:module"`). The rule is
needed on both sides, so it lives in its own dependency-free module.
## Operators — term 3, and a person is not a machine
`/running/operators` is who runs which plant vehicle, and at what rate. **This is TERM 3
of the fixed-expense formula** — the operator wage that varies per machine per month, and
the last unknown in the workbook cost model.
Third independent register under Running Costs, with its own collection. Insurance,
financing and operators are separate commitments that happen to share rate sheets. Never
merge them.
- **`operators`** — one row per PERSON. `name` , `employee_code` and `rate` come from
2026-10-22 09:39:18 +00:00
`data/source/operators.json` ; `asset` (which machine they are on) is changed in the app,
and so can `rate` be — see `rate_set_in_app` under Workforce.
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
- `operators.asset` has **cascadeDelete FALSE** on purpose. Deleting a machine must leave
the person on the register as *unassigned* , not delete them from the payroll.
- `employee_code` is the join key, not the name — names are not unique and can change.
### The row list is SCOPED to the sheet, but still register-linked
`buildOperatorRows` emits **one row per machine the client's operator sheet lists** —
twenty vehicles. It does **not** list every machine in the plant register.
The register holds forty-one coded machines; the twenty-one it leaves out are containers
(`SC01`– `SC07`, `OC01` , `PC01` ), generators (`GN075`, `GN100` , `GN300` ), truck trailers,
an excavator and the Bakkie. No person is ever allocated to those, so listing them would
present twenty-odd permanent blanks as if they were real staffing gaps (client ruling,
2026-10-08: *"containers and generators and all of that doesn't need to be in that list"* ).
The scope is stored as ** `assets.on_operator_sheet` **, a boolean the seed sets from the
P/Unit column of `data/source/operators.json` . Their NAME and CODE still come from the
register, so the list stays linked to it — the sheet decides *which* machines, the
register supplies *who they are* .
⚠️ **Do not derive the scope from "has an operator".** That rule looks equivalent and is
not: three of the twenty (`TLB01`, `TLB02` , `RV01` ) have nobody on them and MUST still
be listed. Inferring the scope from the `operators` collection drops exactly those three
and hides the fact that a listed vehicle is unstaffed.
Every in-scope machine gets a row, staffed or not. A machine with no operator carries
blank operator cells — that is a gap to fill, **never an R0 rate** . Operators the sheet
gave no machine get their own rows at the bottom under an "Operators with no machine"
heading, because they are people on the payroll and must not disappear.
Adding a vehicle to the sheet means adding its P/Unit to `operators.json` with an empty
`operator` ; the flag follows on the next seed.
A machine that has been allocated an operator ALWAYS appears, even if it is not on the
sheet. Otherwise adding a person to an off-sheet machine would look like it silently did
nothing.
### Adding people from the app — and why the seed RECONCILES these two
**`operators` and `mechanics` are NOT deleted by the seed.** Both are reconciled by
`employee_code` instead: the sheet refreshes the name and rate it defines, and any row
the client added in the app is left alone and named in the log. Re-adding a person was
the whole point — people join and leave between rate sheets.
⚠️ **Three ways this goes wrong, all hit for real:**
1. **The upsert must write `asset`.** `assets` is re-created every seed with NEW record
ids, so the stored relation dangles — and PocketBase reads a dangling relation as
EMPTY. Updating only `name` /`rate` left every existing operator unallocated while the
seed still cheerfully reported "17 allocated".
2. **PocketBase CLEARS a relation when its target record is deleted.** So an empty
relation is AMBIGUOUS: "deliberately unassigned" or "the machine row was rebuilt".
Losing a real allocation is worse than restoring the client's own sheet value, so the
sheet is the tie-breaker whenever the captured allocation is empty.
3. **An app-added operator has no sheet row** , so nothing would ever re-point their
relation at the rebuilt asset. That restore loop is explicit; removing it silently
drops the machine of anyone added in the app on every single seed.
`POST /api/operators` and `POST /api/mechanics` both mark the row `source: "Added in
app"`. Neither accepts a person with no employee number: it is what a machine allocation
is preserved against, and for a mechanic it is what the badge QR encodes.
⚠️ Which machine someone is on is moved by hand in the app and exists nowhere on disk, so
`scripts/seed.mjs` captures it before the wipe and re-applies it by `employee_code` —
otherwise a re-seed quietly puts everyone back where the sheet last said. The client's
allocation WINS over the sheet, and a preserved "no machine" is honoured as *deliberately
unassigned* rather than falling back to the sheet.
⚠️ ** `operators.rate` is NOT `required` **, for the same reason
`financing_terms.installment` is not: PocketBase rejects `0` on a required number, so an
operator could never be written. The "an operator must have a rate" rule lives in
`scripts/seed.mjs` , which fails loudly instead of half-building the database.
### Two people on one machine is allowed, not blocked
A shared machine is a real thing. `PATCH /api/operators/:id` **reports** the clash in its
response so the UI can warn, rather than refusing — deciding who may share a machine is
the client's call, not an API rule.
### Open items
- **JOHN NKOSI's employee code reads `MWC178` ** on the sheet while all 18 others are
`MCW###` . Transcribed exactly as printed; confirm it is not `MCW178` .
- **No VAT basis has been stated for these rates**, unlike the insurance premium and the
finance instalment which are incl VAT (client notes 2026-10-08). The page therefore
prints them unqualified rather than labelling them with a guess — wages are not a
standard-rated supply, but that is not a confirmed answer.
- **Not wired to `cost_lines` .** The hourly rate is not a monthly cost without hours
worked, which nothing records yet. Term 3 stays unbooked until the client supplies
hours per machine per month.
## Mechanics — a badge is an identity, not a cost
`/running/mechanics` is the badge register: who works here, and the QR on their badge.
A mechanic is a PERSON with an employee number — **not a plant item and not an operator** .
An operator is allocated TO a machine and is a cost; a mechanic is neither. Separate
collection, separate page, nothing wired into `cost_lines` .
- **`mechanics`** — `name` , `employee_code` , `role` , optional `photo` .
Seeded from `data/source/mechanics.json` ; **also addable in the app** via
`POST /api/mechanics` .
- **`employee_code` must be unique** — the seed refuses a duplicate. It is what the QR
encodes, so two badges sharing a code are two people a scanner cannot tell apart.
### The QR encodes the NAME and the EMPLOYEE NUMBER — never a URL
`mechanicQrPayload()` (`lib/mechanics.ts`) builds `"FORTUNE NCUBE | MCW053"` — client
ruling, 2026-10-08. **Not a URL, on purpose:** this app sits behind a login and is only
reachable inside the network, so a badge scanned on the yard would dead-end at a sign-in
screen. Plain text reads on any scanner, survives the app moving host, and stays short
enough that a cheap camera picks it up at an angle.
` | ` separates the halves so the decoded string is both human-readable and trivially
splittable. **All three surfaces — the small inline QR, the enlarged one and the print
sheet — call the same builder through `MechanicBadge` .** Do not hand-build a payload in
a component; a badge whose printed code differs from its on-screen code is a badge that
identifies the wrong person.
A mechanic with **no** employee code cannot be issued a badge at all: the QR would encode
nothing and scan as a dead code. The page and the print sheet both refuse to print one.
### Download: a REAL vector PDF, not a print job
**"Download QR codes" builds an actual `.pdf` file and saves it** — it is not a browser
print dialog and not a screenshot of the page. `pdf-lib` writes it in the browser.
⚠️ ** `pdfjs-dist` is NOT the writer.** It is a PDF *reader* and was already in the
project. Do not reach for it here. `pdf-lib` is the dependency that creates files.
There is **no separate print screen** — the page downloads directly, and the enlarged
badge offers "Download this badge" for a single card. The old `/running/mechanics/print`
route and its `@media print` block in `globals.css` were both removed; a print
stylesheet only half-works here anyway, because the browser prints the whole app frame
unless the chrome is stripped, which is a worse outcome than a file.
### ONE encoder, two surfaces
`lib/qr.ts` is the only place a QR is encoded. It returns the raw module MATRIX from
`qrcode.create()` , and both consumers draw it:
- **On screen** — `qrSvgPath()` emits one SVG `<path>` of merged 1-module rects.
- **In the PDF** — `lib/qr-pdf.ts` draws each dark module as a real `drawRectangle` .
Both are **vector** , which is the entire reason for doing it this way: a PNG-rastered QR
looks fine on screen and goes fuzzy on a badge somebody scans off a lanyard.
⚠️ ** `react-qr-code` was removed on purpose.** It could only render a widget, not
expose the matrix, so keeping it would have meant two independent encoders — and a badge
whose printed code differs from its on-screen code identifies the wrong person. Never
add a QR widget back; draw from `qrMatrix()` .
The A4 sheet lays badges out three per row with a hairline border doubling as the cut
line, and draws a **white rectangle behind each QR** — a PDF page is transparent by
default, and a transparent QR prints as nothing on most printers.
2026-10-22 09:39:18 +00:00
## Plant Clock — the Plant is NOT a site
`/clock` is a standalone PWA that clocks Plant staff (mechanics, operators) in and out **of
the Plant**. Mega hires people from the Plant out to sites, and sites clock people in with
their own app. Client ruling, 2026-10-22: the Plant clock is **only the scanner on the main
screen**. Do not re-add a site picker, a "check before confirming" step or a GPS prompt.
- Scan = saved. `POST /api/clock/scan` takes `{employee_no, request_id}` and the server picks
IN/OUT (`clockAction`: same-day open IN → OUT, else IN). There is no lookup endpoint.
- Without a confirm tap, the only thing stopping a held badge from flipping IN/OUT is
`REPEAT_SCAN_MS` (60 s): a repeat is refused with HTTP 409 + `repeat` , never saved.
- `clock_events.site` stays in the schema but is optional and empty. Never make it required again.
- Full notes: `docs/plant-clock.md` . Setup: `node --env-file=.env.local scripts/setup-clock.mjs --apply` .
## Workforce — Employees, Attendance, one dashboard per person
The Workforce group sits **below Plant Register** and has three sibling leaves (client
request, 2026-10-22): **Employees** (`/employees`), **Attendance** (`/attendance`) and the
**Employee clock** (`/clock`). Employees and Attendance were one page; they are split.
- `/employees` is the people register: every operator and mechanic, the machine an operator
runs and their rate, a "Machines with no operator" panel and **Add employee** (posts to the
existing `/api/operators` or `/api/mechanics` ).
- `/employees/<kind>-<recordId>` (`employeeSlug` in `lib/workforce.ts` ) is the per-employee
dashboard: hours today / this week (from Monday) / this month, days present, missed
clock-outs, daily hours bars, scan history, the QR badge and — for operators — the machine
picker, which uses `PATCH /api/operators/:id` . It is under `/employees` , so the Employees
leaf stays highlighted.
- `lib/workforce.ts` is the one place time is summed. **An IN never clocked out on an earlier
day counts 0 hours** and is listed as missed; an IN still open today counts up to now.
Never invent hours for a missing clock-out.
- **An operator's rate and machine are edited on their dashboard** (client request,
2026-10-22) via `PATCH /api/operators/:id` with `{rate}` or `{assetId}` . A rate saved there
sets `operators.rate_set_in_app` , and **the seed keeps that rate instead of restoring the
sheet's**. Without the flag every `npm run seed` would silently undo the client's change.
Run `node scripts/setup-operators-collections.mjs` on a fresh database to add the field.
- `/attendance` is a table: one row per employee, one column per day, with a Day / Week /
Month switch (pills from `lg` , a select below) and back/forward arrows. A missed
clock-out cell shows `!` and counts 0 hours, matching the dashboard.
- `lib/workforce-store.ts` loads everything in one go. Any API that changes a person or an
allocation must `revalidatePath("/employees", "layout")` .
**`/running/operators` is now redundant** — everything on it (machine, rate, empty machines,
add operator, re-allocate) lives on the Employees pages. It stays until the client confirms
they are happy, then remove the page, its nav leaf and `OperatorsView` . The operators
**collection and API stay**: the Employees pages read and write through them.
Round 4: regressions fixed + mobile standard
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.
2026-09-12 10:05:43 +00:00
## Color palette — never change
Coolors:
- `#EDAE49` Sunny — orange/positive accent
- `#D1495B` Brick Red — deficit/destructive
- `#00798C` Notartis Seaweed — teal/stable
- `#30638E` Lights Casual — main blue/brand
- `#003D5B` Indigo Dye — deep navy
Chart marks (deeper steps of the same hues — see HANDOFF-PROMPT-3 §A.4):
- `--color-chart-income` (light `#0090A6` , dark `#00A6BE` )
- `--color-chart-fixed` (`#BE851F` both)
2026-10-03 07:20:29 +00:00
- `--color-chart-running` (`#8B5CF6` both) — the project's existing
`--color-brand-purple` , so Running introduces no new hue. **Client ruling, 2026-10-02:**
Running was moved off red because red is now reserved for a month that made a loss, and a
red Running line sitting next to a red loss segment was unreadable. Yellow was considered
and rejected — `--color-chart-fixed` is already a gold.
- `--color-chart-loss` (light `#C13B4D` , dark `#D94F60` ) — a month that did not make
profit. **It has its own token on purpose:** it used to read
`--color-chart-running` , so recolouring Running silently dragged the loss red with it.
- `--color-chart-profit` (`#7A9E06` both) — the profit line's green. **Client ruling,
2026-10-02: this is a second deliberate exception to the "never change the palette" rule.**
It is a bright yellow-green, chosen so the profit line reads as its own series instead of
blending into the teal income line.
`globals.css` hard-codes `.recharts-cartesian-axis-tick text { font-size: 11px }` , which
**beats Recharts' `tick.fontSize` prop**. Any "bump chart text to 12px on mobile" work has to
happen in that CSS rule or it silently does nothing.
## The profit line on `/charts` — Recharts constraints, do not re-derive
`MachineLineChart` draws profit as teal where the month made money and red where it did not,
fading across each sign crossing. Three things make this fragile:
- **A `Line` is one `<Curve>` path.** `<Cell>` does **not** recolour a segment — Cells only
work for `Bar` . Per-segment colour therefore requires **one `<Line>` per same-sign run** ,
plus one extra `<Line>` per crossing for the gradient.
- **Never give a `Line` its own `data` prop inside a shared chart.** Recharts builds a
categorical axis by concatenating the data of *every* series, so a short sliced `data`
re-adds the month labels: the axis renders "Jan … Aug, Jan … Aug" while that series plots
at the wrong months. Put each variant on the shared dataset as its own key
(`profitRun0`, `profitCross0` ) and null it outside its run; `connectNulls={false}` then
draws just that run in the right place.
- **Profit `Line` s must be direct children of the chart.** Recharts parses only known child
types, so wrapping them in a `<g>` makes the entire profit line disappear without an error.
- **The tooltip needs a custom `content` component** (`ChartTooltip` in `MachineLineChart` ).
Recharts feeds the tooltip one entry per series, so the per-run keys would surface as
`profitRun0` / `profitCross0` and "profit" would appear three or four times. The component
drops every profit-backed series and adds one computed row, coloured by the month's sign.
The base series carry an explicit `name` , because Recharts otherwise labels them by key
(`running` reads as "Running cost", which is what the key is called).
Two deliberate honesty rules: profit stays **null** in a month with no income figure (never
treated as 0, which would draw a green dot for a month that was never billed), and a month
with no `monthly_running_costs` row gets a **hollow dot** because its profit is optimistic.
`/charts` alone reads profit including running cost — `lib/profit.ts` keeps its workbook
default for every other surface, and the card badge, the profit line and the Profit bars
view all share the one flag in `ChartsGrid` .
The toolbar's **Series** button toggles which lines a card draws (`mp:chartsSeries` in
localStorage, so the choice survives a reload). Two rules keep it honest: a hidden series
leaves the legend **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, because an empty chart reads
as broken. The Profit toggle also owns the loss colour, since red is a sign of the profit
line rather than a series of its own.
Round 4: regressions fixed + mobile standard
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.
2026-09-12 10:05:43 +00:00
## Cost model
- `lib/profit.ts` is the single source of truth for profit. Default `includeRunningCost: false` (matches workbook). To flip: one-line change in `lib/profit.ts` .
- Term 1 = instalment ✅ confirmed.
- Term 2 = insurance ✅ confirmed.
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
- Term 3 = operator wages — the RATE per hour and the allocation now exist (`/running/operators`), but **hours worked per machine per month are still unknown** , so term 3 is not yet booked.
Round 4: regressions fixed + mobile standard
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.
2026-09-12 10:05:43 +00:00
## Open questions
- Q1: Does profit include running cost? (default: no, matches workbook).
- Q2: What is term 3 of the fixed-expense formula? (See Round 1/2/3 HANDOFF-PROMPT files.)
## PocketBase
- URL: `process.env.NEXT_PUBLIC_PB_URL` (env or `.env.local` )
- Auth: `pb.collection("_superusers").authWithPassword(env)` (Round 1 §5.3)
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
- Collections: `assets` , `monthly_income` , `monthly_running_costs` , `overheads` , `cost_lines` , `invoices` , `invoice_lines` , `insurance_rates` , `financing_terms`
Round 4: regressions fixed + mobile standard
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.
2026-09-12 10:05:43 +00:00
- `monthly_income` no longer carries `running_cost` — derive from `monthly_running_costs` at read time
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
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>
2026-10-01 13:59:30 +00:00
- `monthly_income.invoice_line` → `invoice_lines` , nullable. Empty = workbook row, set = invoice-derived row
- `invoice_lines.booked_asset` → `assets` , nullable. Empty = line not yet booked to a machine
2026-10-21 04:39:15 +00:00
<!-- BEGIN:nextjs - agent - rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js` . Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs - agent - rules -->