feat(clock): add PersonScanner camera + QR decode component

This commit is contained in:
DEls 2026-09-12 00:16:22 +02:00
parent 13c29eb93d
commit af6f7153fb

View file

@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import jsQR from "jsqr";
import { cn } from "@/lib/utils";
const SCAN_COOLDOWN_MS = 3000;
const BANNER_MS = 2500;
type ScanResult =
| { kind: "in"; employeeName: string }
| { kind: "out"; employeeName: string; sessionHours: number; dailyTotalHours: number }
| { kind: "error"; message: string };
export function PersonScanner() {
const videoRef = React.useRef<HTMLVideoElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const lastCodeRef = React.useRef<{ value: string; at: number } | null>(null);
const busyRef = React.useRef(false);
const detectorRef = React.useRef<BarcodeDetector | null>(null);
const [result, setResult] = React.useState<ScanResult | null>(null);
const [cameraError, setCameraError] = React.useState<string | null>(null);
const handleDecoded = React.useCallback((value: string) => {
const now = Date.now();
const last = lastCodeRef.current;
if (last && last.value === value && now - last.at < SCAN_COOLDOWN_MS) return;
if (busyRef.current) return;
lastCodeRef.current = { value, at: now };
busyRef.current = true;
fetch("/api/clock/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ employee_no: value }),
})
.then(async (res) => {
const responseBody = await res.json();
if (!res.ok) {
setResult({ kind: "error", message: responseBody.error ?? "Scan failed" });
return;
}
if (responseBody.direction === "in") {
setResult({ kind: "in", employeeName: responseBody.employeeName });
} else {
setResult({
kind: "out",
employeeName: responseBody.employeeName,
sessionHours: responseBody.sessionHours,
dailyTotalHours: responseBody.dailyTotalHours,
});
}
})
.catch(() => {
setResult({ kind: "error", message: "Couldn't reach the server — try again" });
})
.finally(() => {
busyRef.current = false;
setTimeout(() => setResult(null), BANNER_MS);
});
}, []);
React.useEffect(() => {
let stream: MediaStream | null = null;
let rafId = 0;
let cancelled = false;
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"] });
}
tick();
}
start();
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
stream?.getTracks().forEach((t) => t.stop());
};
}, [handleDecoded]);
return (
<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" />
{cameraError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 p-6 text-center text-white">
{cameraError}
</div>
)}
{result && (
<div
className={cn(
"absolute inset-0 flex items-center justify-center p-6 text-center text-2xl font-bold text-white",
result.kind === "in" && "bg-green-600/90",
result.kind === "out" && "bg-blue-600/90",
result.kind === "error" && "bg-red-600/90",
)}
>
{result.kind === "in" && `✓ ${result.employeeName} clocked IN`}
{result.kind === "out" &&
`✓ ${result.employeeName} clocked OUT — ${result.sessionHours} hrs this session, ${result.dailyTotalHours} hrs today`}
{result.kind === "error" && `✗ ${result.message}`}
</div>
)}
</div>
);
}