From 708e0b274295c3b3d6c7a2bdb0997f2c766b1dfe Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 21 Oct 2026 06:34:05 +0200 Subject: [PATCH] feat(clock): a scan at another site moves workers and vehicles there automatically A destination scan now ends the worker's previous site session at that moment (transfer OUT, hours booked at the origin) and opens the new one, so nothing is left for the office to review. Vehicle scans take a scanning site, move the unit there and log the reading against it. Adds a script to close already-flagged moves. Co-Authored-By: Claude Sonnet 5.5 --- scripts/close-flagged-site-moves.ts | 67 ++++++++++++++++++++ src/app/api/clock/scan/route.test.ts | 2 +- src/app/api/clock/scan/route.ts | 16 +++-- src/app/api/clock/vehicle-lookup/route.ts | 11 +++- src/app/api/clock/vehicle-scan/route.test.ts | 16 ++++- src/app/api/clock/vehicle-scan/route.ts | 20 ++++-- src/app/clock/clock-view.tsx | 2 +- src/app/clock/fleet-scanner.tsx | 49 ++++++++++++-- src/app/clock/person-scanner.tsx | 4 +- 9 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 scripts/close-flagged-site-moves.ts diff --git a/scripts/close-flagged-site-moves.ts b/scripts/close-flagged-site-moves.ts new file mode 100644 index 0000000..00dfd11 --- /dev/null +++ b/scripts/close-flagged-site-moves.ts @@ -0,0 +1,67 @@ +/** + * Run with npx tsx --env-file=.env.local scripts/close-flagged-site-moves.ts [--apply]. Defaults to a read-only preview. + * + * Before 2026-10-21 a scan at a second site left the first site's session open and + * parked the worker in "Site moves awaiting office review". A scan at the new site + * now ends the old session itself. This closes the ones already flagged the same way: + * an OUT at the origin site at the moment of the destination scan (a transfer OUT, + * so no shift-end rounding), the origin's hours added to that day's timesheet entry, + * and the flag marked resolved. Workers whose origin day already holds uploaded or + * overlapping hours are listed and left alone. + */ +import { createHash } from "node:crypto"; +import { pbAdmin } from "../src/lib/pb"; +import { dateLogged, workDate } from "../src/lib/clock-day"; +import { clockDayPaidMinutes } from "../src/lib/clock-session-hours"; +import type { ClockEventRecord, ClockMissedOutCorrectionRecord, TimesheetEntryRecord } from "../src/lib/pb-types"; + +const idFor = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 15); + +async function main() { + const args = process.argv.slice(2); + if (args.some((arg) => arg !== "--apply")) throw new Error("Only --apply is supported"); + const apply = args.includes("--apply"); + const pb = await pbAdmin(); + const flagged = await pb.collection("clock_events").getFullList({ + filter: `previous_unclosed_in!="" && office_resolved_at=""`, + sort: "scanned_at", + expand: "employee,site,previous_unclosed_in,previous_unclosed_in.site", + }); + let closed = 0; + for (const dest of flagged) { + const origin = (dest.expand as { previous_unclosed_in?: ClockEventRecord & { expand?: { site?: { name?: string } } } } | undefined)?.previous_unclosed_in; + const who = `${dest.expand?.employee?.name ?? dest.employee} (${dest.expand?.employee?.employee_no ?? "?"})`; + if (!origin) { console.log(`SKIP ${who}: origin scan missing`); continue; } + const day = workDate(origin.scanned_at); + const corrections = await pb.collection("clock_missed_out_corrections").getFullList({ filter: `employee="${dest.employee}"` }); + if (corrections.some((c) => c.clock_in_event === origin.id)) { console.log(`SKIP ${who}: origin already corrected by the office`); continue; } + const outId = idFor(`clock-transfer-out:${dest.employee}:${day}:${origin.id}`); + const outAt = new Date(Date.parse(dest.scanned_at) - 1).toISOString(); + const events = await pb.collection("clock_events").getFullList({ + filter: `employee="${dest.employee}" && scanned_at>="${new Date(Date.parse(dateLogged(day)) - 7_200_000).toISOString()}" && scanned_at<="${dest.scanned_at}"`, + sort: "scanned_at", + }); + if (events.some((e) => e.id === outId)) { console.log(`SKIP ${who}: already closed`); continue; } + const before = clockDayPaidMinutes(events, corrections, day); + const after = clockDayPaidMinutes([...events, { ...origin, id: outId, direction: "out", scanned_at: outAt, is_transfer: true }], corrections, day); + const hours = ((after.get(origin.site) ?? 0) - (before.get(origin.site) ?? 0)) / 60; + const entryDate = dateLogged(day); + const entries = await pb.collection("timesheet_entries").getFullList({ filter: `employee="${dest.employee}" && site="${origin.site}" && date_logged="${entryDate.replace("T", " ")}"` }); + if (entries.length > 1 || entries.some((e) => e.timesheet)) { console.log(`SKIP ${who}: ${day} already holds uploaded or overlapping hours at the origin site`); continue; } + const entry = entries[0]; + const entryId = entry?.id ?? idFor(`clock-direct:${dest.employee}:${origin.site}:${day}`); + const total = (Math.round((entry?.hours_worked ?? 0) * 60) + Math.round(hours * 60)) / 60; + console.log(`${apply ? "CLOSE" : "WOULD CLOSE"} ${who}: ${origin.expand?.site?.name ?? origin.site} -> ${dest.expand?.site?.name ?? dest.site}, ${day} ${hours.toFixed(2)} h at the origin (day total ${total.toFixed(2)} h)`); + closed++; + if (!apply) continue; + const batch = pb.createBatch(); + if (entry) batch.collection("timesheet_entries").update(entry.id, { hours_worked: total }); + else batch.collection("timesheet_entries").create({ id: entryId, employee: dest.employee, site: origin.site, date_logged: entryDate, hours_worked: total, import_key: `${dest.employee}:${origin.site}:${day}` }); + batch.collection("clock_events").create({ id: outId, employee: dest.employee, site: origin.site, direction: "out", scanned_at: outAt, timesheet_entry: entryId, is_transfer: true }); + batch.collection("clock_events").update(dest.id, { office_resolved_at: new Date().toISOString(), office_note: "Closed automatically: the scan at the new site ended the previous session." }); + const results = await batch.send(); + if (results.some((r) => r.status < 200 || r.status >= 300)) throw new Error(`Could not verify the close for ${who}`); + } + console.log(JSON.stringify({ flagged: flagged.length, closed, applied: apply })); +} +main().catch((error) => { console.error("Site-move close-out failed. Nothing further was changed.", error); process.exitCode = 1; }); diff --git a/src/app/api/clock/scan/route.test.ts b/src/app/api/clock/scan/route.test.ts index b40852e..4c0d5d7 100644 --- a/src/app/api/clock/scan/route.test.ts +++ b/src/app/api/clock/scan/route.test.ts @@ -28,7 +28,7 @@ it("allows a destination-site scanner to scan a badge assigned elsewhere",async( it("keeps daily direct hours separate by site and creates a paid full-day total",async()=>{m.latest.mockResolvedValue(first);m.events.mockResolvedValue([first]);const response=await post({...request,expected_event_id:first.id});expect(await response.json()).toMatchObject({direction:"out",sessionHours:9,dailyTotalHours:9});expect(m.entries).toHaveBeenCalledWith(expect.objectContaining({filter:expect.stringContaining(`site="${employee.site}"`)}));expect(m.ops).toContainEqual(expect.objectContaining({collection:"timesheet_entries",body:expect.objectContaining({site:employee.site,hours_worked:9,date_logged:"2026-10-06T00:00:00.000Z"})}));}); it("adds repeated sessions to the same site's direct total using whole minutes",async()=>{m.latest.mockResolvedValue({...first,scanned_at:"2026-10-06T13:00:00Z"});m.events.mockResolvedValue([{...first,scanned_at:"2026-10-06T13:00:00Z"}]);m.entries.mockResolvedValue([{id:"entry",hours_worked:4}]);const response=await post({...request,expected_event_id:first.id});expect(await response.json()).toMatchObject({dailyTotalHours:5.5});expect(m.ops).toContainEqual({collection:"timesheet_entries",method:"update",id:"entry",body:{hours_worked:5.5}});}); it("refuses to double-count a spreadsheet daily total on clock-out",async()=>{m.latest.mockResolvedValue(first);m.entries.mockResolvedValue([{id:"upload",timesheet:"doc",hours_worked:9}]);expect((await post({...request,expected_event_id:first.id})).status).toBe(409);expect(m.send).not.toHaveBeenCalled();}); -it("flags an unclosed origin session, records the destination IN and never invents an OUT",async()=>{m.latest.mockResolvedValue(first);m.events.mockResolvedValue([first]);const response=await post({...request,site:"site00000000002",expected_event_id:first.id});expect(await response.json()).toMatchObject({direction:"in",transferred:true});const events=m.ops.filter(op=>op.collection==="clock_events");expect(events).toHaveLength(1);expect(events[0].body).toMatchObject({direction:"in",site:"site00000000002",previous_unclosed_in:first.id});expect(m.ops.some(op=>op.collection==="timesheet_entries")).toBe(false);expect(m.send).toHaveBeenCalledOnce();}); +it("a scan at another site closes the origin session at that moment and opens the new site, with nothing left for the office",async()=>{m.latest.mockResolvedValue(first);m.events.mockResolvedValue([first]);const response=await post({...request,site:"site00000000002",expected_event_id:first.id});expect(await response.json()).toMatchObject({direction:"in",transferred:true,sessionHours:expect.any(Number)});const events=m.ops.filter(op=>op.collection==="clock_events");expect(events).toHaveLength(2);expect(events[0].body).toMatchObject({direction:"out",site:employee.site,is_transfer:true});expect(events[1].body).toMatchObject({direction:"in",site:"site00000000002"});expect(events[1].body).not.toHaveProperty("previous_unclosed_in");expect(Date.parse(String(events[0].body.scanned_at))).toBeLessThan(Date.parse(String(events[1].body.scanned_at)));expect(m.ops).toContainEqual(expect.objectContaining({collection:"timesheet_entries",body:expect.objectContaining({site:employee.site})}));expect(m.ops).toContainEqual(expect.objectContaining({collection:"employees",method:"update",body:{site:"site00000000002"}}));expect(m.send).toHaveBeenCalledOnce();}); it("does not claim success for a failed transaction",async()=>{m.send.mockRejectedValue(new Error("offline"));expect((await post(request)).status).toBe(503);expect(m.unlock).toHaveBeenCalled();}); it("inactive workers can finish a shift but cannot start or transfer",async()=>{m.employee.mockResolvedValue({...employee,employment_status:"inactive"});expect((await post(request)).status).toBe(409);m.latest.mockResolvedValue(first);m.events.mockResolvedValue([first]);expect((await post({...request,expected_event_id:first.id})).status).toBe(200);expect((await post({...request,site:"site00000000002",expected_event_id:first.id})).status).toBe(409);}); it("rejects a confirmation from yesterday before changing the day or direction",async()=>{ diff --git a/src/app/api/clock/scan/route.ts b/src/app/api/clock/scan/route.ts index 14a7b53..2908917 100644 --- a/src/app/api/clock/scan/route.ts +++ b/src/app/api/clock/scan/route.ts @@ -81,15 +81,19 @@ export async function POST(request: Request) { if (body.expected_direction !== undefined && body.expected_direction !== direction) return fail("The clock action has changed. Scan the badge again.",409); if (direction === "in" && !isWorkerActive(employee)) return fail("Worker is inactive. Reactivate them before clocking in.",409); const eventId = requestId ?? idFor(`clock:${employee.id}:${today}:${last?.id??"first"}:${direction}`); + const transferOutId = idFor(`clock-transfer-out:${employee.id}:${today}:${last?.id??"first"}`); const batch = pb.createBatch(); let operationCount = 0; let sessionHours: number | undefined; let dailyTotalHours: number | undefined; - if (open && last && !transfer) { - // A move never invents an OUT. Only a physical scan at this site closes it. - const outAt = now; + if (open && last) { + // Scanning at another site is the worker's physical move: it closes the + // origin session at the scan time (a transfer OUT, no shift-end rounding) + // and the same scan opens the new one. Nothing is left for the office. + const outAt = transfer ? new Date(Date.parse(now)-1).toISOString() : now; + const outId = transfer ? transferOutId : eventId; const dayEvents = await pb.collection("clock_events").getFullList({filter:`employee="${employee.id}" && scanned_at>="${new Date(Date.parse(dateLogged(today))-7_200_000).toISOString()}" && scanned_at<="${now}"`,sort:"scanned_at",requestKey:null}); const corrections = await pb.collection("clock_missed_out_corrections").getFullList({filter:`employee="${employee.id}"`,requestKey:null}); const before=clockDayPaidMinutes(dayEvents,corrections,today); - const after=clockDayPaidMinutes([...dayEvents,{...last,id:eventId,direction:"out",scanned_at:outAt}],corrections,today); + const after=clockDayPaidMinutes([...dayEvents,{...last,id:outId,direction:"out",scanned_at:outAt,is_transfer:transfer}],corrections,today); sessionHours=((after.get(last.site)??0)-(before.get(last.site)??0))/60; const entryDate = dateLogged(today); const entries = await pb.collection("timesheet_entries").getFullList({filter:`employee="${employee.id}" && site="${escapePb(last.site)}" && date_logged="${entryDate.replace("T"," ")}"`,requestKey:null}); @@ -99,10 +103,10 @@ export async function POST(request: Request) { if (entry) batch.collection("timesheet_entries").update(entry.id,{hours_worked:dailyTotalHours}); else batch.collection("timesheet_entries").create({id:entryId,employee:employee.id,site:last.site,date_logged:entryDate,hours_worked:dailyTotalHours,import_key:`${employee.id}:${last.site}:${today}`}); operationCount++; - batch.collection("clock_events").create({id:eventId,employee:employee.id,site:last.site,direction:"out",scanned_at:outAt,timesheet_entry:entryId,is_transfer:transfer,...gps});operationCount++; + batch.collection("clock_events").create({id:outId,employee:employee.id,site:last.site,direction:"out",scanned_at:outAt,timesheet_entry:entryId,is_transfer:transfer,...gps});operationCount++; } if (direction === "in") { - batch.collection("clock_events").create({id:eventId,employee:employee.id,site,direction:"in",scanned_at:now,...gps,...(transfer ? {previous_unclosed_in:last!.id} : {})}); operationCount++; + batch.collection("clock_events").create({id:eventId,employee:employee.id,site,direction:"in",scanned_at:now,...gps}); operationCount++; if (employee.site !== site) { batch.collection("employees").update(employee.id,{site}); operationCount++; } } const results = await batch.send({requestKey:null}); diff --git a/src/app/api/clock/vehicle-lookup/route.ts b/src/app/api/clock/vehicle-lookup/route.ts index 8a12db6..5857808 100644 --- a/src/app/api/clock/vehicle-lookup/route.ts +++ b/src/app/api/clock/vehicle-lookup/route.ts @@ -14,11 +14,18 @@ export async function POST(request:Request) { try { const pb=await pbAdmin(); const vehicle=await resolveScannableVehicle(pb,qr); - const denied=requireSiteAccess(user,vehicle.site); + // A unit can be scanned into any site the scanner is authorised for; the + // scan then moves it there. Access is checked against the scanning site. + const scanSite=typeof body.site==="string" && /^[a-z0-9]{15}$/.test(body.site) ? body.site : vehicle.site; + const denied=requireSiteAccess(user,scanSite); if (denied) return denied; + if (scanSite!==vehicle.site) { + try { await pb.collection("sites").getOne(scanSite,{fields:"id"}); } + catch(error) { if ((error as {status?:number})?.status===404) throw new VehicleScanError("Scanning site is unavailable",404); throw error; } + } const previous=await latestVehicleReading(pb,vehicle.id); return Response.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}, + unitType:vehicle.unit_type,transfer:scanSite!==vehicle.site,fuelLimits:vehicle.fuel_limits,costPerHour:vehicle.cost_per_hour}, hasPrevious:previous!==null,previousReading:previous?.odo_reading ?? null,previousLogId:previous?.id ?? null, workDate:workDate(new Date().toISOString()), }); diff --git a/src/app/api/clock/vehicle-scan/route.test.ts b/src/app/api/clock/vehicle-scan/route.test.ts index afaf36e..e084afe 100644 --- a/src/app/api/clock/vehicle-scan/route.test.ts +++ b/src/app/api/clock/vehicle-scan/route.test.ts @@ -1,10 +1,10 @@ import {namedAdmin} from "@/test-utils/named-admin-session"; import {getCurrentUser} from "@/lib/auth/session"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mocks=vi.hoisted(()=>({vehicles:vi.fn(),logs:vi.fn(),create:vi.fn(),update:vi.fn(),send:vi.fn(),release:vi.fn(),lock:vi.fn()})); +const mocks=vi.hoisted(()=>({vehicles:vi.fn(),logs:vi.fn(),create:vi.fn(),update:vi.fn(),send:vi.fn(),release:vi.fn(),lock:vi.fn(),site:vi.fn()})); vi.mock("@/lib/pb",()=>({pbAdmin:async()=>({ filter:(s:string,args:Record)=>s.replace(/\{:(\w+)\}/g,(_,key)=>JSON.stringify(args[key])), - collection:(name:string)=>({getList:name==="vehicles"?mocks.vehicles:mocks.logs}), + collection:(name:string)=>({getList:name==="vehicles"?mocks.vehicles:mocks.logs,getOne:mocks.site}), createBatch:()=>({collection:()=>({create:mocks.create,update:mocks.update}),send:mocks.send}), })})); vi.mock("@/lib/fuel-import-store",()=>({createFuelImportStore:()=>({acquireLock:mocks.lock})})); @@ -20,6 +20,18 @@ beforeEach(()=>{ }); afterEach(()=>vi.useRealTimers()); describe("vehicle meter writes",()=>{ + it("a unit scanned in at another site moves there and logs its reading against that site",async()=>{ + mocks.site.mockResolvedValue({id:"site00000000002"}); + const res=await post({site_id:"site00000000002",latitude:-26.2,longitude:28,location_accuracy_m:6}); + expect(res.status).toBe(200);expect(await res.json()).toMatchObject({transferred:true}); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({vehicle:vehicle.id,site:"site00000000002"})); + expect(mocks.update).toHaveBeenCalledWith(vehicle.id,expect.objectContaining({site:"site00000000002",last_latitude:-26.2})); + }); + it("moves a unit scanned in elsewhere even without a GPS fix",async()=>{ + mocks.site.mockResolvedValue({id:"site00000000002"}); + expect((await post({site_id:"site00000000002"})).status).toBe(200); + expect(mocks.update).toHaveBeenCalledWith(vehicle.id,{site:"site00000000002"}); + }); it("writes one meter-only log and the true scan instant in an atomic batch",async()=>{ const res=await post({latitude:-26.2,longitude:28,location_accuracy_m:6}); expect(res.status).toBe(200); diff --git a/src/app/api/clock/vehicle-scan/route.ts b/src/app/api/clock/vehicle-scan/route.ts index 5f4a74c..70d9657 100644 --- a/src/app/api/clock/vehicle-scan/route.ts +++ b/src/app/api/clock/vehicle-scan/route.ts @@ -11,7 +11,7 @@ export const runtime="nodejs"; const schema=z.object({ qr_code_id:z.string().trim().min(1).max(160),odo_reading:z.number().finite().positive(), request_id:z.string().uuid(),expected_vehicle_id:z.string().min(1),expected_site_id:z.string().min(1), - expected_previous_id:z.string().nullable(),expected_work_date:z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + expected_previous_id:z.string().nullable(),site_id:z.string().regex(/^[a-z0-9]{15}$/).optional(),expected_work_date:z.string().regex(/^\d{4}-\d{2}-\d{2}$/), latitude:z.number().finite().min(-90).max(90).nullish(),longitude:z.number().finite().min(-180).max(180).nullish(), location_accuracy_m:z.number().finite().nonnegative().nullish(), }).refine(b=>(b.latitude==null)===(b.longitude==null),"Supply latitude and longitude together") @@ -33,14 +33,17 @@ export async function POST(request:Request) { const pb=await pbAdmin(); // Authorize the confirmed identity before allowing an idempotent replay. const vehicle=await resolveScannableVehicle(pb,b.qr_code_id); - const denied=requireSiteAccess(user,vehicle.site); + // The scanning site decides where the unit now works; a unit scanned in + // elsewhere is moved there, exactly as a worker's badge is. + const scanSite=b.site_id ?? vehicle.site; + const denied=requireSiteAccess(user,scanSite); if (denied) return denied; if (vehicle.id!==b.expected_vehicle_id) throw new VehicleScanError("The QR now identifies another unit. Scan it again."); const findReplay=async()=>{ const rows=await pb.collection("fuel_logs").getList(1,1,{filter:pb.filter("import_key={:key}",{key:importKey})}); const log=rows.items[0]; if (!log) return null; - if (log.vehicle!==vehicle.id || log.site!==b.expected_site_id || log.odo_reading!==b.odo_reading || + if (log.vehicle!==vehicle.id || log.site!==scanSite || log.odo_reading!==b.odo_reading || (log.latitude ?? 0)!==(b.latitude ?? 0) || (log.longitude ?? 0)!==(b.longitude ?? 0) || (log.location_accuracy_m ?? 0)!==(b.location_accuracy_m ?? 0)) throw new VehicleScanError("This request was already used with different details. Refresh the scanner."); return Response.json({vehicleId:log.vehicle,odoReading:log.odo_reading,dateLogged:log.date_logged,logId:log.id,hasFix:b.latitude!=null,replayed:true}); @@ -60,14 +63,21 @@ export async function POST(request:Request) { if (current.unit_type==="plant" && latest && hoursBetweenReadings({odo:latest.odo_reading,date:latest.date_logged,confirmedZero:latest.source_system === "asset-initial" || hasVerifiedMeterReset(latest)},{odo:b.odo_reading,date:day}).rejected) throw new VehicleScanError("This hour-meter increase exceeds 24 hours per day. Check the reading or ask the office to review it."); const date=dateLogged(day); const batch=pb.createBatch(); - batch.collection("fuel_logs").create({id,site:current.site,vehicle:current.id,date_logged:date,litres:0,odo_reading:b.odo_reading, + const moved=scanSite!==current.site; + if (moved) { + try { await pb.collection("sites").getOne(scanSite,{fields:"id"}); } + catch(error) { if ((error as {status?:number})?.status===404) throw new VehicleScanError("Scanning site is unavailable",404); throw error; } + } + batch.collection("fuel_logs").create({id,site:scanSite,vehicle:current.id,date_logged:date,litres:0,odo_reading:b.odo_reading, latitude:b.latitude ?? null,longitude:b.longitude ?? null,location_accuracy_m:b.location_accuracy_m ?? null, import_key:importKey,source_system:"mega-clock",source_id:b.request_id,source_logged_at:now}); if (b.latitude!=null && b.longitude!=null) batch.collection("vehicles").update(current.id,{ + ...(moved ? {site:scanSite} : {}), last_latitude:b.latitude,last_longitude:b.longitude,last_scanned_at:now,last_location_accuracy_m:b.location_accuracy_m ?? null, }); + else if (moved) batch.collection("vehicles").update(current.id,{site:scanSite}); await batch.send(); - return Response.json({vehicleId:current.id,odoReading:b.odo_reading,dateLogged:date,logId:id,hasFix:b.latitude!=null}); + return Response.json({vehicleId:current.id,odoReading:b.odo_reading,dateLogged:date,logId:id,hasFix:b.latitude!=null,transferred:moved}); } catch(error) { const busy=error instanceof Error && /already running/.test(error.message); return Response.json({error:error instanceof VehicleScanError ? error.message : busy ? "A fuel import or fleet update is running. Retry this reading shortly." : "Could not confirm the meter entry. Retry with the same reading; a successful save will not be duplicated."}, diff --git a/src/app/clock/clock-view.tsx b/src/app/clock/clock-view.tsx index 5bfbc9e..0819047 100644 --- a/src/app/clock/clock-view.tsx +++ b/src/app/clock/clock-view.tsx @@ -41,7 +41,7 @@ export function ClockView({ sites, canOpenInbox = false }: { sites: SitePoint[];
{tab === "person" && setScanCount((c) => c + 1)} />} - {tab === "fleet" && setScanCount((c) => c + 1)} />} + {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. */} diff --git a/src/app/clock/fleet-scanner.tsx b/src/app/clock/fleet-scanner.tsx index f552eb0..cf15c76 100644 --- a/src/app/clock/fleet-scanner.tsx +++ b/src/app/clock/fleet-scanner.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { captureLocation } from "./scan-location"; +import { nearestSite, type SitePoint } from "@/lib/nearest-site"; import { ScannerChrome } from "./scanner-chrome"; import { useKeyboardAwareDialog } from "./use-keyboard-aware-dialog"; import jsQR from "jsqr"; @@ -69,6 +70,8 @@ interface VehiclePayload { unitType: "fuel" | "plant"; fuelLimits: string; costPerHour: number; + /** The unit is registered to another site; this scan moves it to the scanning site. */ + transfer?: boolean; } /** GPS fix captured from `navigator.geolocation`. Accuracy is the @@ -88,7 +91,22 @@ type Phase = | { 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 } = {}) { +export function FleetScanner({ onScan, sites = [] }: { onScan?: () => void; sites?: SitePoint[] } = {}) { + // Where this phone is scanning. A unit scanned here is logged against, and + // moved to, this site, the same way a worker's badge is. + const [siteId, setSiteIdRaw] = React.useState(sites.length === 1 ? sites[0].id : ""); + const siteIdRef = React.useRef(siteId); + const setSiteId = React.useCallback((id: string) => { siteIdRef.current = id; setSiteIdRaw(id); }, []); + React.useEffect(() => { + if (sites.length < 2) return; + let live = true; + void captureLocation().then(fix => { + if (!live || !fix || siteIdRef.current) return; + const nearest = nearestSite(sites, fix); + if (nearest?.confident) setSiteId(nearest.site.id); + }); + return () => { live = false; }; + }, [sites, setSiteId]); const videoRef = React.useRef(null); const canvasRef = React.useRef(null); const busyRef = React.useRef(false); @@ -166,10 +184,17 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) { gpsGeneration.current++; gpsFixRef.current=null; try { + if (sites.length > 0 && !siteIdRef.current) { + setPhase({ kind: "error", message: "Choose the scanning site first" }); + setTimeout(() => { + if (phaseRef.current.kind === "error") { busyRef.current = false; setPhase({ kind: "scanning" }); } + }, BANNER_MS); + return; + } const res = await fetch("/api/clock/vehicle-lookup", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ qr_code_id: qrCodeId }), + body: JSON.stringify({ qr_code_id: qrCodeId, ...(siteIdRef.current ? { site: siteIdRef.current } : {}) }), }); const body = await res.json(); if (generation!==lookupGeneration.current) return; @@ -216,7 +241,7 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) { }, BANNER_MS); } }, - [captureGps, setPhase], + [captureGps, setPhase, sites], ); // --- Shared entry point --------------------------------------------- @@ -262,7 +287,7 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) { if (!context.payload || context.payload.odo_reading!==reading) { if (context.payload) context.requestId=crypto.randomUUID(); context.payload={qr_code_id:qrCodeId,odo_reading:reading,request_id:context.requestId, - expected_vehicle_id:vehicle.id,expected_site_id:vehicle.siteId, + expected_vehicle_id:vehicle.id,expected_site_id:vehicle.siteId,...(siteIdRef.current ? {site_id:siteIdRef.current} : {}), expected_previous_id:context.previousId,expected_work_date:context.day, ...(fix ? {latitude:fix.latitude,longitude:fix.longitude,location_accuracy_m:fix.accuracy} : {})}; } @@ -416,6 +441,16 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) { const popupContentRef = useKeyboardAwareDialog(popupOpen); return ( +
+ {sites.length > 0 && ( +
+ +
+ )}
+
); } @@ -599,6 +635,11 @@ function FormBody({
+ {vehicle.transfer && ( +
+ This unit is registered to another site. Saving moves it here and logs its hours and readings against this site from now on. +
+ )}
diff --git a/src/app/clock/person-scanner.tsx b/src/app/clock/person-scanner.tsx index cb8a8c7..e67fa6d 100644 --- a/src/app/clock/person-scanner.tsx +++ b/src/app/clock/person-scanner.tsx @@ -492,7 +492,7 @@ export function PersonScanner({ onScan, sites }: { onScan?: () => void; sites: S )} > {phase.duplicate ? `✓ ${phase.employeeName}: this scan was already saved` : phase.direction === "in" - ? `✓ ${phase.employeeName} ${phase.transferred ? "clocked IN; previous site flagged for review" : "clocked IN"}` + ? `✓ ${phase.employeeName} ${phase.transferred ? "moved here and clocked IN" : "clocked IN"}` : `✓ ${phase.employeeName} clocked OUT — ${hoursLabel(phase.sessionHours ?? 0)} hrs this session, ${hoursLabel(phase.dailyTotalHours ?? 0)} hrs at this site today`} )} @@ -635,7 +635,7 @@ function ConfirmDialog({

{location ? `GPS: ${location.latitude.toFixed(5)}, ${location.longitude.toFixed(5)} (±${Math.round(location.accuracy)} m)` : gpsStatus === "pending" ? "Capturing GPS…" : "No GPS coordinates available."}

{gpsStatus === "unavailable" && } - {transfer &&

This clocks in here and flags the previous site’s missing clock-out for office review. No leaving time will be invented.

} + {transfer &&

This worker is still clocked in at another site. Confirming moves them here: their time at the previous site ends now and today’s hours from this scan are logged against this site.

} {isRecent && lastEvent && gapMs !== null && (