"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, getBackendUrl } from "@/app/utils/api"; import { Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video, RefreshCw, AlertCircle, Trash2, Play, Pause, Save, RotateCcw, Shield, Activity, Sparkles } from "lucide-react"; interface PoseInfo { label: string; hint: string; speech: string; icon: string; } const POSES: Record = { front: { label: "Front Profile", hint: "Look straight into the camera with a neutral expression.", speech: "Please look straight into the camera.", icon: "๐Ÿง‘" }, left: { label: "Left Profile", hint: "Turn your head slowly to the left.", speech: "Please turn your head to the left.", icon: "๐Ÿ‘ˆ" }, right: { label: "Right Profile", hint: "Turn your head slowly to the right.", speech: "Please turn your head to the right.", icon: "๐Ÿ‘‰" }, up: { label: "Looking Up", hint: "Tilt your chin upwards slightly.", speech: "Please tilt your head upwards.", icon: "โฌ†๏ธ" }, down: { label: "Looking Down", hint: "Tilt your chin downwards slightly.", speech: "Please tilt your head downwards.", icon: "โฌ‡๏ธ" }, smile: { label: "Smiling Face", hint: "Give a natural, relaxed smile.", speech: "Now, smile naturally.", icon: "๐Ÿ˜Š" }, neutral: { label: "Neutral Face", hint: "Keep a relaxed, standard neutral expression.", speech: "Relax your face, show a neutral expression.", icon: "๐Ÿ˜" }, indoor: { label: "Indoor Light", hint: "Look straight with standard indoor room lighting.", speech: "Look straight for typical indoor lighting.", icon: "๐Ÿ’ก" }, outdoor: { label: "Outdoor Light", hint: "Look straight with bright/outdoor lighting.", speech: "Look straight for bright light capture.", icon: "โ˜€๏ธ" }, glasses: { label: "Glasses Option", hint: "Put on glasses if you wear them, otherwise look straight.", speech: "If you wear glasses, put them on. Otherwise, look straight.", icon: "๐Ÿ•ถ๏ธ" } }; const POSE_KEYS = Object.keys(POSES); export default function EnrollPage() { const params = useParams(); const router = useRouter(); const queryClient = useQueryClient(); const employeeId = params.id; // State Machine for biometric scanner: // "idle": Pre-start screen // "capturing": Active auto-capture loop // "review": Review grid of all 10 captured poses // "saving": Uploading to server/indexing vectors // "success": Completed successfully screen const [captureState, setCaptureState] = useState<"idle" | "capturing" | "review" | "saving" | "success">("idle"); const [currentPoseIndex, setCurrentPoseIndex] = useState(0); const [countdown, setCountdown] = useState(3); const [isPaused, setIsPaused] = useState(false); // Store local previews: { [poseKey]: base64DataUrl } const [capturedImages, setCapturedImages] = useState>({}); // Upload status const [uploadIndex, setUploadIndex] = useState(0); const [uploadProgress, setUploadProgress] = useState(0); const [errorMsg, setErrorMsg] = useState(null); const [successMsg, setSuccessMsg] = useState(null); const [webcamActive, setWebcamActive] = useState(false); const [singleRetakePose, setSingleRetakePose] = useState(null); const videoRef = useRef(null); const canvasRef = useRef(null); const streamRef = useRef(null); const countdownIntervalRef = useRef(null); // Queries 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(); setCapturedImages({}); setSuccessMsg("All registered facial profiles cleared from database."); setErrorMsg(null); } }); // Offline Web Audio API Sound Generator (synthesized camera click & alert beeps) const playSound = (type: "beep" | "click") => { if (typeof window === "undefined") return; try { const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); if (type === "beep") { osc.type = "sine"; osc.frequency.setValueAtTime(600, audioCtx.currentTime); gain.gain.setValueAtTime(0.08, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12); osc.start(); osc.stop(audioCtx.currentTime + 0.13); } else if (type === "click") { // Shutter click noise synthesis osc.type = "triangle"; osc.frequency.setValueAtTime(100, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(1000, audioCtx.currentTime + 0.08); gain.gain.setValueAtTime(0.2, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1); osc.start(); osc.stop(audioCtx.currentTime + 0.12); } } catch (e) { console.warn("Failed to generate audio feedback:", e); } }; // Browser offline speech engine const speakDirection = (text: string) => { if (typeof window !== "undefined" && window.speechSynthesis) { window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.rate = 0.92; utterance.pitch = 1.05; const voices = window.speechSynthesis.getVoices(); const eng = voices.find(v => v.lang.startsWith("en")); if (eng) utterance.voice = eng; window.speechSynthesis.speak(utterance); } }; // Webcam controls const startWebcam = async () => { setErrorMsg(null); setSuccessMsg(null); try { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720, facingMode: "user" } }); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play().catch(() => {}); } setWebcamActive(true); } catch { setErrorMsg("Webcam permission denied. Check your browser settings."); } }; const stopWebcam = () => { if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } streamRef.current?.getTracks().forEach(t => t.stop()); streamRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; setWebcamActive(false); }; useEffect(() => { return () => stopWebcam(); }, []); // Trigger auto capture session const startAutoCapture = async () => { setCapturedImages({}); setCurrentPoseIndex(0); setCountdown(3); setIsPaused(false); setSingleRetakePose(null); setCaptureState("capturing"); await startWebcam(); }; // Automated capture timer loop useEffect(() => { if (captureState !== "capturing" || isPaused || !webcamActive) return; const currentKey = POSE_KEYS[currentPoseIndex]; if (!currentKey) { // Completed all poses stopWebcam(); setCaptureState("review"); return; } // Speak pose directions on start of each pose countdown if (countdown === 3) { speakDirection(POSES[currentKey].speech); } countdownIntervalRef.current = setInterval(() => { setCountdown((prev) => { if (prev <= 1) { clearInterval(countdownIntervalRef.current!); captureFrame(currentKey); return 3; } playSound("beep"); return prev - 1; }); }, 1000); return () => { if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); }; }, [captureState, currentPoseIndex, countdown, isPaused, webcamActive]); // Capture current frame from HTML5 Video const captureFrame = (poseKey: string) => { if (!videoRef.current || !canvasRef.current) return; const video = videoRef.current; const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); if (!ctx || video.readyState < 2) return; canvas.width = 640; canvas.height = 480; // Draw mirrored video frame ctx.translate(canvas.width, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.setTransform(1, 0, 0, 1, 0, 0); const base64 = canvas.toDataURL("image/jpeg", 0.90); playSound("click"); setCapturedImages((prev) => ({ ...prev, [poseKey]: base64 })); if (singleRetakePose) { // Re-taking a single pose from review screen stopWebcam(); setSingleRetakePose(null); setCaptureState("review"); } else { // Auto sequence: Move to next pose setCurrentPoseIndex((prev) => prev + 1); setCountdown(3); } }; // Perform single re-take for a specific pose const handleRetakeSingle = async (poseKey: string) => { setSingleRetakePose(poseKey); setSelectedPose(poseKey); setCountdown(3); setCaptureState("capturing"); // Set index to match keys const index = POSE_KEYS.indexOf(poseKey); setCurrentPoseIndex(index); await startWebcam(); }; // Sequential batch upload to FastAPI backend const saveBiometricProfile = async () => { setCaptureState("saving"); setErrorMsg(null); setUploadProgress(0); const keysToUpload = POSE_KEYS; let completedCount = 0; for (let i = 0; i < keysToUpload.length; i++) { const key = keysToUpload[i]; const base64 = capturedImages[key]; if (!base64) continue; setUploadIndex(i + 1); try { // Convert base64 data url to blob const resBlob = await fetch(base64); const blob = await resBlob.blob(); const fd = new FormData(); fd.append("employee_id", employeeId as string); fd.append("pose_type", key); fd.append("file", blob, `${key}.jpg`); await fetchApi("/enrollment/upload", { method: "POST", body: fd }); completedCount++; setUploadProgress((completedCount / keysToUpload.length) * 100); } catch (err: any) { setErrorMsg(`Failed to save pose '${POSES[key].label}': ${err.message || "Network Error"}.`); setCaptureState("review"); return; } } refetchStatus(); setCaptureState("success"); }; // Manual fallback file upload const [selectedPose, setSelectedPose] = useState("front"); const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setErrorMsg(null); setSuccessMsg(null); try { const reader = new FileReader(); reader.onload = (event) => { const base64 = event.target?.result as string; setCapturedImages(prev => ({ ...prev, [selectedPose]: base64 })); if (captureState === "idle") { setCaptureState("review"); } }; reader.readAsDataURL(file); } catch (err: any) { setErrorMsg(err.message || "Failed to process photo."); } e.target.value = ""; }; // Progress Calculations const enrolledCount = status?.enrolled_poses?.length || 0; const isProfileComplete = status?.is_complete || false; return (
{/* CSS Scanner Animations style block */} {/* โ”€โ”€โ”€ Breadcrumbs & Header โ”€โ”€โ”€ */}
{employee?.name?.split(" ").map((n: string) => n[0]).join("").substring(0, 2) || "?"}

