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>
139 lines
7.8 KiB
Markdown
139 lines
7.8 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/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:
|
||
|
||
```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.
|
||
|
||
## 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
|