MEGAINFRA-WEBAPP/docs/modules.md
DEls 08ed89d9c8 feat(dashboard): enhance operations dashboard with rain outlook and coordinate support
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.
2026-09-06 19:59:52 +02:00

319 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).