- 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>
59 lines
3.3 KiB
TypeScript
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>;
|
|
}
|