mega-plant/components/BadgeCamera.tsx
David df53130861 Add Workforce pages, Plant Clock tab bar and oil & diesel book-out
- Workforce: Employees register, per-employee dashboards, Attendance table
- Plant Clock: scan-only clock, bottom tab bar (Employees / Oil & Diesel / Job cards)
- Oil & Diesel stock: diesel tank (650 L, opening 589 L), level overwrite dialog,
  QR on each card, book-out screen with searchable vehicle picker,
  filterable "Booked out" table (today/week/month/all/range)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:39:18 +02:00

59 lines
3.3 KiB
TypeScript

'use client';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import jsQR from 'jsqr';
import { Camera, ScanLine } from 'lucide-react';
/** Starts on mount: the clock IS the scanner, so there is nothing to open first. */
export function BadgeCamera({onDecode,paused,overlay,hint='Hold the badge QR inside the frame'}:{onDecode:(code:string)=>void;paused:boolean;overlay?:ReactNode;hint?:string}) {
const video=useRef<HTMLVideoElement>(null);
const callback=useRef(onDecode);callback.current=onDecode;
const pause=useRef(paused);pause.current=paused;
const [enabled,setEnabled]=useState(true);
const [live,setLive]=useState(false);
const [error,setError]=useState('');
useEffect(()=>{
if(!enabled)return;
let stopped=false;let stream:MediaStream|null=null;let timer:ReturnType<typeof setTimeout>;
const canvas=document.createElement('canvas');
const context=canvas.getContext('2d',{willReadFrequently:true});
const tick=()=>{
if(stopped)return;
const frame=video.current;
if(!pause.current && context && frame && frame.readyState>=2 && frame.videoWidth) {
const scale=Math.min(1,720/frame.videoWidth);
canvas.width=Math.round(frame.videoWidth*scale);canvas.height=Math.round(frame.videoHeight*scale);
context.drawImage(frame,0,0,canvas.width,canvas.height);
const pixels=context.getImageData(0,0,canvas.width,canvas.height);
const code=jsQR(pixels.data,pixels.width,pixels.height,{inversionAttempts:'attemptBoth'});
if(code?.data)callback.current(code.data);
}
timer=setTimeout(tick,180);
};
async function start() {
try {
if(!navigator.mediaDevices?.getUserMedia)throw new Error('Camera needs HTTPS or localhost. Type the employee code below instead.');
stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}},audio:false});
if(stopped){stream.getTracks().forEach(track=>track.stop());return;}
if(video.current){video.current.srcObject=stream;await video.current.play();}
setLive(true);tick();
}catch(e){
stream?.getTracks().forEach(track=>track.stop());
if(!stopped){setError(e instanceof Error && e.message.startsWith('Camera needs')?e.message:'Camera unavailable or permission denied. Allow camera access and tap Start camera, or type the employee code below.');setEnabled(false);}
}
}
void start();
return ()=>{stopped=true;setLive(false);clearTimeout(timer);stream?.getTracks().forEach(track=>track.stop());};
},[enabled]);
return <div>
<div className="plant-camera">
{enabled && <video ref={video} muted playsInline aria-label="Employee badge camera" className="absolute inset-0 h-full w-full object-cover" />}
<div className="relative z-10 flex flex-col items-center gap-4 p-6 text-center">
<div className="plant-scan-frame"><ScanLine size={38} strokeWidth={1.4}/></div>
{enabled ? <p className="rounded-lg bg-[#003D5B]/90 px-4 py-2 text-sm">{!live?'Starting camera…':paused?'Saving…':hint}</p>
: <button className="plant-clock-button bg-white text-[#003D5B]" onClick={()=>{setError('');setEnabled(true);}}><Camera size={18}/>Start camera</button>}
</div>
{overlay}
</div>
{error && <p role="alert" className="plant-clock-warning mt-3">{error}</p>}
</div>;
}