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 && (