mega-plant/components/PlantClock.tsx
David 29a035a4f2 Clock app: vehicle-QR diesel book-out with meter photo; user access levels
- 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>
2026-10-22 12:25:15 +02:00

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>;
}