- Scanning a vehicle QR (its register code, e.g. TLB04) books diesel from the main tank; litres, optional odometer and a required meter photo (stored protected, viewable from the Booked out table) - Settings: users are Full or Clock-app-only; clock-only users are confined to /clock - Clock app: tapped tab highlights at once plus a loading screen; remove the "scanned on this device" list Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
97 lines
6.3 KiB
TypeScript
97 lines
6.3 KiB
TypeScript
'use client';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { AlertTriangle, ArrowRight, CheckCircle2, LogIn, LogOut, RotateCcw } from 'lucide-react';
|
|
import { clockTime, duration } from '@/lib/clock-display';
|
|
import { clockRequestId } from '@/lib/clock-request';
|
|
import { BadgeCamera } from './BadgeCamera';
|
|
|
|
// Plant staff clock in and out of the Plant itself, not a site: scan = saved.
|
|
// The server decides IN or OUT, so there is nothing to choose or confirm here.
|
|
type Saved={id:string;employeeName:string;employeeNo:string;direction:'in'|'out';at:string;sessionMinutes:number};
|
|
type Banner=
|
|
| {kind:'saved';scan:Saved}
|
|
| {kind:'repeat';employeeName:string;direction:'in'|'out';at:string}
|
|
| {kind:'error';message:string;retry:{employee_no:string;request_id:string}|null};
|
|
|
|
/** How long a result stays on screen before the scanner is ready-looking again. */
|
|
const BANNER_MS=5000;
|
|
/** The camera reads a held badge many times a second; ignore the same QR this long after any outcome. */
|
|
const SAME_BADGE_MS=6000;
|
|
|
|
export function PlantClock() {
|
|
const [code,setCode]=useState('');
|
|
const [busy,setBusy]=useState(false);
|
|
const [banner,setBanner]=useState<Banner|null>(null);
|
|
const [now,setNow]=useState('');
|
|
const gate=useRef(false);
|
|
const lastBadge=useRef({value:'',until:0});
|
|
const bannerTimer=useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
useEffect(()=>{const update=()=>setNow(clockTime(new Date().toISOString()));update();const t=setInterval(update,1000);return()=>clearInterval(t);},[]);
|
|
useEffect(()=>()=>clearTimeout(bannerTimer.current),[]);
|
|
|
|
function show(next:Banner) {
|
|
clearTimeout(bannerTimer.current);setBanner(next);
|
|
// A failed save that can be retried stays until someone acts on it.
|
|
if(!(next.kind==='error' && next.retry))bannerTimer.current=setTimeout(()=>setBanner(null),next.kind==='error'?BANNER_MS*2:BANNER_MS);
|
|
}
|
|
async function send(payload:{employee_no:string;request_id:string}) {
|
|
gate.current=true;setBusy(true);
|
|
try {
|
|
const response=await fetch('/api/clock/scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
const data=await response.json().catch(()=>({}));
|
|
if(response.ok) {
|
|
const scan=data as Saved;
|
|
show({kind:'saved',scan});setCode('');
|
|
navigator.vibrate?.(120);
|
|
} else if(data.repeat) {
|
|
show({kind:'repeat',...data.repeat});setCode('');
|
|
} else {
|
|
// 5xx and 423 (busy) are worth retrying with the SAME request id; anything else is a real answer.
|
|
const retryable=response.status>=500||response.status===423;
|
|
show({kind:'error',message:data.error||'Could not save the scan.',retry:retryable?payload:null});
|
|
navigator.vibrate?.([80,60,80]);
|
|
}
|
|
} catch {
|
|
show({kind:'error',message:'Could not reach the server. Nothing was saved yet.',retry:payload});
|
|
} finally {
|
|
gate.current=false;setBusy(false);
|
|
lastBadge.current={value:payload.employee_no.toUpperCase(),until:Date.now()+SAME_BADGE_MS};
|
|
}
|
|
}
|
|
function scan(raw:string) {
|
|
const badge=raw.trim();
|
|
if(!badge || gate.current)return;
|
|
if(lastBadge.current.value===badge.toUpperCase() && Date.now()<lastBadge.current.until)return;
|
|
void send({employee_no:badge,request_id:clockRequestId()});
|
|
}
|
|
|
|
return <section className="plant-clock-page p-4 sm:p-6 lg:p-8 max-w-3xl mx-auto">
|
|
<div className="flex items-end justify-between gap-4 mb-4">
|
|
<div className="min-w-0"><p className="plant-clock-eyebrow">MEGA PLANT</p><h1 className="text-2xl font-semibold tracking-tight mt-1">Scan to clock in or out</h1></div>
|
|
<p className="text-3xl font-semibold tabular-nums shrink-0" aria-label="Current time, South Africa">{now||'--:--'}</p>
|
|
</div>
|
|
|
|
<BadgeCamera onDecode={scan} paused={busy} overlay={
|
|
<div aria-live="assertive" className="absolute inset-x-3 bottom-3 z-20">
|
|
{busy && !banner && <div className="plant-clock-banner bg-[#003D5B] text-white"><RotateCcw size={22} className="animate-spin"/><p className="text-lg font-semibold">Saving…</p></div>}
|
|
{banner?.kind==='saved' && <div className={`plant-clock-banner text-white ${banner.scan.direction==='in'?'bg-[#00798C]':'bg-[#30638E]'}`}>
|
|
{banner.scan.direction==='in'?<LogIn size={30} className="shrink-0"/>:<LogOut size={30} className="shrink-0"/>}
|
|
<div className="min-w-0"><p className="text-xl font-semibold break-words">{banner.scan.employeeName}</p>
|
|
<p className="text-base mt-0.5">Clocked <strong>{banner.scan.direction.toUpperCase()}</strong> at {clockTime(banner.scan.at)}{banner.scan.direction==='out'?` · ${duration(banner.scan.sessionMinutes)} today`:''}</p></div>
|
|
<CheckCircle2 size={26} className="shrink-0 ml-auto"/></div>}
|
|
{banner?.kind==='repeat' && <div className="plant-clock-banner bg-[#EDAE49] text-[#003D5B]"><AlertTriangle size={26} className="shrink-0"/>
|
|
<div className="min-w-0"><p className="text-lg font-semibold break-words">{banner.employeeName}</p><p className="text-sm mt-0.5">Already clocked {banner.direction.toUpperCase()} at {clockTime(banner.at)}. Not saved again — scan in a minute to clock {banner.direction==='in'?'OUT':'IN'}.</p></div></div>}
|
|
{banner?.kind==='error' && <div role="alert" className="plant-clock-banner bg-[#D1495B] text-white"><AlertTriangle size={26} className="shrink-0"/>
|
|
<div className="min-w-0 flex-1"><p className="text-base font-semibold break-words">{banner.message}</p>
|
|
{banner.retry && <button className="plant-clock-button bg-white text-[#D1495B] mt-2" disabled={busy} onClick={()=>{const r=banner.retry!;setBanner(null);void send(r);}}><RotateCcw size={16}/>Retry this scan</button>}</div></div>}
|
|
</div>
|
|
}/>
|
|
|
|
<form className="mt-4" onSubmit={e=>{e.preventDefault();lastBadge.current={value:'',until:0};scan(code);}}>
|
|
<label htmlFor="employee-code" className="block text-sm font-medium">No badge? Type the employee code</label>
|
|
<div className="flex gap-2 mt-2"><input id="employee-code" className="plant-clock-input min-w-0" value={code} disabled={busy} onChange={e=>setCode(e.target.value)} placeholder="e.g. MCW067" maxLength={100} autoComplete="off" autoCapitalize="characters"/>
|
|
<button className="plant-clock-button plant-clock-primary shrink-0" disabled={!code.trim()||busy} type="submit">Clock<ArrowRight size={16}/></button></div>
|
|
</form>
|
|
|
|
</section>;
|
|
}
|