"use client"; import React, { useState, useEffect, useRef } from "react"; import { useParams, useRouter } from "next/navigation"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import SidebarLayout from "@/components/SidebarLayout"; import { fetchApi } from "@/app/utils/api"; import { Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video, RefreshCw, AlertCircle, Trash2 } from "lucide-react"; const POSES: Record = { front: { label: "Front", hint: "Look straight into the camera with neutral expression.", icon: "๐Ÿง‘" }, left: { label: "Left", hint: "Turn head slowly to the left (profile view).", icon: "๐Ÿ‘ˆ" }, right: { label: "Right", hint: "Turn head slowly to the right (profile view).", icon: "๐Ÿ‘‰" }, up: { label: "Up", hint: "Tilt your chin upwards slightly.", icon: "โฌ†๏ธ" }, down: { label: "Down", hint: "Tilt your chin downwards slightly.", icon: "โฌ‡๏ธ" }, smile: { label: "Smile", hint: "Give a natural, relaxed smile.", icon: "๐Ÿ˜Š" }, neutral: { label: "Neutral", hint: "Keep a relaxed, standard neutral expression.", icon: "๐Ÿ˜" }, indoor: { label: "Indoor", hint: "Enroll with typical indoor room lighting conditions.", icon: "๐Ÿ’ก" }, outdoor: { label: "Outdoor", hint: "Enroll with natural outdoor or bright lighting.", icon: "โ˜€๏ธ" }, glasses: { label: "Glasses", hint: "Wear glasses if applicable. Optional โ€“ skip if not wearing.", icon: "๐Ÿ•ถ๏ธ" }, }; export default function EnrollPage() { const params = useParams(); const router = useRouter(); const queryClient = useQueryClient(); const employeeId = params.id; const [selectedPose, setSelectedPose] = useState("front"); const [uploading, setUploading] = useState(false); const [errorMsg, setErrorMsg] = useState(null); const [successMsg, setSuccessMsg] = useState(null); const [capturedPreview, setCapturedPreview] = useState(null); const [webcamActive, setWebcamActive] = useState(false); const videoRef = useRef(null); const canvasRef = useRef(null); const streamRef = useRef(null); const { data: employee } = useQuery({ queryKey: ["employee", employeeId], queryFn: () => fetchApi(`/employees/${employeeId}`) }); const { data: status, refetch: refetchStatus } = useQuery({ queryKey: ["enroll-status", employeeId], queryFn: () => fetchApi(`/enrollment/status/${employeeId}`), enabled: !!employeeId }); const clearMutation = useMutation({ mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }), onSuccess: () => { refetchStatus(); setSuccessMsg("All facial data cleared."); setErrorMsg(null); } }); const startWebcam = async () => { setErrorMsg(null); setSuccessMsg(null); try { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: "user" } }); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.play(); } setWebcamActive(true); } catch { setErrorMsg("Unable to access webcam. Check browser permissions."); } }; const stopWebcam = () => { streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setWebcamActive(false); setCapturedPreview(null); }; useEffect(() => () => { stopWebcam(); }, []); const handleCapture = async () => { if (!videoRef.current || !canvasRef.current || !webcamActive) return; const video = videoRef.current; const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); if (!ctx) return; const vw = video.videoWidth, vh = video.videoHeight; if (!vw || !vh) { setErrorMsg("Camera not ready yet. Please wait a moment."); return; } setUploading(true); setErrorMsg(null); setSuccessMsg(null); setCapturedPreview(null); canvas.width = vw; canvas.height = vh; ctx.drawImage(video, 0, 0, vw, vh); // Black frame detection const data = ctx.getImageData(0, 0, vw, vh).data; let sum = 0; for (let i = 0; i < data.length; i += 4) sum += (data[i] + data[i+1] + data[i+2]) / 3; if (sum / (data.length / 4) < 5) { setErrorMsg("Frame appears black. Ensure webcam is working and retry."); setUploading(false); return; } setCapturedPreview(canvas.toDataURL("image/jpeg", 0.85)); canvas.toBlob(async (blob) => { if (!blob) { setErrorMsg("Capture failed."); setUploading(false); return; } await uploadFile(blob); }, "image/jpeg", 0.95); }; const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setUploading(true); setErrorMsg(null); setSuccessMsg(null); await uploadFile(file); e.target.value = ""; }; const uploadFile = async (blob: Blob) => { const fd = new FormData(); fd.append("employee_id", employeeId as string); fd.append("pose_type", selectedPose); fd.append("file", blob, `${selectedPose}.jpg`); try { const res = await fetchApi("/enrollment/upload", { method: "POST", body: fd }); setSuccessMsg(res.message); refetchStatus(); // Auto-advance to next missing pose const missing = status?.missing_poses || []; const keys = Object.keys(POSES); const next = keys[keys.indexOf(selectedPose) + 1]; if (next && missing.includes(next)) setSelectedPose(next); } catch (err: any) { setErrorMsg(err.message || "Enrollment failed. Please retry."); } finally { setUploading(false); } }; const isEnrolled = (pose: string) => status?.enrolled_poses?.some((p: string) => p.toLowerCase() === pose.toLowerCase()); const progress = Math.min(100, Math.round(((status?.enrolled_poses?.filter( (p: string) => p.toLowerCase() !== "glasses" ).length || 0) / 9) * 100)); const enrolledCount = status?.enrolled_poses?.length || 0; const totalCount = Object.keys(POSES).length; return (
{/* Back */} {/* Header */}
{employee?.name?.charAt(0) || "?"}

Biometric Enrollment

{employee?.name} ยท {employee?.employee_id}

{/* Main Grid */}
{/* Left: Pose checklist */}

Enrollment Progress

{enrolledCount}/{totalCount}
{/* Progress bar */}

{progress}% complete

{Object.entries(POSES).map(([key, pose]) => { const done = isEnrolled(key); const active = selectedPose === key; return ( ); })}
{/* Right: Camera capture panel */}
{POSES[selectedPose]?.icon}

Capture {POSES[selectedPose]?.label} Pose

{POSES[selectedPose]?.hint}

{/* Status messages */} {errorMsg && (
{errorMsg}
)} {successMsg && (
{successMsg}
)} {/* Camera view */}
{/* Corner brackets */} {(webcamActive || capturedPreview) && ( <>
)}
{/* Action buttons */}
{webcamActive ? ( <> ) : ( )} {/* File upload fallback */}
{/* Tips */}

Enrollment Tips

Enroll at least 5โ€“7 poses for reliable recognition. Ensure good lighting, avoid glasses for the first pose, and maintain consistent distance from the camera (approx. 50โ€“80cm). Multiple angles improve matching accuracy significantly.

); }