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.
This commit is contained in:
parent
f1dbc96435
commit
3077223d38
9 changed files with 1398 additions and 15 deletions
44
.claude/settings.local.json
Normal file
44
.claude/settings.local.json
Normal file
|
|
@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "<pocketBase field-level 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
|
||||
|
|
|
|||
132
src/app/api/clock/vehicle-lookup/route.test.ts
Normal file
132
src/app/api/clock/vehicle-lookup/route.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
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\\""`),
|
||||
);
|
||||
});
|
||||
});
|
||||
97
src/app/api/clock/vehicle-lookup/route.ts
Normal file
97
src/app/api/clock/vehicle-lookup/route.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<VehicleRecord>(`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,
|
||||
});
|
||||
}
|
||||
161
src/app/api/clock/vehicle-scan/route.test.ts
Normal file
161
src/app/api/clock/vehicle-scan/route.test.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
121
src/app/api/clock/vehicle-scan/route.ts
Normal file
121
src/app/api/clock/vehicle-scan/route.ts
Normal file
|
|
@ -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<string, { message?: string; code?: string }>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
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<VehicleRecord>(`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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] }) {
|
|||
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{tab === "person" && <PersonScanner onScan={() => setScanCount((c) => c + 1)} />}
|
||||
{tab === "fleet" && <FleetComingSoon />}
|
||||
{tab === "fleet" && <FleetScanner onScan={() => 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}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FleetComingSoon() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<p className="text-lg font-semibold text-foreground">Fleet scanning is coming soon</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Vehicle QR scanning for plant hours isn't wired up yet — check back later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
759
src/app/clock/fleet-scanner.tsx
Normal file
759
src/app/clock/fleet-scanner.tsx
Normal file
|
|
@ -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<HTMLVideoElement>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const busyRef = React.useRef(false);
|
||||
const detectorRef = React.useRef<BarcodeDetector | null>(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<Phase>({ kind: "scanning" });
|
||||
const [phase, setPhaseRaw] = React.useState<Phase>({ kind: "scanning" });
|
||||
const [cameraError, setCameraError] = React.useState<string | null>(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 (
|
||||
<div className="relative flex-1 overflow-hidden bg-black">
|
||||
<video ref={videoRef} muted playsInline className="h-full w-full object-cover" />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{phase.kind === "scanning" && (
|
||||
<FleetOverlay onManualEntry={() => setManualOpen(true)} />
|
||||
)}
|
||||
|
||||
{cameraError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/85 p-6 text-center text-white">
|
||||
{cameraError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === "error" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-red-600/90 p-6 text-center text-2xl font-bold text-white">
|
||||
✗ {phase.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={popupOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
if (
|
||||
phase.kind === "loading" ||
|
||||
phase.kind === "form" ||
|
||||
phase.kind === "submitError"
|
||||
) {
|
||||
busyRef.current = false;
|
||||
setPhase({ kind: "scanning" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md border-border bg-card p-0 sm:rounded-2xl">
|
||||
<DialogBody
|
||||
phase={phase}
|
||||
onSubmit={(reading) => {
|
||||
if (phase.kind !== "form" && phase.kind !== "submitError") return;
|
||||
const { qrCodeId, vehicle, previousReading } = phase;
|
||||
void submit(qrCodeId, vehicle, previousReading, reading);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ManualEntryDialog
|
||||
open={manualOpen}
|
||||
onOpenChange={setManualOpen}
|
||||
onSubmit={(qr) => {
|
||||
// Close the manual dialog first so the lookup dialog that
|
||||
// startLookup triggers (loading → form) isn't stacked
|
||||
// behind it.
|
||||
setManualOpen(false);
|
||||
startLookup(qr);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-3 p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-base font-semibold text-foreground">Looking up vehicle…</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{phase.qrCodeId}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase.kind === "submitting") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-base font-semibold text-foreground">Saving reading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase.kind === "submitted") {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 bg-emerald-600 p-8 text-center text-white">
|
||||
<p className="text-3xl">✓</p>
|
||||
<p className="text-lg font-bold">{phase.vehicle.description}</p>
|
||||
<p className="text-2xl font-bold tracking-tight">
|
||||
{phase.odoReading.toLocaleString()} {unitSuffix(phase.vehicle.unitType)}
|
||||
</p>
|
||||
<p className="text-sm opacity-90">Saved</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase.kind === "form" || phase.kind === "submitError") {
|
||||
return (
|
||||
<FormBody
|
||||
qrCodeId={phase.qrCodeId}
|
||||
vehicle={phase.vehicle}
|
||||
previousReading={phase.previousReading}
|
||||
submitError={phase.kind === "submitError" ? phase.message : null}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(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 (
|
||||
<>
|
||||
<DialogHeader className="space-y-1 px-6 pt-6">
|
||||
<DialogDescription className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{qrCodeId} · {vehicle.unitType === "plant" ? "Plant" : "Vehicle"}
|
||||
</DialogDescription>
|
||||
<DialogTitle className="text-xl leading-tight">{vehicle.description}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-2">
|
||||
<PreviousReadingStrip previousReading={previousReading} suffix={suffix} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-3 px-6 pb-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (movesForward) onSubmit(numeric as number);
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
Current {label.toLowerCase()}
|
||||
</span>
|
||||
<div className="relative mt-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.1"
|
||||
min={previousReading !== null ? previousReading + 0.1 : 0.1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
previousReading !== null
|
||||
? `> ${previousReading.toLocaleString()}`
|
||||
: "0"
|
||||
}
|
||||
aria-label={`Current ${label.toLowerCase()}`}
|
||||
className="h-14 bg-input/60 px-4 pr-16 text-2xl font-bold tracking-tight"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-sm font-semibold text-muted-foreground">
|
||||
{suffix}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{helper && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
submitError ? "text-red-600" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{helper}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
{/* The Dialog's own X button closes — the form has no Cancel
|
||||
because closing the dialog = cancelling. */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!movesForward}
|
||||
size="lg"
|
||||
className="w-full text-base"
|
||||
>
|
||||
Save reading
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviousReadingStrip({
|
||||
previousReading,
|
||||
suffix,
|
||||
}: {
|
||||
previousReading: number | null;
|
||||
suffix: string;
|
||||
}) {
|
||||
if (previousReading === null) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
No previous reading — this is the first entry for this vehicle.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg bg-muted/60 px-3 py-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Previous reading
|
||||
</span>
|
||||
<span className="font-mono text-base font-bold text-foreground">
|
||||
{previousReading.toLocaleString()} {suffix}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 h-[58vh] max-h-[320px] w-[58vh] max-w-[320px] -translate-x-1/2 -translate-y-1/2 rounded-2xl"
|
||||
style={{ boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)" }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 h-[58vh] max-h-[320px] w-[58vh] max-w-[320px] -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<div className="absolute -left-1 -top-1 h-10 w-10 rounded-tl-2xl border-l-[5px] border-t-[5px] border-primary" />
|
||||
<div className="absolute -right-1 -top-1 h-10 w-10 rounded-tr-2xl border-r-[5px] border-t-[5px] border-primary" />
|
||||
<div className="absolute -bottom-1 -left-1 h-10 w-10 rounded-bl-2xl border-b-[5px] border-l-[5px] border-primary" />
|
||||
<div className="absolute -bottom-1 -right-1 h-10 w-10 rounded-br-2xl border-b-[5px] border-r-[5px] border-primary" />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
>
|
||||
<div className="absolute left-1/2 top-1/2 h-[58vh] max-h-[320px] w-[58vh] max-w-[320px] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="relative h-full w-full overflow-hidden rounded-2xl">
|
||||
<div
|
||||
className="clock-scan-line absolute left-6 right-6 h-[2px] bg-gradient-to-r from-transparent via-primary to-transparent"
|
||||
style={{ filter: "drop-shadow(0 0 6px rgba(253, 177, 60, 0.7))" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute left-1/2 top-6 -translate-x-1/2">
|
||||
<div className="flex items-center gap-2 rounded-full bg-black/60 px-4 py-2 ring-1 ring-white/10 backdrop-blur">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold tracking-wide text-white">
|
||||
Scanning for vehicle QR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-6 px-6 text-center">
|
||||
<p className="text-sm font-medium text-white/90 drop-shadow-md">
|
||||
Hold the vehicle QR sticker inside the box
|
||||
</p>
|
||||
{/* 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. */}
|
||||
<div className="pointer-events-auto mt-3 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManualEntry}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-black/70 px-4 py-2 text-sm font-semibold text-white ring-1 ring-white/30 backdrop-blur transition active:bg-black/90"
|
||||
aria-label="Enter vehicle QR manually"
|
||||
>
|
||||
<Keyboard className="h-4 w-4" aria-hidden />
|
||||
Enter manually
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLInputElement>(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className="max-w-sm border-border bg-card p-0 sm:rounded-2xl">
|
||||
<DialogHeader className="space-y-1 px-6 pt-6">
|
||||
<DialogTitle className="text-lg leading-tight">Enter vehicle QR</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Type the QR code printed on the vehicle's sticker. Use
|
||||
this when the camera can't read it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-3 px-6 pb-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canSubmit) onSubmit(trimmed);
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="sr-only">Vehicle QR code</span>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="e.g. QR-PLANT-001"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
aria-label="Vehicle QR code"
|
||||
className="h-12 bg-input/60 px-4 font-mono text-base"
|
||||
/>
|
||||
</label>
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
size="lg"
|
||||
className="w-full text-base"
|
||||
>
|
||||
Look up vehicle
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue