MEGAINFRA-WEBAPP/src/app/clock/fleet-scanner.tsx
DEls e0c07cbf5f feat(fuel): delete a test or mistaken meter reading with a reason
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>
2026-10-26 17:16:01 +02:00

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&apos;s sticker. Use
this when the camera can&apos;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>
);
}