# 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](./multi-site-assessment.md#1-hard-50-site-cap-on-the-overview-page)), `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=''" })` 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](./multi-site-assessment.md#2-load-everything-then-filter-in-memory)). **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`](./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`](./operations.md#1-environment-variables)). **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](./multi-site-assessment.md#7-the-chatagent-layer).