Pavanupadhyay27 commited on
Commit
536e22c
Β·
1 Parent(s): c40c998

feat: automated hands-free biometric enrollment with speech directions and HUD

Browse files
Files changed (1) hide show
  1. frontend/app/enroll/[id]/page.tsx +718 -266
frontend/app/enroll/[id]/page.tsx CHANGED
@@ -4,42 +4,119 @@ import React, { useState, useEffect, useRef } from "react";
4
  import { useParams, useRouter } from "next/navigation";
5
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
6
  import SidebarLayout from "@/components/SidebarLayout";
7
- import { fetchApi } from "@/app/utils/api";
8
  import {
9
  Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video,
10
- RefreshCw, AlertCircle, Trash2
11
  } from "lucide-react";
12
 
13
- const POSES: Record<string, { label: string; hint: string; icon: string }> = {
14
- front: { label: "Front", hint: "Look straight into the camera with neutral expression.", icon: "πŸ§‘" },
15
- left: { label: "Left", hint: "Turn head slowly to the left (profile view).", icon: "πŸ‘ˆ" },
16
- right: { label: "Right", hint: "Turn head slowly to the right (profile view).", icon: "πŸ‘‰" },
17
- up: { label: "Up", hint: "Tilt your chin upwards slightly.", icon: "⬆️" },
18
- down: { label: "Down", hint: "Tilt your chin downwards slightly.", icon: "⬇️" },
19
- smile: { label: "Smile", hint: "Give a natural, relaxed smile.", icon: "😊" },
20
- neutral: { label: "Neutral", hint: "Keep a relaxed, standard neutral expression.", icon: "😐" },
21
- indoor: { label: "Indoor", hint: "Enroll with typical indoor room lighting conditions.", icon: "πŸ’‘" },
22
- outdoor: { label: "Outdoor", hint: "Enroll with natural outdoor or bright lighting.", icon: "β˜€οΈ" },
23
- glasses: { label: "Glasses", hint: "Wear glasses if applicable. Optional – skip if not wearing.", icon: "πŸ•ΆοΈ" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  };
25
 
 
 
26
  export default function EnrollPage() {
27
  const params = useParams();
28
  const router = useRouter();
29
  const queryClient = useQueryClient();
30
  const employeeId = params.id;
31
 
32
- const [selectedPose, setSelectedPose] = useState("front");
33
- const [uploading, setUploading] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
35
  const [successMsg, setSuccessMsg] = useState<string | null>(null);
36
- const [capturedPreview, setCapturedPreview] = useState<string | null>(null);
37
  const [webcamActive, setWebcamActive] = useState(false);
 
38
 
39
  const videoRef = useRef<HTMLVideoElement>(null);
40
  const canvasRef = useRef<HTMLCanvasElement>(null);
41
  const streamRef = useRef<MediaStream | null>(null);
 
42
 
 
43
  const { data: employee } = useQuery({
44
  queryKey: ["employee", employeeId],
45
  queryFn: () => fetchApi(`/employees/${employeeId}`)
@@ -53,323 +130,698 @@ export default function EnrollPage() {
53
 
54
  const clearMutation = useMutation({
55
  mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }),
56
- onSuccess: () => { refetchStatus(); setSuccessMsg("All facial data cleared."); setErrorMsg(null); }
 
 
 
 
 
57
  });
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  const startWebcam = async () => {
60
- setErrorMsg(null); setSuccessMsg(null);
 
61
  try {
62
- const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: "user" } });
 
 
63
  streamRef.current = stream;
64
- if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.play(); }
 
 
 
65
  setWebcamActive(true);
66
  } catch {
67
- setErrorMsg("Unable to access webcam. Check browser permissions.");
68
  }
69
  };
70
 
71
  const stopWebcam = () => {
 
 
 
 
72
  streamRef.current?.getTracks().forEach(t => t.stop());
73
  streamRef.current = null;
74
  if (videoRef.current) videoRef.current.srcObject = null;
75
  setWebcamActive(false);
76
- setCapturedPreview(null);
77
  };
78
 
