From 05162201109f55552bc12e5051b0d11aff3f7d6e Mon Sep 17 00:00:00 2001 From: Mega Plant Date: Sat, 12 Sep 2026 12:05:43 +0200 Subject: [PATCH] Round 4: regressions fixed + mobile standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A — Round 3 regressions: - A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure - A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum) - A.3: incl20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart) - A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00) - A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack' Part B — Mobile standard: - B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses - B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char - B.3: Plant type filter is a ` 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. + +## 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` +- `monthly_income` no longer carries `running_cost` — derive from `monthly_running_costs` at read time diff --git a/COOLIFY.md b/COOLIFY.md new file mode 100644 index 0000000..f17eb6e --- /dev/null +++ b/COOLIFY.md @@ -0,0 +1,97 @@ +# Deploying Mega Plant to Coolify + +## The one thing that catches everyone + +`NEXT_PUBLIC_PB_URL` must be set as a **Build Argument**, not only as an +environment variable. + +Next.js compiles `NEXT_PUBLIC_*` values into the JavaScript the browser +downloads. That happens at `npm run build`, inside the Docker build. Setting the +value only as a runtime env var means the server sees the right URL while the +browser bundle still contains whatever was baked in at build time. + +The Dockerfile fails the build with an explicit message if the arg is missing, +so you'll find out immediately rather than after a confusing deploy. + +## Setup + +**1. Create the application** + +Coolify → Project → New Resource → **Application** → point at this repository. +Build Pack: **Dockerfile**. Port: **3000**. + +**2. Build Arguments** (Build → Build Arguments) + +| Name | Value | +| --- | --- | +| `NEXT_PUBLIC_PB_URL` | `https://pb.mega.plant.example` | + +**3. Environment Variables** (runtime — Environment Variables tab) + +| Name | Value | Notes | +| --- | --- | --- | +| `NEXT_PUBLIC_PB_URL` | same as above | server-side reads use it too | +| `PB_ADMIN` | the PocketBase superuser email | server-only, never exposed | +| `PB_PASS` | the superuser password | server-only, never exposed | +| `PORT` | `3000` | optional, defaults to 3000 | + +**4. Health check** + +Coolify → Health Checks → path `/api/health`, port `3000`. The Dockerfile also +declares its own `HEALTHCHECK` against the same endpoint. + +The probe deliberately does not touch PocketBase — it answers "is the web server +up?". If it also checked the database, a brief PocketBase outage would make +Coolify restart a perfectly healthy container. + +**5. Domain and TLS** + +Set the FQDN in Coolify and let Traefik issue the certificate. **HTTPS is +required** — the PWA will not install over plain HTTP, and the service worker +won't register. + +## PocketBase must be on HTTPS too + +Server-side rendering reaches PocketBase from inside the container, so an +`http://` URL works there. But the moment any browser-side code calls +PocketBase from an HTTPS page, the request is blocked as mixed content. + +Put PocketBase behind TLS. If it's another Coolify resource, give it a domain +and let Traefik handle the certificate, the same as this app. + +## Before the first deploy + +- [ ] **Rotate the PocketBase superuser password.** The old one + (`plant@megainfra.co.za` / `1234567890`) was committed to this repository + in an early version of `scripts/seed.mjs`. Treat it as compromised. +- [ ] Confirm `.env.local` is not committed (it's in `.gitignore`). +- [ ] Run `npm run typecheck` and `npm run build` locally first — a Docker build + failure costs several minutes to discover. + +## Test the image locally + +```bash +docker build --build-arg NEXT_PUBLIC_PB_URL=https://pb.mega.plant.example -t mega-plant . +``` + +```bash +docker run --rm -p 3000:3000 -e PB_ADMIN=you@example.com -e PB_PASS=secret mega-plant +``` + +Then check the health endpoint: + +```bash +curl -s localhost:3000/api/health +``` + +## Notes + +- **`--webpack` is not optional.** `@ducanh2912/next-pwa` is Webpack-only and + generates `public/sw.js`. `package.json` already passes the flag in `build`; + don't remove it or the PWA silently stops working. +- **`data/source/` is excluded from the image** (~2MB of workbook and extracted + JSON). It's only needed by `npm run seed`, which is a one-off you run locally + against PocketBase — not something the container does. +- **Seeding is not part of deployment.** The container never writes to + PocketBase on boot. Run `npm run seed` deliberately, from your machine. +- The image runs as a non-root `nextjs` user on Node 20 Alpine. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5074a7e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,87 @@ +# syntax=docker/dockerfile:1.7 +# +# Production image for MEGA PLANT — designed for Coolify. +# +# Multi-stage build: +# 1. deps — fetch and cache npm dependencies +# 2. builder — compile the Next.js standalone bundle +# 3. runner — minimal runtime image with just the standalone server +# +# The container listens on ${PORT:-3000} and binds 0.0.0.0 so Coolify's +# Traefik proxy can reach it. /api/health is the liveness probe. +# +# IMPORTANT — NEXT_PUBLIC_PB_URL must be passed as a BUILD ARG, not just a +# runtime variable. Next.js inlines NEXT_PUBLIC_* into the client bundle at +# build time; setting it only at runtime leaves the browser pointing at +# whatever was baked in. See COOLIFY.md. + +# --------------------------------------------------------------------------- +# 1. deps +# --------------------------------------------------------------------------- +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copy only the manifest + lockfile so this layer stays cached for as long +# as dependencies don't change. +COPY package.json package-lock.json* ./ +RUN npm ci --no-audit --no-fund + +# --------------------------------------------------------------------------- +# 2. builder +# --------------------------------------------------------------------------- +FROM node:20-alpine AS builder +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# The PocketBase URL is compiled into the client bundle here. Fail loudly +# rather than shipping an image that silently talks to the wrong database. +ARG NEXT_PUBLIC_PB_URL +ENV NEXT_PUBLIC_PB_URL=${NEXT_PUBLIC_PB_URL} +RUN test -n "$NEXT_PUBLIC_PB_URL" \ + || (echo "" \ + && echo "ERROR: build arg NEXT_PUBLIC_PB_URL is required." \ + && echo " Coolify -> Build -> Build Arguments -> NEXT_PUBLIC_PB_URL=https://pb.mega.plant.example" \ + && echo " Local: docker build --build-arg NEXT_PUBLIC_PB_URL=https://... ." \ + && echo "" && exit 1) + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# `next build --webpack` is deliberate: @ducanh2912/next-pwa is Webpack-only +# and is what generates public/sw.js + public/workbox-*.js. Dropping the flag +# silently ships an app with no service worker and no offline install. +RUN npm run build + +# --------------------------------------------------------------------------- +# 3. runner +# --------------------------------------------------------------------------- +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Static assets — includes the manifest, icons, and the service worker that +# next-pwa generated during the build. +COPY --from=builder /app/public ./public + +# `output: "standalone"` produces server.js plus a trimmed node_modules, but +# does NOT include .next/static — that has to be copied in separately. +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1 + +CMD ["node", "server.js"] diff --git a/HANDOFF-PROMPT-4.md b/HANDOFF-PROMPT-4.md new file mode 100644 index 0000000..c60b38c --- /dev/null +++ b/HANDOFF-PROMPT-4.md @@ -0,0 +1,243 @@ +# Mega Plant — Round 4 + +Two parts: three regressions from round 3, then the mobile standard. + +**The desktop layout is signed off. Do not change how any page looks at ≥1024px.** +Everything below is about what happens *below* that width, plus three data bugs. + +--- + +# Part A — Round 3 regressions + +## A.1 `verify-cost-reconciliation.mjs` crashes, and reports success anyway + +``` +TypeError: Failed to parse URL from /api/collections/_superusers/auth-with-password +exit=0 +``` + +The script never loads `.env.local`, so the PocketBase URL is empty and every request +fails. **It then exits 0.** A script whose entire job is to catch drift currently +cannot run and claims it passed. + +- [ ] Load `.env.local` the way `scripts/seed.mjs` does. +- [ ] Wrap the body in try/catch and `process.exit(1)` on any failure. +- [ ] Exit non-zero when the reconciliation total is off, not just when it throws. +- [ ] Run it and paste the real output. + +## A.2 Vehicle Costs regressed — the workbook's error was adopted + +Round 3 changed Vehicle Costs from **R14,996.31** to **R9,673.87** by dropping +`Santam versekering` (R5,322.44). + +That is backwards. Round 3 §B.4 said explicitly: the workbook's row-50 SUM starts one +row too low and omits Santam, so **the app was right and the spreadsheet was wrong**. +The fix was to report the spreadsheet error to the client — not to reproduce it. + +- [ ] Restore `Santam versekering` to the `Vehicle Costs` section. Correct total is + **R14,996.31** across 4 rows. +- [ ] Add a comment in the seed and a line in the reconciliation output recording + that the app deliberately differs from the workbook by +R5,322.44 here, and why. +- [ ] Never "fix" app data to match a source error that has been identified as an + error. Report the mismatch instead. + +## A.3 The `incl < excl` validation isn't firing + +Round 3 §B.3 asked the seed to reject any row where `incl < excl` or VAT falls outside +0–20%. This row is still in PocketBase: + +``` +Buildsmart Yearly update excl 14,827.91 vat 2,224.19 incl 1,421.01 +``` + +`incl` is less than `excl`, which is arithmetically impossible. + +- [ ] Add the validation and make it actually fail the seed. +- [ ] Confirm it trips on this row. Paste the failure output. +- [ ] Do not silently correct the value — it needs the client's confirmation + (correct figure is most likely R17,052.10). + +## A.4 Correction to the round-3 target figure + +The acceptance number in round 3 (**R689,640.49**) was wrong — I subtracted the +R5,322.44 Santam amount twice. The workbook's grand total already excludes it via the +broken subtotal. + +The correct arithmetic: + +``` +Workbook grand total (Cost row 98) R1,110,486.87 +less Plant Overheads (now on assets) − R415,523.94 + = R694,962.93 ← reconciliation target +``` + +Current app total is **R675,162.93**. After restoring Santam (A.2) it becomes +**R680,485.37**. The remaining **R14,477.56** is fully explained: + +| Amount | Cause | +| ---: | --- | +| R19,800.00 | Workbook's own Personnel total (row 77 = R412,000) exceeds its two line items (157,200 + 235,000 = 392,200). **Source error — ask the client.** | +| −R5,322.44 | Santam, where the app is deliberately correct and the workbook is not | + +- [ ] Encode `R694,962.93` as the target in the reconciliation script, with both + reconciling items listed explicitly so the delta is expected, not mysterious. + +## A.5 The "Lines" view draws filled areas, not lines + +The toggle works and all three series now render with the validated colours — good. +But the "Lines" view uses `recharts-area-curve` with translucent fills, so the amber +Fixed area paints over the teal Income area. + +The client asked for this view specifically because it matches their spreadsheet, +and the spreadsheet draws **lines**. + +- [ ] Make the "Lines" view actual lines — stroked, no fill. It is the view whose + whole purpose is matching the workbook. +- [ ] Keep fills in "Cost stack", where stacking is meaningful. + +--- + +# Part B — The mobile standard + +The client works from a phone. Desktop is signed off; mobile is not. Below is the +definition of done — **apply it to every page that exists now and every page added +later.** + +## B.1 The hard rule: no page scrolls sideways + +A page may never be wider than the viewport. Individual *tables* may scroll inside +their own container; the **page** may not. + +Measured at 375px right now: + +| Page | Page width | Over by | | +| --- | ---: | ---: | --- | +| `/income` | 549px | **+174** | ✗ | +| `/charts` | 406px | +31 | ✗ | +| `/overheads` | 397px | +22 | ✗ | +| `/running` | 389px | +14 | ✗ | +| `/expenses` | 383px | +8 | ✗ | +| `/` | 375px | 0 | ✓ | +| `/plant` | 375px | 0 | ✓ | + +Five of seven pages fail. Run this in the console on every page at 375px — it must +return `false` everywhere: + +```js +document.documentElement.scrollWidth > document.documentElement.clientWidth +``` + +The usual cause is a `flex … justify-between` header whose right-hand element can't +shrink. `/income` is the worst: the "YTD Income" gradient card and the +`Sort: Profit | Income` control both hang off the right edge. + +- [ ] Fix all five. Headers wrap to a second row on small screens rather than + overflowing. + +## B.2 Content must stay readable, not just fit + +On `/income` at 375px the machine names are truncated to a **single character** — +"C…", "D…", "I…". The code badge and profit badge hold their width while the name +column collapses to nothing. A card you can't identify is worse than no card. + +- [ ] Below `sm`, stack the card contents: name on its own full-width line, badges + on the line beneath. Never truncate the name to less than ~20 characters. +- [ ] Same review for `/running` and the charts cards. + +## B.3 Filters must not bury the data + +`/plant` has 18 type-filter pills. At 375px they wrap to 13 rows and consume the +entire first screen — you scroll past every pill before reaching a single row. + +- [ ] Below `lg`, collapse the type filter into a ` setQuery(e.target.value)} placeholder="Search by code or name…" - className="w-full h-9 pl-9 pr-3 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" + className="w-full min-h-[44px] pl-9 pr-3 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" />
@@ -105,7 +108,7 @@ export function ChartsGrid({ items }: { items: Item[] }) { ))}
-
- View +
+ View {[ { id: "lines", label: "Lines", icon: LineChartIcon }, { id: "stack", label: "Cost stack", icon: BarChart3 }, @@ -125,7 +128,7 @@ export function ChartsGrid({ items }: { items: Item[] }) {
-
+
Sort: {[ { id: "profit", label: "Profit" }, @@ -70,7 +73,7 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in ))}
+ {/* Part B.3: mobile sort dropdown */} +
+ + +
@@ -115,42 +131,63 @@ function CardsView({ > {isOpen && ( diff --git a/components/MachineLineChart.tsx b/components/MachineLineChart.tsx index 23d235c..56eb9ca 100644 --- a/components/MachineLineChart.tsx +++ b/components/MachineLineChart.tsx @@ -1,7 +1,7 @@ "use client"; import { - ComposedChart, Line, Bar, AreaChart, Area, Cell, + ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { MONTH_NAMES } from "@/lib/data"; @@ -14,15 +14,12 @@ type Props = { fixed: (number | null)[]; running: (number | null)[]; view?: ChartView; - // CSS-var-resolved hex strings for chart marks (Part A.4). - // Defaults fall back to validated hues if not provided. incomeColor?: string; fixedColor?: string; runningColor?: string; profitColor?: string; }; -// Helper: read a CSS custom property at runtime so we never hardcode hex in components. function readVar(name: string, fallback: string): string { if (typeof window === "undefined") return fallback; const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); @@ -34,13 +31,11 @@ export function MachineLineChart({ view = "lines", incomeColor, fixedColor, runningColor, profitColor, }: Props) { - // Resolve CSS vars to actual hex once per render (server falls back to defaults). const INC = incomeColor ?? readVar("--color-chart-income", "#0090A6"); const FIX = fixedColor ?? readVar("--color-chart-fixed", "#BE851F"); const RUN = runningColor ?? readVar("--color-chart-running", "#C13B4D"); const PROF = profitColor ?? readVar("--color-chart-running", "#D1495B"); - // Clip to last actual month (Part A.3) — months past that are null and we trim the array. const lastActual = Math.max( income.findLastIndex((v) => v != null), fixed.findLastIndex((v) => v != null), @@ -60,9 +55,8 @@ export function MachineLineChart({ new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v); if (view === "profit") { - // Single diverging bar series (Part A.5 — View 3): teal above 0, red below return ( -
+
@@ -81,17 +75,14 @@ export function MachineLineChart({ } if (view === "stack") { - // Part A.5 — View 2: stacked bars (fixed + running) with income as line. No 4th series for profit. return ( -
+
- {/* 2px surface-coloured gap between stacked segments via barCategoryGap + barGap */} fmt(v)} /> - {/* Surface-coloured spacer to create the 2px gap — drawn as transparent rect via offset */} @@ -101,35 +92,23 @@ export function MachineLineChart({ ); } - // Default: lines (View 1) + // Round 4 Part A.5: default "Lines" view draws ACTUAL LINES (stroked, no fill). + // The chart's whole purpose is to match the workbook — the workbook draws lines. return ( -
+
- - - - - - - - - - - - - - - + fmt(v)} /> - - - - + + + +
); } + diff --git a/components/PlantRegisterTable.tsx b/components/PlantRegisterTable.tsx index 9d80aa7..3c36b7f 100644 --- a/components/PlantRegisterTable.tsx +++ b/components/PlantRegisterTable.tsx @@ -95,33 +95,54 @@ export function PlantRegisterTable({ assets }: { assets: Asset[] }) { value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter code, name, supplier…" - className="h-9 pl-9 pr-3 w-64 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" + className="h-11 min-h-[44px] pl-9 pr-3 w-full sm:w-64 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" />
- {/* Type filter pills */} -
- {types.map((t) => { - const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length; - return ( - - ); - })} + {/* Type filter — Round 4 Part B.3: pills on lg+, dropdown below lg to avoid 13-row wrap */} +
+ {/* Desktop: pills */} +
+ {types.map((t) => { + const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length; + return ( + + ); + })} +
+ {/* Mobile: dropdown */} +
+ + +
- {/* Table */} -
+ {/* Table — Round 4 Part B.4: sticky first column + touch scroll + right-edge fade */} +
@@ -137,7 +158,7 @@ export function PlantRegisterTable({ assets }: { assets: Asset[] }) { {filtered.map((a) => ( -
+ {a.is_provisional && ( )} diff --git a/components/RunningView.tsx b/components/RunningView.tsx index dee4df7..f15818e 100644 --- a/components/RunningView.tsx +++ b/components/RunningView.tsx @@ -30,18 +30,19 @@ export function RunningView({ return ( <> -
-
+
+
{rows.filter((r) => r.ytd > 0).length} machines with costs · sorted by {sort === "ytd" ? "total spend" : "code"}
-
+
-
+
Sort: {[ { id: "ytd", label: "Total" }, @@ -69,7 +71,7 @@ export function RunningView({ ))}
+
+ + +
diff --git a/data/source/overheads.json b/data/source/overheads.json index 24b9fb6..66bf7b0 100644 --- a/data/source/overheads.json +++ b/data/source/overheads.json @@ -1,4 +1,15 @@ [ + { + "section": "Vehicle Costs", + "description": "Santam versekering", + "gl_account": "", + "supplier": "", + "qty": 0, + "unit_price": 0, + "amount_excl": 4524.0740000000005, + "vat": 798.366, + "amount_incl": 5322.4400000000005 + }, { "section": "Vehicle Costs", "description": "Tracking", diff --git a/lib/pb.ts b/lib/pb.ts index d0b8609..f415cb9 100644 --- a/lib/pb.ts +++ b/lib/pb.ts @@ -1,8 +1,17 @@ import PocketBase from "pocketbase"; -export const PB_URL = - process.env.NEXT_PUBLIC_PB_URL || - "http://pocketbase-v2usyugdbnffl6anlefm0f79.169.239.180.130.sslip.io"; +// No fallback on purpose. NEXT_PUBLIC_PB_URL is inlined at build time, so a +// default here would silently bake the wrong database into a production image +// whenever the build arg is missing. Better to fail at startup than to serve +// someone else's data. The Dockerfile checks for it before building too. +export const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? ""; + +if (!PB_URL) { + throw new Error( + "NEXT_PUBLIC_PB_URL is not set. Set it in .env.local for local dev, and as " + + "a Coolify Build Argument (not just an env var) for deploys — see COOLIFY.md.", + ); +} // Lazy singleton — the browser SDK auto-cancels in-flight requests when the // client is recreated, so we keep one instance for the whole browser session. diff --git a/next.config.ts b/next.config.ts index 744a662..5fc58e5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,12 @@ import withPWAInit from "@ducanh2912/next-pwa"; const nextConfig: NextConfig = { // No `env` block — NEXT_PUBLIC_ vars are picked up automatically at build time. + + // Emits `.next/standalone/server.js` with a trimmed node_modules, so the + // runtime image doesn't need the full dependency tree. Required by the + // Dockerfile. Note it does NOT bundle `.next/static` or `public/` — the + // Dockerfile copies those in separately. + output: "standalone", }; const withPWA = withPWAInit({ diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 2cb51ed..2e0a794 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -196,21 +196,29 @@ const ohErrors = []; const VAT_WARN = []; // Part B.3: collect VAT anomalies — DO NOT silent-correct, report loudly for (const o of overheads) { if (o.section === "Plant Overheads") continue; // Part 1.3 - if (o.description === "Santam versekering") continue; // Part B.4 + // Note: Santam is kept (Round 4 Part A.2 — round 3 was wrong to drop it). - // Part B.3: VAT validation + // Part B.3 / Round 4 Part A.3: VAT validation — collect warnings, then FAIL the seed + // if any row is arithmetically broken (incl < excl or VAT math doesn't add up). + // Per A.3: do not silently correct. Trip loudly so the client confirms the right number. + const vatErrors = []; if (o.amount_incl > 0 && o.amount_excl > 0) { const computedIncl = o.amount_excl + o.vat; if (Math.abs(computedIncl - o.amount_incl) > 0.5) { - VAT_WARN.push(`VAT math broken: "${o.description}" (${o.section}) — excl ${o.amount_excl} + vat ${o.vat} = ${computedIncl}, but incl is ${o.amount_incl}`); + vatErrors.push(`VAT math broken: "${o.description}" (${o.section}) — excl ${o.amount_excl} + vat ${o.vat} = ${computedIncl}, but incl is ${o.amount_incl}`); } if (o.amount_incl < o.amount_excl) { - VAT_WARN.push(`incl < excl: "${o.description}" (${o.section}) — excl ${o.amount_excl}, incl ${o.amount_incl}`); + vatErrors.push(`incl < excl: "${o.description}" (${o.section}) — excl ${o.amount_excl}, incl ${o.amount_incl}`); } if (o.amount_excl > 0 && (o.vat / o.amount_excl) > 0.20) { - VAT_WARN.push(`VAT > 20%: "${o.description}" (${o.section}) — ${((o.vat / o.amount_excl) * 100).toFixed(1)}%`); + vatErrors.push(`VAT > 20%: "${o.description}" (${o.section}) — ${((o.vat / o.amount_excl) * 100).toFixed(1)}%`); } } + // A.3: hard fail on VAT errors. Move them to FATAL so seed exits non-zero. + if (vatErrors.length > 0) { + ohErrors.push(...vatErrors.map((v) => `FATAL-VAT: ${v}`)); + VAT_WARN.push(...vatErrors); + } try { await pb.collection(OVERHEADS_COLL).create({ diff --git a/scripts/verify-cost-reconciliation.mjs b/scripts/verify-cost-reconciliation.mjs index 0e02f15..93964f8 100644 --- a/scripts/verify-cost-reconciliation.mjs +++ b/scripts/verify-cost-reconciliation.mjs @@ -1,10 +1,43 @@ // Part D + Part B.2 — verify term 2 (insurance) reconciliation AND overheads roll-up. // Run: node --env-file=.env.local scripts/verify-cost-reconciliation.mjs +// +// Part A.1: load .env.local explicitly, wrap in try/catch, exit 1 on any failure. +// The script must NEVER claim success unless every check actually passed. import PocketBase from "pocketbase"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +// Load .env.local (Round 4 Part A.1) +const __dirname = dirname(fileURLToPath(import.meta.url)); +try { + const env = readFileSync(resolve(__dirname, "..", ".env.local"), "utf8"); + for (const line of env.split("\n")) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/); + if (m && !process.env[m[1]]) process.env[m[1]] = m[2]; + } +} catch { + // .env.local optional; env may already be set by --env-file flag +} const PB = process.env.NEXT_PUBLIC_PB_URL; const EMAIL = process.env.PB_ADMIN; const PASS = process.env.PB_PASS; +if (!PB || !EMAIL || !PASS) { + console.error("✗ Missing required env (NEXT_PUBLIC_PB_URL, PB_ADMIN, PB_PASS). Copy .env.example to .env.local."); + process.exit(1); +} + +let exitCode = 0; +try { + await main(); +} catch (e) { + console.error(`✗ Script failed: ${e?.stack || e?.message || e}`); + exitCode = 1; +} +process.exit(exitCode); + +async function main() { const pb = new PocketBase(PB); pb.autoCancellation(false); await pb.collection("_superusers").authWithPassword(EMAIL, PASS); @@ -60,52 +93,53 @@ for (const o of overheads) { } const rollupTotal = Object.values(bySection).reduce((s, v) => s + v, 0); -// Workbook's own totals (Part B.2) +// Round 4 Part A.4: corrected target const WORKBOOK_GRAND_TOTAL = 1110486.87; // Cost sheet row 98 const PLANT_INSTALMENTS = 415523.94; // "Plant Overheads" section (correctly excluded) -const SANTAM_DOUBLED = 5322.44; // Part B.4 — double-counted in workbook subtotal -const EXPECTED_ROLLUP = WORKBOOK_GRAND_TOTAL - PLANT_INSTALMENTS - SANTAM_DOUBLED; // 689,640.49 +const TARGET_ROLLUP = WORKBOOK_GRAND_TOTAL - PLANT_INSTALMENTS; // R 694,962.93 -// Source row sum (what's actually in the workbook, not what's stated) -const SOURCE_ROW_SUM = 415523.94 + 14996.31 + 128330.05 + 392200 + 6159.01 + 28800 + 110000; // 1,096,009.31 -const EXPECTED_FROM_ROW_SUM = SOURCE_ROW_SUM - PLANT_INSTALMENTS - 5322.44; // excludes Santam +// Two known reconciling items (Round 4 Part A.4): +const KNOWN_PERSONNEL_OVERSTATEMENT = 19800.00; // workbook's own Personnel total R 412,000 vs R 392,200 line items +const KNOWN_SANTAM_DELTA = -5322.44; // app is correct; workbook subtotal skips Santam -console.log(`Workbook stated grand total: R ${WORKBOOK_GRAND_TOTAL.toFixed(2)} (Cost sheet row 98)`); -console.log(`Workbook row sum (all sections): R ${SOURCE_ROW_SUM.toFixed(2)} (R 14,477.56 less than stated — workbook internally inconsistent)`); +console.log(`Workbook grand total (Cost row 98): R ${WORKBOOK_GRAND_TOTAL.toFixed(2)}`); +console.log(`Target (workbook grand total - Plant instalments): R ${TARGET_ROLLUP.toFixed(2)}`); console.log(""); -console.log("Per-section rollup (Plant Overheads + Santam already excluded):"); +console.log("Per-section rollup (Plant Overheads already excluded):"); for (const [s, t] of Object.entries(bySection)) { console.log(` ${s.padEnd(22)} R ${t.toFixed(2).padStart(12)}`); } console.log(` ${"TOTAL".padEnd(22)} R ${rollupTotal.toFixed(2).padStart(12)}`); -console.log(""); -console.log(`Expected (from stated grand total): R ${EXPECTED_ROLLUP.toFixed(2)}`); -console.log(`Expected (from row sum): R ${EXPECTED_FROM_ROW_SUM.toFixed(2)}`); -console.log(`Actual: R ${rollupTotal.toFixed(2)}`); -const driftStated = Math.abs(rollupTotal - EXPECTED_ROLLUP); -const driftRows = Math.abs(rollupTotal - EXPECTED_FROM_ROW_SUM); -console.log(`Drift (vs stated): R ${driftStated.toFixed(2)}`); -console.log(`Drift (vs rows): R ${driftRows.toFixed(2)}`); -if (driftRows > 1) { - console.log(`\n✗ Roll-up drift exceeds R 1.00 vs workbook row sum. Investigate.`); +const driftTarget = Math.abs(rollupTotal - TARGET_ROLLUP); +const explainedDrift = + (TARGET_ROLLUP - rollupTotal) - (KNOWN_PERSONNEL_OVERSTATEMENT + KNOWN_SANTAM_DELTA); +console.log(""); +console.log(`Drift (vs target): R ${driftTarget.toFixed(2)}`); +console.log(` = Personnel overstatement (workbook error): R +${KNOWN_PERSONNEL_OVERSTATEMENT.toFixed(2)}`); +console.log(` + Santam delta (app is right; workbook wrong): R ${KNOWN_SANTAM_DELTA.toFixed(2)}`); +console.log(` = Unexplained: R ${explainedDrift.toFixed(2)}`); + +if (Math.abs(explainedDrift) > 1) { + console.log(`\n✗ Roll-up drift beyond the two known reconciling items. Investigate.`); exitCode = 2; } else { - console.log(`\n✓ Roll-up reconciles to workbook row sum within R 1.00.`); + console.log(`\n✓ Roll-up reconciles to target within R 1.00, with both known deltas itemised.`); } -console.log("\n=== Part B.4 — Vehicle Costs subtotal skips Santam ==="); +console.log("\n=== Part B.4 — Vehicle Costs subtotal ==="); const vehicleRows = (bySection["Vehicle Costs"] || 0); -// The workbook's Vehicle Costs subtotal EXCLUDES Santam (R 5,322.44) because the SUM range starts at row 47 not 46. -// The app's R 9,673.87 = Tracking (5,322.44) + Diesel (1,033.29) + Water (3,318.14). -// If app included Santam, it would be R 14,996.31 (matching workbook's stated Vehicle Costs total). -console.log(`App Vehicle Costs total: R ${vehicleRows.toFixed(2)} (excludes Santam per Part B.4 — correct)`); -console.log(`If Santam were included: R ${(vehicleRows + SANTAM_DOUBLED).toFixed(2)} (matches workbook's stated Vehicle Costs total)`); +// Round 4 Part A.2: app deliberately INCLUDES Santam (R 5,322.44) so Vehicle Costs = R 14,996.31 +// matching the workbook's stated subtotal. Workbook row 50's SUM skips Santam — that is the workbook's error. +console.log(`App Vehicle Costs total: R ${vehicleRows.toFixed(2)} (includes Santam per A.2)`); +console.log(` Workbook subtotal (row 50) = R 9,673.87 (incorrectly skips Santam)`); +console.log(` Workbook row-by-row sum = R 14,996.31 (correct)`); console.log("\n=== Known discrepancies — flagged, NOT silently fixed ==="); -console.log(" • Personnel: workbook total R 412,000, but R 157,200 + R 235,000 = R 392,200. Diff R 19,800 (workbook's own error)."); -console.log(" • Subscriptions: 2 rows have broken VAT (Charl - CCS incl