mega-plant/AGENTS.md
David 876619c3ac Invoices: keep the PDF on the invoice, and search the invoice's own words
Two things this question turned up.

The upload DID store the PDF, but on the intake record, never on the invoice
— so "Open invoice PDF" 404'd for every invoice imported through the app
while working for the older ones. The file now lives on invoices.source_pdf
and the intake record keeps only the provenance (who sent it, what was
parsed), rather than a second copy of the file. The document endpoint also
falls back to the intake record, which makes the seven already-stored
documents reachable again without a migration. All 10 now open.

The machine combobox searched only the register's own words, so an invoice
line reading "CB22 2.2 TON Roller Vibration" could never be matched to the
asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The
line's own invoice code and description are now searchable too, so the two
sides of the same machine can actually be found from either direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 19:09:16 +02:00

9.2 KiB
Raw 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/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.

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.

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.
  • 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().

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 (light #C13B4D, dark #D94F60)

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 — 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_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