79
- useEffect(() => () => { stopWebcam(); }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
- const handleCapture = async () => {
82
- if (!videoRef.current || !canvasRef.current || !webcamActive) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  const video = videoRef.current;
84
  const canvas = canvasRef.current;
85
  const ctx = canvas.getContext("2d");
86
- if (!ctx) return;
87
-
88
- const vw = video.videoWidth, vh = video.videoHeight;
89
- if (!vw || !vh) { setErrorMsg("Camera not ready yet. Please wait a moment."); return; }
90
-
91
- setUploading(true); setErrorMsg(null); setSuccessMsg(null); setCapturedPreview(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- canvas.width = vw; canvas.height = vh;
94
- ctx.drawImage(video, 0, 0, vw, vh);
 
 
 
 
 
 
 
 
 
 
95
 
96
- // Black frame detection
97
- const data = ctx.getImageData(0, 0, vw, vh).data;
98
- let sum = 0;
99
- for (let i = 0; i < data.length; i += 4) sum += (data[i] + data[i+1] + data[i+2]) / 3;
100
- if (sum / (data.length / 4) < 5) {
101
- setErrorMsg("Frame appears black. Ensure webcam is working and retry.");
102
- setUploading(false); return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  }
104
 
105
- setCapturedPreview(canvas.toDataURL("image/jpeg", 0.85));
106
- canvas.toBlob(async (blob) => {
107
- if (!blob) { setErrorMsg("Capture failed."); setUploading(false); return; }
108
- await uploadFile(blob);
109
- }, "image/jpeg", 0.95);
110
  };
111
 
 
 
112
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
113
  const file = e.target.files?.[0];
114
  if (!file) return;
115
- setUploading(true); setErrorMsg(null); setSuccessMsg(null);
116
- await uploadFile(file);
117
- e.target.value = "";
118
- };
119
-
120
- const uploadFile = async (blob: Blob) => {
121
- const fd = new FormData();
122
- fd.append("employee_id", employeeId as string);
123
- fd.append("pose_type", selectedPose);
124
- fd.append("file", blob, `${selectedPose}.jpg`);
125
  try {
126
- const res = await fetchApi("/enrollment/upload", { method: "POST", body: fd });
127
- setSuccessMsg(res.message);
128
- refetchStatus();
129
- // Auto-advance to next missing pose
130
- const missing = status?.missing_poses || [];
131
- const keys = Object.keys(POSES);
132
- const next = keys[keys.indexOf(selectedPose) + 1];
133
- if (next && missing.includes(next)) setSelectedPose(next);
 
134
  } catch (err: any) {
135
- setErrorMsg(err.message || "Enrollment failed. Please retry.");
136
- } finally {
137
- setUploading(false);
138
  }
 
139
  };
140
 
141
- const isEnrolled = (pose: string) =>
142
- status?.enrolled_poses?.some((p: string) => p.toLowerCase() === pose.toLowerCase());
143
-
144
- const progress = Math.min(100, Math.round(((status?.enrolled_poses?.filter(
145
- (p: string) => p.toLowerCase() !== "glasses"
146
- ).length || 0) / 9) * 100));
147
-
148
  const enrolledCount = status?.enrolled_poses?.length || 0;
149
- const totalCount = Object.keys(POSES).length;
150
 
151
  return (
152
  <SidebarLayout>
153
- <div className="space-y-6 max-w-5xl page-enter">
154
- {/* Back */}
155
- <button
156
- onClick={() => router.push("/employees")}
157
- className="flex items-center gap-1.5 text-slate-500 hover:text-slate-900 transition-colors text-[12px] font-medium"
158
- >
159
- <ChevronLeft className="w-4 h-4" />
160
- Back to Employees
161
- </button>
162
-
163
- {/* Header */}
164
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
165
- <div className="flex items-center gap-3">
166
- <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 border border-blue-500/20 flex items-center justify-center">
167
- <span className="text-base font-bold text-blue-400">
168
- {employee?.name?.charAt(0) || "?"}
169
- </span>
170
- </div>
171
- <div>
172
- <h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Biometric Enrollment</h1>
173
- <p className="text-[12px] text-slate-500">
174
- <span className="text-[var(--text-primary)] font-medium">{employee?.name}</span>
175
- <span className="text-slate-400 mx-1.5">Β·</span>
176
- <span className="font-mono text-slate-500">{employee?.employee_id}</span>
177
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  </div>
179
  </div>
180
- <button
181
- onClick={() => { if (confirm("Clear all registered facial data and embeddings?")) clearMutation.mutate(); }}
182
- className="flex items-center gap-2 text-[11px] font-semibold text-rose-400 hover:text-rose-300 bg-rose-500/5 hover:bg-rose-500/10 px-3 py-2 border border-rose-500/15 rounded-xl transition-all"
183
- >
184
- <Trash2 className="w-3.5 h-3.5" />
185
- Clear Facial Data
186
- </button>
 
 
 
187
  </div>
188
 
189
- {/* Main Grid */}
190
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
191
- {/* Left: Pose checklist */}
192
- <div className="glass-card rounded-2xl border border-white/6 p-5 space-y-4">
193
- <div>
194
- <div className="flex items-center justify-between mb-2">
195
- <h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Enrollment Progress</h2>
196
- <span className="text-[10px] text-slate-500 font-mono">{enrolledCount}/{totalCount}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  </div>
198
- {/* Progress bar */}
199
- <div className="relative h-1.5 bg-zinc-150 rounded-full overflow-hidden">
200
- <div
201
- className="absolute inset-y-0 left-0 rounded-full bg-zinc-900 transition-all duration-700"
202
- style={{ width: `${progress}%` }}
203
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  </div>
205
- <p className="text-[10px] text-slate-500 mt-1.5 text-right font-mono">{progress}% complete</p>
206
  </div>
207
 
208
- <div className="space-y-1">
209
- {Object.entries(POSES).map(([key, pose]) => {
210
- const done = isEnrolled(key);
211
- const active = selectedPose === key;
212
- return (
213
- <button
214
- key={key}
215
- onClick={() => { setSelectedPose(key); setErrorMsg(null); setSuccessMsg(null); }}
216
- className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all cursor-pointer ${
217
- active
218
- ? "bg-zinc-100 border border-zinc-300 text-zinc-950 shadow-xs"
219
- : done
220
- ? "bg-zinc-50 border border-transparent text-slate-700 hover:bg-zinc-100"
221
- : "border border-transparent text-slate-500 hover:bg-zinc-50 hover:text-slate-800"
222
- }`}
223
- >
224
- <span className="text-base w-5 text-center leading-none shrink-0">{pose.icon}</span>
225
- <span className="flex-1 text-[12px] font-medium">{pose.label}</span>
226
- {done ? (
227
- <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 shrink-0" />
228
- ) : (
229
- <div className="w-3.5 h-3.5 rounded-full border border-slate-200 shrink-0" />
230
- )}
231
- </button>
232
- );
233
- })}
 
 
 
 
234
  </div>
235
  </div>
236
-
237
- {/* Right: Camera capture panel */}
238
- <div className="lg:col-span-2 glass-card rounded-2xl border border-white/6 flex flex-col">
239
- <div className="p-5 border-b border-white/5">
240
- <div className="flex items-center gap-2.5">
241
- <span className="text-xl">{POSES[selectedPose]?.icon}</span>
 
 
 
 
242
  <div>
243
- <h3 className="text-[13px] font-semibold text-[var(--text-primary)] capitalize">
244
- Capture {POSES[selectedPose]?.label} Pose
245
- </h3>
246
- <p className="text-[11px] text-slate-500 mt-0.5">{POSES[selectedPose]?.hint}</p>
 
 
 
 
 
247
  </div>
248
  </div>
 
 
 
 
 
249
  </div>
250
 
251
- <div className="flex-1 p-5 space-y-4">
252
- {/* Status messages */}
253
- {errorMsg && (
254
- <div className="flex items-start gap-2.5 p-3 rounded-xl bg-rose-50 border border-rose-250 text-rose-800 text-[11px]">
255
- <XCircle className="w-4 h-4 shrink-0 mt-0.5" />
256
- <span className="leading-relaxed">{errorMsg}</span>
257
- </div>
258
- )}
259
- {successMsg && (
260
- <div className="flex items-start gap-2.5 p-3 rounded-xl bg-emerald-50 border border-emerald-250 text-emerald-800 text-[11px]">
261
- <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
262
- <span className="leading-relaxed">{successMsg}</span>
 
 
 
263
  </div>
264
- )}
265
-
266
- {/* Camera view */}
267
- <div className="relative aspect-video w-full rounded-2xl bg-[#070c1a] border border-white/6 overflow-hidden flex items-center justify-center">
268
- {/* Corner brackets */}
269
- {(webcamActive || capturedPreview) && (
270
- <>
271
- <div className="corner-bracket corner-bracket-tl text-zinc-400/60" />
272
- <div className="corner-bracket corner-bracket-tr text-zinc-400/60" />
273
- <div className="corner-bracket corner-bracket-bl text-zinc-400/60" />
274
- <div className="corner-bracket corner-bracket-br text-zinc-400/60" />
275
- </>
276
- )}
277
-
278
- <video
279
- ref={videoRef}
280
- className={`w-full h-full object-cover scale-x-[-1] ${webcamActive ? "" : "hidden"}`}
281
- autoPlay playsInline muted
282
- />
283
- {webcamActive && <div className="scanner-laser" />}
284
-
285
- {!webcamActive && capturedPreview && (
286
- <>
287
- <img src={capturedPreview} alt="Captured" className="w-full h-full object-cover scale-x-[-1]" />
288
- <div className="absolute bottom-3 left-1/2 -translate-x-1/2">
289
- <span className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1 rounded-full font-mono">
290
- <CheckCircle2 className="w-3 h-3" />
291
- Frame captured β€” review or re-take
292
- </span>
293
- </div>
294
- </>
295
- )}
296
 
297
- {!webcamActive && !capturedPreview && (
298
- <div className="text-center space-y-3 p-6">
299
- <div className="w-12 h-12 rounded-2xl bg-white/4 flex items-center justify-center mx-auto">
300
- <Video className="w-5 h-5 text-slate-700" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  </div>
302
- <div>
303
- <p className="text-[12px] text-slate-500 font-medium">Camera inactive</p>
304
- <p className="text-[10px] text-slate-700 mt-0.5">Click "Start Camera" to begin enrollment</p>
 
 
 
 
 
305
  </div>
306
  </div>
307
- )}
 
 
308
 
309
- <canvas ref={canvasRef} className="hidden" />
310
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  </div>
312
 
313
- {/* Action buttons */}
314
- <div className="px-5 pb-5 flex flex-col sm:flex-row items-center gap-3 pt-4 border-t border-white/5">
315
- {webcamActive ? (
316
- <>
317
- <button
318
- onClick={handleCapture}
319
- disabled={uploading}
320
- className="btn-primary flex-1 h-10 flex items-center justify-center gap-2 text-[12px]"
321
- >
322
- {uploading ? (
323
- <><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Processing...</span></>
324
- ) : (
325
- <><Camera className="w-3.5 h-3.5" /><span>Take Snapshot</span></>
326
- )}
327
- </button>
328
- <button
329
- onClick={stopWebcam}
330
- className="btn-ghost h-10 px-5 text-[12px] flex items-center gap-2"
331
- >
332
- <XCircle className="w-3.5 h-3.5" />
333
- Stop Camera
334
- </button>
335
- </>
336
- ) : (
337
- <button
338
- onClick={startWebcam}
339
- className="btn-primary flex-1 h-10 flex items-center justify-center gap-2 text-[12px]"
340
- >
341
- <Video className="w-3.5 h-3.5" />
342
- Start Camera
343
- </button>
344
- )}
345
-
346
- {/* File upload fallback */}
347
- <label className="relative cursor-pointer">
348
- <input
349
- type="file" accept="image/*"
350
- onChange={handleFileChange}
351
- disabled={uploading}
352
- className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:pointer-events-none"
353
  />
354
- <div className="btn-ghost h-10 px-4 text-[12px] flex items-center gap-2 pointer-events-none">
355
- <Upload className="w-3.5 h-3.5" />
356
- Upload Photo
357
- </div>
358
- </label>
359
  </div>
360
  </div>
361
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
- {/* Tips */}
364
- <div className="flex items-start gap-3 p-4 rounded-2xl bg-zinc-50 border border-zinc-200">
365
- <AlertCircle className="w-4 h-4 text-zinc-700 shrink-0 mt-0.5" />
366
- <div>
367
- <p className="text-[11px] font-semibold text-[var(--text-primary)] mb-1">Enrollment Tips</p>
368
- <p className="text-[10px] text-slate-650 leading-relaxed">
369
- Enroll at least <strong className="text-[var(--text-primary)]">5–7 poses</strong> 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.
370
- </p>
 
 
 
 
 
 
 
371
  </div>
372
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  </div>
374
  </SidebarLayout>
375
  );
 
4
  import { useParams, useRouter } from "next/navigation";
5
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
6
  import SidebarLayout from "@/components/SidebarLayout";
7
+ import { fetchApi, getBackendUrl } from "@/app/utils/api";
8
  import {
9
  Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video,
10
+ RefreshCw, AlertCircle, Trash2, Play, Pause, Save, RotateCcw, Shield, Activity, Sparkles
11
  } from "lucide-react";
12
 
13
+ interface PoseInfo {
14
+ label: string;
15
+ hint: string;
16
+ speech: string;
17
+ icon: string;
18
+ }
19
+
20
+ const POSES: Record<string, PoseInfo> = {
21
+ front: {
22
+ label: "Front Profile",
23
+ hint: "Look straight into the camera with a neutral expression.",
24
+ speech: "Please look straight into the camera.",
25
+ icon: "πŸ§‘"
26
+ },
27
+ left: {
28
+ label: "Left Profile",
29
+ hint: "Turn your head slowly to the left.",
30
+ speech: "Please turn your head to the left.",
31
+ icon: "πŸ‘ˆ"
32
+ },
33
+ right: {
34
+ label: "Right Profile",
35
+ hint: "Turn your head slowly to the right.",
36
+ speech: "Please turn your head to the right.",
37
+ icon: "πŸ‘‰"
38
+ },
39
+ up: {
40
+ label: "Looking Up",
41
+ hint: "Tilt your chin upwards slightly.",
42
+ speech: "Please tilt your head upwards.",
43
+ icon: "⬆️"
44
+ },
45
+ down: {
46
+ label: "Looking Down",
47
+ hint: "Tilt your chin downwards slightly.",
48
+ speech: "Please tilt your head downwards.",
49
+ icon: "⬇️"
50
+ },
51
+ smile: {
52
+ label: "Smiling Face",
53
+ hint: "Give a natural, relaxed smile.",
54
+ speech: "Now, smile naturally.",
55
+ icon: "😊"
56
+ },
57
+ neutral: {
58
+ label: "Neutral Face",
59
+ hint: "Keep a relaxed, standard neutral expression.",
60
+ speech: "Relax your face, show a neutral expression.",
61
+ icon: "😐"
62
+ },
63
+ indoor: {
64
+ label: "Indoor Light",
65
+ hint: "Look straight with standard indoor room lighting.",
66
+ speech: "Look straight for typical indoor lighting.",
67
+ icon: "πŸ’‘"
68
+ },
69
+ outdoor: {
70
+ label: "Outdoor Light",
71
+ hint: "Look straight with bright/outdoor lighting.",
72
+ speech: "Look straight for bright light capture.",
73
+ icon: "β˜€οΈ"
74
+ },
75
+ glasses: {
76
+ label: "Glasses Option",
77
+ hint: "Put on glasses if you wear them, otherwise look straight.",
78
+ speech: "If you wear glasses, put them on. Otherwise, look straight.",
79
+ icon: "πŸ•ΆοΈ"
80
+ }
81
  };
82
 
83
+ const POSE_KEYS = Object.keys(POSES);
84
+
85
  export default function EnrollPage() {
86
  const params = useParams();
87
  const router = useRouter();
88
  const queryClient = useQueryClient();
89
  const employeeId = params.id;
90
 
91
+ // State Machine for biometric scanner:
92
+ // "idle": Pre-start screen
93
+ // "capturing": Active auto-capture loop
94
+ // "review": Review grid of all 10 captured poses
95
+ // "saving": Uploading to server/indexing vectors
96
+ // "success": Completed successfully screen
97
+ const [captureState, setCaptureState] = useState<"idle" | "capturing" | "review" | "saving" | "success">("idle");
98
+ const [currentPoseIndex, setCurrentPoseIndex] = useState(0);
99
+ const [countdown, setCountdown] = useState(3);
100
+ const [isPaused, setIsPaused] = useState(false);
101
+
102
+ // Store local previews: { [poseKey]: base64DataUrl }
103
+ const [capturedImages, setCapturedImages] = useState<Record<string, string>>({});
104
+
105
+ // Upload status
106
+ const [uploadIndex, setUploadIndex] = useState(0);
107
+ const [uploadProgress, setUploadProgress] = useState(0);
108
+
109
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
110
  const [successMsg, setSuccessMsg] = useState<string | null>(null);
 
111
  const [webcamActive, setWebcamActive] = useState(false);
112
+ const [singleRetakePose, setSingleRetakePose] = useState<string | null>(null);
113
 
114
  const videoRef = useRef<HTMLVideoElement>(null);
115
  const canvasRef = useRef<HTMLCanvasElement>(null);
116
  const streamRef = useRef<MediaStream | null>(null);
117
+ const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
118
 
119
+ // Queries
120
  const { data: employee } = useQuery({
121
  queryKey: ["employee", employeeId],
122
  queryFn: () => fetchApi(`/employees/${employeeId}`)
 
130
 
131
  const clearMutation = useMutation({
132
  mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }),
133
+ onSuccess: () => {
134
+ refetchStatus();
135
+ setCapturedImages({});
136
+ setSuccessMsg("All registered facial profiles cleared from database.");
137
+ setErrorMsg(null);
138
+ }
139
  });
140
 
141
+ // Offline Web Audio API Sound Generator (synthesized camera click & alert beeps)
142
+ const playSound = (type: "beep" | "click") => {
143
+ if (typeof window === "undefined") return;
144
+ try {
145
+ const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
146
+ const osc = audioCtx.createOscillator();
147
+ const gain = audioCtx.createGain();
148
+ osc.connect(gain);
149
+ gain.connect(audioCtx.destination);
150
+
151
+ if (type === "beep") {
152
+ osc.type = "sine";
153
+ osc.frequency.setValueAtTime(600, audioCtx.currentTime);
154
+ gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
155
+ gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12);
156
+ osc.start();
157
+ osc.stop(audioCtx.currentTime + 0.13);
158
+ } else if (type === "click") {
159
+ // Shutter click noise synthesis
160
+ osc.type = "triangle";
161
+ osc.frequency.setValueAtTime(100, audioCtx.currentTime);
162
+ osc.frequency.exponentialRampToValueAtTime(1000, audioCtx.currentTime + 0.08);
163
+ gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
164
+ gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1);
165
+ osc.start();
166
+ osc.stop(audioCtx.currentTime + 0.12);
167
+ }
168
+ } catch (e) {
169
+ console.warn("Failed to generate audio feedback:", e);
170
+ }
171
+ };
172
+
173
+ // Browser offline speech engine
174
+ const speakDirection = (text: string) => {
175
+ if (typeof window !== "undefined" && window.speechSynthesis) {
176
+ window.speechSynthesis.cancel();
177
+ const utterance = new SpeechSynthesisUtterance(text);
178
+ utterance.rate = 0.92;
179
+ utterance.pitch = 1.05;
180
+
181
+ const voices = window.speechSynthesis.getVoices();
182
+ const eng = voices.find(v => v.lang.startsWith("en"));
183
+ if (eng) utterance.voice = eng;
184
+
185
+ window.speechSynthesis.speak(utterance);
186
+ }
187
+ };
188
+
189
+ // Webcam controls
190
  const startWebcam = async () => {
191
+ setErrorMsg(null);
192
+ setSuccessMsg(null);
193
  try {
194
+ const stream = await navigator.mediaDevices.getUserMedia({
195
+ video: { width: 1280, height: 720, facingMode: "user" }
196
+ });
197
  streamRef.current = stream;
198
+ if (videoRef.current) {
199
+ videoRef.current.srcObject = stream;
200
+ await videoRef.current.play().catch(() => {});
201
+ }
202
  setWebcamActive(true);
203
  } catch {
204
+ setErrorMsg("Webcam permission denied. Check your browser settings.");
205
  }
206
  };
207
 
208
  const stopWebcam = () => {
209
+ if (countdownIntervalRef.current) {
210
+ clearInterval(countdownIntervalRef.current);
211
+ countdownIntervalRef.current = null;
212
+ }
213
  streamRef.current?.getTracks().forEach(t => t.stop());
214
  streamRef.current = null;
215
  if (videoRef.current) videoRef.current.srcObject = null;
216
  setWebcamActive(false);
 
217
  };
218
 
219
+ useEffect(() => {
220
+ return () => stopWebcam();
221
+ }, []);
222
+
223
+ // Trigger auto capture session
224
+ const startAutoCapture = async () => {
225
+ setCapturedImages({});
226
+ setCurrentPoseIndex(0);
227
+ setCountdown(3);
228
+ setIsPaused(false);
229
+ setSingleRetakePose(null);
230
+ setCaptureState("capturing");
231
+ await startWebcam();
232
+ };
233
+
234
+ // Automated capture timer loop
235
+ useEffect(() => {
236
+ if (captureState !== "capturing" || isPaused || !webcamActive) return;
237
+
238
+ const currentKey = POSE_KEYS[currentPoseIndex];
239
+ if (!currentKey) {
240
+ // Completed all poses
241
+ stopWebcam();
242
+ setCaptureState("review");
243
+ return;
244
+ }
245
 
246
+ // Speak pose directions on start of each pose countdown
247
+ if (countdown === 3) {
248
+ speakDirection(POSES[currentKey].speech);
249
+ }
250
+
251
+ countdownIntervalRef.current = setInterval(() => {
252
+ setCountdown((prev) => {
253
+ if (prev <= 1) {
254
+ clearInterval(countdownIntervalRef.current!);
255
+ captureFrame(currentKey);
256
+ return 3;
257
+ }
258
+ playSound("beep");
259
+ return prev - 1;
260
+ });
261
+ }, 1000);
262
+
263
+ return () => {
264
+ if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
265
+ };
266
+ }, [captureState, currentPoseIndex, countdown, isPaused, webcamActive]);
267
+
268
+ // Capture current frame from HTML5 Video
269
+ const captureFrame = (poseKey: string) => {
270
+ if (!videoRef.current || !canvasRef.current) return;
271
  const video = videoRef.current;
272
  const canvas = canvasRef.current;
273
  const ctx = canvas.getContext("2d");
274
+ if (!ctx || video.readyState < 2) return;
275
+
276
+ canvas.width = 640;
277
+ canvas.height = 480;
278
+
279
+ // Draw mirrored video frame
280
+ ctx.translate(canvas.width, 0);
281
+ ctx.scale(-1, 1);
282
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
283
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
284
+
285
+ const base64 = canvas.toDataURL("image/jpeg", 0.90);
286
+ playSound("click");
287
+
288
+ setCapturedImages((prev) => ({ ...prev, [poseKey]: base64 }));
289
+
290
+ if (singleRetakePose) {
291
+ // Re-taking a single pose from review screen
292
+ stopWebcam();
293
+ setSingleRetakePose(null);
294
+ setCaptureState("review");
295
+ } else {
296
+ // Auto sequence: Move to next pose
297
+ setCurrentPoseIndex((prev) => prev + 1);
298
+ setCountdown(3);
299
+ }
300
+ };
301
 
302
+ // Perform single re-take for a specific pose
303
+ const handleRetakeSingle = async (poseKey: string) => {
304
+ setSingleRetakePose(poseKey);
305
+ setSelectedPose(poseKey);
306
+ setCountdown(3);
307
+ setCaptureState("capturing");
308
+
309
+ // Set index to match keys
310
+ const index = POSE_KEYS.indexOf(poseKey);
311
+ setCurrentPoseIndex(index);
312
+ await startWebcam();
313
+ };
314
 
315
+ // Sequential batch upload to FastAPI backend
316
+ const saveBiometricProfile = async () => {
317
+ setCaptureState("saving");
318
+ setErrorMsg(null);
319
+ setUploadProgress(0);
320
+
321
+ const keysToUpload = POSE_KEYS;
322
+ let completedCount = 0;
323
+
324
+ for (let i = 0; i < keysToUpload.length; i++) {
325
+ const key = keysToUpload[i];
326
+ const base64 = capturedImages[key];
327
+ if (!base64) continue;
328
+
329
+ setUploadIndex(i + 1);
330
+
331
+ try {
332
+ // Convert base64 data url to blob
333
+ const resBlob = await fetch(base64);
334
+ const blob = await resBlob.blob();
335
+
336
+ const fd = new FormData();
337
+ fd.append("employee_id", employeeId as string);
338
+ fd.append("pose_type", key);
339
+ fd.append("file", blob, `${key}.jpg`);
340
+
341
+ await fetchApi("/enrollment/upload", { method: "POST", body: fd });
342
+ completedCount++;
343
+ setUploadProgress((completedCount / keysToUpload.length) * 100);
344
+ } catch (err: any) {
345
+ setErrorMsg(`Failed to save pose '${POSES[key].label}': ${err.message || "Network Error"}.`);
346
+ setCaptureState("review");
347
+ return;
348
+ }
349
  }
350
 
351
+ refetchStatus();
352
+ setCaptureState("success");
 
 
 
353
  };
354
 
355
+ // Manual fallback file upload
356
+ const [selectedPose, setSelectedPose] = useState("front");
357
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
358
  const file = e.target.files?.[0];
359
  if (!file) return;
360
+
361
+ setErrorMsg(null);
362
+ setSuccessMsg(null);
363
+
 
 
 
 
 
 
364
  try {
365
+ const reader = new FileReader();
366
+ reader.onload = (event) => {
367
+ const base64 = event.target?.result as string;
368
+ setCapturedImages(prev => ({ ...prev, [selectedPose]: base64 }));
369
+ if (captureState === "idle") {
370
+ setCaptureState("review");
371
+ }
372
+ };
373
+ reader.readAsDataURL(file);
374
  } catch (err: any) {
375
+ setErrorMsg(err.message || "Failed to process photo.");
 
 
376
  }
377
+ e.target.value = "";
378
  };
379
 
380
+ // Progress Calculations
 
 
 
 
 
 
381
  const enrolledCount = status?.enrolled_poses?.length || 0;
382
+ const isProfileComplete = status?.is_complete || false;
383
 
384
  return (
385
  <SidebarLayout>
386
+ <div className="space-y-6 max-w-5xl page-enter relative">
387
+ {/* CSS Scanner Animations style block */}
388
+ <style>{`
389
+ @keyframes scanline {
390
+ 0% { top: 0%; opacity: 0; }
391
+ 5% { opacity: 1; }
392
+ 95% { opacity: 1; }
393
+ 100% { top: 100%; opacity: 0; }
394
+ }
395
+ @keyframes pulse-ring {
396
+ 0% { transform: scale(0.92); opacity: 0.15; }
397
+ 50% { transform: scale(1.08); opacity: 0.5; }
398
+ 100% { transform: scale(0.92); opacity: 0.15; }
399
+ }
400
+ .scanner-line {
401
+ position: absolute;
402
+ left: 0;
403
+ right: 0;
404
+ height: 3px;
405
+ background: linear-gradient(to right, transparent, #22d3ee, transparent);
406
+ box-shadow: 0 0 12px #22d3ee, 0 0 24px #0891b2;
407
+ animation: scanline 3s linear infinite;
408
+ z-index: 10;
409
+ pointer-events: none;
410
+ }
411
+ .scanner-target {
412
+ position: absolute;
413
+ width: 260px;
414
+ height: 260px;
415
+ border: 1px dashed rgba(34, 211, 238, 0.4);
416
+ border-radius: 50%;
417
+ animation: pulse-ring 2.5s ease-in-out infinite;
418
+ pointer-events: none;
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ }
423
+ .hud-corner {
424
+ position: absolute;
425
+ width: 20px;
426
+ height: 20px;
427
+ border-color: #22d3ee;
428
+ border-width: 2px;
429
+ pointer-events: none;
430
+ }
431
+ `}</style>
432
+
433
+ <canvas ref={canvasRef} className="hidden" />
434
+
435
+ {/* ─── Breadcrumbs & Header ─── */}
436
+ <div className="flex items-center justify-between pb-5 border-b border-slate-250/60">
437
+ <div className="space-y-2">
438
+ <button
439
+ onClick={() => router.push("/employees")}
440
+ className="flex items-center gap-1.5 text-slate-500 hover:text-slate-900 transition-colors text-[11px] font-semibold uppercase tracking-wider"
441
+ >
442
+ <ChevronLeft className="w-3.5 h-3.5" />
443
+ Back to Employees
444
+ </button>
445
+ <div className="flex items-center gap-3">
446
+ <div className="w-11 h-11 rounded-xl bg-slate-950 border border-slate-800 flex items-center justify-center shadow-md">
447
+ <span className="text-sm font-extrabold text-white font-mono uppercase">
448
+ {employee?.name?.split(" ").map((n: string) => n[0]).join("").substring(0, 2) || "?"}
449
+ </span>
450
+ </div>
451
+ <div>
452
+ <h1 className="text-xl font-black text-slate-900 tracking-tight leading-none">Facial Enrollment</h1>
453
+ <p className="text-[11px] text-slate-500 mt-1 font-mono">
454
+ NAME: <span className="font-bold text-slate-700">{employee?.name}</span> Β· ID: <span className="font-bold text-slate-700">{employee?.employee_id}</span>
455
+ </p>
456
+ </div>
457
  </div>
458
  </div>
459
+
460
+ {enrolledCount > 0 && (
461
+ <button
462
+ onClick={() => { if (confirm("Completely wipe all registered biometric vectors and images? This cannot be undone.")) clearMutation.mutate(); }}
463
+ className="flex items-center gap-2 text-[11.5px] font-bold text-rose-600 hover:text-white bg-white hover:bg-rose-600 border border-rose-200 hover:border-rose-600 px-4 py-2 rounded-xl transition-all cursor-pointer shadow-sm"
464
+ >
465
+ <Trash2 className="w-3.5 h-3.5" />
466
+ Clear Biometric Data
467
+ </button>
468
+ )}
469
  </div>
470
 
471
+ {/* ─── Status Feedback Bar ─── */}
472
+ {errorMsg && (
473
+ <div className="flex items-start gap-3 p-3.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-800 text-xs font-medium animate-shake">
474
+ <XCircle className="w-4 h-4 shrink-0 mt-0.5" />
475
+ <span className="leading-relaxed">{errorMsg}</span>
476
+ </div>
477
+ )}
478
+ {successMsg && (
479
+ <div className="flex items-start gap-3 p-3.5 rounded-xl bg-emerald-50 border border-emerald-250 text-emerald-800 text-xs font-medium">
480
+ <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
481
+ <span className="leading-relaxed">{successMsg}</span>
482
+ </div>
483
+ )}
484
+
485
+ {/* ─── State 1: IDLE / STARTER SCREEN ─── */}
486
+ {captureState === "idle" && (
487
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
488
+
489
+ {/* Left Box: Progress and instructions */}
490
+ <div className="space-y-5">
491
+ <div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4">
492
+ <h3 className="text-[13px] font-bold text-slate-900 uppercase tracking-wider">Facial Registry</h3>
493
+
494
+ <div className="space-y-1">
495
+ <div className="flex items-center justify-between text-xs font-mono font-bold text-slate-650">
496
+ <span>Database Status:</span>
497
+ <span className={isProfileComplete ? "text-emerald-600" : "text-amber-500"}>
498
+ {isProfileComplete ? "Complete" : "Incomplete"}
499
+ </span>
500
+ </div>
501
+ <div className="flex items-center justify-between text-xs font-mono font-bold text-slate-650">
502
+ <span>Active Vectors:</span>
503
+ <span>{enrolledCount} / {POSE_KEYS.length}</span>
504
+ </div>
505
+ </div>
506
+
507
+ <button
508
+ onClick={startAutoCapture}
509
+ className="w-full h-11 bg-slate-950 hover:bg-slate-900 border border-slate-950 text-white font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2.5 transition-all shadow-md cursor-pointer"
510
+ >
511
+ <Camera className="w-4 h-4" />
512
+ Start Auto-Capture Session
513
+ </button>
514
  </div>
515
+
516
+ {/* Upload Fallback File Option */}
517
+ <div className="bg-slate-50 border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
518
+ <h4 className="text-[11.5px] font-bold text-slate-700 uppercase tracking-wider">Manual Photo Upload</h4>
519
+ <p className="text-[10px] text-slate-500 leading-normal">
520
+ If the employee cannot use a live camera, select a target pose and upload a photo from disk.
521
+ </p>
522
+ <div className="flex gap-2">
523
+ <select
524
+ value={selectedPose}
525
+ onChange={(e) => setSelectedPose(e.target.value)}
526
+ className="h-9 px-2 text-[11px] font-bold bg-white border border-slate-200 rounded-lg flex-1 outline-none text-slate-700"
527
+ >
528
+ {POSE_KEYS.map((key) => (
529
+ <option key={key} value={key}>{POSES[key].label}</option>
530
+ ))}
531
+ </select>
532
+ <label className="relative cursor-pointer shrink-0">
533
+ <input
534
+ type="file" accept="image/*"
535
+ onChange={handleFileChange}
536
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
537
+ />
538
+ <div className="h-9 px-3.5 bg-white border border-slate-250 text-slate-800 hover:bg-slate-50 font-bold text-[11px] rounded-lg flex items-center gap-1.5 transition-all shadow-sm">
539
+ <Upload className="w-3.5 h-3.5" />
540
+ Browse
541
+ </div>
542
+ </label>
543
+ </div>
544
  </div>
 
545
  </div>
546
 
547
+ {/* Right: Big visual grid checklist */}
548
+ <div className="md:col-span-2 bg-white border border-slate-200 rounded-2xl p-6 shadow-sm space-y-4">
549
+ <h3 className="text-sm font-black text-slate-900 tracking-tight">Facial Pose Checklist</h3>
550
+ <p className="text-xs text-slate-500">
551
+ To capture accurate biometric details under varying orientations and lighting, we index 10 distinct facial angles.
552
+ </p>
553
+
554
+ <div className="grid grid-cols-2 sm:grid-cols-5 gap-3 pt-2">
555
+ {POSE_KEYS.map((key) => {
556
+ const done = status?.enrolled_poses?.some((p: string) => p.toLowerCase() === key.toLowerCase());
557
+ return (
558
+ <div
559
+ key={key}
560
+ className={`p-3 rounded-xl border flex flex-col items-center text-center justify-center transition-all ${
561
+ done
562
+ ? "bg-emerald-50/50 border-emerald-200 text-emerald-800"
563
+ : "bg-slate-50/50 border-slate-200 text-slate-400"
564
+ }`}
565
+ >
566
+ <span className="text-xl mb-1.5 filter drop-shadow-sm">{POSES[key].icon}</span>
567
+ <span className="text-[10px] font-bold uppercase tracking-wider font-mono">{POSES[key].label.split(" ")[0]}</span>
568
+ {done ? (
569
+ <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 mt-2 shrink-0" />
570
+ ) : (
571
+ <div className="w-3.5 h-3.5 rounded-full border border-slate-250 mt-2 shrink-0 bg-white" />
572
+ )}
573
+ </div>
574
+ );
575
+ })}
576
+ </div>
577
  </div>
578
  </div>
579
+ )}
580
+
581
+ {/* ─── State 2: AUTOMATIC SCANNER HUD FEED ─── */}
582
+ {captureState === "capturing" && (
583
+ <div className="flex flex-col items-center space-y-5">
584
+ {/* Pose directions banner */}
585
+ <div className="w-full bg-slate-950 text-white rounded-2xl p-5 flex items-center justify-between shadow-lg relative overflow-hidden">
586
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] pointer-events-none" />
587
+ <div className="flex items-center gap-4 relative z-10">
588
+ <span className="text-3xl filter drop-shadow-sm">{POSES[POSE_KEYS[currentPoseIndex]]?.icon}</span>
589
  <div>
590
+ <p className="text-[10px] font-bold text-cyan-400 font-mono tracking-widest uppercase">
591
+ SCAN PHASE {currentPoseIndex + 1} OF {POSE_KEYS.length}
592
+ </p>
593
+ <h2 className="text-lg font-black tracking-tight text-white mt-0.5">
594
+ {POSES[POSE_KEYS[currentPoseIndex]]?.label}
595
+ </h2>
596
+ <p className="text-xs text-slate-300 font-medium mt-1">
597
+ {POSES[POSE_KEYS[currentPoseIndex]]?.hint}
598
+ </p>
599
  </div>
600
  </div>
601
+
602
+ {/* Countdown circle HUD */}
603
+ <div className="relative w-14 h-14 flex items-center justify-center shrink-0 border-2 border-white/10 rounded-full font-mono bg-white/5 shadow-inner">
604
+ <span className="text-2xl font-black text-cyan-400 animate-pulse">{countdown}</span>
605
+ </div>
606
  </div>
607
 
608
+ {/* Video stream container with Cybernetic HUD */}
609
+ <div className="relative aspect-video w-full max-w-3xl rounded-3xl overflow-hidden bg-black border border-slate-950 shadow-2xl">
610
+
611
+ {/* Native video element */}
612
+ <video
613
+ ref={videoRef}
614
+ className="w-full h-full object-cover scale-x-[-1]"
615
+ autoPlay playsInline muted
616
+ />
617
+
618
+ {/* Cybernetic HUD elements */}
619
+ <div className="scanner-line" />
620
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
621
+ <div className="scanner-target">
622
+ <div className="w-4 h-4 border border-cyan-400 rounded-full animate-ping" />
623
  </div>
624
+ </div>
625
+
626
+ {/* HUD corners */}
627
+ <div className="hud-corner corner-bracket-tl top-6 left-6 border-t-2 border-l-2" />
628
+ <div className="hud-corner corner-bracket-tr top-6 right-6 border-t-2 border-r-2" />
629
+ <div className="hud-corner corner-bracket-bl bottom-6 left-6 border-b-2 border-l-2" />
630
+ <div className="hud-corner corner-bracket-br bottom-6 right-6 border-b-2 border-r-2" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
 
632
+ {/* Scanner stats HUD */}
633
+ <div className="absolute top-6 left-12 right-12 flex justify-between text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none uppercase">
634
+ <span>SYS.STATUS: ACQUIRING_DATA</span>
635
+ <span>FPS: 60 Β· ISO: 200 Β· SHUTTER: AUTO</span>
636
+ </div>
637
+
638
+ <div className="absolute bottom-6 left-12 right-12 flex justify-between items-center text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none">
639
+ <span>ANGLE: {POSE_KEYS[currentPoseIndex]?.toUpperCase()}</span>
640
+ <span>LIVENESS CHECK: ACTIVE</span>
641
+ </div>
642
+ </div>
643
+
644
+ {/* Controls */}
645
+ <div className="flex gap-4 w-full max-w-lg">
646
+ <button
647
+ onClick={() => setIsPaused(!isPaused)}
648
+ className="flex-1 h-11 bg-white hover:bg-slate-50 border border-slate-250 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
649
+ >
650
+ {isPaused ? <Play className="w-3.5 h-3.5 fill-current" /> : <Pause className="w-3.5 h-3.5 fill-current" />}
651
+ {isPaused ? "Resume Scan" : "Pause Scan"}
652
+ </button>
653
+
654
+ <button
655
+ onClick={() => {
656
+ if (singleRetakePose) {
657
+ stopWebcam();
658
+ setSingleRetakePose(null);
659
+ setCaptureState("review");
660
+ } else {
661
+ // Skip pose
662
+ setCurrentPoseIndex(prev => prev + 1);
663
+ setCountdown(3);
664
+ }
665
+ }}
666
+ className="flex-1 h-11 bg-slate-950 hover:bg-slate-900 border border-slate-950 text-white font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-md cursor-pointer"
667
+ >
668
+ Skip Pose
669
+ </button>
670
+ </div>
671
+ </div>
672
+ )}
673
+
674
+ {/* ─── State 3: REVIEW CAPTURES GRID ─── */}
675
+ {captureState === "review" && (
676
+ <div className="space-y-6">
677
+ <div className="bg-slate-50 border border-slate-200 rounded-2xl p-5 text-center max-w-2xl mx-auto space-y-2">
678
+ <Sparkles className="w-6 h-6 text-slate-900 mx-auto animate-pulse" />
679
+ <h2 className="text-base font-black text-slate-900 tracking-tight">Scan Sequence Completed</h2>
680
+ <p className="text-xs text-slate-500 max-w-md mx-auto">
681
+ Review the 10 captured biometric pose frames. If any photo is blurry or dark, click the re-take icon. Once ready, click "Save Biometric Profile".
682
+ </p>
683
+ </div>
684
+
685
+ {/* Grid of 10 captured poses */}
686
+ <div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
687
+ {POSE_KEYS.map((key) => {
688
+ const imgUrl = capturedImages[key];
689
+ return (
690
+ <div key={key} className="bg-white border border-slate-200 rounded-2xl p-3 shadow-xs relative flex flex-col group overflow-hidden">
691
+ <div className="aspect-[4/3] rounded-xl bg-slate-100 overflow-hidden relative border border-slate-150">
692
+ {imgUrl ? (
693
+ <img src={imgUrl} alt={key} className="w-full h-full object-cover" />
694
+ ) : (
695
+ <div className="w-full h-full flex items-center justify-center text-[10px] text-slate-400 font-mono font-bold uppercase tracking-wider bg-slate-50">
696
+ Missing
697
+ </div>
698
+ )}
699
+
700
+ {/* Hover action bar to retake */}
701
+ <div className="absolute inset-0 bg-slate-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
702
+ <button
703
+ onClick={() => handleRetakeSingle(key)}
704
+ className="bg-white hover:bg-slate-100 text-slate-900 text-[10px] font-bold px-3 py-1.5 rounded-lg flex items-center gap-1.5 shadow-md cursor-pointer"
705
+ >
706
+ <RotateCcw className="w-3 h-3" />
707
+ Re-take
708
+ </button>
709
+ </div>
710
  </div>
711
+
712
+ <div className="mt-2.5 flex items-center justify-between text-[11px] font-bold">
713
+ <span className="text-slate-900 uppercase font-mono">{POSES[key].label.split(" ")[0]}</span>
714
+ {imgUrl ? (
715
+ <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
716
+ ) : (
717
+ <XCircle className="w-3.5 h-3.5 text-rose-500" />
718
+ )}
719
  </div>
720
  </div>
721
+ );
722
+ })}
723
+ </div>
724
 
725
+ {/* Review actions */}
726
+ <div className="flex justify-center gap-4 pt-4">
727
+ <button
728
+ onClick={startAutoCapture}
729
+ className="h-11 px-8 bg-white hover:bg-slate-50 border border-slate-250 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
730
+ >
731
+ <RotateCcw className="w-3.5 h-3.5" />
732
+ Discard & Re-take All
733
+ </button>
734
+
735
+ <button
736
+ onClick={saveBiometricProfile}
737
+ className="h-11 px-8 bg-slate-950 hover:bg-slate-900 border border-slate-950 text-white font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-md cursor-pointer"
738
+ >
739
+ <Save className="w-3.5 h-3.5" />
740
+ Save Biometric Profile
741
+ </button>
742
+ </div>
743
+ </div>
744
+ )}
745
+
746
+ {/* ─── State 4: BATCH SAVING ANIMATION ─── */}
747
+ {captureState === "saving" && (
748
+ <div className="max-w-md mx-auto bg-white border border-slate-250/80 rounded-3xl p-8 shadow-2xl text-center space-y-6 animate-scaleIn relative overflow-hidden">
749
+ {/* Spinning radar graphic */}
750
+ <div className="relative w-24 h-24 mx-auto flex items-center justify-center">
751
+ <div className="absolute inset-0 rounded-full border-2 border-dashed border-cyan-500/30 animate-spin" />
752
+ <div className="absolute inset-2 rounded-full border border-dashed border-cyan-500/40 animate-spin" style={{ animationDirection: "reverse" }} />
753
+ <Shield className="w-10 h-10 text-cyan-500 animate-pulse" />
754
  </div>
755
 
756
+ <div className="space-y-2">
757
+ <h2 className="text-base font-black text-slate-900 uppercase tracking-widest font-mono">
758
+ Saving Facial Registry...
759
+ </h2>
760
+ <p className="text-xs text-slate-500">
761
+ Uploading photo {uploadIndex} of {POSE_KEYS.length} to security gateway.
762
+ </p>
763
+ </div>
764
+
765
+ {/* Progress Bar */}
766
+ <div className="space-y-1.5">
767
+ <div className="relative h-2 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
768
+ <div
769
+ className="absolute inset-y-0 left-0 bg-gradient-to-right from-cyan-400 to-cyan-500 transition-all duration-300"
770
+ style={{ width: `${uploadProgress}%` }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  />
772
+ </div>
773
+ <div className="flex justify-between text-[10px] font-mono font-bold text-slate-550">
774
+ <span>DATABASE VECTOR INDEXING</span>
775
+ <span>{Math.round(uploadProgress)}%</span>
776
+ </div>
777
  </div>
778
  </div>
779
+ )}
780
+
781
+ {/* ─── State 5: SUCCESS SCREEN ─── */}
782
+ {captureState === "success" && (
783
+ <div className="max-w-md mx-auto bg-white border border-slate-200 rounded-3xl p-8 shadow-2xl text-center space-y-6 animate-scaleIn">
784
+ <div className="relative w-20 h-20 mx-auto bg-emerald-50 rounded-full flex items-center justify-center border border-emerald-100">
785
+ <CheckCircle2 className="w-10 h-10 text-emerald-500" />
786
+ </div>
787
+
788
+ <div className="space-y-2">
789
+ <h2 className="text-lg font-black text-slate-900 tracking-tight">Biometric Profile Secured</h2>
790
+ <p className="text-xs text-slate-550 leading-relaxed">
791
+ All 10 facial profiles and mathematical vectors have been successfully registered for <strong className="text-slate-800">{employee?.name}</strong>. The kiosk scan terminal is now ready to verify attendance.
792
+ </p>
793
+ </div>
794
 
795
+ <div className="flex gap-3 justify-center pt-2">
796
+ <button
797
+ onClick={() => router.push("/employees")}
798
+ className="h-10 px-6 bg-slate-100 hover:bg-slate-150 border border-slate-200 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl transition-all cursor-pointer"
799
+ >
800
+ Employees List
801
+ </button>
802
+
803
+ <button
804
+ onClick={startAutoCapture}
805
+ className="h-10 px-6 bg-slate-950 hover:bg-slate-900 border border-slate-950 text-white font-bold text-xs uppercase tracking-wider rounded-xl transition-all shadow-md cursor-pointer"
806
+ >
807
+ Re-enroll Profile
808
+ </button>
809
+ </div>
810
  </div>
811
+ )}
812
+
813
+ {/* ─── Tips Section ─── */}
814
+ {captureState === "idle" && (
815
+ <div className="flex items-start gap-3 p-4 rounded-2xl bg-slate-50 border border-slate-200">
816
+ <AlertCircle className="w-4 h-4 text-slate-700 shrink-0 mt-0.5" />
817
+ <div>
818
+ <p className="text-[11px] font-bold text-slate-900 uppercase tracking-wider mb-1 font-mono">Registry Specifications</p>
819
+ <p className="text-[10px] text-slate-650 leading-relaxed font-mono uppercase">
820
+ AUTOMATED ENROLLMENT PROCESS IS COMPLETELY HANDS-FREE. SYSTEM WILL INSTRUCT AND SYNC CAMERAS IN SEQUENCE. ENSURE STABLE ROOM LIGHTING AND POSES TO ACCURATELY INDEX FACIAL VECTORS.
821
+ </p>
822
+ </div>
823
+ </div>
824
+ )}
825
  </div>
826
  </SidebarLayout>
827
  );