Updated the operations dashboard to include a rain outlook feature, providing users with a 7-day forecast based on site coordinates. Added latitude and longitude fields to the site form for better weather data integration. Enhanced the fuel log and site management functionalities to utilize dynamic fuel pricing based on historical data. Improved overall data handling and user experience across various dashboard components.
319 lines
15 KiB
Markdown
319 lines
15 KiB
Markdown
# 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='<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](./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).
|
||
|
||
**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]`.
|
||
|
||
---
|
||
|
||
## 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).
|