Facial Enrollment

NAME: {employee?.name} ยท ID: {employee?.employee_id}

{enrolledCount > 0 && ( )}
{/* โ”€โ”€โ”€ Status Feedback Bar โ”€โ”€โ”€ */} {errorMsg && (
{errorMsg}
)} {successMsg && (
{successMsg}
)} {/* โ”€โ”€โ”€ State 1: IDLE / STARTER SCREEN โ”€โ”€โ”€ */} {captureState === "idle" && (
{/* Left Box: Progress and instructions */}

Facial Registry

Database Status: {isProfileComplete ? "Complete" : "Incomplete"}
Active Vectors: {enrolledCount} / {POSE_KEYS.length}
{/* Upload Fallback File Option */}

Manual Photo Upload

If the employee cannot use a live camera, select a target pose and upload a photo from disk.

{/* Right: Big visual grid checklist */}

Facial Pose Checklist

To capture accurate biometric details under varying orientations and lighting, we index 10 distinct facial angles.

{POSE_KEYS.map((key) => { const done = status?.enrolled_poses?.some((p: string) => p.toLowerCase() === key.toLowerCase()); return (
{POSES[key].icon} {POSES[key].label.split(" ")[0]} {done ? ( ) : (
)}
); })}
)} {/* โ”€โ”€โ”€ State 2: AUTOMATIC SCANNER HUD FEED โ”€โ”€โ”€ */} {captureState === "capturing" && (
{/* Pose directions banner */}
{POSES[POSE_KEYS[currentPoseIndex]]?.icon}

SCAN PHASE {currentPoseIndex + 1} OF {POSE_KEYS.length}

{POSES[POSE_KEYS[currentPoseIndex]]?.label}

{POSES[POSE_KEYS[currentPoseIndex]]?.hint}

{/* Countdown circle HUD */}
{countdown}
{/* Video stream container with Cybernetic HUD */}
{/* Native video element */}