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:
parent
7194e78079
commit
0516220110
30 changed files with 880 additions and 155 deletions
39
.dockerignore
Normal file
39
.dockerignore
Normal 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
54
AGENTS.md
Normal 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
97
COOLIFY.md
Normal 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
87
Dockerfile
Normal 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
243
HANDOFF-PROMPT-4.md
Normal 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
13
app/api/health/route.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
15
lib/pb.ts
15
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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue