MEGAINFRA-WEBAPP/docs/modules.md
DEIs 87f75dd98f feat(plant): fleet map page with GPS-denormalised vehicles, clustering, and tile switcher
Adds /dashboard/plant/map: a Leaflet view of every vehicle scanned from
the MEGA Clock kiosk, plotted at its last known position.

Schema
- vehicles.last_latitude / last_longitude / last_scanned_at /
  last_location_accuracy_m denormalised off fuel_logs so the map page
  renders every vehicle in a single query. Same best-effort pattern as
  attention_dismissed_at; updated by /api/clock/vehicle-scan after every
  successful scan. PB init defaults new number cols to 0 (not null) —
  filter uses 'last_latitude > 0 || last_latitude < 0' to mean 'real fix'.
- Indexed via idx_vehicles_last_scanned.

Map UI
- Custom SVG markers per unit_type: car silhouette for fuel, tractor
  silhouette for plant, 24x24, drop-shadowed.
- Clustering via react-leaflet-cluster over Leaflet.markercluster.
  Cluster icons colour-ramp with size (green->sky->amber->rose) and
  scale 32->54px.
- Three-button base-layer switcher: Street (OSM), Dark (ESRI Dark
  Gray Canvas — CartoDB watermarks tiles without a key), Satellite
  (ESRI World Imagery). Last choice persists in localStorage.
- Site pins: small purple map-marker divIcon, click flies the map to
  zoom level 15.
- Empty-state is a floating chip on the map rather than a full-page
  card so the map is always visible.

APIs
- /api/clock/vehicle-scan: stores the GPS fix on the fuel_logs row
  AND denormalises onto the vehicle when present; missing-coord
  scan leaves last_* untouched.

Sidebar: new Map entry under Plant group.
2026-09-22 21:39:25 +02:00

16 KiB
Raw Blame History

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:35-43) lists seven modules. 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 (EditSiteButton on 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/employees → src/app/dashboard/employees/page.tsx 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 and /dashboard/plant/[vehicleId] Render: server component, force-dynamic Files: src/app/dashboard/plant/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 uses odo_reading as 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 unit_type="plant" || unit_type="" || unit_type=null, perPage: 5000), fuel_logs (full, perPage: 5000), sites (perPage: 5000).

What it computes. Per-vehicle: sort logs by (date_logged, odo_reading) ascending, then sum the consecutive odo diffs. Multiply by cost_per_hour for the value. The page-level KPIs (total / billable / avg rate) come from the already-aggregated rows.

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 to unit_type="fuel". The fleet bucket (L/km) — see §5.
  • /dashboard/plant/equipment — same data shape, filtered to unit_type="plant". The plant bucket (L/h).
  • /dashboard/plant/fuel-logs — flat list of every fuel_logs row across both buckets.
  • /dashboard/plant/map — Leaflet map of every vehicle that has been scanned from the MEGA Clock kiosk, plotted at its last_* position (denormalised off fuel_logs by /api/clock/vehicle-scan on 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 for unit_type="fuel", tractor silhouette for unit_type="plant", 24×24. Clustering via react-leaflet-cluster over the standard Leaflet.markercluster algorithm; 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/[batchId], 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/fuel and /dashboard/fuel/[vehicleId] Render: server component, force-dynamic Files: src/app/dashboard/fuel/page.tsx, [vehicleId]/page.tsx, fuel-view.tsx

What it shows.

  • List page — every fuel vehicle (unit_type="fuel" || unit_type="" || unit_type=null), 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). Variance buckets come from src/lib/fuel-variance.ts:

  • ≤ 12% → ok
  • 12–24% → watch
  • 24–48% → alert
  • 48% → critical (terminology in code: warn for watch, alert for both alert and critical — the four-bucket table in the old README is legacy; see operations.md for the current source).

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/purchasing and /dashboard/purchasing/[poId] Render: server component, force-dynamic Files: src/app/dashboard/purchasing/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 (UploadPurchaseOrderButton posts to POST /api/purchase-orders/upload, then a UI poll against GET /api/data-import-batches/[id]/status).
  • Upload a delivery note (UploadDeliveryNoteButton posts to POST /api/delivery-notes/upload).
  • Manually assign a PO to a delivery note that OCR couldn't match (assign-po route via NeedsReviewDns panel).
  • 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 financials
  • site_financials — one site's full P&L
  • attention — current vehicle flags
  • list_purchase_orders, purchase_order — PO lookup
  • list_delivery_notes, delivery_note — DN lookup
  • list_vehicles, vehicle_fuel_logs — fleet drill-down
  • workforce_hours — payroll and weekly hours
  • site_expenses / site_income — focused views
  • import_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.