- Overview/Plant tiles and pages share one vehicle bucket rule (blank unit_type = fleet) - Plant landing equipment figures were always 0; hour diffs now reject 0 baselines and >24h/day jumps - km/L limits on plant (and L/h on vehicles) report unit-mismatch instead of false alerts - formatPercent/formatDecimal replace display toFixed (en-ZA) - POST /api/vehicles/[id]/merge + Merge duplicate dialog (dry run first) - Attention links go to equipment vs vehicles by unit type; PO upload view link fixed - Conditional hook fixed in DN document view; ESLint flat config (next lint removed in 16) - Docs: current modules/routes/env vars; login + Ask reports plans Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
21 KiB
Modules
One section per dashboard module. For each: what it is, where it lives, what it reads, what it computes, what the user can do there, and which API routes it talks to.
The sidebar (src/components/layout/sidebar.tsx, the NAV array) has
Overview, Sites, three groups — Workforce, Plant, Financial —
and Ask. The MEGA Clock phone PWA at /clock sits outside the sidebar.
Old flat paths (/dashboard/employees, /dashboard/fuel[/id],
/dashboard/purchasing[/id], /dashboard/plant/[id]) are thin
redirect() pages that keep ?site=.
The "Ask" chat is intentionally distinct — it reads through a tool layer
rather than the dashboard's per-page data fetching.
0. Shared metrics surface
Every page that computes money or hours imports from @/lib/metrics
(src/lib/metrics/index.ts). The single barrel re-exports seven pure
modules so that the Sites list, the Site detail page, the Overview
monthly tile, the chat tool layer, and the MCP server all agree to
the last digit on the same numbers.
| Module | Pure? | What it does |
|---|---|---|
constants.ts |
✅ | RATE_PER_LITRE, MS_PER_MONTH, MS_PER_WEEK, DEFAULT_SITE_WEEKLY_HOURS, WORKING_DAYS_PER_MONTH |
duration.ts |
✅ | parseDurationMonths, isoWeekKey, monthKey — all injectable now for test determinism |
site-start-date.ts |
✅ | resolveSiteStartDate — explicit job_start_date wins; else earliest of fixed costs / timesheets / fuel / income |
site-spend.ts |
✅ | computeSiteSpend — fixed + one-off + payroll + material + fuel → { totalSpent, payrollByEmployee, ... } |
site-projection.ts |
✅ | computeSiteProjection — burn %, run-rate, status (upcoming / just-started / under / on-track / over) |
income-vs-expenses.ts |
✅ | buildWeeklyIncomeVsExpenses, buildMonthlyIncomeVsExpenses, buildDailyFuelCost |
fuel-attention.ts |
✅ | computeFuelLogStatuses, groupAttentionBySite — the canonical per-log variance pass |
delivery-note-value.ts |
✅ | computeDeliveryNoteValue — the rand value of a DN (qty × rate, with unmatched-item penalty) |
Plus the small partition helper src/lib/partition-fixed-costs.ts that
splits fixed costs into monthly vs one-off buckets.
The point: every figure that shows up in a KPI tile, a chart, a P&L, or a chat answer is the output of one of these functions. The risk to track is "two surfaces computing the same thing differently" — a regression in that, not in the page rendering, is the failure mode.
1. Overview
Path: /dashboard → src/app/dashboard/page.tsx
Render: server component, force-dynamic
Mounted at: Always (root of the dashboard)
What it shows. Six KPI tiles (Sites / Employees / Vehicles / Plant / Fixed costs (this month) / Pending verification), the "Attention required" card (vehicles over their fuel limit, grouped by site), and a list of every site with its contract value and the current month's fixed-cost burn.
What it reads. sites (capped at 50 — see
multi-site-assessment §1),
employees (counts only), vehicles (count where cost_per_hour>0),
fixed_costs (full list, this-month filter in JS), fuel_logs (full list
with expand: "vehicle", drives the attention roll-up), purchase_orders
and delivery_notes (count where is_verified=false).
What it computes. The "Attention required" grouping is
groupAttentionBySite(computeVehicleAttention(...), { excludeAcknowledged: true })
— the same pure function the chat agent's attention tool uses, so the
Overview card and the chat answer agree on what's wrong.
What the user can do. Click a flagged vehicle → its fuel drill-down at
/dashboard/fuel/[vehicleId]. Edit a site → the inline edit dialog on the
sites list. Otherwise: read-only.
API routes called: none. Everything is server-rendered.
2. Sites
Path: /dashboard/sites and /dashboard/sites/[id]
Render: server components, force-dynamic
Files: src/app/dashboard/sites/page.tsx, [id]/page.tsx, sites-view.tsx, [id]/site-detail-view.tsx
What it shows.
- List page — every site's profitability summary: contract value, total
spent, burn %, remaining budget, projected final cost and profit, current
status (
upcoming/just-started/under/on-track/over). - Detail page — the full P&L for one site: monthly + one-off fixed costs (each with daily cost, recurring flag, notes), top-5 payroll by employee, every PO sorted by PO number, weekly + monthly income-vs-expense charts, daily fuel cost, plant utilisation, and the income-claim history.
What it reads. Sites list: sites (full), fixed_costs (full),
timesheets (full), employees (fields-projected to id,rate_per_hour,site,name),
purchase_orders (fields-projected to id,site,total_value,is_verified),
fuel_logs (fields-projected to id,site,litres,date_logged), income_claims
(fields-projected to id,site,paid_on,period_end).
Site detail: per-site getFullList({ filter: "site='<id>'" }) for fixed costs,
POs, fuel logs, vehicles, income — but timesheets and employees are
fetched unfiltered and bucketed in JS (the line-comment at
src/app/dashboard/sites/[id]/page.tsx:48-57 flags this as a known
load-everything pattern; see multi-site-assessment §2).
What it computes. The list page calls
computeSiteSpend → computeSiteProjection for every site in a tight loop.
The detail page adds buildWeeklyIncomeVsExpenses,
buildMonthlyIncomeVsExpenses, and buildDailyFuelCost over the same data.
What the user can do.
- Add a new site (
AddSiteButton,src/components/site-form-dialog.tsx). The form is template-aware: every existing site is offered as a clone source. - Edit an existing site (
EditSiteButtonon the detail page). - Add a one-off or recurring fixed cost (
FixedCostDialog).
API routes called. The list page calls nothing — read-only.
The detail page calls POST /api/sites/[id]/fixed-costs for the cost entry
form, and the edit dialog calls PATCH /api/sites/[id].
3. Workforce
Path: /dashboard/workforce/workers → src/app/dashboard/workforce/workers/page.tsx + employees-view.tsx
(worker detail with QR badge: workers/[employeeId]; all entries: /dashboard/workforce/timesheets; landing: /dashboard/workforce)
Render: server component, force-dynamic
What it shows. Per-employee weekly hours (8-week sparkline), this-month hours, total cost, last-30-days hours, position and site. Plus a 12-week workforce-trend chart across all employees.
What it reads. employees (full, with expand: "site"), timesheets
(full), sites (full). Buckets in JS for the per-employee roll-up and the
weekly/monthly aggregates.
What it computes. buildWeeklyHours (per-employee, last 8 weeks) and
buildWeeklyTrend (company-wide, last 12 weeks) — both anchored to Monday
of the current week. sumHoursInWindow for the 30-day and calendar-month
totals.
What the user can do. Import a timesheet spreadsheet (ImportTimesheetsButton,
which posts to POST /api/timesheets/import). Per-row actions live in the
view component; for v1 this is read-only plus import.
API routes called. POST /api/timesheets/import (with optional
siteId and createMissingEmployees=true form fields).
Not implemented yet. Edit rates, change worker site, archive terminated workers. All those are currently PocketBase admin operations.
4. Plant & Equipment
Path: /dashboard/plant/equipment and /dashboard/plant/equipment/[vehicleId] (landing: /dashboard/plant)
Render: server component, force-dynamic
Files: src/app/dashboard/plant/equipment/page.tsx, [vehicleId]/page.tsx, plant-view.tsx
What it shows.
- List page — every plant item (
unit_type="plant") with total hours and total value derived from fuel_logs (the source of truth for both plant and fuel; plant usesodo_readingas the hour-meter end-of-shift value, so "hours" is the diff between consecutive readings). - Detail page — the hour-meter history for one plant item, week-by-week, with budget vs actual and any over-budget flags.
What it reads. vehicles (filter PLANT_FILTER = unit_type="plant"
from src/lib/vehicle-buckets.ts), fuel_logs (full), sites.
Vehicle buckets. Every surface splits vehicles with the one rule in
src/lib/vehicle-buckets.ts: unit_type="plant" is plant (L/h); anything
else — including legacy blank rows — is fleet (km/L). The Overview tiles,
Plant landing, Vehicles and Equipment pages all use it, so each vehicle is
counted and listed exactly once.
What it computes. Per-vehicle: sort logs by (date_logged, odo_reading)
ascending, then sum the hours between consecutive readings with
hoursBetweenReadings (src/lib/metrics/plant-hours.ts). A pair counts
as 0 h — rejected — when the earlier reading is 0, the meter went
backwards, or it advanced more than 24 h per calendar day (a single
0 → 32 623 reading used to be billed as 32 623 hours). Multiply by
cost_per_hour for the value. The page-level KPIs (total / billable / avg
rate) come from the already-aggregated rows.
Wrong-unit limits. A plant item whose fuel_limits says km/L (or a
vehicle whose limit says L/h) shows an amber Wrong unit badge in the
lists and a banner on its detail page; see §5.
Merging duplicates. Each vehicle / equipment detail page has a
Merge duplicate button: pick the record that is really the same
machine, preview what moves, confirm. Its fuel logs and plant-hours rows
move onto the page's vehicle, blank fields are filled from the duplicate,
and the duplicate is deleted. The duplicate's QR sticker stops working —
the dialog says which one. Blocked when the two are on different sites,
different types, or both have plant hours for the same week.
POST /api/vehicles/[id]/merge.
What the user can do. Add a plant (AddPlantButton posts to
POST /api/vehicles). Edit a plant's description, project_code, or
unit_type (PATCH /api/vehicles/[id]). Delete a plant (only when
plant_hours_log has no rows pointing at it; the DELETE /api/vehicles/[id]
route surfaces the FK constraint as a 409 with a friendly message).
Sibling pages.
/dashboard/plant/vehicles— same data shape, filtered tounit_type="fuel". The fleet bucket (L/km) — see §5./dashboard/plant/equipment— same data shape, filtered tounit_type="plant". The plant bucket (L/h)./dashboard/plant/fuel-logs— flat list of everyfuel_logsrow across both buckets./dashboard/plant/map— Leaflet map of every vehicle that has been scanned from the MEGA Clock kiosk, plotted at itslast_*position (denormalised offfuel_logsby/api/clock/vehicle-scanon every successful scan). The map is the only surface on this page — vehicle details live in marker popups (click to expand). Custom SVG markers: car silhouette forunit_type="fuel", tractor silhouette forunit_type="plant", 24×24. Clustering viareact-leaflet-clusterover the standardLeaflet.markerclusteralgorithm; clusters colour-ramp with size and spiderfy out when fully zoomed in. A three-button segmented control in the top-right swaps the base tiles between Street (OpenStreetMap), Dark (ESRI Dark Gray Canvas — free, no API key, the CarCartoDB Dark Matter tileset watermarks every tile with "API KEY REQUIRED" without one) and Satellite (ESRI World Imagery, also free). Sites get a smaller purple pin marker; clicking a site pin flies the map to zoom level 15 (single-site detail).
API routes called. POST /api/vehicles, PATCH /api/vehicles/[id],
DELETE /api/vehicles/[id], POST /api/plant-hours/import (separate
week-by-week import), GET /api/plant-hours/exception-report/[sheetId],
DELETE /api/plant-hours/[id], POST /api/clock/vehicle-lookup,
POST /api/clock/vehicle-scan (writes the last_* denormalisation
that powers the map).
5. Fleet & Fuel
Path: /dashboard/plant/vehicles and /dashboard/plant/vehicles/[vehicleId]
Render: server component, force-dynamic
Files: src/app/dashboard/plant/vehicles/page.tsx, [vehicleId]/page.tsx, fuel-view.tsx
What it shows.
- List page — every fleet vehicle (
FLEET_FILTER=unit_type!="plant", so legacy blank rows are included), per-vehicle summary (litres, spend, flagged count, avg km/L if applicable), every fuel log row (date, odo, litres, latest_consumption, variance vs limit, status). - Detail page — the fuel history for one vehicle, with the same attention flags plus the dismiss button.
What it reads. vehicles (full, fleet bucket), fuel_logs (full,
expand: "vehicle").
What it computes. The single shared variance pass via
computeFuelLogStatuses in src/lib/metrics/fuel-attention.ts. Variance
is computed pairwise — each log is compared to the previous log for the
same vehicle, sorted by (date_logged, odo_reading). Statuses come from
computeFuelVariance in src/lib/fuel-variance.ts (km/L limits only —
L/h limits are not variance-checked yet and report n/a):
- at or above the km/L target →
ok - 0–20% below target →
warn - more than 20% below target →
alert - no limit / no previous reading →
no-limit/no-baseline - limit unit does not match the meter (km/L on plant, L/h on a vehicle)
→
unit-mismatch. These vehicles get no alerts (the variance would be meaningless) but appear in the Overview "Attention required" card as Fix limit unit, and are never hidden by the acknowledge button.
What the user can do. Acknowledge an alert
(PATCH /api/vehicles/[id]/attention-dismiss sets attention_dismissed_at).
Import a fuel spreadsheet (ImportFuelLogsButton posts to
POST /api/fuel-logs/import).
API routes called. POST /api/fuel-logs/import,
PATCH /api/vehicles/[id]/attention-dismiss.
6. Purchasing
Path: /dashboard/financial/purchase-orders and /dashboard/financial/purchase-orders/[poId]
Render: server component, force-dynamic
Files: src/app/dashboard/financial/purchase-orders/page.tsx, [poId]/page.tsx, purchasing-view.tsx
What it shows.
- List page — every PO with supplier, value, project, fulfilment %, pending delivery note count, plus the "needs review" DN panel for delivery notes the OCR pipeline couldn't match.
- Detail page — every PO line, the received items grouped by DN, and the per-DN OCR result metadata.
What it reads. purchase_orders (full, expand: "site"),
purchase_order_items (full), delivery_notes (full plus a separate
getList(1, 200) for the needs-review bucket), sites (full),
received_items (full, projected to id,purchase_order_item,qty_received).
The comment at src/app/dashboard/purchasing/page.tsx:27-31 documents
that this used to be a 500-row getList that silently truncated and
produced 0% fulfilment whenever low-id POs were dropped — fixed in 2026.
What it computes. Per-PO fulfilment % from received_items.qty_received
(joined by purchase_order_item id), clamped to the ordered qty. PO-level
KPIs: active count, committed spend, fulfilment %, pending PO count,
pending DN count.
What the user can do.
- Upload a PO (
UploadPurchaseOrderButtonposts toPOST /api/purchase-orders/upload, then a UI poll againstGET /api/data-import-batches/[id]/status). - Upload a delivery note (
UploadDeliveryNoteButtonposts toPOST /api/delivery-notes/upload). - Manually assign a PO to a delivery note that OCR couldn't match
(
assign-poroute viaNeedsReviewDnspanel). - Verify a record (
POST /api/records/[collection]/[id]/verify). - Retry a failed import batch (
POST /api/data-import-batches/[id]/retry).
API routes called. POST /api/purchase-orders/upload,
GET /api/data-import-batches/[id]/status,
POST /api/data-import-batches/[id]/retry,
POST /api/delivery-notes/upload,
POST /api/delivery-notes/[id]/assign-po,
POST /api/records/[collection]/[id]/verify,
GET /api/purchase-orders/by-number/[poNumber]/items,
PATCH /api/purchase-orders/[id]/supplier.
7. Ask (chat)
Path: /dashboard/chat → src/app/dashboard/chat/page.tsx
Render: server component (gated by chatEnabled()) with a
client-side stream consumer
Files: src/lib/chat/, src/lib/agent/, src/app/dashboard/chat/
What it shows. A chat panel that streams a model's response as
Server-Sent Events. The model calls a read-only tool layer backed by the
same metrics functions the dashboard uses.
What it reads. The model never reads directly. Each tool call goes
through src/lib/agent/load.ts (the only module in agent/ that touches
the network) and into the metrics helpers.
What it computes. Whatever the tools return. The system prompt
(src/lib/chat/system-prompt.ts) bans the model from doing any
arithmetic — every figure comes from a metric, every money string is
pre-formatted. The "you must not compute" rule is the load-bearing
correctness property of this module.
Tools exposed (full list in src/lib/agent/tools.ts:125-...):
list_sites— portfolio financialssite_financials— one site's full P&Lattention— current vehicle flagslist_purchase_orders,purchase_order— PO lookuplist_delivery_notes,delivery_note— DN lookuplist_vehicles,vehicle_fuel_logs— fleet drill-downworkforce_hours— payroll and weekly hourssite_expenses/site_income— focused viewsimport_batches— what the importers are doing right now
Each tool's free-text site query is resolved through resolveSite first —
ambiguity is returned as an error string that the model relays to the user
verbatim.
What the user can do. Ask questions. Upload an image with the message
(vision is model-gated — see the MiniMax-M3 note in
operations.md §1).
API routes called. POST /api/chat (Server-Sent Events stream).
Gating. Both the page (src/app/dashboard/chat/page.tsx:31) and the
route (src/app/api/chat/route.ts:69-71) 404 unless ENABLE_CHAT=true.
The .env.example comment is explicit: "MUST stay unset in production until
real authentication exists — the chat reads every site's financials and
spends LLM credits for anyone who can reach it." See
multi-site-assessment §7.
8. Financial landing, Delivery Notes, Suppliers
Financial (/dashboard/financial) — open PO value (processed POs
only), slips received, and uploads waiting on an operator.
Delivery Notes (/dashboard/financial/delivery-notes) — the staged
pipeline's UI. A document (photo, PDF, multi-page scan) becomes a
dn_batches row; the tick route renders pages, the vision model reads
them, and each note becomes a candidate that is only turned into
delivery_notes + received_items when an operator posts it.
| Page | Use |
|---|---|
/delivery-notes |
Every slip and its status, plus the review queue |
/delivery-notes/inbox |
Every uploaded document and where it is in the process |
/delivery-notes/inbox/[id] |
One batch: settle page grouping, dismiss non-DN pages, rerun |
/delivery-notes/work-queue |
Supplier / PO / line-item exceptions to resolve before posting |
/delivery-notes/document/[id] |
One candidate note beside its slip and the PO: match lines, post, correct a posted receipt |
API: /api/dn-batches/** (see api-reference.md §12a).
Rollout and the DN_STAGED_INGESTION_ENABLED switch:
operations.md §8.
Suppliers (/dashboard/financial/suppliers[/[supplierId]]) — ordered,
delivered, and outstanding per supplier, computed live from POs and
received items. Add / rename via POST /api/suppliers and
PATCH /api/suppliers/[id]; names are stored normalised so PO uploads
match them exactly.
9. On site now + MEGA Clock
On site now (/dashboard/workforce/on-site) — today's attendance
from clock_events, in four columns: On site now, Clocked out today, Not arrived today (regulars
seen in the last 7 days), and Needs attention — earlier-day IN
scans with no OUT. Clicking a Needs-attention card opens
correction-dialog.tsx, which posts the real leaving time and a reason to
POST /api/clock/events/[id]/correct (needs server APP_PIN). The
original scan is kept; the correction is its own audit row. Details:
clock-daily-exceptions-rollout.md.
MEGA Clock (/clock, src/app/clock/) — the installable phone PWA
used on site. Three tabs:
- Person (
person-scanner.tsx) — scan a worker's badge QR →GET /api/clock/employee-lookup→ confirm →POST /api/clock/scan. The day boundary isAfrica/Johannesburgmidnight. - Fleet (
fleet-scanner.tsx) — scan a vehicle QR, capture the hour meter / odometer and GPS →/api/clock/vehicle-lookup+/api/clock/vehicle-scan(feeds the Plant map). - Delivery (
document-scanner.tsx) — photograph a multi-page slip, check it is readable, pick the nearest site, and upload it as one batch toPOST /api/delivery-notes/upload→ Delivery Notes inbox.