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 <noreply@anthropic.com>
This commit is contained in:
parent
527446d1ce
commit
708e0b2742
9 changed files with 164 additions and 23 deletions
67
scripts/close-flagged-site-moves.ts
Normal file
67
scripts/close-flagged-site-moves.ts
Normal file
|
|
@ -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<ClockEventRecord>({
|
||||
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<ClockMissedOutCorrectionRecord>({ 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<ClockEventRecord>({
|
||||
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<TimesheetEntryRecord>({ 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; });
|
||||
|
|
@ -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()=>{
|
||||
|
|
|
|||
|
|
@ -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<ClockEventRecord>({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<ClockMissedOutCorrectionRecord>({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<TimesheetEntryRecord>({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});
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string,unknown>)=>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);
|
||||
|
|
|
|||
|
|
@ -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."},
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function ClockView({ sites, canOpenInbox = false }: { sites: SitePoint[];
|
|||
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{tab === "person" && <PersonScanner sites={sites} onScan={() => setScanCount((c) => c + 1)} />}
|
||||
{tab === "fleet" && <FleetScanner onScan={() => setScanCount((c) => c + 1)} />}
|
||||
{tab === "fleet" && <FleetScanner sites={sites} 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. */}
|
||||
|
|
|
|||
|
|
@ -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<HTMLVideoElement>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{sites.length > 0 && (
|
||||
<div className="shrink-0 border-b border-border bg-card px-3 py-2">
|
||||
<label className="mx-auto block max-w-xl text-xs font-medium">Scanning at
|
||||
<select aria-label="Scanning site" value={siteId} disabled={phase.kind !== "scanning"} onChange={event => setSiteId(event.target.value)} className="mt-1 block min-h-11 w-full rounded-md border border-input bg-background px-2 text-sm">
|
||||
<option value="">Choose site before scanning</option>{sites.map(site => <option key={site.id} value={site.id}>{site.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<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" />
|
||||
|
|
@ -471,6 +506,7 @@ export function FleetScanner({ onScan }: { onScan?: () => void } = {}) {
|
|||
}}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -599,6 +635,11 @@ function FormBody({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 px-6 pb-2">
|
||||
{vehicle.transfer && (
|
||||
<div className="rounded-lg border border-amber-400/50 bg-amber-50/40 px-3 py-2 text-xs dark:bg-amber-950/20">
|
||||
This unit is registered to another site. Saving moves it here and logs its hours and readings against this site from now on.
|
||||
</div>
|
||||
)}
|
||||
<PreviousReadingStrip previousReading={previousReading} suffix={suffix} />
|
||||
<GpsChip status={gpsStatus} fix={gpsFix} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -635,7 +635,7 @@ function ConfirmDialog({
|
|||
|
||||
<p className="text-xs text-muted-foreground">{location ? `GPS: ${location.latitude.toFixed(5)}, ${location.longitude.toFixed(5)} (±${Math.round(location.accuracy)} m)` : gpsStatus === "pending" ? "Capturing GPS…" : "No GPS coordinates available."}</p>
|
||||
{gpsStatus === "unavailable" && <label className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" checked={allowNoGps} onChange={event=>setAllowNoGps(event.target.checked)}/>Save at the selected site without GPS</label>}
|
||||
{transfer && <p className="text-sm text-muted-foreground">This clocks in here and flags the previous site’s missing clock-out for office review. No leaving time will be invented.</p>}
|
||||
{transfer && <p className="text-sm text-muted-foreground">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.</p>}
|
||||
{isRecent && lastEvent && gapMs !== null && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-amber-900 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||
|
|
|
|||
Loading…
Reference in a new issue