mega-plant/AGENTS.md
David cfd54385b3 Add Suppliers list; book purchase orders and petty-cash slips against it
- /expenses/suppliers: terms (cash / 30 / 60 days), PO + petty cash totals per supplier,
  period filters, add/edit/delete, due dates
- POs and slips resolve to a supplier by code then folded name; new PO suppliers start
  with terms unset, new slip suppliers as cash
- Full-user guard on cash-float and job-card write routes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:43:57 +02:00

58 KiB
Raw Permalink Blame History

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:

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:

{ "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 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 Lines 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

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.

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.