An invoice saved with no amount landed as amount_incl = 0 (PocketBase stores a number field as zero, not null), so the PO read "R 0,00 invoiced" and the money never booked. There were two ways to give it an amount and neither worked: the Upload-invoice modal let a blank amount save silently, while the double-click correction form refused to save without a value AND a written reason, and sat behind Edit mode. - lib/purchase-invoice-amount.ts holds the rules, shared by the screen and the API so they cannot disagree. R0 is not an amount: a blank amount and a figure of zero are both refused, so an invoice can never land as zero. - The API now separates PRICING an invoice that never had an amount (no reason owed, still logged) from CORRECTING one that was booked (Full user + a reason, as before). - Double-clicking an amount on a PO opens the ordinary invoice modal on that invoice, with the amount field waiting. The inline correction form is gone — one window, one rule. There is no pencil button: a booked figure must take a deliberate second gesture, so a single stray tap never opens a form that can rewrite money. - The PO-lines table freezes its Description column with an opaque background and fades its right edge, so it passes the mobile checklist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1013 lines
61 KiB
Markdown
1013 lines
61 KiB
Markdown
# 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.
|
||
- `node scripts/setup-invoice-collections.mjs` → create the `invoices` / `invoice_lines` collections (idempotent).
|
||
- `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-job-cards-collection.mjs` → create the `job_cards` collection (idempotent).
|
||
- `node scripts/setup-cash-float-collection.mjs` → create the `cash_float_entries` collection (idempotent).
|
||
- `node scripts/setup-suppliers-collection.mjs` → create `suppliers` and add `supplier_ref` to `purchases` / `cash_float_entries` (idempotent); then `node --import tsx scripts/backfill-suppliers.mjs` books existing rows.
|
||
- `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).
|
||
- `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.
|
||
|
||
## 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.
|
||
|
||
### 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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|
||
- **`/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.
|
||
|
||
## 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
|
||
`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.
|
||
|
||
## 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
|
||
`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.
|
||
- `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()`.
|
||
|
||
## 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.
|
||
|
||
### The PO-lines table freezes its DESCRIPTION column, not the `#`
|
||
|
||
The lines table inside a PO is wider than a phone, so it scrolls in its own box with a fade on
|
||
the right edge. The column that sticks is **Description** — the `#` is a line number and tells
|
||
you nothing, while the description is what identifies the line when the money columns come into
|
||
view. Do not "fix" it by freezing the first column instead. The sticky cell carries an **opaque**
|
||
background (`bg-white` / `bg-gray-50` for the header) for the same reason the invoice register
|
||
does: a translucent sticky cell lets the columns under it bleed through on scroll.
|
||
|
||
### 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.
|
||
|
||
## 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.
|
||
|
||
### 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.
|
||
|
||
### 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.
|
||
|
||
## 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
|
||
`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.
|
||
- `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.
|
||
|
||
## 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` has been removed** (client confirmed, 2026-10-22): its page, nav leaf and `OperatorsView` are gone. Everything it did lives on the Employees pages. The operators **collection and API stay** — Employees reads and writes through them.
|
||
|
||
## 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)
|
||
- `--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.
|
||
|
||
## 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.
|
||
- 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.
|
||
|
||
## 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)
|
||
- Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`, `cost_lines`, `invoices`, `invoice_lines`, `insurance_rates`, `financing_terms`
|
||
- `monthly_income` no longer carries `running_cost` — derive from `monthly_running_costs` at read time
|
||
- `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
|
||
|
||
<!-- 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 -->
|
||
|
||
|
||
## Job cards — two scans, one job
|
||
|
||
`/running/job-cards` (replaced the old `/running/mechanics` badge page, 2026-10-22) lists work
|
||
logged from the Plant Clock app's **Job cards** tab (`/clock/job-cards`).
|
||
|
||
- **First scan of a vehicle's QR starts a job** (who is working is picked from `mechanics`,
|
||
remembered per device). **Second scan finishes it**: odometer (or "no meter"), kind of work
|
||
(service / breakdown / general repairs) and a note. Time on the job = first scan → second.
|
||
- Collection `job_cards`; `status` is `open` or `closed`. A **partial unique index** allows only
|
||
one open job per vehicle, so two phones scanning at once cannot both start it.
|
||
- The vehicle QR is the register code (`TLB04`); matching is `assetFromQr` in `lib/stock-out.ts`.
|
||
- Not wiped by the seed. `node scripts/setup-job-cards-collection.mjs` creates it.
|
||
- The mechanic **badge register** now lives on `/employees` ("Download mechanic badges"); the
|
||
mechanics collection, API and badge QR rules above are unchanged.
|
||
|
||
|
||
## Cash float card — a ledger, and the Office role
|
||
|
||
`/expenses/petty-cash` shows the cash-float **card** above the old workbook figures. Slips are
|
||
photographed in the Plant Clock app's **Slips** tab (`/clock/slips`) by an **Office** user.
|
||
|
||
- **Balance = SUM of `cash_float_entries`**, never an overwritten number: `opening` and `topup` add,
|
||
`expense` (a slip) subtracts. `amount` is always positive; the kind sets the sign (`lib/cash-float.ts`).
|
||
- The **opening balance is asked for once** (first time, in the clock app or the main page) and can only be
|
||
*corrected* afterwards, not deleted. The slip API refuses until it exists.
|
||
- A slip carries supplier, cost code, comments, amount and the photo (`slip_photo`, protected, proxied by
|
||
`/api/cash-float/entries/:id/photo`). A cost code that matches a register vehicle also links `asset`.
|
||
- **Access levels** are now Full / Clock-in / Mechanic / **Office**. What each may reach inside `/clock` and
|
||
`/api/clock` is ONE table in `lib/clock-access.ts`, used by `proxy.ts` and the bottom nav. Office sees only
|
||
Slips, and ONLY Office sees Slips (not full or clock-in users).
|
||
|
||
|
||
## Suppliers — one list, every purchase booked against it
|
||
|
||
`/expenses/suppliers` is the single supplier list. Every purchase order (`purchases.supplier_ref`) and every
|
||
petty-cash slip (`cash_float_entries.supplier_ref`) is booked against a supplier, so spend per supplier is one sum.
|
||
|
||
- **Terms** per supplier: `cash`, `30` or `60` (account days), or `""` = not set yet. A supplier that first
|
||
appears on an uploaded PO is created with terms **unset** and flagged on the page; one that first appears on a
|
||
till slip is created as `cash`. An existing supplier's terms are never changed by a PO or slip — only a person does that.
|
||
- **Matching** (`lib/supplier-server.ts` `resolveSupplier`): the PO's supplier CODE first (`09YTS001`), then the
|
||
folded NAME (`supplierKey` drops case, punctuation and PTY/LTD/CC). `suppliers.key` is UNIQUE. Not fuzzy:
|
||
"Your Tyre Shop" and "Tyre Shop" are different suppliers.
|
||
- Due date = PO date + terms (`dueDate`). **Payments are not tracked**, so nothing is ever called "overdue".
|
||
- A supplier with anything booked against it cannot be deleted. Editing writes need a Full user.
|
||
|
||
## Purchase invoice amounts — PRICING is not CORRECTING
|
||
|
||
A supplier invoice against a PO carries an `amount_incl`. Two acts look alike in the data and
|
||
must not be treated alike, because only one of them changes money other screens have booked.
|
||
|
||
- **PRICING** — putting a first amount on an invoice that never had one. Nothing was booked, so
|
||
there is nothing to justify: **no reason is demanded** and no Full user is required. It is still
|
||
written to `purchase_invoice_edits` with the reason "Amount entered", so the trail shows when the
|
||
figure was entered.
|
||
- **CORRECTING** — changing a figure that was already booked, or the invoice's number or date. A
|
||
Full user does this, a reason of at least three characters is compulsory, and every changed field
|
||
is logged (old value, new value, reason, who).
|
||
|
||
`lib/purchase-invoice-amount.ts` holds both rules and the screen and the API read them from
|
||
there, so they cannot disagree.
|
||
|
||
### R0 is NOT an amount
|
||
|
||
`purchase_invoices.amount_incl` is a PocketBase **number** field, so an invoice saved with no
|
||
amount is stored as **0**, not null. That reads as a real invoice for nothing and quietly
|
||
swallows the money — the PO shows "R 0,00 invoiced" and the supplier's payable ignores it.
|
||
|
||
So an amount is **required** (`AMOUNT_REQUIRED_ON_NEW_INVOICE`): a blank amount and a figure of
|
||
zero are both refused, on the create route and on the screen. Never "fix" that by treating 0 as a
|
||
valid amount.
|
||
|
||
### There is ONE window for putting an amount on an invoice
|
||
|
||
`InvoiceUploadModal` does both jobs. With no `invoice` it ADDS one to a PO; with an `invoice` it
|
||
PRICES one that is already on file. Double-clicking the amount on a PO opens that same window on
|
||
that invoice, with the amount field focused.
|
||
|
||
- The amount opens on a **double-click and on nothing else** — there is **no pencil button**. A
|
||
booked figure must take a deliberate second gesture; a single stray tap must never open a form
|
||
that can rewrite money. A long press does the same on a phone, and Enter/Space from the keyboard.
|
||
- The old inline "correct this amount" form is **gone**. Two ways to set an amount is two ways to
|
||
drift apart; there is one window and one rule.
|
||
- A price that was never entered is not a correction, so that window asks for **no reason**. The
|
||
reason field appears only when a booked figure is being changed, or the number or date is being
|
||
touched.
|
||
|
||
`/running/purchases` shows an unpriced invoice as "no amount" in amber, and the PO header says how
|
||
many are unpriced, so a missing figure is visible rather than silent.
|