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

32 KiB
Raw Permalink Blame History

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

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 Findings ({ 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 hrefs.

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

API routes called. POST /api/chat (Server-Sent Events stream, including chart events — see api-reference §12).

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.

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.

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). Rollout and the DN_STAGED_INGESTION_ENABLED switch: operations.md §8.

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.

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.