Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.8 KiB
AGENTS.md — Mega Plant
Mobile checklist (Round 4 standing rule)
Every page must pass the mobile checklist before it is considered complete:
- No horizontal page scroll at 375px. Individual tables may scroll inside their own container; the page itself may not.
- No text under 12px on mobile. Bump
text-[10px]labels up. - No tap target under 44×44px. Keep the visual size if you like — add padding to the hit area.
- 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). - Filter controls collapse below
lg— pills become a<select>dropdown. Don't stack 13 pills into 13 rows. - Verify at 375×812 and 768×1024. Run
document.documentElement.scrollWidth > document.documentElement.clientWidthand confirm it returnsfalse. 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 --noEmitnpm run seed→ wipe + re-importdata/source/*.jsoninto PocketBase. Fails loudly on any unmatched row or broken VAT.node scripts/setup-invoice-collections.mjs→ create theinvoices/invoice_linescollections (idempotent).node scripts/book-invoice.mjs→ post invoice lines intomonthly_incomeas 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.
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.
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 currentlytrue: 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 tofalseand re-runnpm run seed && node scripts/book-invoice.mjsto 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:
/incomeshows "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/bookbody{ assetId }(""clears) — setsbooked_assetand moves themonthly_incomerow to the new machine rather than adding a second one.POST /api/invoices/uploadmultipartfile+invoiceNo— PDF only, 20 MB cap, stored oninvoices.source_pdf.GET /api/invoices/:id/document— proxies the PDF. Do not link toNEXT_PUBLIC_PB_URL/api/files/<filename>: theinvoicescollection 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 withpb.files.getURL().
Color palette — never change
Coolors:
#EDAE49Sunny — orange/positive accent#D1495BBrick Red — deficit/destructive#00798CNotartis Seaweed — teal/stable#30638ELights Casual — main blue/brand#003D5BIndigo 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(#BE851Fboth)--color-chart-running(light#C13B4D, dark#D94F60)
Cost model
lib/profit.tsis the single source of truth for profit. DefaultincludeRunningCost: false(matches workbook). To flip: one-line change inlib/profit.ts.- Term 1 = instalment ✅ confirmed.
- Term 2 = insurance ✅ confirmed.
- Term 3 = operator wages — varies per month per machine — STILL NEEDS CLIENT INPUT.
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 monthly_incomeno longer carriesrunning_cost— derive frommonthly_running_costsat read timemonthly_income.invoice_line→invoice_lines, nullable. Empty = workbook row, set = invoice-derived rowinvoice_lines.booked_asset→assets, nullable. Empty = line not yet booked to a machine