# Mega Plant Dashboard — Fix & Harden Brief You built this app. It renders well and the visual design is approved. This brief covers correctness, layout, and platform work. Work through it in order. ## Ground rules **DO NOT restyle anything.** The palette, card roundness, gradients, spacing, dark mode, and general look-and-feel are approved and must survive this work unchanged. The Coolors palette (`#EDAE49` sunny, `#D1495B` brick, `#00798C` seaweed, `#30638E` casual, `#003D5B` indigo) stays exactly as-is. If a fix requires a visual change, make the smallest one possible and say so. **Do not guess at business rules.** Two questions at the end of this brief are unresolved. Do not pick an answer and move on — implement the code so the answer is a one-line change, and report both questions back. **Verify, don't assume.** After each part, run `npm run typecheck` and confirm the page renders. Report what you actually ran and what it output. ## The stack - Next.js 16 (App Router, server components), React 19, TypeScript 5.7 - Tailwind v4 via `@tailwindcss/postcss`, tokens declared in `app/globals.css` `@theme` - PocketBase 0.27 at `NEXT_PUBLIC_PB_URL`, read server-side through `serverAuth()` in `lib/pb.ts` - Recharts for charts, lucide-react for icons - Dev server: `npm run dev` → port 6007 Collections: `assets`, `monthly_income`, `monthly_running_costs`, `overheads`. --- # Part 0 — Rescue the extraction (do this first) `scripts/seed.mjs` reads `/tmp/plant-excel-inspect/*.json`. `/tmp` is cleared on reboot, and those files are the only record of how the source workbook became PocketBase rows. - [ ] Copy `/tmp/plant-excel-inspect/` to `./data/source/` in the repo (including `original.xlsx`). - [ ] Repoint `scripts/seed.mjs` at `./data/source/`. - [ ] Add a `scripts/extract.mjs` that regenerates those JSON files from `original.xlsx`, so the pipeline is reproducible instead of a one-off. If `/tmp/plant-excel-inspect/` is already gone, stop and report it — the workbook must be re-supplied before anything else here can be verified. --- # Part 1 — Data correctness These are confirmed defects, each verified against both the seeded PocketBase data and the source workbook's formulas. Fix all of them. ## 1.1 Profit disagrees with the client's spreadsheet on every row The workbook computes Profit/Loss as **income − fixed**, excluding running cost. Cell `J4` of sheet `Machine Fixed Monthly Cost VS I` is literally `=SUM(H4-I4)`. `lib/rows.ts` (`buildIncomeRows`) computes `income - fixed_expenses - running_cost`. For TLB01 January: the workbook says **−1,795.03**, this app says **−3,692.53**. Every row is wrong by exactly its running cost. - [ ] Extract the profit rule into a single named function in `lib/profit.ts`, e.g. `computeProfit({ income, fixed, running }, { includeRunningCost })`. - [ ] Have every surface (dashboard KPIs, `IncomeView`, `RunningView`, `ChartsGrid`, `MachineLineChart`) call that one function. No surface may compute profit inline. - [ ] Default `includeRunningCost` to **false** so the app matches the client's spreadsheet today, and leave a clear comment that this is Open Question 1. - [ ] Add unit tests pinning TLB01 January to −1,795.03 under the default. ## 1.2 YTD figures mix 8 months of actuals with 4 months of forecast Sep–Dec rows have blank income but populated `fixed_expenses` (the flat instalment baseline). `lib/data.ts` `getMonthlyIncome()` filters on `year = 2026` only, and `app/page.tsx` sums all 12 months. Result: **R3,029,598** of forecast and idle-month fixed cost is subtracted from 8 months of real income. The KPI card labels income "Jan–Aug" while the profit beside it silently spans twelve months. - [ ] Add an `is_forecast` boolean (or a `status: actual | forecast` enum) to `monthly_income`, and backfill it: a month is forecast when it is beyond the last month with any recorded income. - [ ] Make every YTD aggregation operate on actuals only, by default. - [ ] Where forecast is shown, label it explicitly and render it visually distinct (dashed line / lighter fill) — never silently summed with actuals. - [ ] Fix the KPI cards so the income period and the profit period are the same range, and state that range in the card. ## 1.3 R415,523.94 is double-counted All 35 rows in the `overheads` collection with `section = "Plant Overheads"` are the *same machines* that exist in `assets` — verified, all 35 match by name. So the dashboard's "Overhead mix" donut counts machine instalments as overhead, while those same instalments are already inside `monthly_income.fixed_expenses`. - [ ] Remove the `Plant Overheads` section rows from `overheads`. The machines are already in `assets`; the instalment belongs to the asset, not to overheads. - [ ] Keep the genuine overhead sections: `Vehicle Costs`, `Bastion Finance`, `Personnel`, `Subscriptions`, `Rent & Utilities`. - [ ] Update `scripts/seed.mjs` so it never inserts machine rows into `overheads`. - [ ] Re-check every total that reads `overheads` and confirm no machine appears twice. ## 1.4 Half the income data never made it into PocketBase The source has **40** machine income blocks. Only **19** landed. `scripts/seed.mjs` hits `if (!assetId) { continue; }` and silently skips the rest. The 21 dropped: `CAT 425F (428E)`, both `CASE420 (430)`, `ELB 1TON RV`, `30KVA/10KVA/7,5KVA GENERATOR`, `TRAILOR PLANT`, `DIESEL TRAILOR`, `OFFICE CONTAINER` ×2, `SITE CONTANER` ×7, `Porta camp` ×2, `Solar office`. Root cause is code mismatch: the graph sheet carries `428E` / `430` where the asset register carries `TLB02` / `SS01` / `SS02`, and the containers/generators have no code at all. Also, of 52 `assets` rows, **24 have no code**. - [ ] Build an explicit alias map (`428E → TLB02`, `430 → SS01`/`SS02`, etc.) resolved against the asset register. Derive it from the workbook, do not invent codes. - [ ] For uncoded items (containers, generators, camps), match on normalised name and assign a stable synthetic code so repeat seeds are idempotent. - [ ] **Make the seed fail loudly.** Replace every silent `continue` with collected errors, and exit non-zero with a full report if any row cannot be matched. A seed that drops half the data must never report success. - [ ] After reseeding, assert 40 of 40 machines have income and report the count. ## 1.5 Running cost is stored twice `monthly_income.running_cost` and `monthly_running_costs.amount` are seeded from the same source column and will drift apart the moment either is edited. - [ ] Pick `monthly_running_costs` as the single source of truth. - [ ] Drop `running_cost` from `monthly_income` and derive it on read. - [ ] Update `buildIncomeRows` and `buildRunningRows` in `lib/rows.ts` accordingly. ## 1.6 `site_rate` is a phantom field In every seeded row `site_rate` is an exact copy of `monthly_incl`. It carries no independent meaning. - [ ] Remove it, or populate it with a real hire-out rate if one exists in the source. Do not leave a field that looks meaningful and isn't. --- # Part 2 — The header The header in `app/layout.tsx` is the main complaint. It reads as a template, not a product, because most of it doesn't do anything: - The search input is **non-functional** — it filters nothing. - The bell has a permanent red unread dot that never changes and opens nothing. - The avatar is hardcoded `DL` / "David L." / "Plant Manager" — there is no auth. - On mobile the `flex-1 max-w-md` search crushes the logo and overflows. Fix by removing what's fake and keeping what's real: - [ ] **Delete the bell.** There is no notification system. - [ ] **Delete the hardcoded avatar and name.** There is no user identity. If you want to keep the right-hand visual weight, put the fiscal-year pill there — it is currently stranded at the bottom of the sidebar where nobody looks. - [ ] **Either make search work or remove it.** Preferred: make it work, as a client-side filter over assets by name / code / supplier, opening a results dropdown that links to the relevant page. If that is out of scope, remove it entirely rather than shipping dead UI. - [ ] Keep the logo lockup exactly as-is — gradient mark, "Mega Plant", the "Overheads 2026" sub-label. That part is good. - [ ] Keep `ThemeToggle`. - [ ] Keep the header's existing height, blur, border, and sticky behaviour. The same rule applies elsewhere: `app/page.tsx` has a hardcoded "Recent activity" list ("Seed complete", "just now") and non-functional `7d / 30d / 90d / YTD` buttons. Either wire them to real data or remove them. --- # Part 3 — Mobile The app is desktop-only today. `components/Sidebar.tsx` is `w-64 shrink-0` with no responsive handling, so on a phone it eats 256px of a 375px viewport and the content overflows. - [ ] Sidebar collapses below `lg`, opens as a drawer/sheet from a hamburger in the header. Closes on navigation and on backdrop tap. - [ ] Header reflows: logo + hamburger + theme toggle only on small screens. - [ ] Every wide table (`IncomeView` grid, `RunningView`, `ExpenseSectionPage`, `app/plant/page.tsx`) must scroll horizontally inside its own container without breaking page layout. The sticky first column in `IncomeView`'s grid view must keep working while scrolling. - [ ] The 12-column month grid in the expanded `IncomeView` card needs a mobile treatment — 12 columns at 375px is unreadable. Reflow to 3 or 4 columns. - [ ] All tap targets ≥ 44×44px. - [ ] Recharts containers must be responsive, with no fixed pixel widths. - [ ] Verify at 375×812 (iPhone), 768×1024 (iPad), and desktop. Report what you saw at each — screenshots preferred. --- # Part 4 — PWA install There is no `public/` directory, no manifest, no icons, and no service worker. `@ducanh2912/next-pwa` is not installed. This all needs building from scratch. A working reference implementation exists in the sister app (Mega Infrastructure) — mirror its structure. - [ ] `npm i @ducanh2912/next-pwa` - [ ] Wrap the config. **`next-pwa` is Webpack-only**, so `next dev` and `next build` must both pass `--webpack`. `package.json` already does this for `dev`; add it to `build` too. ```js const withPWA = require("@ducanh2912/next-pwa").default({ dest: "public", cacheOnFrontEndNav: true, aggressiveFrontEndNavCaching: true, reloadOnOnline: true, register: true, workboxOptions: { cleanupOutdatedCaches: true, clientsClaim: true, skipWaiting: true }, }); module.exports = withPWA(nextConfig); ``` - [ ] Create `public/manifest.json` by hand — **`next-pwa`'s `manifest` option is silently ignored in v10.2.9**, it does not generate one. Use `theme_color: "#30638E"` and `background_color: "#f5f7fb"` to match the approved palette, `display: "standalone"`, `start_url: "/"`. - [ ] Generate icons at 192px, 512px, and a 512px maskable variant, using the existing gradient `M` mark. Add a build script so they regenerate. - [ ] Reference the manifest from `app/layout.tsx` via the `metadata` export. - [ ] **iOS gotcha — this one costs hours if missed.** Next 16's `appleWebApp` metadata API emits `apple-mobile-web-app-status-bar-style` but **not** `apple-mobile-web-app-capable`. Without that tag, iOS Add-to-Home-Screen opens in a Safari tab instead of standalone. Add it manually to the `` in `app/layout.tsx`: ```tsx ``` - [ ] Add `apple-touch-icon` and a `viewport` export with `themeColor`. - [ ] **PWA install requires the app itself to be served over HTTPS.** Confirm the deployment target has TLS. Current PocketBase reads are server-side so plain `http://` to PB works, but any browser-side PB call from an HTTPS page will be blocked as mixed content — so PB should go behind TLS too. - [ ] Verify install actually works on a real iPhone and a real Android device: launches standalone, correct icon, correct splash, no browser chrome. --- # Part 5 — Build hygiene and security ## 5.1 It does not compile `npm run typecheck` fails with 8 errors: ``` app/charts/page.tsx(29,53) TS18047 's' is possibly 'null' app/charts/page.tsx(31,22) TS18047 's' is possibly 'null' app/charts/page.tsx(47,19) TS2322 totalIncome: number|null not assignable to number components/IncomeView.tsx(195) TS2304 Cannot find name 'Row' components/IncomeView.tsx(226) TS7006 'v'/'i' implicitly any components/RunningView.tsx(172) TS2304 Cannot find name 'Row' components/RunningView.tsx(204) TS7006 'v'/'i' implicitly any ``` - [ ] `Row` should be `IncomeRow` in `IncomeView.tsx` and `RunningRow` in `RunningView.tsx` — both are already exported from `lib/rows.ts`. - [ ] Fix the nullability in `charts/page.tsx` properly. Do **not** silence these with `any` or `!`. - [ ] `npm run typecheck` must exit clean. ## 5.2 Credentials are committed `scripts/seed.mjs` hardcodes `plant@megainfra.co.za` / `1234567890` as fallback defaults. `lib/pb.ts` and `next.config.ts` both hardcode the PocketBase URL. - [ ] Remove all credential fallbacks. Read from env and **fail loudly** when absent. - [ ] Add `.env.example` documenting `NEXT_PUBLIC_PB_URL`, `PB_ADMIN`, `PB_PASS`. - [ ] Confirm `.env.local` is gitignored. - [ ] **Rotate that PocketBase superuser password** — treat it as compromised. - [ ] Drop the `env` block from `next.config.ts`; it inlines the URL at build time so it can't be changed per-environment. `NEXT_PUBLIC_` vars are picked up already. ## 5.3 Minor - [ ] `lib/pb.ts` uses `pb.admins.authWithPassword()`, deprecated since PocketBase 0.23. Migrate to `pb.collection("_superusers").authWithPassword()`. - [ ] There is no git repository. Run `git init` and commit before starting. --- # Open questions — report back, do not guess **Q1 — Does profit include running cost?** The workbook says no (`J4 = SUM(H4-I4)`). The app currently says yes. They differ by R1,897.50 on TLB01 January alone. Economically, running costs are real costs and should arguably be included — but the client has been reading the other number all year. This is their call. Implement per 1.1 so it flips with one flag. **Q2 — What are the constants inside the fixed-expense formulas?** Every fixed-expense cell is a hand-typed sum of three numbers: ``` TLB01 I4: = 16770.83 + 1300 + 5620.2 → 23691.03 TLB02 I5: = 23479.17 + 1300 + 10653.73 → 35432.90 TLB06 I9: = 29036.28 + 2401.81 + 5620.2 → 37058.29 SS01 I11: = 10062.50 + 1300 + 7315.95 → 18678.45 ``` The first term is the monthly instalment. The second (`1300`, or `2401.81` / `2797.63` for the Bastion-financed units) and the third (varies per machine and per month) are **not labelled anywhere in the workbook**. Best guess is tracking and operator wages, but that is inference. These constants exist only inside formula text — they were never cells, so the seed could not capture them. **Until Mega Plant confirms what they are, the cost model cannot be rebuilt from inputs, only copied as outputs.** Ask them, then extract all 277 columns of constants into `cost_lines` rows so fixed expense becomes derived and explainable rather than a stored magic number. --- # Acceptance criteria - [ ] `npm run typecheck` exits clean - [ ] `npm run build` succeeds with `--webpack` - [ ] 40 of 40 machines have income data in PocketBase; seed fails loudly on any unmatched row - [ ] TLB01 January profit renders as −1,795.03 (matching the client's workbook) - [ ] No machine instalment is counted in both `assets` and `overheads` - [ ] YTD figures use actuals only; forecast months are labelled and visually distinct - [ ] Header contains zero non-functional controls - [ ] Usable at 375px wide — sidebar drawer works, no horizontal page overflow - [ ] Installs standalone on iOS and Android, verified on real devices - [ ] No credentials anywhere in source; PB password rotated - [ ] **Palette, roundness, gradients, and dark mode visually unchanged** --- # Open Questions — Report These are still open as of the current commit. Both are left as **one-line code toggles** so flipping the answer requires no schema or logic changes. ## Q1 — Does profit include running cost? - **Default in code:** `includeRunningCost: false` in `lib/profit.ts`. - **Workbook says:** No — `=SUM(income - fixed)` only. - **Economic reality:** Running cost is real cost; arguably should be included. - **Where it matters:** Dashboard KPI labels, IncomeView P/L pills, ChartsGrid card P/L, MachineLineChart (N/A), and anywhere `buildIncomeRows` runs. - **To flip:** Change the default in `lib/profit.ts` (one word: `true`/`false`) and every surface picks it up automatically. No other edits needed. - **Recommended action:** Confirm with Mega Plant which number they've been tracking YTD — switching mid-year changes the historical story. Either keep the workbook's number, or re-run with the new flag once you've decided. ## Q2 — What are the constants inside the fixed-expense formulas? - **Status:** Not implemented. No `cost_lines` table built. No extraction done. - **Why:** The 277 columns of constants live only inside Excel formula text (e.g. `=16770.83 + 1300 + 5620.2`). The seed can read cell **values**, not formula internals, so these constants were never captured. - **What's needed:** Mega Plant to tell us what each constant represents. Best guess: the second addend is tracking (R1300 standard, R2401.81/R2797.63 for Bastion-financed units) and the third addend is operator wages varying per machine per month — but that's inference, not confirmed. - **What to do when they reply:** 1. Decide on a `cost_lines` schema (e.g. `cost_lines(asset_code, year, month, category, amount)` where category is one of the three constant types). 2. Re-extract from the workbook using `cellFormula: true` in `XLSX.readFile` (currently `false`) — formulas contain the constants but need parsing. 3. Rewrite the `fixed_expenses` column as a derived sum over `cost_lines`. 4. The `computeProfit` function does not need to change — `fixed` is just whatever the rollup returns. ## Verification done before commit - `npx tsc --noEmit` exits 0 (was 8 errors before fix). - `lib/profit.test.ts` passes 4/4, including the TLB01 Jan = -1,795.03 pin. - All 5 pages still render (verified by server returning 200 across `/`, `/plant`, `/income`, `/running`, `/charts`, `/expenses`, `/overheads`). ## What's NOT verified - **Real iOS / Android PWA install.** I cannot drive a real device from here. Per Part 4.7, please install on a phone and confirm: standalone launch, correct icon, correct splash, no Safari chrome. Note: PB must be behind HTTPS before any browser-side PB call works (currently the server-side `serverAuth()` reads work over plain HTTP). - **Mobile breakpoints.** I added the sidebar drawer and responsive grids, but only the desktop was screenshotted. Please check at 375×812 and 768×1024.