From af6f7153fb3766e4dce83fa6bef67f4030fb73ce Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 12 Sep 2026 00:16:22 +0200 Subject: [PATCH] feat(clock): add PersonScanner camera + QR decode component --- src/app/clock/person-scanner.tsx | 160 +++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/app/clock/person-scanner.tsx diff --git a/src/app/clock/person-scanner.tsx b/src/app/clock/person-scanner.tsx new file mode 100644 index 0000000..8fa715e --- /dev/null +++ b/src/app/clock/person-scanner.tsx @@ -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(null); + const canvasRef = React.useRef(null); + const lastCodeRef = React.useRef<{ value: string; at: number } | null>(null); + const busyRef = React.useRef(false); + const detectorRef = React.useRef(null); + const [result, setResult] = React.useState(null); + const [cameraError, setCameraError] = React.useState(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 ( +
+
+ ); +} \ No newline at end of file