diff --git a/docs/superpowers/plans/2026-08-29-chat-detail-tools-upload-pwa.md b/docs/superpowers/plans/2026-08-29-chat-detail-tools-upload-pwa.md new file mode 100644 index 0000000..f56ab35 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-chat-detail-tools-upload-pwa.md @@ -0,0 +1,1310 @@ +# Chat Detail Tools, PO Upload, and Standalone PWA Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Three independently shippable pieces — (A) five new read tools so the agent can answer about PO line items, delivery note items, a vehicle's fuel history and worker hours; (B) uploading a purchase order from the chat composer; (C) installing the chat as its own PWA, separate from the dashboard. + +**Architecture:** Part A extends the existing `src/lib/agent/` layer with no new business math. Part B deliberately keeps the LLM out of the write path — the human uploads through the existing `/api/purchase-orders/upload` endpoint and the model only ever reads the result. Part C adds a second web manifest scoped to `/dashboard/chat`. + +**Tech Stack:** TypeScript, zod, PocketBase SDK, Next.js 16, vitest. + +**Predecessor:** `2026-08-29-chat-ui-phase3.md` (merged). Parts A, B and C are independent — ship in any order, or stop after any one. + +--- + +## Read this first: the read-only guarantee survives + +Phase 2B established that the agent layer cannot write, enforced by `src/lib/agent/read-only.test.ts` scanning for `.create(` / `.update(` / `.delete(`. + +**Part B does not weaken that.** "Upload a PO from chat" is implemented as a UI feature, not a tool: + +1. The human attaches a file in the composer. +2. The browser POSTs it to `/api/purchase-orders/upload` — a route that already exists, with SHA-256 dedup, MIME allowlist and a 20 MB cap. +3. The UI polls `/api/data-import-batches/[id]/status`. +4. The model is only *told* the outcome, and can then **read** the resulting PO with `get_purchase_order`. + +The LLM never calls a write endpoint and never holds file bytes. `read-only.test.ts` stays green and unmodified. If a future change makes the model call an upload tool directly, that test must fail — do not relax it to accommodate one. + +**The MCP server also stays read-only.** It runs headless with no human to confirm anything. Everything in this plan is either a read tool (safe for both surfaces) or chat-UI-only. + +--- + +## Part A — Five new read tools + +Takes the tool set from 6 to 11. No new business math: every figure still comes from `@/lib/metrics` or straight off a PocketBase record. + +| Tool | Answers | +|---|---| +| `get_purchase_order` | "What's on PO MEGCPO9199?" — header plus line items | +| `list_purchase_orders` | "Which POs are unverified?" | +| `get_delivery_note` | "What did we receive on DN 4471?" — header plus received items | +| `get_vehicle_fuel_log` | "Show me the Grader's fill-ups" — per-fill variance | +| `top_workers` | "Who worked the most hours?" | + +--- + +### Task A1: Vehicle resolver + +Same problem `resolveSite` solved: humans say "the Grader", not a record id. + +**Files:** +- Modify: `src/lib/agent/resolve.ts` +- Modify: `src/lib/agent/resolve.test.ts` + +- [ ] **Step 1: Add the failing tests** + +Append to `src/lib/agent/resolve.test.ts`: + +```typescript +import { resolveVehicle, type VehicleRef } from "./resolve"; + +const VEHICLES: VehicleRef[] = [ + { id: "v1", description: "Center Point Grader", qrCodeId: "1626", projectCode: "C26/09" }, + { id: "v2", description: "Isuzu MY85HGGP", qrCodeId: "1936", projectCode: "C26/09" }, + { id: "v3", description: "TLB centre point", qrCodeId: "2201", projectCode: "C26/09" }, +]; + +describe("resolveVehicle", () => { + it("matches an exact record id", () => { + const r = resolveVehicle("v2", VEHICLES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.vehicle.id).toBe("v2"); + }); + + it("matches a QR code", () => { + const r = resolveVehicle("1626", VEHICLES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.vehicle.id).toBe("v1"); + }); + + it("matches a full description case-insensitively", () => { + const r = resolveVehicle("center point grader", VEHICLES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.vehicle.id).toBe("v1"); + }); + + it("matches a unique substring", () => { + const r = resolveVehicle("isuzu", VEHICLES); + expect(r.kind).toBe("exact"); + if (r.kind === "exact") expect(r.vehicle.id).toBe("v2"); + }); + + it("reports ambiguity rather than guessing", () => { + const r = resolveVehicle("point", VEHICLES); + expect(r.kind).toBe("ambiguous"); + if (r.kind === "ambiguous") expect(r.matches.length).toBe(2); + }); + + it("returns none for no match", () => { + expect(resolveVehicle("helicopter", VEHICLES).kind).toBe("none"); + }); + + it("returns none for an empty query", () => { + expect(resolveVehicle(" ", VEHICLES).kind).toBe("none"); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/lib/agent/resolve.test.ts` +Expected: FAIL — `resolveVehicle` is not exported. + +- [ ] **Step 3: Implement** + +Append to `src/lib/agent/resolve.ts`: + +```typescript +export interface VehicleRef { + id: string; + description: string; + qrCodeId: string; + projectCode: string; +} + +export type ResolveVehicleResult = + | { kind: "exact"; vehicle: VehicleRef } + | { kind: "ambiguous"; matches: VehicleRef[] } + | { kind: "none" }; + +/** + * Free-text vehicle lookup. Mirrors resolveSite: id, then QR code, then + * exact description, then substring — and ambiguity is reported, never + * guessed. Picking the wrong vehicle silently attributes fuel spend to + * the wrong machine. + */ +export function resolveVehicle( + query: string, + vehicles: VehicleRef[], +): ResolveVehicleResult { + const q = query.trim().toLowerCase(); + if (!q) return { kind: "none" }; + + const byId = vehicles.find((v) => v.id === query.trim()); + if (byId) return { kind: "exact", vehicle: byId }; + + const byQr = vehicles.filter((v) => v.qrCodeId.toLowerCase() === q); + if (byQr.length === 1) return { kind: "exact", vehicle: byQr[0] }; + if (byQr.length > 1) return { kind: "ambiguous", matches: byQr }; + + const byDesc = vehicles.filter((v) => v.description.toLowerCase() === q); + if (byDesc.length === 1) return { kind: "exact", vehicle: byDesc[0] }; + if (byDesc.length > 1) return { kind: "ambiguous", matches: byDesc }; + + const bySub = vehicles.filter((v) => v.description.toLowerCase().includes(q)); + if (bySub.length === 1) return { kind: "exact", vehicle: bySub[0] }; + if (bySub.length > 1) return { kind: "ambiguous", matches: bySub }; + + return { kind: "none" }; +} + +/** Human-readable disambiguation line for an ambiguous vehicle result. */ +export function describeVehicleAmbiguity(matches: VehicleRef[]): string { + return matches.map((m) => `${m.description} (QR ${m.qrCodeId})`).join(", "); +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run src/lib/agent/resolve.test.ts` +Expected: PASS, 17 tests (10 existing + 7 new). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/agent/resolve.ts src/lib/agent/resolve.test.ts +git commit -m "feat(agent): add free-text vehicle resolver" +``` + +--- + +### Task A2: Loaders for the detail tools + +**Files:** +- Modify: `src/lib/agent/load.ts` + +- [ ] **Step 1: Append the loaders** + +```typescript +import type { VehicleRef } from "./resolve"; + +export async function loadVehicleRefs(): Promise { + const pb = await pbAdmin(); + const vehicles = await pb.collection("vehicles").getFullList({ + fields: "id,description,qr_code_id,project_code", + sort: "description", + }); + return vehicles.map((v) => ({ + id: v.id, + description: v.description ?? "", + qrCodeId: v.qr_code_id ?? "", + projectCode: v.project_code ?? "", + })); +} + +/** One purchase order plus its line items, looked up by PO number. */ +export async function loadPurchaseOrderByNumber(poNumber: string) { + const pb = await pbAdmin(); + // PB filters take a quoted literal; escape any quote in the input. + const safe = poNumber.replace(/'/g, ""); + const list = await pb.collection("purchase_orders").getFullList({ + filter: `purchase_order_number='${safe}'`, + }); + const po = list[0]; + if (!po) return null; + + const items = await pb.collection("purchase_order_items").getFullList({ + filter: `purchase_order='${po.id}'`, + }); + return { po, items }; +} + +export async function loadPurchaseOrders(args: { + siteId?: string; + verified?: boolean; +}) { + const pb = await pbAdmin(); + const clauses: string[] = []; + if (args.siteId) clauses.push(`site='${args.siteId}'`); + if (args.verified !== undefined) { + clauses.push(`is_verified=${args.verified ? "true" : "false"}`); + } + return pb.collection("purchase_orders").getFullList({ + ...(clauses.length > 0 ? { filter: clauses.join(" && ") } : {}), + sort: "-created", + }); +} + +/** One delivery note plus the items received against it. */ +export async function loadDeliveryNoteByNumber(dnNumber: string) { + const pb = await pbAdmin(); + const safe = dnNumber.replace(/'/g, ""); + const list = await pb.collection("delivery_notes").getFullList({ + filter: `delivery_note_number='${safe}'`, + expand: "purchase_order", + }); + const dn = list[0]; + if (!dn) return null; + + // Expand the PO item so each received line has a description. + const items = await pb.collection("received_items").getFullList({ + filter: `delivery_note='${dn.id}'`, + expand: "purchase_order_item", + }); + return { dn, items }; +} + +/** Every fuel log for one vehicle, plus the vehicle record. */ +export async function loadVehicleFuelLogs(vehicleId: string) { + const pb = await pbAdmin(); + const [vehicle, logs] = await Promise.all([ + pb.collection("vehicles").getOne(vehicleId), + pb.collection("fuel_logs").getFullList({ + filter: `vehicle='${vehicleId}'`, + sort: "-date_logged", + }), + ]); + return { vehicle, logs }; +} + +/** Timesheets + employees for the worker-hours roll-up. */ +export async function loadWorkforce() { + const pb = await pbAdmin(); + const [employees, timesheets, sites] = await Promise.all([ + pb.collection("employees").getFullList({ + fields: "id,name,rate_per_hour,site,position,worker_type", + }), + pb.collection("timesheets").getFullList(), + pb.collection("sites").getFullList({ fields: "id,name,project_code" }), + ]); + return { employees, timesheets, sites }; +} +``` + +- [ ] **Step 2: Verify the read-only guard still passes** + +Run: `npx vitest run src/lib/agent/read-only.test.ts && npx tsc --noEmit` +Expected: PASS, tsc exits 0. If the guard fails, a mutation crept into a loader. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/agent/load.ts +git commit -m "feat(agent): add loaders for PO, delivery note, fuel and workforce detail" +``` + +--- + +### Task A3: Formatters + +**Files:** +- Modify: `src/lib/agent/format.ts` +- Modify: `src/lib/agent/format.test.ts` + +- [ ] **Step 1: Add the failing tests** + +Append to `src/lib/agent/format.test.ts`: + +```typescript +import { formatTopWorkers, formatFuelLog } from "./format"; + +describe("formatTopWorkers", () => { + const employees = [ + { id: "e1", name: "Alice", rate_per_hour: 100, site: "s1", position: "Foreman" }, + { id: "e2", name: "Bob", rate_per_hour: 50, site: "s1", position: "Labourer" }, + { id: "e3", name: "Carol", rate_per_hour: 80, site: "s2", position: "Operator" }, + ]; + const timesheets = [ + { employee: "e1", hours_worked: 10 }, + { employee: "e1", hours_worked: 5 }, + { employee: "e2", hours_worked: 40 }, + { employee: "e3", hours_worked: 20 }, + ]; + const siteNames = new Map([ + ["s1", "The Hills"], + ["s2", "N1 Yard"], + ]); + + it("ranks by hours descending", () => { + const r = formatTopWorkers(employees, timesheets, siteNames, {}); + expect(r.workers.map((w) => w.worker)).toEqual(["Bob", "Carol", "Alice"]); + }); + + it("sums hours across timesheets for one worker", () => { + const r = formatTopWorkers(employees, timesheets, siteNames, {}); + expect(r.workers.find((w) => w.worker === "Alice")!.hours).toBe(15); + }); + + it("computes cost as hours x rate", () => { + const r = formatTopWorkers(employees, timesheets, siteNames, {}); + expect(r.workers.find((w) => w.worker === "Alice")!.cost).toBe(1500); + }); + + it("filters to a site when given one", () => { + const r = formatTopWorkers(employees, timesheets, siteNames, { siteId: "s2" }); + expect(r.workers.map((w) => w.worker)).toEqual(["Carol"]); + }); + + it("honours the limit", () => { + const r = formatTopWorkers(employees, timesheets, siteNames, { limit: 2 }); + expect(r.workers).toHaveLength(2); + }); + + it("excludes workers with no recorded hours", () => { + const r = formatTopWorkers(employees, [], siteNames, {}); + expect(r.workers).toEqual([]); + }); + + it("resolves site names", () => { + const r = formatTopWorkers(employees, timesheets, siteNames, {}); + expect(r.workers[0].site).toBe("The Hills"); + }); +}); + +describe("formatFuelLog", () => { + const vehicle = { + id: "v1", + description: "Grader", + qr_code_id: "1626", + project_code: "C26/09", + fuel_limits: "10 km per L", + }; + const logs = [ + { id: "l2", vehicle: "v1", date_logged: "2026-08-02", odo_reading: 1350, litres: 50 }, + { id: "l1", vehicle: "v1", date_logged: "2026-08-01", odo_reading: 1000, litres: 50 }, + ]; + + it("reports the vehicle and its limit", () => { + const r = formatFuelLog(vehicle, logs, {}); + expect(r.vehicle).toBe("Grader"); + expect(r.fuelLimit).toBe("10 km per L"); + }); + + it("returns fill-ups newest first", () => { + const r = formatFuelLog(vehicle, logs, {}); + expect(r.fillUps[0].date).toBe("2026-08-02"); + }); + + it("attaches a variance status to each fill-up", () => { + const r = formatFuelLog(vehicle, logs, {}); + const flagged = r.fillUps.find((f) => f.date === "2026-08-02")!; + expect(flagged.status).toBe("alert"); + }); + + it("totals litres and spend", () => { + const r = formatFuelLog(vehicle, logs, {}); + expect(r.totalLitres).toBe(100); + expect(r.display.totalSpend).toMatch(/^R /); + }); + + it("honours the limit argument", () => { + const r = formatFuelLog(vehicle, logs, { limit: 1 }); + expect(r.fillUps).toHaveLength(1); + }); + + it("handles a vehicle with no logs", () => { + const r = formatFuelLog(vehicle, [], {}); + expect(r.fillUps).toEqual([]); + expect(r.totalLitres).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/lib/agent/format.test.ts` +Expected: FAIL — `formatTopWorkers` / `formatFuelLog` are not exported. + +- [ ] **Step 3: Implement** + +Append to `src/lib/agent/format.ts` (and add `computeFuelLogStatuses`, `RATE_PER_LITRE` to the existing `@/lib/metrics` import): + +```typescript +export interface TopWorkersResult { + scope: string; + workers: { + worker: string; + position: string; + site: string; + hours: number; + cost: number; + ratePerHour: number; + display: { cost: string }; + }[]; +} + +export function formatTopWorkers( + employees: { + id: string; + name?: string; + rate_per_hour?: number; + site?: string; + position?: string; + }[], + timesheets: { employee: string; hours_worked?: number }[], + siteNames: Map, + opts: { siteId?: string; limit?: number }, +): TopWorkersResult { + const byId = new Map(employees.map((e) => [e.id, e])); + const acc = new Map(); + + for (const t of timesheets) { + const emp = byId.get(t.employee); + if (!emp) continue; + if (opts.siteId && emp.site !== opts.siteId) continue; + acc.set(t.employee, (acc.get(t.employee) ?? 0) + (t.hours_worked ?? 0)); + } + + const workers = Array.from(acc.entries()) + .filter(([, hours]) => hours > 0) + .map(([id, hours]) => { + const e = byId.get(id)!; + const rate = e.rate_per_hour ?? 0; + const cost = hours * rate; + return { + worker: e.name ?? "(unnamed)", + position: e.position ?? "", + site: siteNames.get(e.site ?? "") ?? e.site ?? "", + hours, + cost, + ratePerHour: rate, + display: { cost: formatMoney(cost) }, + }; + }) + .sort((a, b) => b.hours - a.hours) + .slice(0, opts.limit ?? 10); + + return { + scope: opts.siteId ? (siteNames.get(opts.siteId) ?? opts.siteId) : "all sites", + workers, + }; +} + +export interface FuelLogResult { + vehicle: string; + qrCodeId: string; + projectCode: string; + fuelLimit: string; + fillUpCount: number; + totalLitres: number; + totalSpend: number; + flagged: number; + fillUps: { + date: string; + litres: number; + odoReading: number; + variancePct: number | null; + status: string; + cost: number; + display: { cost: string }; + }[]; + display: { totalSpend: string }; +} + +export function formatFuelLog( + vehicle: { + id: string; + description?: string; + qr_code_id?: string; + project_code?: string; + fuel_limits?: string; + }, + logs: { + id: string; + vehicle: string; + date_logged?: string; + odo_reading?: number; + litres?: number; + }[], + opts: { limit?: number }, +): FuelLogResult { + // Reuse the same pass the dashboard uses so per-fill status matches. + const statuses = new Map( + computeFuelLogStatuses({ vehicles: [vehicle], fuelLogs: logs }).map((s) => [ + s.logId, + s, + ]), + ); + + const totalLitres = logs.reduce((s, l) => s + (l.litres ?? 0), 0); + const flagged = Array.from(statuses.values()).filter( + (s) => s.status === "warn" || s.status === "alert", + ).length; + + const fillUps = logs + .slice() + .sort((a, b) => (a.date_logged ?? "") < (b.date_logged ?? "") ? 1 : -1) + .slice(0, opts.limit ?? 20) + .map((l) => { + const st = statuses.get(l.id); + const cost = (l.litres ?? 0) * RATE_PER_LITRE; + return { + date: l.date_logged ?? "", + litres: l.litres ?? 0, + odoReading: l.odo_reading ?? 0, + variancePct: st?.variance ?? null, + status: st?.status ?? "no-limit", + cost, + display: { cost: formatMoney(cost) }, + }; + }); + + return { + vehicle: vehicle.description ?? "", + qrCodeId: vehicle.qr_code_id ?? "", + projectCode: vehicle.project_code ?? "", + fuelLimit: vehicle.fuel_limits ?? "", + fillUpCount: logs.length, + totalLitres, + totalSpend: totalLitres * RATE_PER_LITRE, + flagged, + fillUps, + display: { totalSpend: formatMoney(totalLitres * RATE_PER_LITRE) }, + }; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npx vitest run src/lib/agent/format.test.ts` +Expected: PASS, 28 tests (15 existing + 13 new). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/agent/format.ts src/lib/agent/format.test.ts +git commit -m "feat(agent): add worker-hours and vehicle fuel-log formatters" +``` + +--- + +### Task A4: The five tools + +**Files:** +- Modify: `src/lib/agent/tools.ts` + +- [ ] **Step 1: Add the shared vehicle resolver helper** + +Add next to `requireSite`: + +```typescript +async function requireVehicle(query: string) { + const refs = await loadVehicleRefs(); + const r = resolveVehicle(query, refs); + if (r.kind === "exact") return { ok: true as const, vehicle: r.vehicle }; + if (r.kind === "ambiguous") { + return { + ok: false as const, + error: `"${query}" matches more than one vehicle: ${describeVehicleAmbiguity(r.matches)}. Ask which one is meant.`, + }; + } + return { ok: false as const, error: `No vehicle matches "${query}".` }; +} +``` + +Extend the imports: add `loadVehicleRefs`, `loadPurchaseOrderByNumber`, `loadPurchaseOrders`, `loadDeliveryNoteByNumber`, `loadVehicleFuelLogs`, `loadWorkforce` from `./load`; `resolveVehicle`, `describeVehicleAmbiguity` from `./resolve`; `formatTopWorkers`, `formatFuelLog` from `./format`. + +- [ ] **Step 2: Append the five tools to the `tools` array** + +```typescript + { + name: "get_purchase_order", + description: + "Full detail for ONE purchase order including every line item: material code, description, quantity ordered, quantity still outstanding, unit, rate and amount. Use for 'what's on PO X', 'what did we order from supplier Y on that PO', 'what's still outstanding on PO X'.", + schema: { + poNumber: z.string().describe("Purchase order number, e.g. MEGCPO9199"), + }, + handler: async (args) => { + const found = await loadPurchaseOrderByNumber(String(args.poNumber)); + if (!found) { + return { error: `No purchase order numbered "${String(args.poNumber)}".` }; + } + const { po, items } = found; + const refs = await loadSiteRefs(); + const siteName = refs.find((s) => s.id === po.site)?.name ?? po.site ?? ""; + + return { + poNumber: po.purchase_order_number, + supplier: po.supplier, + site: siteName, + orderedBy: po.ordered_by ?? "", + requiredBy: po.required_by_date ?? "", + isVerified: !!po.is_verified, + itemCount: items.length, + totalValue: po.total_value ?? 0, + display: { totalValue: formatMoney(po.total_value ?? 0) }, + items: items.map((i) => ({ + materialCode: i.material_code ?? "", + description: i.description ?? "", + qty: i.qty ?? 0, + qtyOutstanding: i.qty_outstanding ?? 0, + unit: i.unit ?? "", + rate: i.rate ?? 0, + amount: i.amount ?? 0, + display: { rate: formatMoney(i.rate ?? 0), amount: formatMoney(i.amount ?? 0) }, + })), + }; + }, + }, + + { + name: "list_purchase_orders", + description: + "List purchase orders, optionally filtered to one site and/or to verified status. Use for 'show me unverified POs', 'what POs are on the Hills'. For the contents of a specific PO use get_purchase_order.", + schema: { + site: z.string().optional().describe("Site name, project code, or id"), + verified: z + .boolean() + .optional() + .describe("true for verified only, false for unverified only"), + limit: z.number().optional().describe("Defaults to 25"), + }, + handler: async (args) => { + let siteId: string | undefined; + let siteLabel = "all sites"; + if (args.site) { + const found = await requireSite(String(args.site)); + if (!found.ok) return { error: found.error }; + siteId = found.site.id; + siteLabel = found.site.name; + } + + const pos = await loadPurchaseOrders({ + siteId, + verified: args.verified as boolean | undefined, + }); + const refs = await loadSiteRefs(); + const siteNames = new Map(refs.map((r) => [r.id, r.name])); + const limit = typeof args.limit === "number" ? args.limit : 25; + + return { + scope: siteLabel, + total: pos.length, + showing: Math.min(limit, pos.length), + purchaseOrders: pos.slice(0, limit).map((p) => ({ + poNumber: p.purchase_order_number, + supplier: p.supplier, + site: siteNames.get(p.site) ?? p.site ?? "", + requiredBy: p.required_by_date ?? "", + isVerified: !!p.is_verified, + totalValue: p.total_value ?? 0, + display: { totalValue: formatMoney(p.total_value ?? 0) }, + })), + }; + }, + }, + + { + name: "get_delivery_note", + description: + "Full detail for ONE delivery note including every item received against it, the purchase order it was matched to, who signed for it, and the OCR confidence and processing status. Use for 'what came in on DN X', 'was delivery note X verified'.", + schema: { + deliveryNoteNumber: z.string().describe("Delivery note number"), + }, + handler: async (args) => { + const found = await loadDeliveryNoteByNumber(String(args.deliveryNoteNumber)); + if (!found) { + return { + error: `No delivery note numbered "${String(args.deliveryNoteNumber)}".`, + }; + } + const { dn, items } = found; + const refs = await loadSiteRefs(); + + return { + deliveryNoteNumber: dn.delivery_note_number || "(unnumbered)", + site: refs.find((s) => s.id === dn.site)?.name ?? dn.site ?? "", + purchaseOrder: dn.expand?.purchase_order?.purchase_order_number ?? null, + dateReceived: dn.date_received ?? "", + receivedBy: dn.received_by_name || dn.received_by || "", + processingStatus: dn.processing_status ?? "pending", + ocrConfidence: dn.ocr_confidence ?? null, + isVerified: !!dn.is_verified, + itemCount: items.length, + items: items.map((i) => ({ + description: i.expand?.purchase_order_item?.description ?? "", + materialCode: i.expand?.purchase_order_item?.material_code ?? "", + qtyReceived: i.qty_received ?? 0, + qtyOrdered: i.expand?.purchase_order_item?.qty ?? null, + unit: i.expand?.purchase_order_item?.unit ?? "", + conditionNotes: i.condition_notes ?? "", + })), + }; + }, + }, + + { + name: "get_vehicle_fuel_log", + description: + "Fill-up history for ONE vehicle: date, litres, odometer reading, cost, and how each fill compared with the vehicle's configured consumption limit. Use for 'show me the Grader's fuel history', 'why is vehicle X flagged'. Accepts a description, QR code, or id.", + schema: { + vehicle: z.string().describe("Vehicle description, QR code, or record id"), + limit: z.number().optional().describe("Most recent N fill-ups, default 20"), + }, + handler: async (args) => { + const found = await requireVehicle(String(args.vehicle)); + if (!found.ok) return { error: found.error }; + + const { vehicle, logs } = await loadVehicleFuelLogs(found.vehicle.id); + return formatFuelLog( + vehicle as Parameters[0], + logs as Parameters[1], + { limit: typeof args.limit === "number" ? args.limit : undefined }, + ); + }, + }, + + { + name: "top_workers", + description: + "Workers ranked by hours worked, with their labour cost. Optionally limited to one site. Use for 'who worked the most hours', 'top 10 workers', 'labour cost by person'.", + schema: { + site: z.string().optional().describe("Site name, project code, or id"), + limit: z.number().optional().describe("How many to return, default 10"), + }, + handler: async (args) => { + let siteId: string | undefined; + if (args.site) { + const found = await requireSite(String(args.site)); + if (!found.ok) return { error: found.error }; + siteId = found.site.id; + } + + const { employees, timesheets, sites } = await loadWorkforce(); + const siteNames = new Map(sites.map((s) => [s.id, s.name as string])); + + return formatTopWorkers( + employees as Parameters[0], + timesheets as Parameters[1], + siteNames, + { siteId, limit: typeof args.limit === "number" ? args.limit : undefined }, + ); + }, + }, +``` + +- [ ] **Step 3: Verify** + +Run: `npx vitest run && npx tsc --noEmit` +Expected: all pass; `read-only.test.ts` still green; tsc exits 0. + +- [ ] **Step 4: Confirm all eleven tools register** + +```bash +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | npm run --silent mcp +``` + +Expected: 11 tool names. + +- [ ] **Step 5: Spot-check against the dashboard** + +Call `get_vehicle_fuel_log` for "Center Point Grader" and confirm the flagged fill-ups match `/dashboard/fuel/`. This is the acceptance criterion for Part A — the tools must agree with the pages. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/agent/tools.ts +git commit -m "feat(agent): add PO, delivery note, fuel log and worker tools" +``` + +--- + +## Part B — Upload a purchase order from chat + +The endpoint already exists and needs no changes: `POST /api/purchase-orders/upload` takes multipart with a single `file` field, allows `application/pdf`, `image/jpeg`, `image/png`, `image/webp` up to 20 MB, SHA-256 dedups against the last 30 days, and creates a `data_import_batches` row. Progress is polled at `GET /api/data-import-batches/[id]/status`. + +**No new API route, and no write tool.** + +--- + +### Task B0: Return the PO number from the status route + +The status route currently reports `processed_record_id` — a PocketBase id, which is useless to show a human. The upload card needs the readable order number. + +**Files:** +- Modify: `src/app/api/data-import-batches/[id]/status/route.ts` + +- [ ] **Step 1: Add the label lookup** + +The route already selects `id,status,attempts,last_error,processed_record_id,processed_at,batch_type`. After fetching the batch and before returning, resolve a human label for a processed purchase order: + +```typescript + // A record id is meaningless to a human. For processed purchase orders, + // resolve the order number so the UI can name what it just ingested. + let processed_record_label: string | null = null; + if (batch.batch_type === "purchase_orders" && batch.processed_record_id) { + try { + const po = await pb + .collection("purchase_orders") + .getOne(batch.processed_record_id, { fields: "purchase_order_number" }); + processed_record_label = po.purchase_order_number ?? null; + } catch { + // The PO may have been deleted, or the id may point elsewhere. + // A missing label is not an error — the batch status still stands. + } + } +``` + +Add `processed_record_label` to the returned JSON object alongside `processed_record_id`. + +- [ ] **Step 2: Verify** + +Run: `npx vitest run src/app/api/data-import-batches && npx tsc --noEmit` +Expected: the route's existing tests still pass; tsc exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/api/data-import-batches/[id]/status/route.ts" +git commit -m "feat(imports): return the purchase order number from batch status" +``` + +--- + +### Task B1: Upload card + +**Files:** +- Create: `src/app/dashboard/chat/upload-card.tsx` + +- [ ] **Step 1: Write it** + +```typescript +"use client"; + +import * as React from "react"; +import { FileText, CheckCircle2, AlertTriangle, Loader2 } from "lucide-react"; + +export type UploadState = + | { phase: "uploading"; fileName: string } + | { phase: "duplicate"; fileName: string } + | { phase: "processing"; fileName: string; batchId: string } + | { phase: "done"; fileName: string; poNumber: string | null } + | { phase: "failed"; fileName: string; error: string }; + +export function UploadCard({ + state, + onAsk, +}: { + state: UploadState; + onAsk?: (poNumber: string) => void; +}) { + const busy = state.phase === "uploading" || state.phase === "processing"; + const failed = state.phase === "failed"; + + return ( +
+ + {busy ? ( + + ) : failed ? ( + + ) : ( + + )} + + +
+

