Round 4: regressions fixed + mobile standard

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: incl<excl / VAT math / VAT>20% 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 <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance

AGENTS.md created with the standing mobile checklist rule.
This commit is contained in:
Mega Plant 2026-09-12 12:05:43 +02:00
parent 7194e78079
commit 0516220110
30 changed files with 880 additions and 155 deletions

39
.dockerignore Normal file
View file

@ -0,0 +1,39 @@
# Reinstalled inside the image by `npm ci`
node_modules
# Build output — must not leak into the image or it poisons the fresh build
.next
.next-build
out
dist
# Secrets. Runtime config comes from Coolify's environment variables.
.env
.env.local
.env.*.local
# Generated by next-pwa during the Docker build; a stale copy from a local
# dev run would be baked in over the fresh one.
public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
public/swe-worker-*.js
# Source workbook + extracted JSON. Only used by `npm run seed`, which is a
# local one-off against PocketBase — not something the container does. ~2MB.
data/source
# Local tooling and docs
.git
.gitignore
.vscode
.mavis
.superpowers
*.log
.DS_Store
HANDOFF-PROMPT*.md
README.md
COOLIFY.md
tsconfig.tsbuildinfo
test-pb.mjs

54
AGENTS.md Normal file
View file

@ -0,0 +1,54 @@
# 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.
## 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

97
COOLIFY.md Normal file
View file

@ -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.

87
Dockerfile Normal file
View file

@ -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"]

243
HANDOFF-PROMPT-4.md Normal file
View file

@ -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 `<select>` dropdown, or a
horizontally-scrolling single row of pills. Not a 13-row block.
- [ ] Same for the `Sort:` control on `/income` and the view toggle on `/charts`.
## B.4 Wide tables: scroll inside the card, with an anchor column
`/plant` does this right — the table is 917px inside a 375px `overflow-x-auto`
container, so the page itself doesn't move. Keep that pattern. But scrolling six
columns of financials on a phone is still hard work.
- [ ] The first column (Code, or Description on expense pages) stays pinned with
`position: sticky; left: 0` and a background, so you always know which row
you're reading.
- [ ] Add `-webkit-overflow-scrolling: touch` and a visible affordance that the
table scrolls — a fade on the right edge is enough.
- [ ] On expense pages, consider hiding `GL Account` and `Supplier` below `md`.
Description and the three money columns are what matter on a phone.
## B.5 Tap targets and text size
Measured on `/charts` at 375px: **19 interactive elements under 44px tall.**
On `/` : **10 text elements under 11px.**
- [ ] Every `button`, `a`, and `input` is at least 44×44px on touch. Keep the visual
size if you like — add padding to the hit area.
- [ ] No text below 12px on mobile. The `text-[10px]` labels need bumping.
## B.6 Charts on a phone
- [ ] The legend on `/charts` is cut off at the right edge ("Income · Fixed · Ru…").
Wrap it below the title on small screens.
- [ ] Charts need a minimum height on mobile — squashing to 176px with 8 x-labels
makes them unreadable. Go taller on narrow screens, not shorter.
- [ ] The machine card grid must be a single column below `sm`.
## B.7 The PWA angle
The app is meant to install to the home screen, so mobile isn't a nice-to-have —
it's the primary surface.
- [ ] Verify the installed standalone app at 375px, not just Safari/Chrome. Standalone
has no browser chrome, so sticky headers and safe-area insets behave differently.
- [ ] Add `viewport-fit=cover` and use `env(safe-area-inset-*)` padding on the sticky
header and the mobile drawer, so content clears the notch and home indicator.
## B.8 How to verify — do this, and report it
Do not report mobile work as done without this. For **every** route
(`/`, `/plant`, `/income`, `/running`, `/charts`, `/overheads`, `/expenses`, and each
`/expenses/*`):
1. Set the viewport to **375×812**.
2. Run the overflow check from B.1 — record the number.
3. Screenshot the page.
4. Count tap targets under 44px and text under 12px.
5. Repeat at **768×1024** (tablet).
- [ ] Paste a table of route → overflow → tap-target count for all routes.
- [ ] Attach 375px screenshots.
- [ ] Any route that still overflows is not done.
---
# Standing rule for future work
Add this to the project's `AGENTS.md` so it applies to every future change:
> Every page must pass the mobile checklist before it is considered complete:
> no horizontal page scroll at 375px, no text under 12px, no tap target under 44px,
> wide tables scroll inside their own container with a sticky first column, and
> filter controls collapse rather than stacking into many rows. Verify at 375×812
> and 768×1024 and report the measurements — do not assert it from reading the code.
- [ ] Create `AGENTS.md` with that rule if it doesn't exist.
---
# Acceptance criteria
- [ ] `npm run typecheck` exits clean
- [ ] `npm run build` succeeds
- [ ] `verify-cost-reconciliation.mjs` runs, and exits **non-zero** when it fails
- [ ] Vehicle Costs is back to R14,996.31 with Santam included
- [ ] Seed rejects the Buildsmart row; failure output pasted
- [ ] Reconciliation targets R694,962.93 with both deltas itemised
- [ ] "Lines" view draws lines, not filled areas
- [ ] **Zero routes** report horizontal page overflow at 375px
- [ ] No tap target under 44px; no text under 12px
- [ ] Wide tables scroll in-container with a sticky first column
- [ ] Verification table + 375px screenshots pasted for every route
- [ ] **Desktop (≥1024px) is visually unchanged — palette, roundness, gradients,
spacing, dark mode all identical to today**

