From 3077223d388f904919972563798ee7630bd864fa Mon Sep 17 00:00:00 2001 From: DEls Date: Tue, 22 Sep 2026 12:42:39 +0200 Subject: [PATCH] feat(clock): add MEGA Clock API endpoints and update documentation Introduced three new server-only API endpoints for the MEGA Clock PWA: - `POST /api/clock/scan` for clocking workers in and out via badge scanning. - `POST /api/clock/vehicle-lookup` for looking up vehicle details using QR codes. - `POST /api/clock/vehicle-scan` for logging odometer readings from scanned vehicles. Updated the README and API reference documentation to include these new endpoints and their functionalities. Replaced the placeholder for fleet scanning in the ClockView component with the new FleetScanner component. --- .claude/settings.local.json | 44 + README.md | 1 + docs/api-reference.md | 84 +- .../api/clock/vehicle-lookup/route.test.ts | 132 +++ src/app/api/clock/vehicle-lookup/route.ts | 97 +++ src/app/api/clock/vehicle-scan/route.test.ts | 161 ++++ src/app/api/clock/vehicle-scan/route.ts | 121 +++ src/app/clock/clock-view.tsx | 14 +- src/app/clock/fleet-scanner.tsx | 759 ++++++++++++++++++ 9 files changed, 1398 insertions(+), 15 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/app/api/clock/vehicle-lookup/route.test.ts create mode 100644 src/app/api/clock/vehicle-lookup/route.ts create mode 100644 src/app/api/clock/vehicle-scan/route.test.ts create mode 100644 src/app/api/clock/vehicle-scan/route.ts create mode 100644 src/app/clock/fleet-scanner.tsx 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