+ + {state.fileName} +

+

+ {state.phase === "uploading" && "Uploading…"} + {state.phase === "processing" && "Reading the document…"} + {state.phase === "duplicate" && + "Already uploaded in the last 30 days — nothing to do."} + {state.phase === "done" && + (state.poNumber + ? `Processed as ${state.poNumber}` + : "Processed. The order number is still being matched.")} + {state.phase === "failed" && state.error} +

+ + {state.phase === "done" && state.poNumber && onAsk && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/app/dashboard/chat/upload-card.tsx +git commit -m "feat(chat): add purchase order upload card" +``` + +--- + +### Task B2: Wire uploading into the composer + +**Files:** +- Modify: `src/app/dashboard/chat/chat-view.tsx` + +- [ ] **Step 1: Extend the turn type** + +Replace the `Turn` interface with: + +```typescript +type Turn = + | { role: "user"; text: string } + | { role: "assistant"; text: string; tools?: string[] } + | { role: "upload"; upload: UploadState }; +``` + +Add the imports: + +```typescript +import { Paperclip } from "lucide-react"; +import { UploadCard, type UploadState } from "./upload-card"; +``` + +- [ ] **Step 2: Add the upload handler** + +Insert before `send`: + +```typescript + const ACCEPT = "application/pdf,image/jpeg,image/png,image/webp"; + const MAX_BYTES = 20 * 1024 * 1024; + const fileRef = React.useRef(null); + + /** Replace the last upload turn in place as its status advances. */ + const setUpload = React.useCallback((next: UploadState) => { + setTurns((prev) => { + const copy = [...prev]; + for (let i = copy.length - 1; i >= 0; i--) { + if (copy[i].role === "upload") { + copy[i] = { role: "upload", upload: next }; + break; + } + } + return copy; + }); + }, []); + + const upload = React.useCallback( + async (file: File) => { + if (file.size > MAX_BYTES) { + setError(`${file.name} is larger than the 20 MB limit.`); + return; + } + + setError(null); + setTurns((prev) => [ + ...prev, + { role: "upload", upload: { phase: "uploading", fileName: file.name } }, + ]); + + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/purchase-orders/upload", { + method: "POST", + body: form, + }); + const data = await res.json(); + + if (!res.ok) { + setUpload({ + phase: "failed", + fileName: file.name, + error: data?.error ?? `Upload failed (${res.status})`, + }); + return; + } + + if (data.duplicate) { + setUpload({ phase: "duplicate", fileName: file.name }); + return; + } + + // The upload route answers 202 with { batch_id, status: "queued" }. + const batchId: string = data.batch_id; + setUpload({ phase: "processing", fileName: file.name, batchId }); + + // The document is read by a background pipeline, so poll rather + // than block. ~2 minutes at 3s is well past the usual turnaround. + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 3000)); + const s = await fetch(`/api/data-import-batches/${batchId}/status`); + if (!s.ok) continue; + const status = await s.json(); + + if (status.status === "processed") { + // processed_record_label is the order number, added in Task B0. + setUpload({ + phase: "done", + fileName: file.name, + poNumber: status.processed_record_label ?? null, + }); + return; + } + if (status.status === "failed" || status.status === "dead") { + setUpload({ + phase: "failed", + fileName: file.name, + error: status.last_error ?? "The document could not be read.", + }); + return; + } + } + + setUpload({ + phase: "failed", + fileName: file.name, + error: "Still processing after two minutes — check the uploads page.", + }); + } catch (err) { + setUpload({ + phase: "failed", + fileName: file.name, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + [setUpload], + ); +``` + +- [ ] **Step 3: Render upload turns** + +In the `turns.map` body, before the `t.role === "user"` branch: + +```typescript + if (t.role === "upload") { + return ( + send(`What's on purchase order ${po}?`)} + /> + ); + } +``` + +Because `Turn` is now a union, the history builder must skip uploads. Change it to: + +```typescript + const history = [ + ...turns + .filter( + (t): t is Extract => + t.role !== "upload", + ) + .map((t) => ({ role: t.role, text: t.text })), + { role: "user" as const, text: q }, + ]; +``` + +- [ ] **Step 4: Add the attach button** + +Inside the composer `
`, before the `