13
app/api/health/route.ts Normal file
View file

@ -0,0 +1,13 @@
// Liveness probe for the container healthcheck and Coolify's own monitor.
// Deliberately does NOT touch PocketBase — this answers "is the Next.js
// server up?", not "is the database reachable?". Mixing the two makes the
// container restart itself whenever PocketBase hiccups.
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json({
status: "ok",
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
}

View file

@ -2,6 +2,11 @@ import { getAssets, getActualMonthlyIncome, getRunningCosts } from "@/lib/data";
import { ChartsGrid } from "@/components/ChartsGrid";
import { buildIncomeRows } from "@/lib/rows";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default async function ChartsPage() {
const [assets, incomeActual, running] = await Promise.all([
getAssets(),

View file

@ -1,6 +1,11 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { CreditCard } from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default function Finance() {
return (
<ExpenseSectionPage

View file

@ -6,6 +6,11 @@ import {
ArrowUpRight, Coins,
} from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
const expenseSections = [
{
key: "Plant Overheads",

View file

@ -1,6 +1,11 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { Users } from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default function Personnel() {
return (
<ExpenseSectionPage

View file

@ -1,6 +1,11 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { Coins } from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default function PettyCash() {
return (
<ExpenseSectionPage

View file

@ -1,6 +1,11 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { Wallet } from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default function Rent() {
return (
<ExpenseSectionPage

View file

@ -1,6 +1,11 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { PackageOpen } from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default function Subscriptions() {
return (
<ExpenseSectionPage

View file

@ -1,6 +1,11 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { Truck } from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default function Vehicles() {
return (
<ExpenseSectionPage

View file

@ -4,6 +4,11 @@ import { TrendingUp } from "lucide-react";
import { IncomeView } from "@/components/IncomeView";
import { buildIncomeRows } from "@/lib/rows";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default async function IncomePage() {
const [assets, incomeActual, running] = await Promise.all([
getAssets(),

View file

@ -17,6 +17,11 @@ import { ThemeToggle } from "@/components/ThemeToggle";
import { GlobalSearch } from "@/components/GlobalSearch";
import { getAssets } from "@/lib/data";
// The layout itself reads PocketBase (GlobalSearch needs the asset list), so it
// must be dynamic too — otherwise Next prerenders the built-in /_not-found page,
// which inherits this layout, and the build fails without a reachable database.
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Mega Plant Dashboard",
description: "Mega Plant cost & P&L dashboard",

View file

@ -2,6 +2,11 @@ import { getOverheads } from "@/lib/data";
import { ZAR } from "@/lib/utils";
import { Receipt } from "lucide-react";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default async function OverheadsPage() {
const overheads = await getOverheads();

View file

@ -4,6 +4,11 @@ import { ZAR } from "@/lib/utils";
import { Truck, Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { IncomeAreaChart, IncomeBarChart, OverheadDonut, PIE_COLORS } from "@/components/DashboardCharts";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
// Part 1.2: act on actuals only for YTD aggregations.
const [assets, incomeActual, incomeAll, running, overheads] = await Promise.all([

View file

@ -1,6 +1,11 @@
import { getAssets } from "@/lib/data";
import { PlantRegisterTable } from "@/components/PlantRegisterTable";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default async function PlantPage() {
const assets = await getAssets();
return (

View file

@ -4,6 +4,11 @@ import { Wrench } from "lucide-react";
import { RunningView } from "@/components/RunningView";
import { buildRunningRows } from "@/lib/rows";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
export default async function RunningPage() {
const [assets, costs] = await Promise.all([getAssets(), getRunningCosts()]);

View file

@ -63,19 +63,22 @@ export function ChartsGrid({ items }: { items: Item[] }) {
return (
<>
<div className="flex items-end justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
<LineChartIcon className="w-5 h-5" />
{/* Round 4 Part B.1/B.6: title + subtitle stack above legend on mobile; legend wraps below */}
<div className="flex flex-col gap-3 mb-6">
<div className="flex items-center justify-between gap-3">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
<LineChartIcon className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Machine Charts</h1>
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Machine Charts</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{stats.total} machines · {stats.profitable} profitable · {ZAR2(stats.totalIncome)} total income
</p>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{stats.total} machines · {stats.profitable} profitable · {ZAR2(stats.totalIncome)} total income
</p>
</div>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-3 flex-wrap text-xs">
{legend.map((l) => (
<div key={l.label} className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ background: l.color }} />
@ -85,15 +88,15 @@ export function ChartsGrid({ items }: { items: Item[] }) {
</div>
</div>
{/* Toolbar */}
{/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */}
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
value={query}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-1">
@ -105,7 +108,7 @@ export function ChartsGrid({ items }: { items: Item[] }) {
<button
key={f.id}
onClick={() => setFilter(f.id as typeof filter)}
className={`px-3 h-9 text-xs font-medium rounded-lg transition ${
className={`min-h-[44px] px-3 text-xs font-medium rounded-lg transition ${
filter === f.id
? "bg-[#30638E] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
@ -115,8 +118,8 @@ export function ChartsGrid({ items }: { items: Item[] }) {
</button>
))}
</div>
<div className="flex items-center gap-1 pl-3 border-l border-gray-200 dark:border-white/10">
<span className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-500 mr-1">View</span>
<div className="flex items-center gap-1 sm:pl-3 sm:border-l sm:border-gray-200 sm:dark:border-white/10">
<span className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-500 mr-1 hidden sm:inline">View</span>
{[
{ id: "lines", label: "Lines", icon: LineChartIcon },
{ id: "stack", label: "Cost stack", icon: BarChart3 },
@ -125,7 +128,7 @@ export function ChartsGrid({ items }: { items: Item[] }) {
<button
key={v.id}
onClick={() => setView(v.id as ChartView)}
className={`px-2 h-8 text-xs font-medium rounded transition flex items-center gap-1 ${
className={`min-h-[44px] px-3 text-xs font-medium rounded transition flex items-center gap-1 ${
view === v.id
? "bg-[#003D5B] text-white"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"

View file

@ -27,9 +27,9 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
return (
<>
{/* View toggle + stats */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
{/* Round 4 Part B.1/B.3/B.5: toolbar wraps onto two rows on mobile, controls collapse gracefully */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span className="px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-medium">
{totals.profitable} profitable
</span>
@ -38,11 +38,13 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
</span>
<span className="hidden md:inline">· sorted by {sort}</span>
</div>
<div className="flex items-center gap-2">
{/* Round 4 Part B.3: Sort collapses to a select on mobile. View toggle stays as buttons. */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-white/5">
<button
onClick={() => setView("cards")}
className={`px-3 h-8 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
aria-label="Cards view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "cards"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
@ -52,7 +54,8 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
</button>
<button
onClick={() => setView("grid")}
className={`px-3 h-8 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
aria-label="Grid view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "grid"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
@ -61,7 +64,7 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
<LayoutGrid className="w-3.5 h-3.5" /> Grid
</button>
</div>
<div className="flex items-center gap-1">
<div className="hidden sm:flex items-center gap-1">
<span className="text-xs text-gray-400 dark:text-gray-500 mr-1">Sort:</span>
{[
{ id: "profit", label: "Profit" },
@ -70,7 +73,7 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
<button
key={s.id}
onClick={() => setSort(s.id as typeof sort)}
className={`px-2 h-7 text-xs font-medium rounded transition ${
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
sort === s.id ? "bg-[#EDAE49] text-white" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
@ -78,6 +81,19 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in
</button>
))}
</div>
{/* Part B.3: mobile sort dropdown */}
<div className="sm:hidden">
<label className="sr-only" htmlFor="income-sort">Sort by</label>
<select
id="income-sort"
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
>
<option value="profit">Sort by Profit</option>
<option value="income">Sort by Income</option>
</select>
</div>
</div>
</div>
@ -115,42 +131,63 @@ function CardsView({
>
<button
onClick={() => setExpanded(isOpen ? null : r.id)}
className="w-full flex items-center gap-4 p-4 text-left"
className="w-full text-left p-4"
>
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
{r.code}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white">
{positive ? (
<TrendingUp className="w-3.5 h-3.5 text-[#00798C] shrink-0" />
) : (
<TrendingDown className="w-3.5 h-3.5 text-[#D1495B] shrink-0" />
)}
<span className="truncate">{r.name}</span>
{/* Row 1: code + name + sparkline + YTD + P/L pill + chevron */}
<div className="flex items-center gap-3 sm:gap-4 min-h-[44px]">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
{r.code}
</span>
{/* Round 4 Part B.2: name gets its own line on mobile, never truncated to a single char */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white min-w-0">
{positive ? (
<TrendingUp className="w-3.5 h-3.5 text-[#00798C] shrink-0" />
) : (
<TrendingDown className="w-3.5 h-3.5 text-[#D1495B] shrink-0" />
)}
<span className="truncate">{r.name}</span>
</div>
</div>
<div className="hidden sm:block shrink-0">
<Sparkline data={r.months} color={palette.income} />
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">YTD income</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(r.income)}
</div>
</div>
<div
className={`shrink-0 hidden md:flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
positive
? "bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20"
: "bg-[#D1495B]/10 text-[#D1495B] dark:bg-[#D1495B]/20"
}`}
>
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
<ChevronRight
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
</div>
<div className="hidden sm:block shrink-0">
{/* Row 2 (mobile only): sparkline + YTD + P/L pill */}
<div className="flex sm:hidden items-center justify-between gap-3 pt-2 text-xs">
<Sparkline data={r.months} color={palette.income} />
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">YTD income</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(r.income)}
<div className="text-right">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">YTD income</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.income)}</div>
</div>
<div
className={`shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
positive
? "bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20"
: "bg-[#D1495B]/10 text-[#D1495B] dark:bg-[#D1495B]/20"
}`}
>
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
</div>
<div
className={`shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
positive
? "bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20"
: "bg-[#D1495B]/10 text-[#D1495B] dark:bg-[#D1495B]/20"
}`}
>
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
<ChevronRight
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
</button>
{isOpen && (

View file

@ -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 (
<div className="h-44">
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
@ -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 (
<div className="h-44">
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }} stackOffset="sign">
{/* 2px surface-coloured gap between stacked segments via barCategoryGap + barGap */}
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
{/* Surface-coloured spacer to create the 2px gap — drawn as transparent rect via offset */}
<Bar dataKey="fixed" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />
<Bar dataKey="running" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
@ -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 (
<div className="h-44">
<div className="h-44 sm:h-52">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`gIncome-${view}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={INC} stopOpacity={0.4} />
<stop offset="100%" stopColor={INC} stopOpacity={0.02} />
</linearGradient>
<linearGradient id={`gFixed-${view}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={FIX} stopOpacity={0.35} />
<stop offset="100%" stopColor={FIX} stopOpacity={0.02} />
</linearGradient>
<linearGradient id={`gRunning-${view}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={RUN} stopOpacity={0.3} />
<stop offset="100%" stopColor={RUN} stopOpacity={0.02} />
</linearGradient>
</defs>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
<Area type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} fill={`url(#gIncome-${view})`} connectNulls={false} />
<Area type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} fill={`url(#gFixed-${view})`} connectNulls={false} />
<Area type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} fill={`url(#gRunning-${view})`} connectNulls={false} />
</AreaChart>
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
<Line type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} dot={{ r: 3, fill: FIX }} connectNulls={false} />
<Line type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} dot={{ r: 3, fill: RUN }} connectNulls={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -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"
/>
</div>
</div>
{/* Type filter pills */}
<div className="flex flex-wrap gap-1.5 mb-4">
{types.map((t) => {
const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length;
return (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
typeFilter === t
? "bg-[#003D5B] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{t} <span className="ml-1 text-[10px] opacity-60">({count})</span>
</button>
);
})}
{/* Type filter — Round 4 Part B.3: pills on lg+, dropdown below lg to avoid 13-row wrap */}
<div className="mb-4">
{/* Desktop: pills */}
<div className="hidden lg:flex flex-wrap gap-1.5">
{types.map((t) => {
const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length;
return (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`min-h-[44px] px-3 rounded-lg text-xs font-medium transition ${
typeFilter === t
? "bg-[#003D5B] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{t} <span className="ml-1 text-[10px] opacity-60">({count})</span>
</button>
);
})}
</div>
{/* Mobile: dropdown */}
<div className="lg:hidden">
<label className="sr-only" htmlFor="type-filter">Filter by type</label>
<select
id="type-filter"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="w-full min-h-[44px] px-3 text-sm rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
>
{types.map((t) => {
const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length;
return <option key={t} value={t}>{t} ({count})</option>;
})}
</select>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-white/5">
{/* Table — Round 4 Part B.4: sticky first column + touch scroll + right-edge fade */}
<div
className="overflow-x-auto rounded-lg border border-gray-100 dark:border-white/5 relative"
style={{ WebkitOverflowScrolling: "touch" }}
>
<table className="w-full text-sm">
<thead className="sticky top-0 z-10">
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-white/10 bg-gray-50/95 dark:bg-[#1a222b]/95 backdrop-blur">
@ -137,7 +158,7 @@ export function PlantRegisterTable({ assets }: { assets: Asset[] }) {
<tbody>
{filtered.map((a) => (
<tr key={a.id} className="border-b border-gray-50 dark:border-white/5 hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<td className="py-2.5 px-3">
<td className="py-2.5 px-3 sticky left-0 z-10 bg-white dark:bg-[#131a21]">
{a.is_provisional && (
<AlertTriangle className="inline w-3 h-3 text-[#EDAE49] mr-1" aria-label="provisional code" />
)}

View file

@ -30,18 +30,19 @@ export function RunningView({
return (
<>
<div className="flex flex-wrap items-center justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium">
{rows.filter((r) => r.ytd > 0).length} machines with costs
</span>
<span className="hidden md:inline">· sorted by {sort === "ytd" ? "total spend" : "code"}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-white/5">
<button
onClick={() => setView("cards")}
className={`px-3 h-8 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
aria-label="Cards view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "cards"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
@ -51,7 +52,8 @@ export function RunningView({
</button>
<button
onClick={() => setView("grid")}
className={`px-3 h-8 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
aria-label="Grid view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "grid"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
@ -60,7 +62,7 @@ export function RunningView({
<LayoutGrid className="w-3.5 h-3.5" /> Grid
</button>
</div>
<div className="flex items-center gap-1">
<div className="hidden sm:flex items-center gap-1">
<span className="text-xs text-gray-400 dark:text-gray-500 mr-1">Sort:</span>
{[
{ id: "ytd", label: "Total" },
@ -69,7 +71,7 @@ export function RunningView({
<button
key={s.id}
onClick={() => setSort(s.id as typeof sort)}
className={`px-2 h-7 text-xs font-medium rounded transition ${
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
sort === s.id ? "bg-[#EDAE49] text-white" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
@ -77,6 +79,18 @@ export function RunningView({
</button>
))}
</div>
<div className="sm:hidden">
<label className="sr-only" htmlFor="running-sort">Sort by</label>
<select
id="running-sort"
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
>
<option value="ytd">Sort by Total</option>
<option value="code">Sort by Code</option>
</select>
</div>
</div>
</div>

View file

@ -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",

View file

@ -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.

View file

@ -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({

View file

@ -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({

View file

@ -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<vat, Buildsmart incl<excl). Seed flagged — needs client fix.");
console.log(" • Workbook grand total R 1,110,486.87 ≠ sum of its own rows (R 1,096,009.31). Diff R 14,477.56.");
console.log(" • Vehicle Costs subtotal excludes Santam. App R 9,673.87 is correct; workbook is wrong.");
console.log(" • Personnel: workbook's own total R 412,000, but R 157,200 + R 235,000 = R 392,200. Diff R 19,800.");
console.log(" • Subscriptions: 2 rows have broken VAT (Charl - CCS incl<vat, Buildsmart incl<excl). Seed now EXITS NON-ZERO — needs client fix.");
console.log(" • Workbook Vehicle Costs subtotal (row 50) skips Santam. App keeps Santam → R 14,996.31 (matches row-by-row sum).");
console.log(" • Workbook grand total R 1,110,486.87 - Plant instalments R 415,523.94 = R 694,962.93 (target).");
process.exit(exitCode);
}