2026-09-06 17:59:52 +00:00
|
|
|
|
# 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.
|
|
|
|
|
|
|
2026-09-25 20:04:08 +00:00
|
|
|
|
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=`.
|
2026-09-06 17:59:52 +00:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**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`)
|
2026-09-06 17:59:52 +00:00
|
|
|
|
**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
|
|
|
|
|
|
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**Path:** `/dashboard/plant/equipment` and `/dashboard/plant/equipment/[vehicleId]` (landing: `/dashboard/plant`)
|
2026-09-06 17:59:52 +00:00
|
|
|
|
**Render:** server component, `force-dynamic`
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**Files:** `src/app/dashboard/plant/equipment/page.tsx`, `[vehicleId]/page.tsx`, `plant-view.tsx`
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**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.
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
**What it computes.** Per-vehicle: sort logs by `(date_logged, odo_reading)`
|
2026-09-25 20:04:08 +00:00
|
|
|
|
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`.
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
**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).
|
|
|
|
|
|
|
2026-09-22 19:39:25 +00:00
|
|
|
|
**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).
|
|
|
|
|
|
|
2026-09-06 17:59:52 +00:00
|
|
|
|
**API routes called.** `POST /api/vehicles`, `PATCH /api/vehicles/[id]`,
|
|
|
|
|
|
`DELETE /api/vehicles/[id]`, `POST /api/plant-hours/import` (separate
|
2026-09-25 20:04:08 +00:00
|
|
|
|
week-by-week import), `GET /api/plant-hours/exception-report/[sheetId]`,
|
2026-09-22 19:39:25 +00:00
|
|
|
|
`DELETE /api/plant-hours/[id]`, `POST /api/clock/vehicle-lookup`,
|
|
|
|
|
|
`POST /api/clock/vehicle-scan` (writes the `last_*` denormalisation
|
|
|
|
|
|
that powers the map).
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 5. Fleet & Fuel
|
|
|
|
|
|
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**Path:** `/dashboard/plant/vehicles` and `/dashboard/plant/vehicles/[vehicleId]`
|
2026-09-06 17:59:52 +00:00
|
|
|
|
**Render:** server component, `force-dynamic`
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**Files:** `src/app/dashboard/plant/vehicles/page.tsx`, `[vehicleId]/page.tsx`, `fuel-view.tsx`
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
**What it shows.**
|
2026-09-25 20:04:08 +00:00
|
|
|
|
- **List page** — every fleet vehicle (`FLEET_FILTER` = `unit_type!="plant"`, so legacy blank rows are included),
|
2026-09-06 17:59:52 +00:00
|
|
|
|
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
|
2026-09-25 20:04:08 +00:00
|
|
|
|
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.
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
**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
|
|
|
|
|
|
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**Path:** `/dashboard/financial/purchase-orders` and `/dashboard/financial/purchase-orders/[poId]`
|
2026-09-06 17:59:52 +00:00
|
|
|
|
**Render:** server component, `force-dynamic`
|
2026-09-25 20:04:08 +00:00
|
|
|
|
**Files:** `src/app/dashboard/financial/purchase-orders/page.tsx`, `[poId]/page.tsx`, `purchasing-view.tsx`
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
**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.
|
|
|
|
|
|
|
2026-09-26 07:57:28 +00:00
|
|
|
|
**Tools exposed** (all read-only; definitions in `src/lib/agent/tools.ts`,
|
|
|
|
|
|
also served by the MCP server `src/mcp/server.ts`):
|
|
|
|
|
|
|
|
|
|
|
|
- Lookups: `list_sites`, `get_site_financials`, `get_site_trend`,
|
|
|
|
|
|
`vehicles_needing_attention`, `get_fuel_summary`, `get_vehicle_fuel_log`,
|
|
|
|
|
|
`get_pending_verification`, `get_purchase_order`, `list_purchase_orders`,
|
|
|
|
|
|
`get_delivery_note`, `list_delivery_notes`, `list_plant_hours`,
|
|
|
|
|
|
`list_fixed_costs`, `list_income_claims`, `list_employees`, `top_workers`,
|
|
|
|
|
|
`list_imports`.
|
|
|
|
|
|
- Knowledge tools (2026-09): `attendance_today` (the On-site page's grouping,
|
|
|
|
|
|
`src/lib/attendance.ts`), `supplier_balances` (the Suppliers page's roll-up,
|
|
|
|
|
|
`src/lib/metrics/supplier-balance.ts`), `delivery_note_queue` (inbox board
|
|
|
|
|
|
counts via `src/lib/dn-pipeline/board.ts` + open work-queue exceptions),
|
|
|
|
|
|
`plant_equipment_summary` (guarded `hoursBetweenReadings`, `isPlant`;
|
|
|
|
|
|
matches the Equipment page's hours and value), `rain_outlook`
|
|
|
|
|
|
(`loadRainOutlook`, as on the Overview rain card).
|
|
|
|
|
|
- Report tools (return charts): `site_report`, `portfolio_report`,
|
|
|
|
|
|
`problem_report`, `improvement_report`. Pure builders live in
|
|
|
|
|
|
`src/lib/reports/` (`site-report.ts`, `portfolio-report.ts`,
|
|
|
|
|
|
`problems.ts`, `improvements.ts`, `findings.ts`); `src/lib/reports/load.ts`
|
|
|
|
|
|
is that folder's only network code. `list_sites` and `portfolio_report`
|
|
|
|
|
|
share `computeSiteSummaries` (`src/lib/reports/site-summary.ts`).
|
|
|
|
|
|
|
|
|
|
|
|
**Findings.** Problem and improvement reports return `Finding`s
|
|
|
|
|
|
(`{ id, severity: high|medium|low, area, title, detail, href?, site? }`),
|
|
|
|
|
|
ranked high → low, plus a `summary` count per severity. Every title and
|
|
|
|
|
|
detail is display-ready; a rule that spans sites returns one finding with
|
|
|
|
|
|
the total in its title and the per-site split in its detail, so the model
|
|
|
|
|
|
never adds counts. A site "on track" but projecting a loss is flagged as a
|
|
|
|
|
|
high-severity loss, and its status KPI carries a "Projected loss" note.
|
|
|
|
|
|
|
|
|
|
|
|
**Charts.** A report tool returns `charts: ChartSpec[]`
|
|
|
|
|
|
(`src/lib/reports/chart-spec.ts`: `id`, `kind` bar/line/area/donut,
|
|
|
|
|
|
`title`, `xKey`, `series[{ key, label, format }]`, `rows`). The chat route
|
|
|
|
|
|
runs every tool result through `extractCharts`: the specs are streamed to
|
|
|
|
|
|
the browser as `chart` SSE events and the model receives only
|
|
|
|
|
|
`chartsAvailable: { id, title }[]` — it never sees a chart's numbers. The
|
|
|
|
|
|
model places a chart by writing `[[chart:<id>]]` alone on a line;
|
|
|
|
|
|
`splitAnswer` (`src/lib/reports/answer-parts.ts`) swaps the token for
|
|
|
|
|
|
`<ReportChart>` (`src/app/dashboard/chat/report-chart.tsx`, Recharts).
|
|
|
|
|
|
Tokens for unknown ids stay as text; charts the model never placed are
|
|
|
|
|
|
appended after the answer finishes. Each chart has a Table view and a
|
|
|
|
|
|
Download CSV button (raw numbers). Colours are the `--series-N` slots on
|
|
|
|
|
|
`.report-chart` in `globals.css`, validated separately for light and dark.
|
|
|
|
|
|
"Save as PDF" (browser print) pins chart size via the `.printing-answer`
|
|
|
|
|
|
print rule.
|
|
|
|
|
|
|
|
|
|
|
|
**Prompt.** `buildSystemPrompt(now)` is built per request (the date is
|
|
|
|
|
|
Johannesburg's today, not the server's boot date). Besides the arithmetic
|
|
|
|
|
|
ban it carries a business glossary (P&G, burn vs elapsed, fleet km/L vs
|
|
|
|
|
|
plant L/h, DN/PO fulfilment, MEGA Clock), report layout rules (headline,
|
|
|
|
|
|
KPI table, charts, findings by severity; "My read" and "What I'd do next"
|
|
|
|
|
|
for `/findings` and improvement reports only), the chart-token grammar and
|
|
|
|
|
|
a rule to link `href`s.
|
|
|
|
|
|
|
|
|
|
|
|
**Slash shortcuts.** Typed in the chat input; expanded client-side by
|
|
|
|
|
|
`src/lib/chat/slash-commands.ts` into a prompt that names the tools to
|
|
|
|
|
|
call. Typing `/` opens a menu (↑/↓ move, Tab or Enter completes, Esc
|
|
|
|
|
|
closes). The user's turn shows the short command; the model gets the
|
|
|
|
|
|
expanded prompt. `/help` is answered locally with no model call. The empty
|
|
|
|
|
|
state shows `/today`, `/portfolio`, `/problems`, `/findings` chips.
|
|
|
|
|
|
|
|
|
|
|
|
| Command | Args | Tools it asks for |
|
|
|
|
|
|
| --- | --- | --- |
|
|
|
|
|
|
| `/site <name>` | site name or code (required) | `site_report`, `problem_report` for that site |
|
|
|
|
|
|
| `/portfolio` | — | `portfolio_report` |
|
|
|
|
|
|
| `/problems [site]` | optional site | `problem_report` |
|
|
|
|
|
|
| `/improvements [site]` | optional site | `improvement_report` (+ "My read") |
|
|
|
|
|
|
| `/findings` | — | `portfolio_report`, `problem_report`, `improvement_report` (+ "My read", "What I'd do next") |
|
|
|
|
|
|
| `/fuel [site]` | optional site | `get_fuel_summary` + `vehicles_needing_attention` (site: `site_report` + attention) |
|
|
|
|
|
|
| `/plant [site]` | optional site | `plant_equipment_summary` |
|
|
|
|
|
|
| `/workforce [site]` | optional site | `top_workers`, `attendance_today` |
|
|
|
|
|
|
| `/purchasing [site]` | optional site | `list_purchase_orders`, `problem_report`, `delivery_note_queue`, `supplier_balances` |
|
|
|
|
|
|
| `/today` | — | `attendance_today`, `problem_report`, `rain_outlook`, `delivery_note_queue` |
|
|
|
|
|
|
| `/help` | — | none (local) |
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
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)).
|
|
|
|
|
|
|
2026-09-26 07:57:28 +00:00
|
|
|
|
**API routes called.** `POST /api/chat` (Server-Sent Events stream,
|
|
|
|
|
|
including `chart` events — see [api-reference §12](./api-reference.md#12-chat-gated)).
|
2026-09-06 17:59:52 +00:00
|
|
|
|
|
|
|
|
|
|
**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).
|
2026-09-25 20:04:08 +00:00
|
|
|
|
|
docs(ask): agent actions
docs/modules.md §7.1: threads, memory, files, free queries/virtual
datasets, the propose/confirm/apply/undo gateway, both PDF export paths
(manual answer export and Ask-made reports via make_pdf_report),
images, and change requests — with the actor/x-ask-user model and the
three new PocketBase collections.
docs/data-model.md §4.17: field-by-field ask_threads/ask_messages/
ask_log reference, cross-linked from modules.md via explicit anchors.
docs/api-reference.md §12/§12b: /api/chat's new body shape and its
thread/action/chart/image/pdf SSE events, plus every new agent-actions
route (picker, threads, attachments, confirm-card actions, memories,
change requests) and their auth-matrix entries; the pb-files proxy
entry now notes its ask_messages admin-token path.
README.md: the intro, the modules table (adds Change requests), and
the API surface summary now describe an agent that acts, not just a
read-only chat.
Also marks Tasks 1–12 and 9b's implementation checkbox done in the plan
doc (docs/superpowers/plans/2026-09-26-ask-agent-actions.md) — the
remaining unchecked box is 9b's live check, skipped because
ask_threads/ask_messages/ask_log aren't applied on the target
PocketBase yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:32:53 +00:00
|
|
|
|
<a id="ask-agent-actions-modules"></a>
|
|
|
|
|
|
|
|
|
|
|
|
### 7.1 Agent actions — threads, memory, files, free queries, proposed changes, PDF reports, images, change requests
|
|
|
|
|
|
|
|
|
|
|
|
Ask went from a report reader to an agent that reads any file, measures
|
|
|
|
|
|
anything with its own charts, changes data through confirm-first actions
|
|
|
|
|
|
with undo, and files its own PDF reports and app-change requests. Three
|
|
|
|
|
|
PocketBase collections back all of it (`scripts/pb-add-ask-collections.py`,
|
|
|
|
|
|
additive, `--apply` is a human step): `ask_threads`, `ask_messages`
|
|
|
|
|
|
(append-only), `ask_log` (`type`: `change` | `memory` | `request`). Schema
|
|
|
|
|
|
details: [data-model.md](./data-model.md#ask-agent-actions).
|
|
|
|
|
|
|
|
|
|
|
|
**Per-user, no logins yet.** `src/lib/agent/context.ts`'s `resolveActor`
|
|
|
|
|
|
trusts an `x-ask-user` header — a `users` record id the "Who's using
|
|
|
|
|
|
Ask?" picker (`GET /api/ask/users`, only records with a `name`) writes to
|
|
|
|
|
|
`localStorage`. This is identification, not security, until real logins
|
|
|
|
|
|
land — see "When logins land" in the plan doc. `AgentContext { actor,
|
|
|
|
|
|
threadId }` is threaded through every tool and the gateway.
|
|
|
|
|
|
|
|
|
|
|
|
**Threads.** `src/lib/chat/threads.ts` loads/appends `ask_messages` rows
|
|
|
|
|
|
(`autoTitle`, `loadThreadEntries`, `appendMessage` — creates only, never
|
|
|
|
|
|
rewrites, so concurrent tabs/users can't clobber each other) under
|
|
|
|
|
|
`/api/chat/threads` (list/create) and `/api/chat/threads/[id]`
|
|
|
|
|
|
(get/rename/archive/delete). `/api/chat` accepts
|
|
|
|
|
|
`{ threadId?, message, display?, attachmentIds?, images? }` with
|
|
|
|
|
|
`x-ask-user`; no `threadId` creates one titled from the first message. The
|
|
|
|
|
|
old `{ history }` body (no `threadId`) still works, unpersisted, for one
|
|
|
|
|
|
release.
|
|
|
|
|
|
|
|
|
|
|
|
**Memory.** `src/lib/agent/memory.ts`: `remember`/`forget`/`listMemories`
|
|
|
|
|
|
against `ask_log` (`type: "memory"`), deduped by normalised text. Personal
|
|
|
|
|
|
memories are private to their owner; `shared: true` ones are visible to
|
|
|
|
|
|
everyone. `formatMemoriesForPrompt` feeds the prompt's "What you remember"
|
|
|
|
|
|
section. A Memory drawer in `chat-view.tsx` lists Mine / Shared with
|
|
|
|
|
|
delete buttons and a manual add.
|
|
|
|
|
|
|
|
|
|
|
|
**Files.** `src/lib/agent/attachments.ts`'s `parseAttachment` reads
|
|
|
|
|
|
xlsx/xls/csv (SheetJS/PapaParse), PDF (`pdftotext -layout`; under ~50
|
|
|
|
|
|
chars/page marks it `scanned`), images and plain text.
|
|
|
|
|
|
`POST /api/chat/attachments` stores the result as an `ask_messages` row
|
|
|
|
|
|
(`kind: "attachment"`); a scanned PDF's first pages are rasterised and
|
|
|
|
|
|
returned as vision images the same way a photographed slip is. The
|
|
|
|
|
|
`read_attachment` tool re-parses on demand and pages a sheet 200 rows at a
|
|
|
|
|
|
time. `send_to_po_pipeline` posts a stored attachment into the existing
|
|
|
|
|
|
purchase-order pipeline (hash-dedupe, same as `/api/purchase-orders/upload`)
|
|
|
|
|
|
once the user confirms it's a PO/DN. `src/lib/agent/people-match.ts`'s
|
|
|
|
|
|
`matchPeople` (employee number → normalised full name → surname+initial
|
|
|
|
|
|
"likely") backs the "load workers from a list" recipe: `match_workers` →
|
|
|
|
|
|
`propose_changes`, never re-creating an exact match.
|
|
|
|
|
|
|
|
|
|
|
|
**Free queries and charts.** `src/lib/agent/query.ts`'s `runQuery` reads
|
|
|
|
|
|
either a live PocketBase collection (fields validated against
|
|
|
|
|
|
`schema-cache.ts`, filters bound with `pb.filter()`) or one of the seven
|
|
|
|
|
|
virtual datasets in `src/lib/agent/datasets.ts` (`site_summary`,
|
|
|
|
|
|
`fuel_log_status`, `plant_hours`, `payroll_hours`, `attendance`,
|
|
|
|
|
|
`supplier_balance`, `po_fulfilment` — each built from the same
|
|
|
|
|
|
`src/lib/metrics` functions the dashboard pages use, never re-derived).
|
|
|
|
|
|
Grouping, Monday-start week/day/month bucketing, and
|
|
|
|
|
|
sum/avg/min/max/count/count_distinct aggregation happen in JS on rows
|
|
|
|
|
|
already fetched — the model never computes. Results are cached under a
|
|
|
|
|
|
`resultId` (2h TTL); `make_chart` turns one into a `ChartSpec` by column
|
|
|
|
|
|
name only, so the model designs its own chart without ever holding a
|
|
|
|
|
|
number.
|
|
|
|
|
|
|
|
|
|
|
|
**Proposed changes (the gateway).** `src/lib/agent/gateway.ts` is the one
|
|
|
|
|
|
seam every write goes through: `authorize()` (a stub today — rejects
|
|
|
|
|
|
`NEVER_WRITABLE` collections and `dn_batches` lock/stage fields; the seam
|
|
|
|
|
|
where logins will add real permission checks) → `planOps` (schema
|
|
|
|
|
|
validation, `before` snapshots, relation-id existence, the
|
|
|
|
|
|
sites/employees duplicate rule, capped at 500 ops) → `createPendingAction`
|
|
|
|
|
|
(a pending `ask_log` row) → `applyAction`/`undoAction`. `propose_changes`
|
|
|
|
|
|
is the tool; `/api/agent/actions/[id]/{apply,reject,undo}` are the
|
|
|
|
|
|
same-origin-only confirm-card routes. The full plan streams to the
|
|
|
|
|
|
browser as an `action` SSE event; the model itself only ever sees
|
|
|
|
|
|
`{ actionId, status, problems }` — never the preview — the same split
|
|
|
|
|
|
`extractCharts` already does for chart numbers. `action-card.tsx` renders
|
|
|
|
|
|
it (created rows in green, updated fields old→new, deleted in red) with
|
|
|
|
|
|
Confirm/Cancel, then Undo once applied.
|
|
|
|
|
|
|
|
|
|
|
|
**PDF export.** Two independent paths. (1) Any answer can be exported
|
|
|
|
|
|
as-is: the toolbar's **PDF** button (`src/app/dashboard/chat/answer-pdf.ts`)
|
|
|
|
|
|
clones the rendered answer at a fixed A4 width, renders it with
|
|
|
|
|
|
`html-to-image`, and slices it into pages that never cut through a chart
|
|
|
|
|
|
or table row (`slicePages`), embedding each with `pdf-lib`. (2) Ask can
|
|
|
|
|
|
build a report on request ("give me a PDF on fuel at The Hills for
|
|
|
|
|
|
September"): it writes the report itself (headings, narrative, KPI
|
|
|
|
|
|
tables quoting tool display strings, charts via `make_chart`), then calls
|
|
|
|
|
|
`make_pdf_report({ title, subtitle? })` **once, last** — a pure metadata
|
|
|
|
|
|
signal (`src/lib/agent/pdf-report.ts`), split out of the tool result and
|
|
|
|
|
|
streamed as a `pdf` SSE event the same way an action card is. The turn
|
|
|
|
|
|
then renders a report cover (wordmark, title, date, "Prepared by Ask for
|
|
|
|
|
|
{name}") and reuses path (1)'s `answerToPdf`/`slicePages` for a Download
|
|
|
|
|
|
button that also fires once automatically
|
|
|
|
|
|
(`src/app/dashboard/chat/pdf-auto-download.ts`'s `nextPdfTurnToDownload`,
|
|
|
|
|
|
a pure "once per turn" decision). The report meta persists in the
|
|
|
|
|
|
assistant row's existing `parsed` json field, so reopening the thread
|
|
|
|
|
|
still shows the cover and button.
|
|
|
|
|
|
|
|
|
|
|
|
**Images.** `generate_image` (`src/lib/agent/images.ts`) calls MiniMax
|
|
|
|
|
|
`image-01` and stores the result as its own `ask_messages` row
|
|
|
|
|
|
(`kind: "image"`), streamed as an `image` SSE event and placed with
|
|
|
|
|
|
`[[image:<id>]]` — the same split/placement mechanism as a chart, but an
|
|
|
|
|
|
illustration only: never a chart, a number, or anything that looks like
|
|
|
|
|
|
data. A `base_resp.status_code` in `{1028, 1030, 2061}` (the MiniMax Token
|
|
|
|
|
|
Plan tier gate) surfaces as "Image generation isn't enabled on this
|
|
|
|
|
|
MiniMax plan" rather than retrying.
|
|
|
|
|
|
|
|
|
|
|
|
**Change requests.** "Can we add this to the app?" can't be done by Ask
|
|
|
|
|
|
itself — `file_change_request` logs an `ask_log` row (`type: "request"`,
|
|
|
|
|
|
`status: "open"`) instead of `propose_changes`, which is for data.
|
|
|
|
|
|
`/dashboard/change-requests` lists and re-statuses them
|
|
|
|
|
|
(open/planned/done/declined); `list_change_requests` (read-only) is also
|
|
|
|
|
|
served by the MCP server (`src/mcp/server.ts`) so Claude Code can pick
|
|
|
|
|
|
them up.
|
|
|
|
|
|
|
2026-09-25 20:04:08 +00:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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`](./api-reference.md#12a-routes-added-after-the-2026-09-06-pass)).
|
|
|
|
|
|
Rollout and the `DN_STAGED_INGESTION_ENABLED` switch:
|
|
|
|
|
|
[`operations.md §8`](./operations.md#8-staged-delivery-note-ingestion).
|
|
|
|
|
|
|
|
|
|
|
|
**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`](./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 is `Africa/Johannesburg` midnight.
|
|
|
|
|
|
- **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 to `POST /api/delivery-notes/upload` → Delivery Notes inbox.
|
|
|
|
|
|
|