19 KiB
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 inapp/globals.css@theme - PocketBase 0.27 at
NEXT_PUBLIC_PB_URL, read server-side throughserverAuth()inlib/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 (includingoriginal.xlsx). - Repoint
scripts/seed.mjsat./data/source/. - Add a
scripts/extract.mjsthat regenerates those JSON files fromoriginal.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
includeRunningCostto 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_forecastboolean (or astatus: actual | forecastenum) tomonthly_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 Overheadssection rows fromoverheads. The machines are already inassets; 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.mjsso it never inserts machine rows intooverheads. - Re-check every total that reads
overheadsand 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
continuewith 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_costsas the single source of truth. - Drop
running_costfrommonthly_incomeand derive it on read. - Update
buildIncomeRowsandbuildRunningRowsinlib/rows.tsaccordingly.
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-mdsearch 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 (
IncomeViewgrid,RunningView,ExpenseSectionPage,app/plant/page.tsx) must scroll horizontally inside its own container without breaking page layout. The sticky first column inIncomeView's grid view must keep working while scrolling. - The 12-column month grid in the expanded
IncomeViewcard 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-pwais Webpack-only, sonext devandnext buildmust both pass--webpack.package.jsonalready does this fordev; add it tobuildtoo.
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.jsonby hand —next-pwa'smanifestoption is silently ignored in v10.2.9, it does not generate one. Usetheme_color: "#30638E"andbackground_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
Mmark. Add a build script so they regenerate. -
Reference the manifest from
app/layout.tsxvia themetadataexport. -
iOS gotcha — this one costs hours if missed. Next 16's
appleWebAppmetadata API emitsapple-mobile-web-app-status-bar-stylebut notapple-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<head>inapp/layout.tsx:
<meta name="apple-mobile-web-app-capable" content="yes" />
- Add
apple-touch-iconand aviewportexport withthemeColor. - 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
Rowshould beIncomeRowinIncomeView.tsxandRunningRowinRunningView.tsx— both are already exported fromlib/rows.ts.- Fix the nullability in
charts/page.tsxproperly. Do not silence these withanyor!. npm run typecheckmust 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.exampledocumentingNEXT_PUBLIC_PB_URL,PB_ADMIN,PB_PASS. - Confirm
.env.localis gitignored. - Rotate that PocketBase superuser password — treat it as compromised.
- Drop the
envblock fromnext.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.tsusespb.admins.authWithPassword(), deprecated since PocketBase 0.23. Migrate topb.collection("_superusers").authWithPassword().- There is no git repository. Run
git initand 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 typecheckexits cleannpm run buildsucceeds 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
assetsandoverheads - 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: falseinlib/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
buildIncomeRowsruns. - 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_linestable 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:
- Decide on a
cost_linesschema (e.g.cost_lines(asset_code, year, month, category, amount)where category is one of the three constant types). - Re-extract from the workbook using
cellFormula: trueinXLSX.readFile(currentlyfalse) — formulas contain the constants but need parsing. - Rewrite the
fixed_expensescolumn as a derived sum overcost_lines. - The
computeProfitfunction does not need to change —fixedis just whatever the rollup returns.
- Decide on a
Verification done before commit
npx tsc --noEmitexits 0 (was 8 errors before fix).lib/profit.test.tspasses 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.