feat(clock): add PersonScanner camera + QR decode component
This commit is contained in:
parent
13c29eb93d
commit
af6f7153fb
1 changed files with 160 additions and 0 deletions
160
src/app/clock/person-scanner.tsx
Normal file
160
src/app/clock/person-scanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue