diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8ddea4e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,44 @@ +{ + "permissions": { + "allow": [ + "Bash(claude mcp *)", + "Bash(python3 *)", + "mcp__n8n__search_workflows", + "mcp__n8n__get_workflow_details", + "mcp__n8n__search_executions", + "mcp__n8n__get_execution", + "mcp__n8n__get_workflow_history", + "mcp__n8n__update_workflow", + "mcp__n8n__execute_workflow", + "mcp__n8n__publish_workflow", + "Bash(node /tmp/check-po-state.mjs)", + "Bash(node scripts/_tmp-check-po-state.mjs)", + "Bash(kill 59938)", + "Bash(N8N_INGEST_SECRET=test-secret nohup npm run dev)", + "Bash(curl -s -o /dev/null -w \"Port 6004: %{http_code}\\\\n\" --max-time 3 http://localhost:6004/)", + "Bash(kill 97248)", + "Bash(grep -E \"^\\(src|app|lib|components\\)$\")", + "Bash(git *)", + "Bash(curl -s -o /tmp/prod-health.txt -w \"HTTP %{http_code}\\\\n\" https://mega.dash.apphub.co.za/api/health)", + "Bash(curl *)", + "Bash(npm install *)", + "Bash(node *)", + "Bash(npx tsx *)", + "Bash(rm -rf /Volumes/EXT/DEPLOYMENT)", + "Read(//Volumes/EXT/DEPLOYMENT/**)", + "Bash(ENABLE_CHAT='' node -e ' *)", + "Bash(/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/node_modules/.bin/tsc --noEmit --project /Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/tsconfig.json)", + "Bash(pkill -f \"next dev\")", + "Bash(/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/node_modules/.bin/tsx --env-file=/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/.env.local -e \"import\\('./src/lib/agent/load.ts'\\).then\\(async m => { try { const r = await m.loadPurchaseOrders\\({ verified: false }\\); console.log\\('OK:', r.length\\); } catch \\(e\\) { console.log\\('ERR:', e.message ?? e\\); console.log\\('data:', JSON.stringify\\(e?.response?.data ?? null\\)\\); console.log\\('status:', e?.status\\); } }\\)\" 2>&1 | tail -10)", + "Bash(/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/node_modules/.bin/tsx --env-file=/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/.env.local -e ' *)", + "Bash(echo \"exit=$?\")", + "Bash(agent-browser screenshot *)", + "Bash(xxd /Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/src/lib/metrics/index.ts)", + "Bash(grep -n \"ref={fileRef}\\\\|type=\\\\\"button\\\\\"\\\\\\\\s*$\\\\|aria-label=\\\\\"Upload\" /Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/src/app/dashboard/chat/chat-view.tsx)", + "Bash(grep *)", + "Bash(./node_modules/.bin/tsc --noEmit --project tsconfig.json)", + "Bash(cd *)", + "Bash(echo *)" + ] + } +} diff --git a/README.md b/README.md index 9c16707..5f80565 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ historical, not authoritative. | `GET / POST /api/data-import-batches/[id]/status\|retry` | server-only | Batch lifecycle | | `POST /api/records/[collection]/[id]/verify` | server-only | Verify toggle for POs and DNs | | `POST /api/chat` | `ENABLE_CHAT=true` (no user auth) | SSE chat stream | +| `POST /api/clock/scan`, `POST /api/clock/vehicle-lookup`, `POST /api/clock/vehicle-scan` | server-only | MEGA Clock PWA: badge clock-in/out + vehicle hour-meter scan | Full reference: [`docs/api-reference.md`](./docs/api-reference.md). diff --git a/docs/api-reference.md b/docs/api-reference.md index ad81072..17e1188 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -489,7 +489,82 @@ everything else returns 400. --- -## 11. Chat (gated) +## 11. MEGA Clock + +The standalone QR timekeeping PWA at `/clock`. The PIN gate handles +auth — these routes trust the caller the same way every other +clock/PWA flow does. + +### `POST /api/clock/scan` + +**File:** `src/app/api/clock/scan/route.ts` +**Auth:** server-only + +Clock a worker in or out by scanning their `employee_no` badge. The +direction is implied by the worker's most recent `clock_events` row: +no row or `out` → in, `in` → out (which also writes/updates a +`timesheet_entries` row). + +**Body:** +```ts +{ employee_no: string } +``` + +**Responses:** +- `200 { direction: "in", employeeName, at }` +- `200 { direction: "out", employeeName, sessionHours, dailyTotalHours }` +- `400 { error: "Body must be JSON" }` / `"employee_no is required"` +- `404 { error: "Unknown badge" }` + +### `POST /api/clock/vehicle-lookup` + +**File:** `src/app/api/clock/vehicle-lookup/route.ts` +**Auth:** server-only + +Called by the Fleet tab the instant a vehicle QR is decoded. Looks up +the vehicle and the most-recently-inserted `odo_reading` for it (the +"previous reading" the popup shows). + +**Body:** +```ts +{ qr_code_id: string } +``` + +**Responses:** +- `200 { vehicle: { id, description, qrCodeId, siteId, unitType, fuelLimits, costPerHour }, hasPrevious: boolean, previousReading: number | null }` +- `400 { error: "Body must be JSON" }` / `"qr_code_id is required"` +- `404 { error: "Unknown vehicle QR" }` + +### `POST /api/clock/vehicle-scan` + +**File:** `src/app/api/clock/vehicle-scan/route.ts` +**Auth:** server-only + +Writes one `fuel_logs` row capturing the current odometer/hour-meter +reading for a vehicle whose QR was just scanned. Pairs with +`/api/clock/vehicle-lookup`, which the UI calls first to resolve the +QR and surface the previous reading. + +The scan endpoint deliberately captures *one* number per scan (the +dash reading) and stamps a zero-litre row. Capturing fuel alongside +is left to the spreadsheet import — the Fleet tab is for the +end-of-shift reading, not for the pump sheet. + +**Body:** +```ts +{ qr_code_id: string, odo_reading: number } +``` + +**Responses:** +- `200 { vehicleId, odoReading, dateLogged, logId }` +- `400 { error: "Body must be JSON" }` / `"qr_code_id is required"` / + `"odo_reading is required"` / `"odo_reading must be greater than zero"` + / `{ error: "" }` +- `404 { error: "Unknown vehicle QR" }` + +--- + +## 12. Chat (gated) ### `POST /api/chat` @@ -515,7 +590,7 @@ tool layer (`src/lib/agent/`). --- -## 12. What is **not** under `/api/` +## 13. What is **not** under `/api/` For completeness — these are out of scope for this reference but may be searched for: @@ -529,7 +604,7 @@ searched for: --- -## 13. Auth matrix +## 14. Auth matrix | Route | Auth | Notes | | --- | --- | --- | @@ -559,6 +634,9 @@ searched for: | `POST /api/data-import-batches/[id]/retry` | server-only | | | `POST /api/records/[collection]/[id]/verify` | server-only | Allowlist: POs + DNs | | `POST /api/chat` | gated by `ENABLE_CHAT=true` | **No user auth** — the gate is the env flag, not identity | +| `POST /api/clock/scan` | server-only | Behind the /clock PIN gate | +| `POST /api/clock/vehicle-lookup` | server-only | Behind the /clock PIN gate | +| `POST /api/clock/vehicle-scan` | server-only | Behind the /clock PIN gate | **Routes with no protection at all:** the chat route (when enabled) and any route that runs purely server-side. The browser-facing routes diff --git a/src/app/api/clock/vehicle-lookup/route.test.ts b/src/app/api/clock/vehicle-lookup/route.test.ts new file mode 100644 index 0000000..b230520 --- /dev/null +++ b/src/app/api/clock/vehicle-lookup/route.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const vehiclesGetFirst = vi.fn(); +const fuelLogsGetFirst = vi.fn(); + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: (name: string) => { + if (name === "vehicles") + return { getFirstListItem: vehiclesGetFirst }; + if (name === "fuel_logs") + return { getFirstListItem: fuelLogsGetFirst }; + throw new Error(`unexpected collection ${name}`); + }, + })), +})); + +import { POST } from "./route"; + +const VEHICLE = { + id: "veh00000000001", + description: "Bell B30E Articulated Dump Truck", + qr_code_id: "QR-PLANT-001", + site: "site0000000001", + unit_type: "plant" as const, + fuel_limits: "Diesel only · 200L max", + cost_per_hour: 185, +}; + +function post(body: Record) { + return POST( + new Request("http://x", { method: "POST", body: JSON.stringify(body) }), + ); +} + +describe("POST /api/clock/vehicle-lookup", () => { + beforeEach(() => { + vehiclesGetFirst.mockReset(); + fuelLogsGetFirst.mockReset(); + }); + + it("returns 400 for non-JSON body", async () => { + const res = await POST( + new Request("http://x", { method: "POST", body: "not-json{" }), + ); + expect(res.status).toBe(400); + expect(vehiclesGetFirst).not.toHaveBeenCalled(); + }); + + it("returns 400 when qr_code_id is missing", async () => { + const res = await post({}); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "qr_code_id is required" }); + expect(vehiclesGetFirst).not.toHaveBeenCalled(); + }); + + it("returns 400 when qr_code_id is empty / whitespace only", async () => { + const res = await post({ qr_code_id: " " }); + expect(res.status).toBe(400); + expect(vehiclesGetFirst).not.toHaveBeenCalled(); + }); + + it("returns 404 when no vehicle matches the QR", async () => { + vehiclesGetFirst.mockRejectedValueOnce(new Error("not found")); + const res = await post({ qr_code_id: "QR-NOPE" }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "Unknown vehicle QR" }); + expect(fuelLogsGetFirst).not.toHaveBeenCalled(); + }); + + it("returns vehicle with no previous reading when none logged yet", async () => { + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsGetFirst.mockRejectedValueOnce(new Error("not found")); + + const res = await post({ qr_code_id: "QR-PLANT-001" }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.vehicle).toEqual({ + id: VEHICLE.id, + description: VEHICLE.description, + qrCodeId: VEHICLE.qr_code_id, + siteId: VEHICLE.site, + unitType: VEHICLE.unit_type, + fuelLimits: VEHICLE.fuel_limits, + costPerHour: VEHICLE.cost_per_hour, + }); + expect(json.hasPrevious).toBe(false); + expect(json.previousReading).toBeNull(); + // Lookup by `-created` (insert time) — `date_logged` is a bare + // date and two readings on the same day would tie. + expect(fuelLogsGetFirst).toHaveBeenCalledWith( + expect.stringContaining(`vehicle="${VEHICLE.id}"`), + expect.objectContaining({ sort: "-created" }), + ); + }); + + it("returns the most-recently-inserted odo_reading as previousReading", async () => { + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsGetFirst.mockResolvedValueOnce({ odo_reading: 12345.6 }); + + const res = await post({ qr_code_id: "QR-PLANT-001" }); + const json = await res.json(); + expect(json.hasPrevious).toBe(true); + expect(json.previousReading).toBe(12345.6); + }); + + it("trims whitespace before querying PocketBase", async () => { + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsGetFirst.mockRejectedValueOnce(new Error("not found")); + + await post({ qr_code_id: " QR-PLANT-001 " }); + + // The filter sent to PB must contain the trimmed value verbatim. + expect(vehiclesGetFirst).toHaveBeenCalledWith( + expect.stringContaining(`qr_code_id="QR-PLANT-001"`), + ); + }); + + it("escapes double-quotes in the QR value to avoid PB filter injection", async () => { + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsGetFirst.mockRejectedValueOnce(new Error("not found")); + + await post({ qr_code_id: `QR-"EVIL"` }); + + // The literal double-quote must be backslash-escaped inside the + // PB filter string, so a stray quote in a QR code can't break out + // of the filter. + expect(vehiclesGetFirst).toHaveBeenCalledWith( + expect.stringContaining(`qr_code_id="QR-\\"EVIL\\""`), + ); + }); +}); \ No newline at end of file diff --git a/src/app/api/clock/vehicle-lookup/route.ts b/src/app/api/clock/vehicle-lookup/route.ts new file mode 100644 index 0000000..145b292 --- /dev/null +++ b/src/app/api/clock/vehicle-lookup/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import type { VehicleRecord } from "@/lib/pb-types"; + +/** + * Escape a string for inclusion inside a PocketBase filter literal. + * PB's filter parser splits on `"` and `\`; if either appears in the + * value (a QR code is user-generated, so it really can) the filter + * parses wrong and the query either fails or matches more rows than + * it should. Backslash-escape both before concatenating. + */ +function escapePb(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * POST /api/clock/vehicle-lookup + * + * Called by the MEGA Clock Fleet scanner the instant a vehicle QR is + * decoded. Looks up the vehicle, returns its display fields, and the + * most-recently-inserted `odo_reading` for it (the "previous reading" + * the popup shows — and the only field a fuel/plant entry needs that + * can't be derived client-side). + * + * Distinct from /api/clock/vehicle-scan, which writes the new row: + * this route is read-only and safe to spam from the camera decode + * loop, where the same code is recognised once per cooldown. The + * scan route is what actually mutates PocketBase. + * + * Auth: server-only (pbAdmin). The /clock PWA sits behind the PIN + * gate, but the underlying transport is open — same posture as + * /api/clock/scan, /api/fuel-logs/import, etc. + * + * Response shape (200): + * { + * vehicle: { id, description, qrCodeId, siteId, unitType, + * fuelLimits, costPerHour }, + * hasPrevious: boolean, + * previousReading: number | null // most recent odo_reading, + * // or null when nothing logged + * } + */ +export async function POST(request: Request) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + + const raw = typeof body.qr_code_id === "string" ? body.qr_code_id : ""; + const qrCodeId = raw.trim(); + if (!qrCodeId) { + return NextResponse.json({ error: "qr_code_id is required" }, { status: 400 }); + } + + const pb = await pbAdmin(); + + let vehicle: VehicleRecord; + try { + vehicle = await pb + .collection("vehicles") + .getFirstListItem(`qr_code_id="${escapePb(qrCodeId)}"`); + } catch { + return NextResponse.json({ error: "Unknown vehicle QR" }, { status: 404 }); + } + + let previousReading: number | null = null; + try { + const last = await pb + .collection("fuel_logs") + .getFirstListItem<{ odo_reading: number }>( + `vehicle="${escapePb(vehicle.id)}"`, + { sort: "-created" }, + ); + previousReading = last?.odo_reading ?? null; + } catch { + // No fuel_logs at all for this vehicle yet — that's the cold-start + // case (the QR was just printed, nothing's been scanned). Treat as + // "no previous reading" rather than an error. + previousReading = null; + } + + return NextResponse.json({ + vehicle: { + id: vehicle.id, + description: vehicle.description, + qrCodeId: vehicle.qr_code_id, + siteId: vehicle.site, + unitType: vehicle.unit_type, + fuelLimits: vehicle.fuel_limits, + costPerHour: vehicle.cost_per_hour, + }, + hasPrevious: previousReading !== null, + previousReading, + }); +} \ No newline at end of file diff --git a/src/app/api/clock/vehicle-scan/route.test.ts b/src/app/api/clock/vehicle-scan/route.test.ts new file mode 100644 index 0000000..18224f5 --- /dev/null +++ b/src/app/api/clock/vehicle-scan/route.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const vehiclesGetFirst = vi.fn(); +const fuelLogsCreate = vi.fn(); + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: (name: string) => { + if (name === "vehicles") + return { getFirstListItem: vehiclesGetFirst }; + if (name === "fuel_logs") + return { create: fuelLogsCreate }; + throw new Error(`unexpected collection ${name}`); + }, + })), +})); + +import { POST } from "./route"; + +const VEHICLE = { + id: "veh00000000001", + description: "Bell B30E Articulated Dump Truck", + qr_code_id: "QR-PLANT-001", + site: "site0000000001", + unit_type: "plant" as const, + fuel_limits: "Diesel only · 200L max", + cost_per_hour: 185, +}; + +function post(body: Record) { + return POST( + new Request("http://x", { method: "POST", body: JSON.stringify(body) }), + ); +} + +describe("POST /api/clock/vehicle-scan", () => { + beforeEach(() => { + vehiclesGetFirst.mockReset(); + fuelLogsCreate.mockReset(); + }); + + it("returns 400 for non-JSON body", async () => { + const res = await POST( + new Request("http://x", { method: "POST", body: "not-json{" }), + ); + expect(res.status).toBe(400); + expect(vehiclesGetFirst).not.toHaveBeenCalled(); + }); + + it("returns 400 when qr_code_id is missing", async () => { + const res = await post({ odo_reading: 1000 }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "qr_code_id is required" }); + expect(vehiclesGetFirst).not.toHaveBeenCalled(); + }); + + it("returns 400 when odo_reading is missing", async () => { + const res = await post({ qr_code_id: "QR-PLANT-001" }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "odo_reading is required" }); + }); + + it("returns 400 when odo_reading is not a number", async () => { + const res = await post({ qr_code_id: "QR-PLANT-001", odo_reading: "abc" }); + expect(res.status).toBe(400); + }); + + it("returns 400 when odo_reading is negative", async () => { + const res = await post({ qr_code_id: "QR-PLANT-001", odo_reading: -5 }); + expect(res.status).toBe(400); + expect(fuelLogsCreate).not.toHaveBeenCalled(); + }); + + it("returns 400 when odo_reading is zero (a fresh QR must be entered manually first via /lookup + UI)", async () => { + // The UI flow is: lookup, see the previous reading, enter the new + // one. Zero is a legal reading for a brand-new vehicle, but only + // when there is no prior row — that path is signalled by the + // `previousReading: null` the lookup returned, not by entering 0 + // into the scan endpoint. So scan rejects 0 outright and the UI + // can show a separate "first reading" prompt for that one case. + const res = await post({ qr_code_id: "QR-PLANT-001", odo_reading: 0 }); + expect(res.status).toBe(400); + expect(fuelLogsCreate).not.toHaveBeenCalled(); + }); + + it("returns 404 when no vehicle matches the QR", async () => { + vehiclesGetFirst.mockRejectedValueOnce(new Error("not found")); + const res = await post({ qr_code_id: "QR-NOPE", odo_reading: 1000 }); + expect(res.status).toBe(404); + expect(fuelLogsCreate).not.toHaveBeenCalled(); + }); + + it("writes a fuel_logs row anchored to today's date (UTC midnight)", async () => { + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsCreate.mockResolvedValueOnce({ id: "log00001" }); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-22T08:30:00.000Z")); + + const res = await post({ qr_code_id: "QR-PLANT-001", odo_reading: 12345.6 }); + const json = await res.json(); + + vi.useRealTimers(); + + expect(res.status).toBe(200); + expect(json).toEqual({ + vehicleId: VEHICLE.id, + odoReading: 12345.6, + dateLogged: "2026-09-22T00:00:00.000Z", + logId: "log00001", + }); + expect(fuelLogsCreate).toHaveBeenCalledWith({ + site: VEHICLE.site, + vehicle: VEHICLE.id, + date_logged: "2026-09-22T00:00:00.000Z", + litres: 0, + odo_reading: 12345.6, + }); + }); + + it("attributes the row to the vehicle's home site even if the QR was scanned at another site", async () => { + // We don't currently ask "which site" on the scan — a fuel log + // stamped against the wrong site is much worse than one stamped + // against the vehicle's home. The site relation on the vehicle + // row is the safer source of truth. + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsCreate.mockResolvedValueOnce({ id: "log00002" }); + + await post({ qr_code_id: "QR-PLANT-001", odo_reading: 999 }); + + expect(fuelLogsCreate).toHaveBeenCalledWith( + expect.objectContaining({ site: VEHICLE.site }), + ); + }); + + it("accepts integer odo_readings (no fractional part)", async () => { + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsCreate.mockResolvedValueOnce({ id: "log00003" }); + + const res = await post({ qr_code_id: "QR-PLANT-001", odo_reading: 4242 }); + expect(res.status).toBe(200); + expect(fuelLogsCreate).toHaveBeenCalledWith( + expect.objectContaining({ odo_reading: 4242 }), + ); + }); + + it("surfaces PocketBase validation errors with a 400 + their message", async () => { + vehiclesGetFirst.mockResolvedValueOnce(VEHICLE); + fuelLogsCreate.mockRejectedValueOnce( + Object.assign(new Error("Failed to create record."), { + data: { odo_reading: { message: "Must be positive." } }, + }), + ); + + const res = await post({ qr_code_id: "QR-PLANT-001", odo_reading: 1000 }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toMatch(/odo_reading/); + expect(json.error).toMatch(/positive/i); + }); +}); \ No newline at end of file diff --git a/src/app/api/clock/vehicle-scan/route.ts b/src/app/api/clock/vehicle-scan/route.ts new file mode 100644 index 0000000..5e6a6e9 --- /dev/null +++ b/src/app/api/clock/vehicle-scan/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import type { VehicleRecord } from "@/lib/pb-types"; + +function escapePb(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Format a PocketBase ClientResponseError the same way the rest of the + * codebase does (see src/app/api/fuel-logs/import/route.ts): + * the top-level message is generic ("Failed to create record."), and + * the field-level detail lives under `.data`. Flatten both into one + * string so the operator can see exactly which field is unhappy. + */ +function formatPbError(err: unknown): string { + const e = err as { + message?: string; + data?: Record; + }; + const head = e?.message ?? "Insert failed"; + if (e?.data && typeof e.data === "object") { + const parts = Object.entries(e.data) + .map(([field, info]) => `${field}: ${info?.message ?? "invalid"}`) + .join("; "); + if (parts) return `${head} (${parts})`; + } + return head; +} + +/** + * POST /api/clock/vehicle-scan + * + * Writes one fuel_logs row for the vehicle whose QR was just scanned. + * Pairs with /api/clock/vehicle-lookup, which the UI calls first to + * resolve the QR and show the previous reading. + * + * The Fleet scanner is intentionally narrow: it captures *one* number + * per scan — the end-of-shift hour-meter / odometer — and stamps a + * zero-litre fuel_logs row against it. Capturing fuel alongside is + * left to the spreadsheet import, which is the only place the pump + * sheet's litres column lives anyway. + * + * Why fuel_logs (the same collection the fuel import writes to): + * - The collection is already unified across fuel and plant units — + * see `VehicleRecord.unit_type` and the plant-view metrics. + * - `odo_reading` is the hour-meter for plant vehicles and the + * odometer for fuel vehicles; the column is one field because the + * audit trail cares about "the number on the dash", not its unit. + * + * Auth: server-only (pbAdmin), same posture as every other write in + * this directory. + * + * Body: + * { qr_code_id: string, odo_reading: number } + * + * Response (200): + * { vehicleId, odoReading, dateLogged, logId } + */ +export async function POST(request: Request) { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + + const qrRaw = typeof body.qr_code_id === "string" ? body.qr_code_id : ""; + const qrCodeId = qrRaw.trim(); + if (!qrCodeId) { + return NextResponse.json({ error: "qr_code_id is required" }, { status: 400 }); + } + + const odoReading = typeof body.odo_reading === "number" ? body.odo_reading : NaN; + if (!Number.isFinite(odoReading)) { + return NextResponse.json({ error: "odo_reading is required" }, { status: 400 }); + } + if (odoReading <= 0) { + return NextResponse.json( + { error: "odo_reading must be greater than zero" }, + { status: 400 }, + ); + } + + const pb = await pbAdmin(); + + let vehicle: VehicleRecord; + try { + vehicle = await pb + .collection("vehicles") + .getFirstListItem(`qr_code_id="${escapePb(qrCodeId)}"`); + } catch { + return NextResponse.json({ error: "Unknown vehicle QR" }, { status: 404 }); + } + + // PB's date field validator wants a full ISO datetime; the rest of + // the codebase anchors date-only fields at UTC midnight (see + // src/app/api/clock/scan/route.ts `dateLoggedFor`). + const dateLogged = new Date(new Date().toISOString().slice(0, 10)).toISOString(); + + try { + const created = await pb.collection("fuel_logs").create({ + site: vehicle.site, + vehicle: vehicle.id, + date_logged: dateLogged, + litres: 0, + odo_reading: odoReading, + }); + return NextResponse.json({ + vehicleId: vehicle.id, + odoReading, + dateLogged, + logId: created.id, + }); + } catch (err) { + return NextResponse.json( + { error: formatPbError(err) }, + { status: 400 }, + ); + } +} \ No newline at end of file diff --git a/src/app/clock/clock-view.tsx b/src/app/clock/clock-view.tsx index 4ad8f4c..9851965 100644 --- a/src/app/clock/clock-view.tsx +++ b/src/app/clock/clock-view.tsx @@ -5,6 +5,7 @@ import { FileText, Truck, User } from "lucide-react"; import { cn } from "@/lib/utils"; import type { SitePoint } from "@/lib/nearest-site"; import { PersonScanner } from "./person-scanner"; +import { FleetScanner } from "./fleet-scanner"; import { DocumentScanner } from "./document-scanner"; type Tab = "person" | "fleet" | "delivery"; @@ -38,7 +39,7 @@ export function ClockView({ sites }: { sites: SitePoint[] }) {
{tab === "person" && setScanCount((c) => c + 1)} />} - {tab === "fleet" && } + {tab === "fleet" && setScanCount((c) => c + 1)} />} {/* Remounted per visit rather than hidden: the camera stream and the screen wake lock must not stay live behind the badge scanner, which opens a camera of its own. */} @@ -95,15 +96,4 @@ function TabButton({ {label} ); -} - -function FleetComingSoon() { - return ( -
-

Fleet scanning is coming soon

-

- Vehicle QR scanning for plant hours isn't wired up yet — check back later. -

-
- ); } \ No newline at end of file diff --git a/src/app/clock/fleet-scanner.tsx b/src/app/clock/fleet-scanner.tsx new file mode 100644 index 0000000..050e875 --- /dev/null +++ b/src/app/clock/fleet-scanner.tsx @@ -0,0 +1,759 @@ +"use client"; + +import * as React from "react"; +import jsQR from "jsqr"; +import { Keyboard, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +/** + * MEGA Clock — Fleet scanner. + * + * Mirrors PersonScanner's camera + decode loop and pipes every + * successful QR read through a two-step server exchange: + * + * 1. POST /api/clock/vehicle-lookup → vehicle + previous odo_reading + * 2. POST /api/clock/vehicle-scan → writes the new fuel_logs row + * + * The popup is the heart of the flow. The vehicle lookup tells us + * what to label the field ("Hour meter" for plant, "Odometer" for fuel) + * and gives us the previous reading to display. The user types the + * current reading, confirms, and the row is written. + * + * Two ways to start the flow: + * - scan: the camera decode loop calls `startLookup(value)` directly + * - manual: the "Enter manually" chip in the bottom of the viewfinder + * opens a small dialog; on submit that calls `startLookup(value)` + * with the typed QR + * + * Both paths go through the same gate (`startLookup`) so the + * `busyRef` / phase state machine never has to care which one fired. + * + * Phases (state machine — `phase.kind`): + * scanning camera live, accepting scans + * loading lookup in flight + * error unknown QR or network blip (brief banner) + * form vehicle info + previous reading + numeric input + * submitting POST in flight + * submitted saved; auto-restarts back to scanning + * submitError save failed; stays on form so the user can retry + * + * The camera effect runs ONCE on mount; transitions are driven via a + * `phaseRef` so the decode loop can read the current phase without + * tearing down the camera stream on every state change. + * + * Remounted per visit (same rationale as DocumentScanner): the camera + * stream must not stay live behind the form, and the wake lock is + * released when this component unmounts. + */ + +const BANNER_MS = 2200; + +interface VehiclePayload { + id: string; + description: string; + qrCodeId: string; + siteId: string; + unitType: "fuel" | "plant"; + fuelLimits: string; + costPerHour: number; +} + +type Phase = + | { kind: "scanning" } + | { kind: "loading"; qrCodeId: string } + | { kind: "error"; message: string } + | { kind: "form"; qrCodeId: string; vehicle: VehiclePayload; previousReading: number | null } + | { kind: "submitting"; vehicle: VehiclePayload; previousReading: number | null; reading: string } + | { kind: "submitted"; vehicle: VehiclePayload; odoReading: number } + | { kind: "submitError"; qrCodeId: string; vehicle: VehiclePayload; previousReading: number | null; reading: string; message: string }; + +export function FleetScanner({ onScan }: { onScan?: () => void } = {}) { + const videoRef = React.useRef(null); + const canvasRef = React.useRef(null); + const busyRef = React.useRef(false); + const detectorRef = React.useRef(null); + // Read by the decode loop without re-binding the effect on every + // transition. setPhase also updates this in a layout effect so the + // ref is always in sync before the next animation frame. + const phaseRef = React.useRef({ kind: "scanning" }); + const [phase, setPhaseRaw] = React.useState({ kind: "scanning" }); + const [cameraError, setCameraError] = React.useState(null); + // Manual-entry dialog is independent of `phase` — it sits over the + // scanning view without flipping the state machine, so the camera + // keeps running behind it. The ref is read by handleDecoded to make + // sure the camera can't intercept while the user is typing. + const [manualOpen, setManualOpen] = React.useState(false); + const manualOpenRef = React.useRef(false); + React.useEffect(() => { + manualOpenRef.current = manualOpen; + }, [manualOpen]); + + const setPhase = React.useCallback((next: Phase) => { + phaseRef.current = next; + setPhaseRaw(next); + }, []); + + // --- Lookup + submit ----------------------------------------------- + // Wrapped in useCallback so the camera effect can call them with a + // stable identity and not tear down the camera every render. + const lookup = React.useCallback( + async (qrCodeId: string) => { + try { + const res = await fetch("/api/clock/vehicle-lookup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ qr_code_id: qrCodeId }), + }); + const body = await res.json(); + if (!res.ok) { + const message = (body as { error?: string }).error ?? "Scan failed"; + setPhase({ kind: "error", message }); + setTimeout(() => { + if (phaseRef.current.kind === "error") { + busyRef.current = false; + setPhase({ kind: "scanning" }); + } + }, BANNER_MS); + return; + } + const ok = body as { + vehicle: VehiclePayload; + hasPrevious: boolean; + previousReading: number | null; + }; + setPhase({ + kind: "form", + qrCodeId, + vehicle: ok.vehicle, + previousReading: ok.previousReading, + }); + // busyRef stays true until Cancel or Submit — see camera + // effect's `handleDecoded` for the gate. + } catch { + setPhase({ kind: "error", message: "Couldn't reach the server — try again" }); + setTimeout(() => { + if (phaseRef.current.kind === "error") { + busyRef.current = false; + setPhase({ kind: "scanning" }); + } + }, BANNER_MS); + } + }, + [setPhase], + ); + + // --- Shared entry point --------------------------------------------- + // Both the camera decode loop and the manual-entry dialog funnel + // through this so the busy/phase gate never has to care which one + // fired. Returns false when the gate refuses the request (caller + // should silently drop), true when a lookup is now in flight. + const startLookup = React.useCallback( + (qrCodeId: string): boolean => { + const trimmed = qrCodeId.trim(); + if (!trimmed) return false; + if (busyRef.current) return false; + if (phaseRef.current.kind !== "scanning") return false; + busyRef.current = true; + setPhase({ kind: "loading", qrCodeId: trimmed }); + void lookup(trimmed); + return true; + }, + [lookup, setPhase], + ); + + const submit = React.useCallback( + async (qrCodeId: string, vehicle: VehiclePayload, previousReading: number | null, reading: number) => { + setPhase({ + kind: "submitting", + vehicle, + previousReading, + reading: String(reading), + }); + try { + const res = await fetch("/api/clock/vehicle-scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ qr_code_id: qrCodeId, odo_reading: reading }), + }); + const body = await res.json(); + if (!res.ok) { + setPhase({ + kind: "submitError", + qrCodeId, + vehicle, + previousReading, + reading: String(reading), + message: body.error ?? `Save failed (${res.status})`, + }); + return; + } + setPhase({ kind: "submitted", vehicle, odoReading: reading }); + onScan?.(); + navigator.vibrate?.([80, 60, 80]); + setTimeout(() => { + if (phaseRef.current.kind === "submitted") { + busyRef.current = false; + setPhase({ kind: "scanning" }); + } + }, 1800); + } catch { + setPhase({ + kind: "submitError", + qrCodeId, + vehicle, + previousReading, + reading: String(reading), + message: "Couldn't reach the server — try again", + }); + } + }, + [onScan, setPhase], + ); + + // --- Camera + QR decode loop ---------------------------------------- + // Mount once. handleDecoded reads phaseRef.current — no re-binding. + React.useEffect(() => { + let stream: MediaStream | null = null; + let rafId = 0; + let cancelled = false; + let wakeLock: WakeLockSentinel | null = null; + + async function requestWakeLock() { + try { + wakeLock = (await navigator.wakeLock?.request("screen")) ?? null; + } catch { + // Kiosk phones may be denied — not fatal. + } + } + + function handleVisibilityChange() { + if (document.visibilityState === "visible") requestWakeLock(); + } + + function handleDecoded(value: string) { + // Manual-entry dialog sits over the scanning view without + // flipping the phase — refuse any camera decode while it's open + // so the user can't have their typed input trampled by a + // stray sticker. + if (manualOpenRef.current) return; + startLookup(value); + } + + async function tick() { + if (cancelled) return; + const video = videoRef.current; + const canvas = canvasRef.current; + if (video && canvas && video.readyState >= video.HAVE_CURRENT_DATA) { + const ctx = canvas.getContext("2d"); + if (ctx) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + let value: string | null = null; + if (detectorRef.current) { + try { + const codes = await detectorRef.current.detect(canvas); + value = codes[0]?.rawValue ?? null; + } catch { + value = null; + } + } else { + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const code = jsQR(imageData.data, imageData.width, imageData.height); + value = code?.data ?? null; + } + if (value) handleDecoded(value); + } + } + rafId = requestAnimationFrame(tick); + } + + async function start() { + if (!navigator.mediaDevices?.getUserMedia) { + setCameraError("Camera not available in this browser."); + return; + } + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + } catch { + setCameraError("Camera access denied or unavailable."); + return; + } + if (cancelled) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + } + if (window.BarcodeDetector) { + detectorRef.current = new window.BarcodeDetector({ formats: ["qr_code"] }); + } + await requestWakeLock(); + tick(); + } + + document.addEventListener("visibilitychange", handleVisibilityChange); + start(); + + return () => { + cancelled = true; + cancelAnimationFrame(rafId); + document.removeEventListener("visibilitychange", handleVisibilityChange); + wakeLock?.release().catch(() => undefined); + stream?.getTracks().forEach((t) => t.stop()); + }; + // Intentional one-shot mount. handleDecoded reads phaseRef / + // manualOpenRef and startLookup is a stable callback; including + // it would tear down the camera stream on every state change. + }, [startLookup]); + + // --- UI ------------------------------------------------------------- + const popupOpen = + phase.kind === "loading" || + phase.kind === "form" || + phase.kind === "submitting" || + phase.kind === "submitted" || + phase.kind === "submitError"; + + return ( +
+
+ ); +} + +function readingLabel(unitType: "fuel" | "plant"): string { + return unitType === "plant" ? "Hour meter" : "Odometer"; +} + +function unitSuffix(unitType: "fuel" | "plant"): string { + return unitType === "plant" ? "hrs" : "km"; +} + +function DialogBody({ + phase, + onSubmit, +}: { + phase: Phase; + onSubmit: (reading: number) => void; +}) { + if (phase.kind === "loading") { + return ( +
+ +

Looking up vehicle…

+

{phase.qrCodeId}

+
+ ); + } + + if (phase.kind === "submitting") { + return ( +
+ +

Saving reading…

+
+ ); + } + + if (phase.kind === "submitted") { + return ( +
+

✓

+

{phase.vehicle.description}

+

+ {phase.odoReading.toLocaleString()} {unitSuffix(phase.vehicle.unitType)} +

+

Saved

+
+ ); + } + + if (phase.kind === "form" || phase.kind === "submitError") { + return ( + + ); + } + + return null; +} + +function FormBody({ + qrCodeId, + vehicle, + previousReading, + submitError, + onSubmit, +}: { + qrCodeId: string; + vehicle: VehiclePayload; + previousReading: number | null; + submitError: string | null; + onSubmit: (reading: number) => void; +}) { + const [value, setValue] = React.useState(""); + const inputRef = React.useRef(null); + + React.useEffect(() => { + // Focus on mount — the kiosk phone's on-screen keyboard is the + // only way to enter this number on a wall-mounted device. + const id = setTimeout(() => inputRef.current?.focus(), 80); + return () => clearTimeout(id); + }, []); + + const numeric = value.trim() === "" ? null : Number(value); + const isValid = numeric !== null && Number.isFinite(numeric) && numeric > 0; + // The reading must move forward. If there's no previous reading, + // any positive number is the starting value and is allowed. + const movesForward = + isValid && (previousReading === null || numeric! > previousReading); + const label = readingLabel(vehicle.unitType); + const suffix = unitSuffix(vehicle.unitType); + + const helper = + submitError ?? + (previousReading === null + ? "No previous reading on file — enter the current reading to start tracking this vehicle." + : movesForward + ? null + : `Must be greater than the previous reading (${previousReading.toLocaleString()} ${suffix}).`); + + return ( + <> + + + {qrCodeId} · {vehicle.unitType === "plant" ? "Plant" : "Vehicle"} + + {vehicle.description} + + +
+ +
+ +
{ + e.preventDefault(); + if (movesForward) onSubmit(numeric as number); + }} + > + + + {helper && ( +

+ {helper} +

+ )} + + + {/* The Dialog's own X button closes — the form has no Cancel + because closing the dialog = cancelling. */} + + +
+ + ); +} + +function PreviousReadingStrip({ + previousReading, + suffix, +}: { + previousReading: number | null; + suffix: string; +}) { + if (previousReading === null) { + return ( +
+ No previous reading — this is the first entry for this vehicle. +
+ ); + } + return ( +
+ + Previous reading + + + {previousReading.toLocaleString()} {suffix} + +
+ ); +} + +/** Viewfinder chrome, themed for fleet (orange = the brand colour + * throughout the dashboard). Same vignette trick as PersonScanner: + * a huge box-shadow on a transparent viewfinder paints the dim + * layer around it without SVG mask math. + * + * The bottom area also carries a manual-entry chip — a wall-mounted + * kiosk can have a damaged sticker, a foggy camera, or just be out of + * camera permission, and the operator still needs to log readings. + * Pressing the chip opens a small dialog with a text input; the same + * lookup flow runs from there as from a scan. */ +function FleetOverlay({ onManualEntry }: { onManualEntry: () => void }) { + return ( + <> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + Scanning for vehicle QR + +
+
+
+

+ Hold the vehicle QR sticker inside the box +

+ {/* pointer-events-auto re-enables taps on this chip alone — the + surrounding hint container is pointer-events-none so the + live camera feed still receives touches. */} +
+ +
+
+ + ); +} + +/** + * Manual-entry dialog. Independent of the main data Dialog — sits over + * the scanning view without flipping the phase state, so the camera + * can keep trying to decode behind it (the ref gate in the camera + * effect blocks those decodes while this is open, so the user never + * has their typed input trampled by a stray sticker). + * + * Validation: the field is required, and trim is applied before + * handing off to startLookup. No character restriction — the QR + * could legitimately contain anything (see the `escapePb` discussion + * in /api/clock/vehicle-lookup/route.ts). + */ +function ManualEntryDialog({ + open, + onOpenChange, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (qrCodeId: string) => void; +}) { + const [value, setValue] = React.useState(""); + const inputRef = React.useRef(null); + + // Reset on every open so a previous attempt's value doesn't leak + // into the next session (a wall-mounted kiosk will be opened many + // times by many people). + React.useEffect(() => { + if (open) { + setValue(""); + const id = setTimeout(() => inputRef.current?.focus(), 80); + return () => clearTimeout(id); + } + return undefined; + }, [open]); + + const trimmed = value.trim(); + const canSubmit = trimmed.length > 0; + + return ( + + + + Enter vehicle QR + + Type the QR code printed on the vehicle's sticker. Use + this when the camera can't read it. + + +
{ + e.preventDefault(); + if (canSubmit) onSubmit(trimmed); + }} + > + + + + +
+
+
+ ); +} \ No newline at end of file