Clock scanner: double-tap the previous reading to remove the latest scanner-entered reading. Fuel Logs and vehicle pages: double-click a meter-only row to delete it. Reason, actor and values are logged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
980 lines
No EOL
36 KiB
TypeScript
980 lines
No EOL
36 KiB
TypeScript
"use client";
|
|
|
|
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";
|
|
import { Loader2, MapPin, MapPinOff } 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;
|
|
/** 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
|
|
* `Position.coords.accuracy` in metres at the moment of capture. */
|
|
interface GpsFix {
|
|
latitude: number;
|
|
longitude: number;
|
|
accuracy: 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, 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);
|
|
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);
|
|
// GPS fix state — captured once when the form opens, displayed in
|
|
// the popup so the operator can see whether a fix is on the row.
|
|
// `pending` means we asked but the browser hasn't answered yet;
|
|
// `ok` means a fix is in `gpsFix`; `unavailable` covers "denied",
|
|
// "no signal", and "no geolocation API" — all three collapse into
|
|
// the same UI ("📍 No location"), because the action from the
|
|
// operator's perspective is identical (submit without coordinates).
|
|
const [gpsStatus, setGpsStatus] = React.useState<"pending" | "ok" | "unavailable">(
|
|
"unavailable",
|
|
);
|
|
const [gpsFix, setGpsFix] = React.useState<GpsFix | null>(null);
|
|
// Snapshot for the submit — the effect that watches submit could
|
|
// otherwise race a slow GPS resolution that lands mid-POST and
|
|
// silently swap what's written. Refs capture the value at the
|
|
// moment submit fires; the state is for UI.
|
|
const gpsFixRef = React.useRef<GpsFix | null>(null);
|
|
// The last reading on file was entered on this scanner, so it may be removed.
|
|
const [previousRemovable, setPreviousRemovable] = React.useState(false);
|
|
const lookupGeneration = React.useRef(0);
|
|
const gpsGeneration = React.useRef(0);
|
|
const confirmation = React.useRef<{requestId:string;previousId:string|null;day:string;payload?:Record<string,unknown>} | null>(null);
|
|
React.useEffect(() => () => { lookupGeneration.current++; gpsGeneration.current++; }, []);
|
|
React.useEffect(() => {
|
|
gpsFixRef.current = gpsFix;
|
|
}, [gpsFix]);
|
|
// 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);
|
|
}, []);
|
|
|
|
// --- GPS capture ---------------------------------------------------
|
|
// Wrapped so each lookup call gets a fresh attempt (cleared at the
|
|
// start of every new form, replaced if the operator cancels and
|
|
// scans again). 8-second cap keeps a denied/quiet GPS from holding
|
|
// the form open forever — the operator can still submit without a
|
|
// fix, and a stalled `pending` chip would just be noise.
|
|
const captureGps = React.useCallback(() => {
|
|
const generation=++gpsGeneration.current;
|
|
gpsFixRef.current=null;
|
|
setGpsFix(null);
|
|
setGpsStatus("pending");
|
|
void captureLocation().then(fix=>{
|
|
if (generation!==gpsGeneration.current) return;
|
|
gpsFixRef.current=fix;
|
|
setGpsFix(fix);
|
|
setGpsStatus(fix ? "ok" : "unavailable");
|
|
});
|
|
}, []);
|
|
|
|
// --- 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) => {
|
|
const generation=++lookupGeneration.current;
|
|
confirmation.current=null;
|
|
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, ...(siteIdRef.current ? { site: siteIdRef.current } : {}) }),
|
|
});
|
|
const body = await res.json();
|
|
if (generation!==lookupGeneration.current) return;
|
|
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;
|
|
previousLogId: string | null;
|
|
previousRemovable?: boolean;
|
|
workDate: string;
|
|
};
|
|
setPreviousRemovable(ok.previousRemovable === true);
|
|
confirmation.current={requestId:crypto.randomUUID(),previousId:ok.previousLogId,day:ok.workDate};
|
|
setPhase({
|
|
kind: "form",
|
|
qrCodeId,
|
|
vehicle: ok.vehicle,
|
|
previousReading: ok.previousReading,
|
|
});
|
|
// Kick off the GPS capture the moment the form opens. The
|
|
// operator will spend several seconds entering the reading;
|
|
// that overlap is exactly what we want — by the time they
|
|
// hit Save the fix is either resolved or already given up.
|
|
captureGps();
|
|
// busyRef stays true until Cancel or Submit — see camera
|
|
// effect's `handleDecoded` for the gate.
|
|
} catch {
|
|
if (generation!==lookupGeneration.current) return;
|
|
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);
|
|
}
|
|
},
|
|
[captureGps, setPhase, sites],
|
|
);
|
|
|
|
// --- 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),
|
|
});
|
|
// Snapshot the current fix so a slow GPS resolution that lands
|
|
// mid-submit can't change what's written. If the operator
|
|
// hasn't seen the chip turn green, the row goes in without
|
|
// coordinates — that's the safer default (a wrong reading from
|
|
// an old fix would be worse than no reading at all).
|
|
const fix = gpsFixRef.current;
|
|
try {
|
|
const res = await fetch("/api/clock/vehicle-scan", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify((() => {
|
|
const context=confirmation.current;
|
|
if (!context) throw new Error("Scan confirmation expired");
|
|
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,...(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} : {})};
|
|
}
|
|
return context.payload;
|
|
})()),
|
|
});
|
|
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],
|
|
);
|
|
|
|
// Removes the unit's latest scanner-entered reading (a test or a typo) so
|
|
// the next one can be entered. Returns an error message, or null on success.
|
|
const removePrevious = React.useCallback(
|
|
async (reason: string): Promise<string | null> => {
|
|
const current = phaseRef.current;
|
|
const context = confirmation.current;
|
|
if ((current.kind !== "form" && current.kind !== "submitError") || !context?.previousId) return "Nothing to remove";
|
|
try {
|
|
const res = await fetch("/api/clock/vehicle-reading-remove", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ qr_code_id: current.qrCodeId, log_id: context.previousId, reason }),
|
|
});
|
|
const body = await res.json();
|
|
if (!res.ok) return (body as { error?: string }).error ?? `Remove failed (${res.status})`;
|
|
const next = body as { previousReading: number | null; previousLogId: string | null; previousRemovable: boolean };
|
|
confirmation.current = { requestId: crypto.randomUUID(), previousId: next.previousLogId, day: context.day };
|
|
setPreviousRemovable(next.previousRemovable === true);
|
|
setPhase({ kind: "form", qrCodeId: current.qrCodeId, vehicle: current.vehicle, previousReading: next.previousReading });
|
|
onScan?.();
|
|
return null;
|
|
} catch {
|
|
return "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";
|
|
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" />
|
|
|
|
{phase.kind === "scanning" && <ScannerChrome mode="fleet" cameraError={cameraError} onManualEntry={() => setManualOpen(true)} />}
|
|
|
|
{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"
|
|
) {
|
|
lookupGeneration.current++;
|
|
gpsGeneration.current++;
|
|
confirmation.current=null;
|
|
busyRef.current = false;
|
|
setPhase({ kind: "scanning" });
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent ref={popupContentRef} className="max-h-[90dvh] overflow-y-auto max-w-md border-border bg-card p-0 max-sm:top-3 max-sm:w-[calc(100vw-1.5rem)] max-sm:translate-y-0 sm:rounded-2xl">
|
|
<DialogBody
|
|
phase={phase}
|
|
gpsStatus={gpsStatus}
|
|
gpsFix={gpsFix}
|
|
removable={previousRemovable}
|
|
onRemovePrevious={removePrevious}
|
|
onSubmit={(reading) => {
|
|
if (phase.kind !== "form" && phase.kind !== "submitError") return;
|
|
const { qrCodeId, vehicle, previousReading } = phase;
|
|
void submit(qrCodeId, vehicle, previousReading, reading);
|
|
}}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{manualOpen && <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>
|
|
</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,
|
|
gpsStatus,
|
|
gpsFix,
|
|
removable,
|
|
onRemovePrevious,
|
|
onSubmit,
|
|
}: {
|
|
phase: Phase;
|
|
gpsStatus: "pending" | "ok" | "unavailable";
|
|
gpsFix: GpsFix | null;
|
|
removable: boolean;
|
|
onRemovePrevious: (reason: string) => Promise<string | null>;
|
|
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}
|
|
gpsStatus={gpsStatus}
|
|
gpsFix={gpsFix}
|
|
initialReading={phase.kind === "submitError" ? phase.reading : ""}
|
|
submitError={phase.kind === "submitError" ? phase.message : null}
|
|
removable={removable}
|
|
onRemovePrevious={onRemovePrevious}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function FormBody({
|
|
qrCodeId,
|
|
vehicle,
|
|
previousReading,
|
|
gpsStatus,
|
|
gpsFix,
|
|
submitError,
|
|
initialReading,
|
|
removable,
|
|
onRemovePrevious,
|
|
onSubmit,
|
|
}: {
|
|
qrCodeId: string;
|
|
vehicle: VehiclePayload;
|
|
previousReading: number | null;
|
|
gpsStatus: "pending" | "ok" | "unavailable";
|
|
gpsFix: GpsFix | null;
|
|
submitError: string | null;
|
|
initialReading: string;
|
|
removable: boolean;
|
|
onRemovePrevious: (reason: string) => Promise<string | null>;
|
|
onSubmit: (reading: number) => void;
|
|
}) {
|
|
const [value, setValue] = React.useState(initialReading);
|
|
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="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}
|
|
removable={removable}
|
|
onRemove={onRemovePrevious}
|
|
/>
|
|
<GpsChip status={gpsStatus} fix={gpsFix} />
|
|
</div>
|
|
|
|
<form
|
|
className="space-y-3 px-6 pb-6"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (movesForward && gpsStatus !== "pending") 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 || gpsStatus === "pending"}
|
|
size="lg"
|
|
className="w-full text-base"
|
|
>
|
|
Save reading
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PreviousReadingStrip({
|
|
previousReading,
|
|
suffix,
|
|
removable,
|
|
onRemove,
|
|
}: {
|
|
previousReading: number | null;
|
|
suffix: string;
|
|
removable: boolean;
|
|
onRemove: (reason: string) => Promise<string | null>;
|
|
}) {
|
|
const [removing, setRemoving] = React.useState(false);
|
|
const [reason, setReason] = React.useState("");
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
if (removing) {
|
|
const canRemove = reason.trim().length >= 5 && !busy;
|
|
return (
|
|
<form
|
|
className="space-y-2 rounded-lg border border-red-500/40 bg-red-500/5 px-3 py-2"
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
if (!canRemove) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
const failure = await onRemove(reason.trim());
|
|
setBusy(false);
|
|
if (failure) setError(failure);
|
|
else { setRemoving(false); setReason(""); }
|
|
}}
|
|
>
|
|
<p className="text-xs font-semibold">
|
|
Remove the previous reading ({previousReading.toLocaleString()} {suffix})?
|
|
</p>
|
|
<Input
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="Reason, e.g. test reading"
|
|
aria-label="Reason for removing the reading"
|
|
autoFocus
|
|
className="h-10 bg-input/60 text-sm"
|
|
/>
|
|
{error && <p className="text-xs font-medium text-red-600">{error}</p>}
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" size="sm" className="flex-1" disabled={busy}
|
|
onClick={() => { setRemoving(false); setReason(""); setError(null); }}>
|
|
Keep it
|
|
</Button>
|
|
<Button type="submit" variant="destructive" size="sm" className="flex-1" disabled={!canRemove}>
|
|
{busy ? "Removing…" : "Remove reading"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn("flex items-center justify-between rounded-lg bg-muted/60 px-3 py-2", removable && "cursor-pointer select-none")}
|
|
onDoubleClick={removable ? () => setRemoving(true) : undefined}
|
|
title={removable ? "Double-tap to remove this reading" : undefined}
|
|
>
|
|
<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>
|
|
{removable && <span className="sr-only">Double-tap to remove</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "📍 Location captured · ±6 m" / "📍 No location" — a small status
|
|
* chip under the previous-reading strip. Tells the operator what
|
|
* will end up on the row before they hit Save.
|
|
*
|
|
* Three states:
|
|
* pending capture is in flight — neutral, doesn't claim more
|
|
* ok a fix is captured; show coords + accuracy
|
|
* unavailable denied, no signal, or no geolocation API — the row
|
|
* will be saved without coordinates, which is the
|
|
* safe default for a denied permission
|
|
*/
|
|
function GpsChip({
|
|
status,
|
|
fix,
|
|
}: {
|
|
status: "pending" | "ok" | "unavailable";
|
|
fix: GpsFix | null;
|
|
}) {
|
|
if (status === "pending") {
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
<span className="font-semibold uppercase tracking-wide">Location</span>
|
|
<span>looking for a fix…</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (status === "ok" && fix) {
|
|
return (
|
|
<div className="flex items-center justify-between rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 ring-1 ring-emerald-500/30 dark:text-emerald-300">
|
|
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wide">
|
|
<MapPin className="h-3.5 w-3.5" aria-hidden />
|
|
Location captured
|
|
</span>
|
|
<span className="font-mono">
|
|
{fix.latitude.toFixed(4)}, {fix.longitude.toFixed(4)}
|
|
<span className="ml-1.5 text-muted-foreground">±{Math.round(fix.accuracy)} m</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex items-center justify-between rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wide">
|
|
<MapPinOff className="h-3.5 w-3.5" aria-hidden />
|
|
No location
|
|
</span>
|
|
<span>reading will save without GPS</span>
|
|
</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);
|
|
const contentRef = useKeyboardAwareDialog(open);
|
|
|
|
// 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) {
|
|
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 ref={contentRef} className="max-w-sm border-border bg-card p-0 max-sm:top-3 max-sm:w-[calc(100vw-1.5rem)] max-sm:translate-y-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>
|
|
);
|
|
} |