MEGAINFRA-WEBAPP/docs/modules.md
DEls fecc885d3e 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 21:32:53 +02:00

611 lines
32 KiB
Markdown
Raw Permalink 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`, 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=`.
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/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`)
**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/equipment` and `/dashboard/plant/equipment/[vehicleId]` (landing: `/dashboard/plant`)
**Render:** server component, `force-dynamic`
**Files:** `src/app/dashboard/plant/equipment/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 `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.
**What it computes.** Per-vehicle: sort logs by `(date_logged, odo_reading)`
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`.
**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/[sheetId]`,
`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/plant/vehicles` and `/dashboard/plant/vehicles/[vehicleId]`
**Render:** server component, `force-dynamic`
**Files:** `src/app/dashboard/plant/vehicles/page.tsx`, `[vehicleId]/page.tsx`, `fuel-view.tsx`
**What it shows.**
- **List page** — every fleet vehicle (`FLEET_FILTER` = `unit_type!="plant"`, so legacy blank rows are included),
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)`. 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.
**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/financial/purchase-orders` and `/dashboard/financial/purchase-orders/[poId]`
**Render:** server component, `force-dynamic`
**Files:** `src/app/dashboard/financial/purchase-orders/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** (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) |
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,
including `chart` events — see [api-reference §12](./api-reference.md#12-chat-gated)).
**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).
<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.
---
## 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.