Pavanupadhyay27 commited on
Commit
22df4e3
·
1 Parent(s): afd6f8b

feat: add option to delete previous system audit logs

Browse files
backend/app/api/v1/analytics.py CHANGED
@@ -3,7 +3,7 @@ from fastapi.responses import StreamingResponse
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy import select, func, and_, desc
5
  from datetime import date, datetime, timedelta
6
- from typing import Dict, Any, List
7
  import asyncio
8
  import json
9
 
@@ -189,3 +189,32 @@ async def live_stream(
189
  event_bus.unsubscribe(queue)
190
 
191
  return StreamingResponse(event_generator(), media_type="text/event-stream")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from sqlalchemy.orm import Session
4
  from sqlalchemy import select, func, and_, desc
5
  from datetime import date, datetime, timedelta
6
+ from typing import Dict, Any, List, Optional
7
  import asyncio
8
  import json
9
 
 
189
  event_bus.unsubscribe(queue)
190
 
191
  return StreamingResponse(event_generator(), media_type="text/event-stream")
192
+
193
+ @router.get("/heatmap")
194
+ def get_attendance_heatmap(
195
+ employee_id: Optional[int] = None,
196
+ db: Session = Depends(get_db),
197
+ current_user: models.User = Depends(checker_view)
198
+ ):
199
+ """
200
+ Returns daily attendance counts for the last 365 days to render a GitHub-style heatmap.
201
+ """
202
+ start_date = date.today() - timedelta(days=365)
203
+
204
+ query = db.query(
205
+ models.Attendance.date,
206
+ func.count(models.Attendance.id)
207
+ ).filter(
208
+ models.Attendance.date >= start_date
209
+ )
210
+
211
+ if employee_id:
212
+ query = query.filter(models.Attendance.employee_id == employee_id)
213
+ query = query.filter(models.Attendance.status.in_(["Present", "Late", "Half Day"]))
214
+ else:
215
+ query = query.filter(models.Attendance.status.in_(["Present", "Late", "Half Day"]))
216
+
217
+ results = query.group_by(models.Attendance.date).all()
218
+
219
+ heatmap_data = {r[0].isoformat(): r[1] for r in results}
220
+ return heatmap_data
backend/app/api/v1/attendance.py CHANGED
@@ -1,7 +1,8 @@
1
  from fastapi import APIRouter, Depends, HTTPException, status, Request
2
  from sqlalchemy.orm import Session
3
  from typing import List, Optional
4
- from datetime import date, datetime, timedelta
 
5
 
6
  from app.core.database import get_db
7
  from app.core import security
@@ -48,6 +49,95 @@ def manual_update_attendance(
48
  )
49
  return updated
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @router.get("/logs", response_model=List[schemas.AttendanceLogOut])
52
  def read_attendance_logs(
53
  skip: int = 0,
 
1
  from fastapi import APIRouter, Depends, HTTPException, status, Request
2
  from sqlalchemy.orm import Session
3
  from typing import List, Optional
4
+ from datetime import date, datetime, timedelta, time
5
+ from sqlalchemy import and_
6
 
7
  from app.core.database import get_db
8
  from app.core import security
 
49
  )
50
  return updated
51
 
52
+ @router.post("/manual", response_model=schemas.AttendanceOut)
53
+ def manual_create_or_update_attendance(
54
+ request: Request,
55
+ data: schemas.AttendanceBase,
56
+ db: Session = Depends(get_db),
57
+ current_user: models.User = Depends(checker_manage)
58
+ ):
59
+ # Verify employee exists
60
+ employee = db.query(models.Employee).filter(models.Employee.id == data.employee_id).first()
61
+ if not employee:
62
+ raise HTTPException(status_code=404, detail="Employee not found")
63
+
64
+ # Check if record already exists for that employee and date
65
+ db_att = db.query(models.Attendance).filter(
66
+ and_(
67
+ models.Attendance.employee_id == data.employee_id,
68
+ models.Attendance.date == data.date
69
+ )
70
+ ).first()
71
+
72
+ if db_att:
73
+ # Update existing record
74
+ db_att.check_in = data.check_in
75
+ db_att.check_out = data.check_out
76
+ db_att.status = data.status
77
+ else:
78
+ # Create new record
79
+ db_att = models.Attendance(
80
+ employee_id=data.employee_id,
81
+ date=data.date,
82
+ check_in=data.check_in,
83
+ check_out=data.check_out,
84
+ status=data.status,
85
+ late_arrival=False,
86
+ early_departure=False,
87
+ working_hours=0.0,
88
+ overtime=0.0,
89
+ emergency_allowed=False
90
+ )
91
+ db.add(db_att)
92
+
93
+ # Determine late arrival flag based on shift or global settings
94
+ if db_att.check_in:
95
+ # Resolve shift start
96
+ if employee.shift:
97
+ shift_start = employee.shift.start_time
98
+ grace_mins = employee.shift.grace_period_minutes
99
+ else:
100
+ start_time_setting = crud.get_setting_by_key(db, "CHECK_IN_START")
101
+ grace_period_setting = crud.get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
102
+
103
+ start_str = start_time_setting.value if start_time_setting else "09:00"
104
+ grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
105
+ try:
106
+ hr, mn = map(int, start_str.split(":"))
107
+ shift_start = time(hr, mn)
108
+ except Exception:
109
+ shift_start = time(9, 0)
110
+
111
+ check_in_deadline = datetime.combine(db_att.date, shift_start) + timedelta(minutes=grace_mins)
112
+ db_att.late_arrival = db_att.check_in > check_in_deadline
113
+ if db_att.status not in ["Absent", "Half Day"]:
114
+ db_att.status = "Late" if db_att.late_arrival else "Present"
115
+ else:
116
+ db_att.late_arrival = False
117
+
118
+ # Recalculate working hours if both check_in and check_out exist
119
+ if db_att.check_in and db_att.check_out:
120
+ diff = db_att.check_out - db_att.check_in
121
+ db_att.working_hours = round(diff.total_seconds() / 3600.0, 2)
122
+ db_att.overtime = max(0.0, round(db_att.working_hours - 8.0, 2))
123
+ else:
124
+ db_att.working_hours = 0.0
125
+ db_att.overtime = 0.0
126
+
127
+ db.commit()
128
+ db.refresh(db_att)
129
+
130
+ # Create audit log
131
+ crud.create_audit_log(
132
+ db=db,
133
+ user_id=current_user.id,
134
+ action="Manual Attendance Override",
135
+ ip_address=request.client.host if request.client else None,
136
+ user_agent=request.headers.get("user-agent"),
137
+ details=f"Manually logged attendance for employee {employee.name} (ID: {employee.employee_id}) on {data.date}. Status: {db_att.status}"
138
+ )
139
+ return db_att
140
+
141
  @router.get("/logs", response_model=List[schemas.AttendanceLogOut])
142
  def read_attendance_logs(
143
  skip: int = 0,
backend/app/api/v1/audit.py CHANGED
@@ -1,4 +1,4 @@
1
- from fastapi import APIRouter, Depends, HTTPException, status
2
  from sqlalchemy.orm import Session
3
  from typing import List
4
 
@@ -21,3 +21,24 @@ def read_audit_logs(
21
  current_user: models.User = Depends(checker_view)
22
  ):
23
  return crud.get_audit_logs(db, skip=skip, limit=limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
2
  from sqlalchemy.orm import Session
3
  from typing import List
4
 
 
21
  current_user: models.User = Depends(checker_view)
22
  ):
23
  return crud.get_audit_logs(db, skip=skip, limit=limit)
24
+
25
+ @router.delete("/")
26
+ def delete_all_audit_logs(
27
+ request: Request,
28
+ db: Session = Depends(get_db),
29
+ current_user: models.User = Depends(checker_view)
30
+ ):
31
+ deleted_count = crud.clear_all_audit_logs(db)
32
+
33
+ # Audit log the deletion itself
34
+ crud.create_audit_log(
35
+ db=db,
36
+ user_id=current_user.id,
37
+ action="Clear Audit Logs",
38
+ ip_address=request.client.host if request.client else None,
39
+ user_agent=request.headers.get("user-agent"),
40
+ details=f"Cleared {deleted_count} system audit logs."
41
+ )
42
+
43
+ return {"message": "Audit logs cleared successfully", "deleted_count": deleted_count}
44
+
backend/app/api/v1/enrollment.py CHANGED
@@ -57,6 +57,11 @@ async def upload_face_image(
57
  if confidence < 0.5:
58
  raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.")
59
 
 
 
 
 
 
60
  # Optional liveness check on enrollment (preventing enroll spoofing)
61
  liveness_enabled_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_CHECK")
62
  liveness_enabled = liveness_enabled_setting.value.lower() == "true" if liveness_enabled_setting else True
 
57
  if confidence < 0.5:
58
  raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.")
59
 
60
+ # Image Quality Validation
61
+ quality = face_engine.validate_image_quality(img)
62
+ if not quality["is_valid"]:
63
+ raise HTTPException(status_code=400, detail=quality["reason"])
64
+
65
  # Optional liveness check on enrollment (preventing enroll spoofing)
66
  liveness_enabled_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_CHECK")
67
  liveness_enabled = liveness_enabled_setting.value.lower() == "true" if liveness_enabled_setting else True
backend/app/api/v1/kiosk.py CHANGED
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request
2
  from fastapi.responses import FileResponse
3
  from sqlalchemy.orm import Session
4
  from pydantic import BaseModel, Field
 
5
  import base64
6
  import cv2
7
  import numpy as np
@@ -59,10 +60,31 @@ def _publish_log(log_obj, employee=None):
59
  except Exception as exc:
60
  logger.warning(f"Failed to publish scan event: {exc}")
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  class KioskScanRequest(BaseModel):
63
  image: str = Field(..., description="Base64 encoded image frame (JPEG/PNG data URL)")
64
  camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
65
  confirm_checkout: bool = Field(False, description="Whether the check-out is confirmed by the employee")
 
 
 
 
 
66
 
67
  @router.post("/scan")
68
  def scan_face(
@@ -107,132 +129,238 @@ def scan_face(
107
  except Exception:
108
  raise HTTPException(status_code=400, detail="Invalid Base64 image data")
109
 
110
- # 2. Detect face
111
- faces = face_engine.detect_faces(img)
112
- if not faces:
113
- return {
114
- "status": "no_face",
115
- "message": "No face detected. Frame your face within the scanner.",
116
- "should_retry": True
117
- }
118
- if len(faces) > 1:
119
- return {
120
- "status": "multiple_faces",
121
- "message": "Multiple faces detected. Please scan one person at a time.",
122
- "should_retry": True
123
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
- face = faces[0]
126
- bbox = face["bbox"]
127
- confidence = face["confidence"]
128
- landmarks = face["landmarks"]
129
-
130
- # 3. Liveness Check
131
- liveness_score, is_live = face_engine.check_liveness(img, bbox, threshold=liveness_threshold)
132
- if not is_live and not face_engine.mock_mode:
133
- # Save spoof log
134
- log_entry = crud.create_attendance_log(
135
- db=db,
136
- employee_id=None,
137
- camera=payload.camera,
138
- confidence=confidence,
139
- liveness_score=liveness_score,
140
- is_spoof=True,
141
- status="Spoof Rejected",
142
- timestamp=now
143
- )
144
- _publish_log(log_entry)
145
- return {
146
- "status": "spoof_detected",
147
- "message": "Liveness check failed! Verification denied.",
148
- "confidence": float(confidence),
149
- "liveness_score": float(liveness_score),
150
- "should_retry": False
151
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- # 4. Extract Embedding
154
- aligned = face_engine.align_face(img, landmarks)
155
- embedding = face_engine.extract_embedding(aligned)
156
-
157
- # 5. DB Matching: fetch all vectors and compute similarity in memory (completely database-agnostic)
158
- if face_engine.embeddings_cache is None:
159
- face_engine.load_embeddings_cache(db)
160
 
161
- all_embeddings = face_engine.embeddings_cache
162
- if not all_embeddings:
163
  match_result = None
164
- else:
165
  try:
166
- # High-performance vectorized search using NumPy matrix multiplication.
167
- # ArcFace embeddings are L2-normalized, so cosine similarity is just the dot product.
168
- embeddings_matrix = np.stack([emb["embedding"] for emb in all_embeddings]) # shape (N, 512)
169
- similarities = np.dot(embeddings_matrix, embedding) # shape (N,)
170
- best_idx = int(np.argmax(similarities))
171
- best_similarity = float(similarities[best_idx])
172
-
173
- best_emb_record = all_embeddings[best_idx]
174
- class MockEmb:
175
- id = best_emb_record["id"]
176
- employee_id = best_emb_record["employee_id"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
- # distance = 1 - similarity
179
- best_dist = 1.0 - best_similarity
180
- match_result = (MockEmb(), best_dist)
181
- except Exception as e:
182
- logger.error(f"Error in vectorized face matching: {e}")
183
- match_result = None
184
-
185
- if not match_result:
186
- # Database has no enrolled embeddings
187
- log_entry = crud.create_attendance_log(
188
- db=db,
189
- employee_id=None,
190
- camera=payload.camera,
191
- confidence=confidence,
192
- liveness_score=liveness_score,
193
- is_spoof=False,
194
- status="Empty Vector Index",
195
- timestamp=now
196
- )
197
- _publish_log(log_entry)
198
- return {
199
- "status": "unknown",
200
- "message": "No employees registered in the system. Please register first.",
201
- "should_retry": False
202
- }
203
 
204
- db_emb, distance = match_result
205
- # Similarity = 1 - Distance
206
- similarity = 1.0 - float(distance)
207
-
208
- if similarity < face_threshold:
209
- # Low confidence match -> Unknown
210
- log_entry = crud.create_attendance_log(
211
- db=db,
212
- employee_id=None,
213
- camera=payload.camera,
214
- confidence=similarity,
215
- liveness_score=liveness_score,
216
- is_spoof=False,
217
- status="Unknown Person",
218
- timestamp=now
219
- )
220
- _publish_log(log_entry)
221
- return {
222
- "status": "unknown",
223
- "message": "Face not recognized. Please try again or contact HR.",
224
- "confidence": similarity,
225
- "liveness_score": liveness_score,
226
- "should_retry": True
227
- }
228
 
229
- # Face Matched!
230
- employee = crud.get_employee_by_id(db, db_emb.employee_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  if not employee or employee.status != "Active":
232
  # Inactive employee
 
233
  log_entry = crud.create_attendance_log(
234
  db=db,
235
- employee_id=db_emb.employee_id,
236
  camera=payload.camera,
237
  confidence=similarity,
238
  liveness_score=liveness_score,
@@ -241,12 +369,89 @@ def scan_face(
241
  timestamp=now
242
  )
243
  _publish_log(log_entry, employee)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  return {
245
  "status": "inactive",
246
  "message": "Employee account is deactivated. Access denied.",
247
- "should_retry": False
 
248
  }
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  global _last_greeted_employee_id
251
  should_greet = True
252
  if _last_greeted_employee_id == employee.id:
@@ -257,6 +462,32 @@ def scan_face(
257
  from sqlalchemy import select, and_
258
  from datetime import time, timedelta
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  # 6. Check state of attendance
261
  stmt = select(models.Attendance).where(
262
  and_(
@@ -268,16 +499,7 @@ def scan_face(
268
 
269
  if not attendance_record:
270
  # --- First scan of the day: Check-In ---
271
- start_time_setting = crud.get_setting_by_key(db, "CHECK_IN_START")
272
- grace_period_setting = crud.get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
273
- start_str = start_time_setting.value if start_time_setting else "09:00"
274
- grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
275
- try:
276
- hr, mn = map(int, start_str.split(":"))
277
- check_in_deadline = datetime.combine(now.date(), time(hr, mn)) + timedelta(minutes=grace_mins)
278
- except Exception:
279
- check_in_deadline = datetime.combine(now.date(), time(9, 15))
280
-
281
  is_late = now > check_in_deadline
282
  status = "Late" if is_late else "Present"
283
 
@@ -300,7 +522,7 @@ def scan_face(
300
  confidence=similarity,
301
  liveness_score=liveness_score,
302
  is_spoof=False,
303
- status="Match Success",
304
  timestamp=now
305
  )
306
  _publish_log(log_entry, employee)
@@ -344,7 +566,8 @@ def scan_face(
344
  "detail": "Attendance Recorded Successfully",
345
  "closing": "Have a Great Day"
346
  },
347
- "tts_url": tts_url
 
348
  }
349
 
350
  else:
@@ -365,7 +588,7 @@ def scan_face(
365
  confidence=similarity,
366
  liveness_score=liveness_score,
367
  is_spoof=False,
368
- status="Match Success",
369
  timestamp=now
370
  )
371
  _publish_log(log_entry, employee)
@@ -408,7 +631,8 @@ def scan_face(
408
  "detail": "Emergency Check-In Recorded",
409
  "closing": "Have a Great Day"
410
  },
411
- "tts_url": tts_url
 
412
  }
413
  else:
414
  # Locked for the day!
@@ -427,7 +651,8 @@ def scan_face(
427
  return {
428
  "status": "locked",
429
  "message": "Attendance locked until tomorrow. Emergency entry must be approved by Admin.",
430
- "should_retry": False
 
431
  }
432
 
433
  else:
@@ -465,7 +690,8 @@ def scan_face(
465
  "detail": "Tap Yes to confirm Check Out",
466
  "closing": f"Duration: {hours} hours"
467
  },
468
- "tts_url": tts_url
 
469
  }
470
  else:
471
  # User confirmed checkout!
@@ -475,18 +701,20 @@ def scan_face(
475
  attendance_record.working_hours = hours
476
 
477
  # Early departure and overtime
478
- end_time_setting = crud.get_setting_by_key(db, "CHECK_OUT_END")
479
- end_str = end_time_setting.value if end_time_setting else "17:00"
480
- try:
481
- ehr, emn = map(int, end_str.split(":"))
482
- departure_deadline = datetime.combine(now.date(), time(ehr, emn))
483
- except Exception:
484
- departure_deadline = datetime.combine(now.date(), time(17, 0))
485
  attendance_record.early_departure = now < departure_deadline
486
- attendance_record.overtime = max(0.0, round(hours - 8.0, 2))
 
 
 
 
 
 
 
487
 
488
- # Update status based on hours
489
- if hours < 8.0:
 
490
  attendance_record.status = "Half Day"
491
  else:
492
  if attendance_record.status in ["Half Day", "Absent"]:
@@ -502,7 +730,7 @@ def scan_face(
502
  confidence=similarity,
503
  liveness_score=liveness_score,
504
  is_spoof=False,
505
- status="Match Success",
506
  timestamp=now
507
  )
508
  _publish_log(log_entry, employee)
@@ -546,7 +774,8 @@ def scan_face(
546
  "detail": "Checkout Recorded Successfully",
547
  "closing": f"Worked: {hours} hours"
548
  },
549
- "tts_url": tts_url
 
550
  }
551
 
552
  @router.get("/tts")
@@ -564,3 +793,153 @@ def play_tts(text: str):
564
  except Exception as e:
565
  logger.error(f"Error serving TTS endpoint: {e}")
566
  raise HTTPException(status_code=500, detail="Voice generation failed")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from fastapi.responses import FileResponse
3
  from sqlalchemy.orm import Session
4
  from pydantic import BaseModel, Field
5
+ from typing import Optional
6
  import base64
7
  import cv2
8
  import numpy as np
 
60
  except Exception as exc:
61
  logger.warning(f"Failed to publish scan event: {exc}")
62
 
63
+ import math
64
+
65
+ def calculate_distance_meters(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
66
+ # Haversine formula
67
+ R = 6371000.0 # Earth radius in meters
68
+ phi1 = math.radians(lat1)
69
+ phi2 = math.radians(lat2)
70
+ delta_phi = math.radians(lat2 - lat1)
71
+ delta_lambda = math.radians(lon2 - lon1)
72
+
73
+ a = math.sin(delta_phi / 2.0)**2 + \
74
+ math.cos(phi1) * math.cos(phi2) * \
75
+ math.sin(delta_lambda / 2.0)**2
76
+ c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
77
+ return R * c
78
+
79
  class KioskScanRequest(BaseModel):
80
  image: str = Field(..., description="Base64 encoded image frame (JPEG/PNG data URL)")
81
  camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
82
  confirm_checkout: bool = Field(False, description="Whether the check-out is confirmed by the employee")
83
+ qr_code: str = Field(None, description="Pre-detected QR code string from frontend")
84
+ qr_only: bool = Field(False, description="If True, only allow QR-based logging and disable face recognition")
85
+ latitude: Optional[float] = Field(None, description="Latitude of the kiosk/device marking attendance")
86
+ longitude: Optional[float] = Field(None, description="Longitude of the kiosk/device marking attendance")
87
+
88
 
89
  @router.post("/scan")
90
  def scan_face(
 
129
  except Exception:
130
  raise HTTPException(status_code=400, detail="Invalid Base64 image data")
131
 
132
+ qr_employee = None
133
+ is_qr_scan = False
134
+ bbox_list = None
135
+
136
+ # Check if qr_code was pre-detected by the frontend
137
+ if getattr(payload, "qr_code", None):
138
+ qr_val = payload.qr_code.strip()
139
+ qr_employee = db.query(models.Employee).filter(
140
+ models.Employee.employee_id == qr_val
141
+ ).first()
142
+ if qr_employee:
143
+ is_qr_scan = True
144
+ logger.info(f"QR code pre-detected by frontend: {qr_employee.employee_id}")
145
+
146
+ if not is_qr_scan:
147
+ try:
148
+ qr_detector = cv2.QRCodeDetector()
149
+ qr_val, _, _ = qr_detector.detectAndDecode(img)
150
+ if qr_val:
151
+ qr_val = qr_val.strip()
152
+ qr_employee = db.query(models.Employee).filter(
153
+ models.Employee.employee_id == qr_val
154
+ ).first()
155
+ if qr_employee:
156
+ is_qr_scan = True
157
+ logger.info(f"QR code scanned successfully for employee: {qr_employee.employee_id}")
158
+ except Exception as qr_err:
159
+ logger.warning(f"QR code parsing error: {qr_err}")
160
+
161
+ if is_qr_scan:
162
+ employee = qr_employee
163
+ similarity = 1.0
164
+ liveness_score = 1.0
165
+ confidence = 1.0
166
+ log_status_success = "Match Success (QR Scanned)"
167
+ else:
168
+ if getattr(payload, "qr_only", False):
169
+ return {
170
+ "status": "unknown",
171
+ "message": "Invalid QR code. Employee badge not found.",
172
+ "should_retry": True
173
+ }
174
+ # 2. Detect face
175
+ faces = face_engine.detect_faces(img)
176
+ if not faces:
177
+ return {
178
+ "status": "no_face",
179
+ "message": "No face detected. Frame your face within the scanner.",
180
+ "should_retry": True
181
+ }
182
+ if len(faces) > 1:
183
+ return {
184
+ "status": "multiple_faces",
185
+ "message": "Multiple faces detected. Please scan one person at a time.",
186
+ "should_retry": True
187
+ }
188
+
189
+ face = faces[0]
190
+ bbox = face["bbox"]
191
+ bbox_list = [float(x) for x in bbox]
192
+ confidence = face["confidence"]
193
+ landmarks = face["landmarks"]
194
 
195
+ # 3. Liveness Check
196
+ liveness_score, is_live = face_engine.check_liveness(img, bbox, threshold=liveness_threshold)
197
+ if not is_live and not face_engine.mock_mode:
198
+ # Save spoof log
199
+ log_entry = crud.create_attendance_log(
200
+ db=db,
201
+ employee_id=None,
202
+ camera=payload.camera,
203
+ confidence=confidence,
204
+ liveness_score=liveness_score,
205
+ is_spoof=True,
206
+ status="Spoof Rejected",
207
+ timestamp=now
208
+ )
209
+ _publish_log(log_entry)
210
+
211
+ # Dispatch Webhook alert
212
+ try:
213
+ from app.services.notifications import trigger_security_alert
214
+ trigger_security_alert(
215
+ db=db,
216
+ alert_type="Spoofing Attempt Rejected",
217
+ details={
218
+ "camera": payload.camera,
219
+ "confidence": float(confidence),
220
+ "liveness_score": float(liveness_score),
221
+ "timestamp": now.strftime("%Y-%m-%d %H:%M:%S")
222
+ }
223
+ )
224
+ except Exception as alert_err:
225
+ logger.error(f"Failed to dispatch security alert: {alert_err}")
226
+
227
+ return {
228
+ "status": "spoof_detected",
229
+ "message": "Liveness check failed! Verification denied.",
230
+ "confidence": float(confidence),
231
+ "liveness_score": float(liveness_score),
232
+ "should_retry": False,
233
+ "bbox": bbox_list
234
+ }
235
 
236
+ # 4. Extract Embedding
237
+ aligned = face_engine.align_face(img, landmarks)
238
+ embedding = face_engine.extract_embedding(aligned)
 
 
 
 
239
 
240
+ # 5. DB Matching: query pgvector if postgresql, else fallback to numpy cache-matching
 
241
  match_result = None
242
+ is_pg = False
243
  try:
244
+ is_pg = (db.bind.dialect.name == "postgresql")
245
+ except Exception as dialect_err:
246
+ logger.warning(f"Could not determine DB dialect: {dialect_err}")
247
+
248
+ if is_pg:
249
+ try:
250
+ # Run database-level query using pgvector's cosine distance (<=>) operator
251
+ emb_list = embedding.tolist() if isinstance(embedding, np.ndarray) else list(embedding)
252
+ distance_expr = models.FaceEmbedding.embedding.op('<=>')(emb_list).label('distance')
253
+ query_res = db.query(models.FaceEmbedding, distance_expr).order_by(distance_expr).limit(1).first()
254
+ if query_res:
255
+ db_emb, distance = query_res
256
+ match_result = (db_emb, float(distance))
257
+ except Exception as pg_err:
258
+ logger.error(f"Failed to query pgvector: {pg_err}. Falling back to SQLite/NumPy matching.")
259
+ match_result = None
260
+
261
+ if match_result is None:
262
+ if face_engine.embeddings_cache is None:
263
+ face_engine.load_embeddings_cache(db)
264
+
265
+ all_embeddings = face_engine.embeddings_cache
266
+ if not all_embeddings:
267
+ match_result = None
268
+ else:
269
+ try:
270
+ # High-performance vectorized search using NumPy matrix multiplication.
271
+ # ArcFace embeddings are L2-normalized, so cosine similarity is just the dot product.
272
+ embeddings_matrix = np.stack([emb["embedding"] for emb in all_embeddings]) # shape (N, 512)
273
+ similarities = np.dot(embeddings_matrix, embedding) # shape (N,)
274
+ best_idx = int(np.argmax(similarities))
275
+ best_similarity = float(similarities[best_idx])
276
+
277
+ best_emb_record = all_embeddings[best_idx]
278
+ class MockEmb:
279
+ id = best_emb_record["id"]
280
+ employee_id = best_emb_record["employee_id"]
281
+
282
+ # distance = 1 - similarity
283
+ best_dist = 1.0 - best_similarity
284
+ match_result = (MockEmb(), best_dist)
285
+ except Exception as e:
286
+ logger.error(f"Error in vectorized face matching: {e}")
287
+ match_result = None
288
+
289
+ if not match_result:
290
+ # Database has no enrolled embeddings
291
+ log_entry = crud.create_attendance_log(
292
+ db=db,
293
+ employee_id=None,
294
+ camera=payload.camera,
295
+ confidence=confidence,
296
+ liveness_score=liveness_score,
297
+ is_spoof=False,
298
+ status="Empty Vector Index",
299
+ timestamp=now
300
+ )
301
+ _publish_log(log_entry)
302
+ return {
303
+ "status": "unknown",
304
+ "message": "No employees registered in the system. Please register first.",
305
+ "should_retry": False,
306
+ "bbox": bbox_list
307
+ }
308
 
309
+ db_emb, distance = match_result
310
+ # Similarity = 1 - Distance
311
+ similarity = 1.0 - float(distance)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
+ employee = crud.get_employee_by_id(db, db_emb.employee_id) if db_emb else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
+ if similarity < face_threshold:
316
+ qr_fallback_setting = crud.get_setting_by_key(db, "QR_FALLBACK_ENABLED")
317
+ qr_fallback_enabled = qr_fallback_setting.value.lower() == "true" if qr_fallback_setting else True
318
+
319
+ if False: # Disable automatic QR fallback on borderline match
320
+ return {
321
+ "status": "needs_qr",
322
+ "message": "Face matched but requires identity verification. Please scan your employee QR code.",
323
+ "employee": {
324
+ "id": employee.id,
325
+ "employee_id": employee.employee_id,
326
+ "name": employee.name,
327
+ "designation": employee.designation,
328
+ "department": employee.department.name if employee.department else "General"
329
+ },
330
+ "confidence": similarity,
331
+ "liveness_score": liveness_score,
332
+ "should_retry": False,
333
+ "bbox": bbox_list
334
+ }
335
+
336
+ # Low confidence match -> Unknown
337
+ log_entry = crud.create_attendance_log(
338
+ db=db,
339
+ employee_id=None,
340
+ camera=payload.camera,
341
+ confidence=similarity,
342
+ liveness_score=liveness_score,
343
+ is_spoof=False,
344
+ status="Unknown Person",
345
+ timestamp=now
346
+ )
347
+ _publish_log(log_entry)
348
+ return {
349
+ "status": "unknown",
350
+ "message": "Face not recognized. Please try again or contact HR.",
351
+ "confidence": similarity,
352
+ "liveness_score": liveness_score,
353
+ "should_retry": True,
354
+ "bbox": bbox_list
355
+ }
356
+ log_status_success = "Match Success"
357
+
358
  if not employee or employee.status != "Active":
359
  # Inactive employee
360
+ emp_id_to_log = employee.id if employee else (db_emb.employee_id if ('db_emb' in locals() and db_emb) else None)
361
  log_entry = crud.create_attendance_log(
362
  db=db,
363
+ employee_id=emp_id_to_log,
364
  camera=payload.camera,
365
  confidence=similarity,
366
  liveness_score=liveness_score,
 
369
  timestamp=now
370
  )
371
  _publish_log(log_entry, employee)
372
+
373
+ # Dispatch Webhook alert
374
+ try:
375
+ from app.services.notifications import trigger_security_alert
376
+ trigger_security_alert(
377
+ db=db,
378
+ alert_type="Deactivated Employee Access Attempt",
379
+ details={
380
+ "camera": payload.camera,
381
+ "confidence": float(similarity),
382
+ "liveness_score": float(liveness_score),
383
+ "timestamp": now.strftime("%Y-%m-%d %H:%M:%S"),
384
+ "employee_name": employee.name if employee else "Unknown",
385
+ "employee_id": employee.employee_id if employee else "Unknown"
386
+ }
387
+ )
388
+ except Exception as alert_err:
389
+ logger.error(f"Failed to dispatch security alert: {alert_err}")
390
+
391
  return {
392
  "status": "inactive",
393
  "message": "Employee account is deactivated. Access denied.",
394
+ "should_retry": False,
395
+ "bbox": bbox_list
396
  }
397
 
398
+ # Geofencing validation check
399
+ loc_enabled_setting = crud.get_setting_by_key(db, "LOCATION_RESTRICTION_ENABLED")
400
+ loc_enabled = loc_enabled_setting.value.lower() == "true" if loc_enabled_setting else False
401
+
402
+ if loc_enabled and not getattr(employee, "allow_wfh", False):
403
+ if payload.latitude is None or payload.longitude is None:
404
+ log_entry = crud.create_attendance_log(
405
+ db=db,
406
+ employee_id=employee.id,
407
+ camera=payload.camera,
408
+ confidence=similarity if 'similarity' in locals() else 1.0,
409
+ liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
410
+ is_spoof=False,
411
+ status="Location Missing",
412
+ timestamp=now
413
+ )
414
+ _publish_log(log_entry, employee)
415
+ return {
416
+ "status": "location_error",
417
+ "message": "GPS coordinates are required to mark attendance.",
418
+ "should_retry": False,
419
+ "bbox": bbox_list
420
+ }
421
+
422
+ loc_lat_setting = crud.get_setting_by_key(db, "LOCATION_LATITUDE")
423
+ loc_lon_setting = crud.get_setting_by_key(db, "LOCATION_LONGITUDE")
424
+ loc_rad_setting = crud.get_setting_by_key(db, "LOCATION_RADIUS_METERS")
425
+
426
+ try:
427
+ office_lat = float(loc_lat_setting.value) if loc_lat_setting else 0.0
428
+ office_lon = float(loc_lon_setting.value) if loc_lon_setting else 0.0
429
+ allowed_radius = float(loc_rad_setting.value) if loc_rad_setting else 50.0
430
+ except ValueError:
431
+ office_lat = 0.0
432
+ office_lon = 0.0
433
+ allowed_radius = 50.0
434
+
435
+ dist = calculate_distance_meters(payload.latitude, payload.longitude, office_lat, office_lon)
436
+ if dist > allowed_radius:
437
+ log_entry = crud.create_attendance_log(
438
+ db=db,
439
+ employee_id=employee.id,
440
+ camera=payload.camera,
441
+ confidence=similarity if 'similarity' in locals() else 1.0,
442
+ liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
443
+ is_spoof=False,
444
+ status="Outside Office Bounds",
445
+ timestamp=now
446
+ )
447
+ _publish_log(log_entry, employee)
448
+ return {
449
+ "status": "location_error",
450
+ "message": f"Outside allowed area. Distance: {dist:.1f}m. Max radius: {allowed_radius}m.",
451
+ "should_retry": False,
452
+ "bbox": bbox_list
453
+ }
454
+
455
  global _last_greeted_employee_id
456
  should_greet = True
457
  if _last_greeted_employee_id == employee.id:
 
462
  from sqlalchemy import select, and_
463
  from datetime import time, timedelta
464
 
465
+ # Resolve shift details for employee
466
+ if employee.shift:
467
+ shift_start = employee.shift.start_time
468
+ shift_end = employee.shift.end_time
469
+ grace_mins = employee.shift.grace_period_minutes
470
+ else:
471
+ start_time_setting = crud.get_setting_by_key(db, "CHECK_IN_START")
472
+ end_time_setting = crud.get_setting_by_key(db, "CHECK_OUT_END")
473
+ grace_period_setting = crud.get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
474
+
475
+ start_str = start_time_setting.value if start_time_setting else "09:00"
476
+ end_str = end_time_setting.value if end_time_setting else "17:00"
477
+ grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
478
+
479
+ try:
480
+ hr, mn = map(int, start_str.split(":"))
481
+ shift_start = time(hr, mn)
482
+ except Exception:
483
+ shift_start = time(9, 0)
484
+
485
+ try:
486
+ hr, mn = map(int, end_str.split(":"))
487
+ shift_end = time(hr, mn)
488
+ except Exception:
489
+ shift_end = time(17, 0)
490
+
491
  # 6. Check state of attendance
492
  stmt = select(models.Attendance).where(
493
  and_(
 
499
 
500
  if not attendance_record:
501
  # --- First scan of the day: Check-In ---
502
+ check_in_deadline = datetime.combine(now.date(), shift_start) + timedelta(minutes=grace_mins)
 
 
 
 
 
 
 
 
 
503
  is_late = now > check_in_deadline
504
  status = "Late" if is_late else "Present"
505
 
 
522
  confidence=similarity,
523
  liveness_score=liveness_score,
524
  is_spoof=False,
525
+ status=log_status_success,
526
  timestamp=now
527
  )
528
  _publish_log(log_entry, employee)
 
566
  "detail": "Attendance Recorded Successfully",
567
  "closing": "Have a Great Day"
568
  },
569
+ "tts_url": tts_url,
570
+ "bbox": bbox_list
571
  }
572
 
573
  else:
 
588
  confidence=similarity,
589
  liveness_score=liveness_score,
590
  is_spoof=False,
591
+ status=log_status_success,
592
  timestamp=now
593
  )
594
  _publish_log(log_entry, employee)
 
631
  "detail": "Emergency Check-In Recorded",
632
  "closing": "Have a Great Day"
633
  },
634
+ "tts_url": tts_url,
635
+ "bbox": bbox_list
636
  }
637
  else:
638
  # Locked for the day!
 
651
  return {
652
  "status": "locked",
653
  "message": "Attendance locked until tomorrow. Emergency entry must be approved by Admin.",
654
+ "should_retry": False,
655
+ "bbox": bbox_list
656
  }
657
 
658
  else:
 
690
  "detail": "Tap Yes to confirm Check Out",
691
  "closing": f"Duration: {hours} hours"
692
  },
693
+ "tts_url": tts_url,
694
+ "bbox": bbox_list
695
  }
696
  else:
697
  # User confirmed checkout!
 
701
  attendance_record.working_hours = hours
702
 
703
  # Early departure and overtime
704
+ departure_deadline = datetime.combine(now.date(), shift_end)
 
 
 
 
 
 
705
  attendance_record.early_departure = now < departure_deadline
706
+
707
+ dt_start = datetime.combine(now.date(), shift_start)
708
+ dt_end = datetime.combine(now.date(), shift_end)
709
+ if dt_end < dt_start:
710
+ dt_end += timedelta(days=1)
711
+ shift_duration_hours = (dt_end - dt_start).total_seconds() / 3600.0
712
+
713
+ attendance_record.overtime = max(0.0, round(hours - shift_duration_hours, 2))
714
 
715
+ # Update status based on hours: Half Day if hours < 50% of shift duration
716
+ half_day_threshold = shift_duration_hours * 0.5 if shift_duration_hours > 0 else 4.0
717
+ if hours < half_day_threshold:
718
  attendance_record.status = "Half Day"
719
  else:
720
  if attendance_record.status in ["Half Day", "Absent"]:
 
730
  confidence=similarity,
731
  liveness_score=liveness_score,
732
  is_spoof=False,
733
+ status=log_status_success,
734
  timestamp=now
735
  )
736
  _publish_log(log_entry, employee)
 
774
  "detail": "Checkout Recorded Successfully",
775
  "closing": f"Worked: {hours} hours"
776
  },
777
+ "tts_url": tts_url,
778
+ "bbox": bbox_list
779
  }
780
 
781
  @router.get("/tts")
 
793
  except Exception as e:
794
  logger.error(f"Error serving TTS endpoint: {e}")
795
  raise HTTPException(status_code=500, detail="Voice generation failed")
796
+
797
+ class KioskQRConfirmRequest(BaseModel):
798
+ employee_id: int
799
+ qr_code: str
800
+ camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
801
+ latitude: Optional[float] = Field(None, description="Latitude of the kiosk/device marking attendance")
802
+ longitude: Optional[float] = Field(None, description="Longitude of the kiosk/device marking attendance")
803
+
804
+ @router.post("/confirm-qr")
805
+ def confirm_qr(
806
+ payload: KioskQRConfirmRequest,
807
+ db: Session = Depends(get_db)
808
+ ):
809
+ employee = crud.get_employee_by_id(db, payload.employee_id)
810
+ if not employee:
811
+ raise HTTPException(status_code=404, detail="Employee not found")
812
+
813
+ # Geofencing check for QR Confirmation
814
+ loc_enabled_setting = crud.get_setting_by_key(db, "LOCATION_RESTRICTION_ENABLED")
815
+ loc_enabled = loc_enabled_setting.value.lower() == "true" if loc_enabled_setting else False
816
+
817
+ if loc_enabled and not getattr(employee, "allow_wfh", False):
818
+ if payload.latitude is None or payload.longitude is None:
819
+ crud.create_attendance_log(
820
+ db=db,
821
+ employee_id=employee.id,
822
+ camera=payload.camera,
823
+ confidence=1.0,
824
+ liveness_score=1.0,
825
+ is_spoof=False,
826
+ status="Location Missing (QR)",
827
+ timestamp=datetime.now()
828
+ )
829
+ raise HTTPException(status_code=400, detail="GPS coordinates are required to mark attendance.")
830
+
831
+ loc_lat_setting = crud.get_setting_by_key(db, "LOCATION_LATITUDE")
832
+ loc_lon_setting = crud.get_setting_by_key(db, "LOCATION_LONGITUDE")
833
+ loc_rad_setting = crud.get_setting_by_key(db, "LOCATION_RADIUS_METERS")
834
+
835
+ try:
836
+ office_lat = float(loc_lat_setting.value) if loc_lat_setting else 0.0
837
+ office_lon = float(loc_lon_setting.value) if loc_lon_setting else 0.0
838
+ allowed_radius = float(loc_rad_setting.value) if loc_rad_setting else 50.0
839
+ except ValueError:
840
+ office_lat = 0.0
841
+ office_lon = 0.0
842
+ allowed_radius = 50.0
843
+
844
+ dist = calculate_distance_meters(payload.latitude, payload.longitude, office_lat, office_lon)
845
+ if dist > allowed_radius:
846
+ crud.create_attendance_log(
847
+ db=db,
848
+ employee_id=employee.id,
849
+ camera=payload.camera,
850
+ confidence=1.0,
851
+ liveness_score=1.0,
852
+ is_spoof=False,
853
+ status="Outside Office Bounds (QR)",
854
+ timestamp=datetime.now()
855
+ )
856
+ raise HTTPException(status_code=400, detail=f"Outside allowed area. Distance: {dist:.1f}m. Max radius: {allowed_radius}m.")
857
+
858
+ if payload.qr_code.strip() != employee.employee_id.strip():
859
+ # Audit log for failed verification
860
+ log_entry = crud.create_attendance_log(
861
+ db=db,
862
+ employee_id=employee.id,
863
+ camera=payload.camera,
864
+ confidence=0.55,
865
+ liveness_score=1.0,
866
+ is_spoof=False,
867
+ status="QR Verification Failed",
868
+ timestamp=datetime.now()
869
+ )
870
+ _publish_log(log_entry, employee)
871
+ raise HTTPException(status_code=400, detail="QR Code verification failed. Badge does not match matched face.")
872
+
873
+ now = datetime.now()
874
+ attendance_record = crud.mark_kiosk_attendance(
875
+ db=db,
876
+ employee_id=employee.id,
877
+ timestamp=now,
878
+ camera=payload.camera,
879
+ confidence=1.0
880
+ )
881
+
882
+ log_entry = crud.create_attendance_log(
883
+ db=db,
884
+ employee_id=employee.id,
885
+ camera=payload.camera,
886
+ confidence=1.0,
887
+ liveness_score=1.0,
888
+ is_spoof=False,
889
+ status="Match Success (QR Verified)",
890
+ timestamp=now
891
+ )
892
+ _publish_log(log_entry, employee)
893
+
894
+ current_hour = now.hour
895
+ if 5 <= current_hour < 12:
896
+ salutation = "Good Morning"
897
+ icon = "☀️"
898
+ elif 12 <= current_hour < 17:
899
+ salutation = "Good Afternoon"
900
+ icon = "🌤️"
901
+ else:
902
+ salutation = "Good Evening"
903
+ icon = "🌙"
904
+
905
+ voice_greeting_setting = crud.get_setting_by_key(db, "VOICE_GREETING_ENABLED")
906
+ voice_enabled = voice_greeting_setting.value.lower() == "true" if voice_greeting_setting else True
907
+ is_checkout = attendance_record.check_out is not None
908
+
909
+ if is_checkout:
910
+ greeting_text = f"Welcome {employee.name}. {salutation}. Checkout Recorded Successfully. Have a Relaxing Evening."
911
+ detail_msg = "Checkout Recorded Successfully"
912
+ closing_msg = f"Worked: {attendance_record.working_hours} hours"
913
+ else:
914
+ greeting_text = f"Welcome {employee.name}. {salutation}. Attendance Recorded Successfully. Have a Great Day."
915
+ detail_msg = "Attendance Recorded Successfully"
916
+ closing_msg = "Have a Great Day"
917
+
918
+ tts_url = f"{settings.API_V1_STR}/kiosk/tts?text={urllib.parse.quote(greeting_text)}" if voice_enabled else None
919
+
920
+ return {
921
+ "status": "success",
922
+ "employee": {
923
+ "id": employee.id,
924
+ "employee_id": employee.employee_id,
925
+ "name": employee.name,
926
+ "designation": employee.designation,
927
+ "department": employee.department.name if employee.department else "General"
928
+ },
929
+ "attendance": {
930
+ "date": str(attendance_record.date),
931
+ "check_in": str(attendance_record.check_in.time().strftime("%H:%M:%S")) if attendance_record.check_in else None,
932
+ "check_out": str(attendance_record.check_out.time().strftime("%H:%M:%S")) if attendance_record.check_out else None,
933
+ "status": attendance_record.status,
934
+ "working_hours": attendance_record.working_hours
935
+ },
936
+ "confidence": 1.0,
937
+ "liveness_score": 1.0,
938
+ "greeting": {
939
+ "title": f"Verified, {employee.name}",
940
+ "subtitle": f"{salutation} {icon}",
941
+ "detail": detail_msg,
942
+ "closing": closing_msg
943
+ },
944
+ "tts_url": tts_url
945
+ }
backend/app/api/v1/reports.py CHANGED
@@ -26,6 +26,9 @@ def export_report(
26
  end_date: Optional[date] = Query(None),
27
  employee_id: Optional[int] = Query(None),
28
  department_id: Optional[int] = Query(None),
 
 
 
29
  db: Session = Depends(get_db),
30
  current_user: models.User = Depends(checker_view)
31
  ):
@@ -43,6 +46,10 @@ def export_report(
43
  filters.append(models.Attendance.employee_id == employee_id)
44
  if department_id:
45
  filters.append(models.Employee.department_id == department_id)
 
 
 
 
46
 
47
  query = query.filter(and_(*filters)).order_by(models.Attendance.date.desc())
48
  records = query.all()
@@ -51,17 +58,19 @@ def export_report(
51
  data = []
52
  pdf_rows = []
53
 
54
- headers = [
55
- "Date", "Employee ID", "Name", "Department",
56
- "Check In", "Check Out", "Hours", "Overtime", "Status"
57
  ]
58
 
 
 
59
  for r in records:
60
  dept_name = r.employee.department.name if r.employee.department else "General"
61
  ci_str = r.check_in.strftime("%H:%M:%S") if r.check_in else "-"
62
  co_str = r.check_out.strftime("%H:%M:%S") if r.check_out else "-"
63
 
64
- row_dict = {
65
  "Date": str(r.date),
66
  "Employee ID": r.employee.employee_id,
67
  "Name": r.employee.name,
@@ -72,19 +81,18 @@ def export_report(
72
  "Overtime": r.overtime,
73
  "Status": r.status
74
  }
 
 
75
  data.append(row_dict)
76
 
77
- pdf_rows.append([
78
- str(r.date),
79
- r.employee.employee_id,
80
- r.employee.name[:15], # Limit length for PDF wrapping
81
- dept_name[:12],
82
- ci_str,
83
- co_str,
84
- str(r.working_hours),
85
- str(r.overtime),
86
- r.status
87
- ])
88
 
89
  title = f"NetraID Attendance Report ({report_type.capitalize()})"
90
  metadata = {
@@ -125,3 +133,70 @@ def export_report(
125
 
126
  else:
127
  raise HTTPException(status_code=400, detail="Invalid format. Supported: csv, xlsx, pdf")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  end_date: Optional[date] = Query(None),
27
  employee_id: Optional[int] = Query(None),
28
  department_id: Optional[int] = Query(None),
29
+ min_hours: Optional[float] = Query(None),
30
+ max_hours: Optional[float] = Query(None),
31
+ columns: Optional[str] = Query(None, description="Comma-separated column names to include"),
32
  db: Session = Depends(get_db),
33
  current_user: models.User = Depends(checker_view)
34
  ):
 
46
  filters.append(models.Attendance.employee_id == employee_id)
47
  if department_id:
48
  filters.append(models.Employee.department_id == department_id)
49
+ if min_hours is not None:
50
+ filters.append(models.Attendance.working_hours >= min_hours)
51
+ if max_hours is not None:
52
+ filters.append(models.Attendance.working_hours <= max_hours)
53
 
54
  query = query.filter(and_(*filters)).order_by(models.Attendance.date.desc())
55
  records = query.all()
 
58
  data = []
59
  pdf_rows = []
60
 
61
+ # Parse columns
62
+ selected_cols = [c.strip() for c in columns.split(",")] if columns else [
63
+ "Date", "Employee ID", "Name", "Department", "Check In", "Check Out", "Hours Worked", "Overtime", "Status"
64
  ]
65
 
66
+ headers = selected_cols
67
+
68
  for r in records:
69
  dept_name = r.employee.department.name if r.employee.department else "General"
70
  ci_str = r.check_in.strftime("%H:%M:%S") if r.check_in else "-"
71
  co_str = r.check_out.strftime("%H:%M:%S") if r.check_out else "-"
72
 
73
+ full_row = {
74
  "Date": str(r.date),
75
  "Employee ID": r.employee.employee_id,
76
  "Name": r.employee.name,
 
81
  "Overtime": r.overtime,
82
  "Status": r.status
83
  }
84
+
85
+ row_dict = {col: full_row.get(col, "-") for col in selected_cols}
86
  data.append(row_dict)
87
 
88
+ pdf_row = []
89
+ for col in selected_cols:
90
+ val = full_row.get(col, "-")
91
+ if col in ["Name", "Department"] and isinstance(val, str):
92
+ pdf_row.append(val[:15])
93
+ else:
94
+ pdf_row.append(str(val))
95
+ pdf_rows.append(pdf_row)
 
 
 
96
 
97
  title = f"NetraID Attendance Report ({report_type.capitalize()})"
98
  metadata = {
 
133
 
134
  else:
135
  raise HTTPException(status_code=400, detail="Invalid format. Supported: csv, xlsx, pdf")
136
+
137
+ @router.get("/preview")
138
+ def preview_report(
139
+ start_date: Optional[date] = Query(None),
140
+ end_date: Optional[date] = Query(None),
141
+ employee_id: Optional[int] = Query(None),
142
+ department_id: Optional[int] = Query(None),
143
+ min_hours: Optional[float] = Query(None),
144
+ max_hours: Optional[float] = Query(None),
145
+ columns: Optional[str] = Query(None, description="Comma-separated column names to include"),
146
+ db: Session = Depends(get_db),
147
+ current_user: models.User = Depends(checker_view)
148
+ ):
149
+ """
150
+ Returns filtered attendance records and columns for custom preview in the UI before export.
151
+ """
152
+ if not start_date:
153
+ start_date = date.today() - timedelta(days=30)
154
+ if not end_date:
155
+ end_date = date.today()
156
+
157
+ query = db.query(models.Attendance).join(models.Employee)
158
+ filters = [models.Attendance.date.between(start_date, end_date)]
159
+
160
+ if employee_id:
161
+ filters.append(models.Attendance.employee_id == employee_id)
162
+ if department_id:
163
+ filters.append(models.Employee.department_id == department_id)
164
+ if min_hours is not None:
165
+ filters.append(models.Attendance.working_hours >= min_hours)
166
+ if max_hours is not None:
167
+ filters.append(models.Attendance.working_hours <= max_hours)
168
+
169
+ query = query.filter(and_(*filters)).order_by(models.Attendance.date.desc())
170
+ records = query.all()
171
+
172
+ # Parse columns
173
+ selected_cols = [c.strip() for c in columns.split(",")] if columns else [
174
+ "Date", "Employee ID", "Name", "Department", "Check In", "Check Out", "Hours Worked", "Overtime", "Status"
175
+ ]
176
+
177
+ data = []
178
+ for r in records:
179
+ dept_name = r.employee.department.name if r.employee.department else "General"
180
+ ci_str = r.check_in.strftime("%H:%M:%S") if r.check_in else "-"
181
+ co_str = r.check_out.strftime("%H:%M:%S") if r.check_out else "-"
182
+
183
+ full_row = {
184
+ "Date": str(r.date),
185
+ "Employee ID": r.employee.employee_id,
186
+ "Name": r.employee.name,
187
+ "Department": dept_name,
188
+ "Check In": ci_str,
189
+ "Check Out": co_str,
190
+ "Hours Worked": r.working_hours,
191
+ "Overtime": r.overtime,
192
+ "Status": r.status
193
+ }
194
+
195
+ filtered_row = {col: full_row.get(col, "-") for col in selected_cols}
196
+ data.append(filtered_row)
197
+
198
+ return {
199
+ "columns": selected_cols,
200
+ "records": data,
201
+ "total_count": len(records)
202
+ }
backend/app/api/v1/settings.py CHANGED
@@ -35,6 +35,16 @@ def update_setting(
35
  old_value = setting.value
36
  updated = crud.set_setting(db, key=key, value=payload.value)
37
 
 
 
 
 
 
 
 
 
 
 
38
  # Audit log setting change
39
  crud.create_audit_log(
40
  db=db,
 
35
  old_value = setting.value
36
  updated = crud.set_setting(db, key=key, value=payload.value)
37
 
38
+ # Dynamically restart RTSP processor if RTSP settings were updated
39
+ if key in ["RTSP_STREAM_ENABLED", "RTSP_STREAM_URL"]:
40
+ try:
41
+ from app.services.singletons import rtsp_processor
42
+ rtsp_processor.stop()
43
+ rtsp_processor.start()
44
+ except Exception as rtsp_err:
45
+ import logging
46
+ logging.getLogger("SettingsAPI").error(f"Failed to dynamically restart RTSP processor: {rtsp_err}")
47
+
48
  # Audit log setting change
49
  crud.create_audit_log(
50
  db=db,
backend/app/core/config.py CHANGED
@@ -2,6 +2,13 @@ import os
2
  from typing import List, Union
3
  from pydantic import AnyHttpUrl, validator
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
 
 
 
 
 
 
 
5
 
6
  class Settings(BaseSettings):
7
  PROJECT_NAME: str = "NetraID AI Face Attendance"
@@ -51,7 +58,7 @@ class Settings(BaseSettings):
51
 
52
  # Face recognition & liveness detection parameters
53
  KIOSK_FACE_THRESHOLD: float = 0.60
54
- KIOSK_LIVENESS_THRESHOLD: float = 0.75
55
  FORCE_MOCK_MODE: bool = False
56
 
57
  # Performance Optimizations
 
2
  from typing import List, Union
3
  from pydantic import AnyHttpUrl, validator
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
5
+ from dotenv import load_dotenv
6
+
7
+ env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
8
+ load_dotenv(dotenv_path=env_path, override=True)
9
+
10
+
11
+
12
 
13
  class Settings(BaseSettings):
14
  PROJECT_NAME: str = "NetraID AI Face Attendance"
 
58
 
59
  # Face recognition & liveness detection parameters
60
  KIOSK_FACE_THRESHOLD: float = 0.60
61
+ KIOSK_LIVENESS_THRESHOLD: float = 0.55
62
  FORCE_MOCK_MODE: bool = False
63
 
64
  # Performance Optimizations
backend/app/core/init_db.py CHANGED
@@ -1,10 +1,11 @@
1
- from sqlalchemy import text
2
  from sqlalchemy.orm import Session
3
  from app.core.database import Base, engine
4
  from app.models import models
5
  from app.crud import crud
6
  from app.schemas import schemas
7
  from app.core.config import settings
 
8
  import logging
9
 
10
  logger = logging.getLogger("InitDB")
@@ -20,6 +21,20 @@ def init_db(db: Session):
20
  logger.info("Creating all database tables if they do not exist...")
21
  Base.metadata.create_all(bind=engine)
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  # Ensure emergency_allowed column exists
24
  try:
25
  db.execute(text("SELECT emergency_allowed FROM attendance LIMIT 1"))
@@ -37,6 +52,39 @@ def init_db(db: Session):
37
  logger.info("Adding image_path column to attendance_logs table...")
38
  db.execute(text("ALTER TABLE attendance_logs ADD COLUMN image_path VARCHAR(255) DEFAULT NULL"))
39
  db.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  # 1. Seed Roles
42
  roles = [
@@ -76,11 +124,23 @@ def init_db(db: Session):
76
  {"key": "KIOSK_FACE_THRESHOLD", "value": str(settings.KIOSK_FACE_THRESHOLD), "description": "Cosine similarity threshold for face match"},
77
  {"key": "KIOSK_LIVENESS_THRESHOLD", "value": str(settings.KIOSK_LIVENESS_THRESHOLD), "description": "Softmax probability threshold for face liveness"},
78
  {"key": "ENROLLMENT_LIVENESS_CHECK", "value": "true", "description": "Enforce liveness check during employee facial enrollment"},
79
- {"key": "ENROLLMENT_LIVENESS_THRESHOLD", "value": "0.70", "description": "Liveness probability threshold specifically for employee facial enrollment"},
80
  {"key": "VOICE_GREETING_ENABLED", "value": "true", "description": "Enable voice greeting on successful attendance"},
81
  {"key": "SYSTEM_MAINTENANCE_MODE", "value": "false", "description": "Toggle maintenance mode to suspend active check-ins"},
82
  {"key": "MAX_ENROLLMENT_IMAGES", "value": "5", "description": "Maximum face images captured during registration"},
83
- {"key": "KIOSK_AUTO_RESET_SECONDS", "value": "5", "description": "Duration in seconds the success screen stays visible before scanning again"}
 
 
 
 
 
 
 
 
 
 
 
 
84
  ]
85
 
86
  for s in default_settings:
 
1
+ from sqlalchemy import text, select
2
  from sqlalchemy.orm import Session
3
  from app.core.database import Base, engine
4
  from app.models import models
5
  from app.crud import crud
6
  from app.schemas import schemas
7
  from app.core.config import settings
8
+ from datetime import time
9
  import logging
10
 
11
  logger = logging.getLogger("InitDB")
 
21
  logger.info("Creating all database tables if they do not exist...")
22
  Base.metadata.create_all(bind=engine)
23
 
24
+ # Ensure HNSW index exists on PostgreSQL
25
+ if db.bind.dialect.name == "postgresql":
26
+ try:
27
+ logger.info("Ensuring face_embeddings pgvector HNSW index is present...")
28
+ db.execute(text("""
29
+ CREATE INDEX IF NOT EXISTS face_embeddings_hnsw_idx
30
+ ON face_embeddings
31
+ USING hnsw (embedding vector_cosine_ops);
32
+ """))
33
+ db.commit()
34
+ except Exception as e:
35
+ db.rollback()
36
+ logger.warning(f"Could not create HNSW index: {e}")
37
+
38
  # Ensure emergency_allowed column exists
39
  try:
40
  db.execute(text("SELECT emergency_allowed FROM attendance LIMIT 1"))
 
52
  logger.info("Adding image_path column to attendance_logs table...")
53
  db.execute(text("ALTER TABLE attendance_logs ADD COLUMN image_path VARCHAR(255) DEFAULT NULL"))
54
  db.commit()
55
+
56
+ # Ensure shift_id column exists in employees table
57
+ try:
58
+ db.execute(text("SELECT shift_id FROM employees LIMIT 1"))
59
+ except Exception:
60
+ db.rollback()
61
+ logger.info("Adding shift_id column to employees table...")
62
+ db.execute(text("ALTER TABLE employees ADD COLUMN shift_id INTEGER REFERENCES shifts(id) DEFAULT NULL"))
63
+ db.commit()
64
+
65
+ # Ensure allow_wfh column exists in employees table
66
+ try:
67
+ db.execute(text("SELECT allow_wfh FROM employees LIMIT 1"))
68
+ except Exception:
69
+ db.rollback()
70
+ logger.info("Adding allow_wfh column to employees table...")
71
+ db.execute(text("ALTER TABLE employees ADD COLUMN allow_wfh BOOLEAN DEFAULT FALSE"))
72
+ db.commit()
73
+
74
+ # 0. Seed Shifts
75
+ shifts_to_seed = [
76
+ {"name": "Regular Day Shift", "start_time": time(9, 0), "end_time": time(17, 0), "grace_period_minutes": 15, "description": "Standard business hours"},
77
+ {"name": "Morning Shift", "start_time": time(7, 0), "end_time": time(15, 0), "grace_period_minutes": 15, "description": "Early morning shift"},
78
+ {"name": "Evening Shift", "start_time": time(15, 0), "end_time": time(23, 0), "grace_period_minutes": 15, "description": "Evening / second shift"},
79
+ {"name": "Night Shift", "start_time": time(23, 0), "end_time": time(7, 0), "grace_period_minutes": 15, "description": "Overnight shift"}
80
+ ]
81
+ for s in shifts_to_seed:
82
+ shift_record = db.execute(select(models.Shift).where(models.Shift.name == s["name"])).scalar_one_or_none()
83
+ if not shift_record:
84
+ logger.info(f"Seeding shift: {s['name']}")
85
+ db_shift = models.Shift(**s)
86
+ db.add(db_shift)
87
+ db.commit()
88
 
89
  # 1. Seed Roles
90
  roles = [
 
124
  {"key": "KIOSK_FACE_THRESHOLD", "value": str(settings.KIOSK_FACE_THRESHOLD), "description": "Cosine similarity threshold for face match"},
125
  {"key": "KIOSK_LIVENESS_THRESHOLD", "value": str(settings.KIOSK_LIVENESS_THRESHOLD), "description": "Softmax probability threshold for face liveness"},
126
  {"key": "ENROLLMENT_LIVENESS_CHECK", "value": "true", "description": "Enforce liveness check during employee facial enrollment"},
127
+ {"key": "ENROLLMENT_LIVENESS_THRESHOLD", "value": "0.50", "description": "Liveness probability threshold specifically for employee facial enrollment"},
128
  {"key": "VOICE_GREETING_ENABLED", "value": "true", "description": "Enable voice greeting on successful attendance"},
129
  {"key": "SYSTEM_MAINTENANCE_MODE", "value": "false", "description": "Toggle maintenance mode to suspend active check-ins"},
130
  {"key": "MAX_ENROLLMENT_IMAGES", "value": "5", "description": "Maximum face images captured during registration"},
131
+ {"key": "KIOSK_AUTO_RESET_SECONDS", "value": "5", "description": "Duration in seconds the success screen stays visible before scanning again"},
132
+ {"key": "NOTIFICATION_WEBHOOK_URL", "value": "", "description": "Slack, Discord or Telegram webhook URL for spoof security alerts"},
133
+ {"key": "QR_FALLBACK_ENABLED", "value": "true", "description": "Enable QR Code badge verification fallback on kiosk if face match is borderline"},
134
+ {"key": "RTSP_STREAM_ENABLED", "value": "false", "description": "Enable asynchronous RTSP IP camera frame processing background service"},
135
+ {"key": "RTSP_STREAM_URL", "value": "rtsp://admin:admin123@192.168.1.100:554/stream1", "description": "RTSP video stream connection URL"},
136
+ {"key": "COMPANY_NAME", "value": "NetraID Enterprise", "description": "Name of the organization displayed on employee ID cards"},
137
+ {"key": "COMPANY_LOGO", "value": "", "description": "Base64 image data or URL of the company logo displayed on ID cards"},
138
+ {"key": "BADGE_THEME_COLOR", "value": "Navy Blue", "description": "Primary color theme of employee ID cards (Navy Blue, Charcoal, Emerald, Saffron)"},
139
+ {"key": "BADGE_PATTERN_TYPE", "value": "Indian Mandala", "description": "Background pattern design style (None, Indian Mandala, Corporate Waves, Cyber Grid)"},
140
+ {"key": "LOCATION_RESTRICTION_ENABLED", "value": "false", "description": "Enable location restriction for kiosk attendance"},
141
+ {"key": "LOCATION_LATITUDE", "value": "0.0", "description": "Office center latitude coordinate"},
142
+ {"key": "LOCATION_LONGITUDE", "value": "0.0", "description": "Office center longitude coordinate"},
143
+ {"key": "LOCATION_RADIUS_METERS", "value": "50", "description": "Allowed radius in meters for marking attendance"}
144
  ]
145
 
146
  for s in default_settings:
backend/app/crud/crud.py CHANGED
@@ -170,7 +170,9 @@ def create_employee(db: Session, emp: schemas.EmployeeCreate, user_id: int = Non
170
  joining_date=emp.joining_date,
171
  status=emp.status,
172
  department_id=emp.department_id,
173
- user_id=user_id
 
 
174
  )
175
  db.add(db_emp)
176
  db.commit()
@@ -353,9 +355,38 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
353
  - If one exists and check_out is empty, mark check-out and calculate hours.
354
  - If both exist, do nothing or update checkout to a later timestamp.
355
  """
356
- local_now = datetime.now()
357
- today = local_now.date()
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  # Check if a record exists
360
  stmt = select(models.Attendance).where(
361
  and_(
@@ -365,22 +396,11 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
365
  )
366
  db_attendance = db.execute(stmt).scalar_one_or_none()
367
 
368
- # Check system configurations for status mapping
369
- start_time_setting = get_setting_by_key(db, "CHECK_IN_START")
370
- grace_period_setting = get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
371
-
372
- start_str = start_time_setting.value if start_time_setting else "09:00"
373
- grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
374
-
375
- try:
376
- hr, mn = map(int, start_str.split(":"))
377
- check_in_deadline = datetime.combine(today, time(hr, mn)) + timedelta(minutes=grace_mins)
378
- except Exception:
379
- check_in_deadline = datetime.combine(today, time(9, 15))
380
 
381
  if not db_attendance:
382
  # First scan of the day -> CHECK-IN
383
- is_late = local_now > check_in_deadline
384
  status = "Late" if is_late else "Present"
385
 
386
  db_attendance = models.Attendance(
@@ -403,22 +423,22 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
403
  hours = round(diff.total_seconds() / 3600.0, 2)
404
  db_attendance.working_hours = hours
405
 
406
- # Early departure: Check if checkout is before e.g., 5:00 PM
407
- end_time_setting = get_setting_by_key(db, "CHECK_OUT_END")
408
- end_str = end_time_setting.value if end_time_setting else "17:00"
409
- try:
410
- ehr, emn = map(int, end_str.split(":"))
411
- departure_deadline = datetime.combine(today, time(ehr, emn))
412
- except Exception:
413
- departure_deadline = datetime.combine(today, time(17, 0))
414
-
415
- db_attendance.early_departure = local_now < departure_deadline
416
 
417
- # Overtime: Hours worked beyond 8 hours
418
- db_attendance.overtime = max(0.0, round(hours - 8.0, 2))
419
 
420
- # Half Day check: If total working hours is less than 8 hours
421
- if hours < 8.0:
 
422
  db_attendance.status = "Half Day"
423
  else:
424
  if db_attendance.status == "Half Day" or db_attendance.status == "Absent":
@@ -532,3 +552,11 @@ def create_audit_log(db: Session, user_id: int, action: str, ip_address: str = N
532
  def get_audit_logs(db: Session, skip: int = 0, limit: int = 100):
533
  query = select(models.AuditLog).order_by(models.AuditLog.timestamp.desc()).offset(skip).limit(limit)
534
  return db.execute(query).scalars().all()
 
 
 
 
 
 
 
 
 
170
  joining_date=emp.joining_date,
171
  status=emp.status,
172
  department_id=emp.department_id,
173
+ shift_id=emp.shift_id,
174
+ user_id=user_id,
175
+ allow_wfh=emp.allow_wfh
176
  )
177
  db.add(db_emp)
178
  db.commit()
 
355
  - If one exists and check_out is empty, mark check-out and calculate hours.
356
  - If both exist, do nothing or update checkout to a later timestamp.
357
  """
358
+ today = timestamp.date()
 
359
 
360
+ employee = db.get(models.Employee, employee_id)
361
+ if not employee:
362
+ raise ValueError(f"Employee {employee_id} not found")
363
+
364
+ # Resolve shift details for employee
365
+ if employee.shift:
366
+ shift_start = employee.shift.start_time
367
+ shift_end = employee.shift.end_time
368
+ grace_mins = employee.shift.grace_period_minutes
369
+ else:
370
+ start_time_setting = get_setting_by_key(db, "CHECK_IN_START")
371
+ end_time_setting = get_setting_by_key(db, "CHECK_OUT_END")
372
+ grace_period_setting = get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
373
+
374
+ start_str = start_time_setting.value if start_time_setting else "09:00"
375
+ end_str = end_time_setting.value if end_time_setting else "17:00"
376
+ grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
377
+
378
+ try:
379
+ hr, mn = map(int, start_str.split(":"))
380
+ shift_start = time(hr, mn)
381
+ except Exception:
382
+ shift_start = time(9, 0)
383
+
384
+ try:
385
+ hr, mn = map(int, end_str.split(":"))
386
+ shift_end = time(hr, mn)
387
+ except Exception:
388
+ shift_end = time(17, 0)
389
+
390
  # Check if a record exists
391
  stmt = select(models.Attendance).where(
392
  and_(
 
396
  )
397
  db_attendance = db.execute(stmt).scalar_one_or_none()
398
 
399
+ check_in_deadline = datetime.combine(today, shift_start) + timedelta(minutes=grace_mins)
 
 
 
 
 
 
 
 
 
 
 
400
 
401
  if not db_attendance:
402
  # First scan of the day -> CHECK-IN
403
+ is_late = timestamp > check_in_deadline
404
  status = "Late" if is_late else "Present"
405
 
406
  db_attendance = models.Attendance(
 
423
  hours = round(diff.total_seconds() / 3600.0, 2)
424
  db_attendance.working_hours = hours
425
 
426
+ # Early departure: Check if checkout is before shift end
427
+ departure_deadline = datetime.combine(today, shift_end)
428
+ db_attendance.early_departure = timestamp < departure_deadline
429
+
430
+ # Overtime: Hours worked beyond shift duration
431
+ dt_start = datetime.combine(today, shift_start)
432
+ dt_end = datetime.combine(today, shift_end)
433
+ if dt_end < dt_start:
434
+ dt_end += timedelta(days=1)
435
+ shift_duration_hours = (dt_end - dt_start).total_seconds() / 3600.0
436
 
437
+ db_attendance.overtime = max(0.0, round(hours - shift_duration_hours, 2))
 
438
 
439
+ # Half Day check: If total working hours is less than 50% of shift duration
440
+ half_day_threshold = shift_duration_hours * 0.5 if shift_duration_hours > 0 else 4.0
441
+ if hours < half_day_threshold:
442
  db_attendance.status = "Half Day"
443
  else:
444
  if db_attendance.status == "Half Day" or db_attendance.status == "Absent":
 
552
  def get_audit_logs(db: Session, skip: int = 0, limit: int = 100):
553
  query = select(models.AuditLog).order_by(models.AuditLog.timestamp.desc()).offset(skip).limit(limit)
554
  return db.execute(query).scalars().all()
555
+
556
+ def clear_all_audit_logs(db: Session) -> int:
557
+ from sqlalchemy import delete
558
+ stmt = delete(models.AuditLog)
559
+ result = db.execute(stmt)
560
+ db.commit()
561
+ return result.rowcount
562
+
backend/app/main.py CHANGED
@@ -1,12 +1,17 @@
1
  import os
 
 
 
2
  from fastapi import FastAPI, Depends
3
  from fastapi.middleware.cors import CORSMiddleware
4
  import logging
 
5
 
6
  from app.core.config import settings
7
  from app.core.database import SessionLocal
8
  from app.core.init_db import init_db
9
  from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit
 
10
 
11
  # Logging configuration
12
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
@@ -36,6 +41,24 @@ app.add_middleware(
36
 
37
  db_error = None
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # Create folders on startup
40
  @app.on_event("startup")
41
  def startup_event():
@@ -55,6 +78,22 @@ def startup_event():
55
  try:
56
  init_db(db)
57
  db_error = "Success"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  except Exception as e:
59
  import traceback
60
  db_error = f"{e}\n{traceback.format_exc()}"
@@ -62,6 +101,15 @@ def startup_event():
62
  finally:
63
  db.close()
64
 
 
 
 
 
 
 
 
 
 
65
  # Health check and root route
66
  @app.get("/")
67
  def read_root():
@@ -115,3 +163,5 @@ app.include_router(reports.router, prefix=f"{settings.API_V1_STR}/reports", tags
115
  app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["Dashboard Analytics"])
116
  app.include_router(settings_api.router, prefix=f"{settings.API_V1_STR}/settings", tags=["System Settings"])
117
  app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"])
 
 
 
1
  import os
2
+ import threading
3
+ import time
4
+ from datetime import datetime, timedelta
5
  from fastapi import FastAPI, Depends
6
  from fastapi.middleware.cors import CORSMiddleware
7
  import logging
8
+ from sqlalchemy import delete
9
 
10
  from app.core.config import settings
11
  from app.core.database import SessionLocal
12
  from app.core.init_db import init_db
13
  from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit
14
+ from app.models import models
15
 
16
  # Logging configuration
17
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
 
41
 
42
  db_error = None
43
 
44
+ def purge_old_audit_logs():
45
+ """Background loop to delete audit logs older than 24 hours."""
46
+ logger.info("Starting background audit log purger thread...")
47
+ while True:
48
+ try:
49
+ db = SessionLocal()
50
+ cutoff = datetime.now() - timedelta(hours=24)
51
+ stmt = delete(models.AuditLog).where(models.AuditLog.timestamp < cutoff)
52
+ result = db.execute(stmt)
53
+ db.commit()
54
+ deleted_count = result.rowcount
55
+ if deleted_count > 0:
56
+ logger.info(f"Purged {deleted_count} audit logs older than 24 hours.")
57
+ db.close()
58
+ except Exception as e:
59
+ logger.error(f"Error purging old audit logs: {e}")
60
+ time.sleep(3600)
61
+
62
  # Create folders on startup
63
  @app.on_event("startup")
64
  def startup_event():
 
78
  try:
79
  init_db(db)
80
  db_error = "Success"
81
+
82
+ # Start background RTSP processor
83
+ try:
84
+ from app.services.singletons import rtsp_processor
85
+ rtsp_processor.start()
86
+ except Exception as rtsp_err:
87
+ logger.error(f"Failed to start background RTSP processor: {rtsp_err}")
88
+
89
+ # Start background audit logs purger
90
+ try:
91
+ purger_thread = threading.Thread(target=purge_old_audit_logs, daemon=True)
92
+ purger_thread.start()
93
+ logger.info("Background audit logs purger started successfully.")
94
+ except Exception as purger_err:
95
+ logger.error(f"Failed to start background audit logs purger: {purger_err}")
96
+
97
  except Exception as e:
98
  import traceback
99
  db_error = f"{e}\n{traceback.format_exc()}"
 
101
  finally:
102
  db.close()
103
 
104
+ @app.on_event("shutdown")
105
+ def shutdown_event():
106
+ logger.info("Stopping NetraID Backend...")
107
+ try:
108
+ from app.services.singletons import rtsp_processor
109
+ rtsp_processor.stop()
110
+ except Exception as e:
111
+ logger.error(f"Failed to stop background RTSP processor: {e}")
112
+
113
  # Health check and root route
114
  @app.get("/")
115
  def read_root():
 
163
  app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["Dashboard Analytics"])
164
  app.include_router(settings_api.router, prefix=f"{settings.API_V1_STR}/settings", tags=["System Settings"])
165
  app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"])
166
+ # Trigger reload - reload 2
167
+
backend/app/models/models.py CHANGED
@@ -73,6 +73,16 @@ class Department(Base):
73
 
74
  employees = relationship("Employee", back_populates="department")
75
 
 
 
 
 
 
 
 
 
 
 
76
  class Employee(Base):
77
  __tablename__ = "employees"
78
 
@@ -85,11 +95,14 @@ class Employee(Base):
85
  joining_date = Column(Date, nullable=False, default=datetime.date.today)
86
  status = Column(String(20), default="Active") # Active, Inactive, Suspended
87
  department_id = Column(Integer, ForeignKey("departments.id"), nullable=True)
 
88
  user_id = Column(Integer, ForeignKey("users.id"), nullable=True, unique=True)
 
89
  created_at = Column(DateTime, default=datetime.datetime.utcnow)
90
  updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
91
 
92
  department = relationship("Department", back_populates="employees")
 
93
  user = relationship("User", back_populates="employee")
94
  images = relationship("EmployeeImage", back_populates="employee", cascade="all, delete-orphan")
95
  embeddings = relationship("FaceEmbedding", back_populates="employee", cascade="all, delete-orphan")
 
73
 
74
  employees = relationship("Employee", back_populates="department")
75
 
76
+ class Shift(Base):
77
+ __tablename__ = "shifts"
78
+
79
+ id = Column(Integer, primary_key=True, index=True)
80
+ name = Column(String(100), unique=True, nullable=False)
81
+ start_time = Column(Time, nullable=False)
82
+ end_time = Column(Time, nullable=False)
83
+ grace_period_minutes = Column(Integer, default=15)
84
+ description = Column(String(255), nullable=True)
85
+
86
  class Employee(Base):
87
  __tablename__ = "employees"
88
 
 
95
  joining_date = Column(Date, nullable=False, default=datetime.date.today)
96
  status = Column(String(20), default="Active") # Active, Inactive, Suspended
97
  department_id = Column(Integer, ForeignKey("departments.id"), nullable=True)
98
+ shift_id = Column(Integer, ForeignKey("shifts.id"), nullable=True)
99
  user_id = Column(Integer, ForeignKey("users.id"), nullable=True, unique=True)
100
+ allow_wfh = Column(Boolean, default=False)
101
  created_at = Column(DateTime, default=datetime.datetime.utcnow)
102
  updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
103
 
104
  department = relationship("Department", back_populates="employees")
105
+ shift = relationship("Shift")
106
  user = relationship("User", back_populates="employee")
107
  images = relationship("EmployeeImage", back_populates="employee", cascade="all, delete-orphan")
108
  embeddings = relationship("FaceEmbedding", back_populates="employee", cascade="all, delete-orphan")
backend/app/schemas/schemas.py CHANGED
@@ -62,6 +62,28 @@ class DepartmentOut(DepartmentBase):
62
  id: int
63
  model_config = ConfigDict(from_attributes=True)
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  # Employee Schemas
66
  class EmployeeBase(BaseModel):
67
  employee_id: str
@@ -72,6 +94,8 @@ class EmployeeBase(BaseModel):
72
  joining_date: date
73
  status: str = "Active"
74
  department_id: Optional[int] = None
 
 
75
 
76
  @field_validator('phone')
77
  @classmethod
@@ -96,6 +120,8 @@ class EmployeeUpdate(BaseModel):
96
  joining_date: Optional[date] = None
97
  status: Optional[str] = None
98
  department_id: Optional[int] = None
 
 
99
 
100
  class EmployeeImageOut(BaseModel):
101
  id: int
@@ -108,6 +134,7 @@ class EmployeeOut(EmployeeBase):
108
  id: int
109
  user_id: Optional[int] = None
110
  department: Optional[DepartmentOut] = None
 
111
  images: List[EmployeeImageOut] = []
112
  created_at: Optional[datetime] = None
113
  model_config = ConfigDict(from_attributes=True)
 
62
  id: int
63
  model_config = ConfigDict(from_attributes=True)
64
 
65
+ # Shift Schemas
66
+ class ShiftBase(BaseModel):
67
+ name: str = Field(..., max_length=100)
68
+ start_time: time
69
+ end_time: time
70
+ grace_period_minutes: int = 15
71
+ description: Optional[str] = None
72
+
73
+ class ShiftCreate(ShiftBase):
74
+ pass
75
+
76
+ class ShiftUpdate(BaseModel):
77
+ name: Optional[str] = None
78
+ start_time: Optional[time] = None
79
+ end_time: Optional[time] = None
80
+ grace_period_minutes: Optional[int] = None
81
+ description: Optional[str] = None
82
+
83
+ class ShiftOut(ShiftBase):
84
+ id: int
85
+ model_config = ConfigDict(from_attributes=True)
86
+
87
  # Employee Schemas
88
  class EmployeeBase(BaseModel):
89
  employee_id: str
 
94
  joining_date: date
95
  status: str = "Active"
96
  department_id: Optional[int] = None
97
+ shift_id: Optional[int] = None
98
+ allow_wfh: bool = False
99
 
100
  @field_validator('phone')
101
  @classmethod
 
120
  joining_date: Optional[date] = None
121
  status: Optional[str] = None
122
  department_id: Optional[int] = None
123
+ shift_id: Optional[int] = None
124
+ allow_wfh: Optional[bool] = None
125
 
126
  class EmployeeImageOut(BaseModel):
127
  id: int
 
134
  id: int
135
  user_id: Optional[int] = None
136
  department: Optional[DepartmentOut] = None
137
+ shift: Optional[ShiftOut] = None
138
  images: List[EmployeeImageOut] = []
139
  created_at: Optional[datetime] = None
140
  model_config = ConfigDict(from_attributes=True)
backend/app/services/face_engine.py CHANGED
@@ -410,26 +410,39 @@ class FaceEngine:
410
 
411
  try:
412
  x1, y1, x2, y2 = bbox
413
- w, h = x2 - x1, y2 - y1
 
 
414
 
415
- # MiniFASNet uses scaled crops. Let's crop with scale=2.7 for 80x80 model
416
  scale_27 = 2.7
417
- cx, cy = x1 + w/2, y1 + h/2
418
-
419
- # Crop 2.7x bounding box
420
- w_new, h_new = w * scale_27, h * scale_27
421
- x1_new = int(max(0, cx - w_new/2))
422
- y1_new = int(max(0, cy - h_new/2))
423
- x2_new = int(min(image_np.shape[1], cx + w_new/2))
424
- y2_new = int(min(image_np.shape[0], cy + h_new/2))
425
-
426
- crop_27 = image_np[y1_new:y2_new, x1_new:x2_new]
 
 
 
 
 
 
 
 
 
 
 
427
  if crop_27.size == 0:
428
  return 0.0, False
429
 
430
  # Resize to 80x80
431
  resized_27 = cv2.resize(crop_27, (80, 80))
432
- # Preprocess: Transpose and batch
433
  blob_27 = np.transpose(resized_27, (2, 0, 1)).astype(np.float32)
434
  blob_27 = np.expand_dims(blob_27, axis=0)
435
 
@@ -446,15 +459,28 @@ class FaceEngine:
446
 
447
  # If 1.8 model is loaded, average the scores
448
  if self.live_session_18 is not None:
449
- # MiniFASNet uses scaled crops. Let's crop with scale=1.8 for 128x128 model
450
  scale_18 = 1.8
451
- w_new_18, h_new_18 = w * scale_18, h * scale_18
452
- x1_new_18 = int(max(0, cx - w_new_18/2))
453
- y1_new_18 = int(max(0, cy - h_new_18/2))
454
- x2_new_18 = int(min(image_np.shape[1], cx + w_new_18/2))
455
- y2_new_18 = int(min(image_np.shape[0], cy + h_new_18/2))
456
 
457
- crop_18 = image_np[y1_new_18:y2_new_18, x1_new_18:x2_new_18]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  if crop_18.size > 0:
459
  # Resize to 128x128
460
  resized_18 = cv2.resize(crop_18, (128, 128))
@@ -478,6 +504,55 @@ class FaceEngine:
478
  except Exception as e:
479
  logger.error(f"Error in check_liveness: {e}")
480
  return 0.0, False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
  def cosine_similarity(self, embedding1, embedding2):
483
  """
 
410
 
411
  try:
412
  x1, y1, x2, y2 = bbox
413
+ box_w, box_h = x2 - x1, y2 - y1
414
+ cx, cy = x1 + box_w / 2, y1 + box_h / 2
415
+ src_h, src_w = image_np.shape[:2]
416
 
417
+ # MiniFASNet uses scaled square crops. Let's crop with scale=2.7 for 80x80 model
418
  scale_27 = 2.7
419
+ crop_size_27 = int(max(box_w, box_h) * scale_27)
420
+ x1_crop_27 = int(cx - crop_size_27 // 2)
421
+ y1_crop_27 = int(cy - crop_size_27 // 2)
422
+ x2_crop_27 = x1_crop_27 + crop_size_27
423
+ y2_crop_27 = y1_crop_27 + crop_size_27
424
+
425
+ pad_left_27 = max(0, -x1_crop_27)
426
+ pad_top_27 = max(0, -y1_crop_27)
427
+ pad_right_27 = max(0, x2_crop_27 - src_w)
428
+ pad_bottom_27 = max(0, y2_crop_27 - src_h)
429
+
430
+ if pad_left_27 > 0 or pad_top_27 > 0 or pad_right_27 > 0 or pad_bottom_27 > 0:
431
+ padded_27 = cv2.copyMakeBorder(image_np, pad_top_27, pad_bottom_27, pad_left_27, pad_right_27, cv2.BORDER_REPLICATE)
432
+ x1_crop_27 += pad_left_27
433
+ x2_crop_27 += pad_left_27
434
+ y1_crop_27 += pad_top_27
435
+ y2_crop_27 += pad_top_27
436
+ crop_27 = padded_27[y1_crop_27:y2_crop_27, x1_crop_27:x2_crop_27]
437
+ else:
438
+ crop_27 = image_np[y1_crop_27:y2_crop_27, x1_crop_27:x2_crop_27]
439
+
440
  if crop_27.size == 0:
441
  return 0.0, False
442
 
443
  # Resize to 80x80
444
  resized_27 = cv2.resize(crop_27, (80, 80))
445
+ # Preprocess: Transpose and batch (Keep [0, 255] range as model expects)
446
  blob_27 = np.transpose(resized_27, (2, 0, 1)).astype(np.float32)
447
  blob_27 = np.expand_dims(blob_27, axis=0)
448
 
 
459
 
460
  # If 1.8 model is loaded, average the scores
461
  if self.live_session_18 is not None:
 
462
  scale_18 = 1.8
463
+ crop_size_18 = int(max(box_w, box_h) * scale_18)
464
+ x1_crop_18 = int(cx - crop_size_18 // 2)
465
+ y1_crop_18 = int(cy - crop_size_18 // 2)
466
+ x2_crop_18 = x1_crop_18 + crop_size_18
467
+ y2_crop_18 = y1_crop_18 + crop_size_18
468
 
469
+ pad_left_18 = max(0, -x1_crop_18)
470
+ pad_top_18 = max(0, -y1_crop_18)
471
+ pad_right_18 = max(0, x2_crop_18 - src_w)
472
+ pad_bottom_18 = max(0, y2_crop_18 - src_h)
473
+
474
+ if pad_left_18 > 0 or pad_top_18 > 0 or pad_right_18 > 0 or pad_bottom_18 > 0:
475
+ padded_18 = cv2.copyMakeBorder(image_np, pad_top_18, pad_bottom_18, pad_left_18, pad_right_18, cv2.BORDER_REPLICATE)
476
+ x1_crop_18 += pad_left_18
477
+ x2_crop_18 += pad_left_18
478
+ y1_crop_18 += pad_top_18
479
+ y2_crop_18 += pad_top_18
480
+ crop_18 = padded_18[y1_crop_18:y2_crop_18, x1_crop_18:x2_crop_18]
481
+ else:
482
+ crop_18 = image_np[y1_crop_18:y2_crop_18, x1_crop_18:x2_crop_18]
483
+
484
  if crop_18.size > 0:
485
  # Resize to 128x128
486
  resized_18 = cv2.resize(crop_18, (128, 128))
 
504
  except Exception as e:
505
  logger.error(f"Error in check_liveness: {e}")
506
  return 0.0, False
507
+
508
+ def validate_image_quality(self, image_np):
509
+ """
510
+ Validates the image quality (checks for blur and lighting conditions).
511
+ Returns: {"is_valid": bool, "blur_score": float, "brightness": float, "reason": str}
512
+ """
513
+ try:
514
+ gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY) if len(image_np.shape) == 3 else image_np
515
+ blur_score = float(cv2.Laplacian(gray, cv2.CV_64F).var())
516
+ brightness = float(np.mean(gray))
517
+
518
+ # Allow mock mode to always pass
519
+ if self.mock_mode:
520
+ return {
521
+ "is_valid": True,
522
+ "blur_score": blur_score if blur_score > 0 else 150.0,
523
+ "brightness": brightness if brightness > 0 else 128.0,
524
+ "reason": None
525
+ }
526
+
527
+ is_valid = True
528
+ reason = None
529
+
530
+ # Check brightness (threshold < 45 is too dark, > 235 is too bright/washed out)
531
+ if brightness < 45.0:
532
+ is_valid = False
533
+ reason = "Image is too dark. Please ensure there is sufficient lighting."
534
+ elif brightness > 235.0:
535
+ is_valid = False
536
+ reason = "Image is too bright or washed out. Please adjust the lighting."
537
+ # Check blur (threshold < 70 is considered blurry)
538
+ elif blur_score < 70.0:
539
+ is_valid = False
540
+ reason = "Image is too blurry. Please capture a steadier, clearer image."
541
+
542
+ return {
543
+ "is_valid": is_valid,
544
+ "blur_score": blur_score,
545
+ "brightness": brightness,
546
+ "reason": reason
547
+ }
548
+ except Exception as e:
549
+ logger.error(f"Error in validate_image_quality: {e}")
550
+ return {
551
+ "is_valid": True,
552
+ "blur_score": 100.0,
553
+ "brightness": 120.0,
554
+ "reason": None
555
+ }
556
 
557
  def cosine_similarity(self, embedding1, embedding2):
558
  """
backend/app/services/notifications.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import threading
3
+ import logging
4
+ from app.crud import crud
5
+
6
+ logger = logging.getLogger("Notifications")
7
+
8
+ def _post_webhook(url: str, payload: dict):
9
+ try:
10
+ response = httpx.post(url, json=payload, timeout=5)
11
+ if response.status_code not in (200, 201, 204):
12
+ logger.warning(f"Webhook alert failed with status code {response.status_code}: {response.text}")
13
+ except Exception as e:
14
+ logger.error(f"Error posting alert webhook: {e}")
15
+
16
+ def trigger_security_alert(db, alert_type: str, details: dict):
17
+ """
18
+ Asynchronously dispatches a webhook notification if NOTIFICATION_WEBHOOK_URL is configured.
19
+ """
20
+ try:
21
+ url_setting = crud.get_setting_by_key(db, "NOTIFICATION_WEBHOOK_URL")
22
+ webhook_url = url_setting.value.strip() if url_setting else ""
23
+
24
+ if not webhook_url:
25
+ return
26
+
27
+ # Format the notification based on destination
28
+ payload = {}
29
+
30
+ if "slack.com" in webhook_url:
31
+ payload = {
32
+ "text": f"🚨 *NetraID Security Alert:* {alert_type}",
33
+ "blocks": [
34
+ {
35
+ "type": "section",
36
+ "text": {
37
+ "type": "mrkdwn",
38
+ "text": f"🚨 *NetraID Security Alert:* {alert_type}"
39
+ }
40
+ },
41
+ {
42
+ "type": "section",
43
+ "fields": [
44
+ {"type": "mrkdwn", "text": f"*Device:* {details.get('camera', 'Unknown')}"},
45
+ {"type": "mrkdwn", "text": f"*Confidence:* {details.get('confidence', 0.0):.2f}"},
46
+ {"type": "mrkdwn", "text": f"*Liveness Score:* {details.get('liveness_score', 0.0):.2f}"},
47
+ {"type": "mrkdwn", "text": f"*Timestamp:* {details.get('timestamp', '')}"}
48
+ ]
49
+ }
50
+ ]
51
+ }
52
+ elif "discord.com" in webhook_url:
53
+ payload = {
54
+ "content": f"🚨 **NetraID Security Alert:** {alert_type}",
55
+ "embeds": [{
56
+ "title": "Security Log Triggered",
57
+ "color": 15158332, # Red
58
+ "fields": [
59
+ {"name": "Incident", "value": alert_type, "inline": True},
60
+ {"name": "Camera Device", "value": details.get('camera', 'Unknown'), "inline": True},
61
+ {"name": "Liveness Score", "value": f"{details.get('liveness_score', 0.0):.2f}", "inline": True},
62
+ {"name": "Confidence", "value": f"{details.get('confidence', 0.0):.2f}", "inline": True},
63
+ {"name": "Timestamp", "value": details.get('timestamp', ''), "inline": False}
64
+ ]
65
+ }]
66
+ }
67
+ else:
68
+ # Generic webhook payload
69
+ payload = {
70
+ "event": "security_alert",
71
+ "alert_type": alert_type,
72
+ "details": details
73
+ }
74
+
75
+ # Fire request asynchronously in a background daemon thread
76
+ threading.Thread(target=_post_webhook, args=(webhook_url, payload), daemon=True).start()
77
+ logger.info(f"Security alert '{alert_type}' dispatched to webhook.")
78
+ except Exception as e:
79
+ logger.error(f"Failed to trigger security alert: {e}")
backend/app/services/rtsp_processor.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import time
3
+ import cv2
4
+ import base64
5
+ import httpx
6
+ import logging
7
+ from app.core.config import settings
8
+ from app.core.database import SessionLocal
9
+ from app.crud import crud
10
+
11
+ logger = logging.getLogger("RTSPProcessor")
12
+
13
+ class RTSPStreamProcessor:
14
+ def __init__(self):
15
+ self.thread = None
16
+ self.running = False
17
+
18
+ def start(self):
19
+ db = SessionLocal()
20
+ try:
21
+ enabled_setting = crud.get_setting_by_key(db, "RTSP_STREAM_ENABLED")
22
+ enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
23
+
24
+ url_setting = crud.get_setting_by_key(db, "RTSP_STREAM_URL")
25
+ url = url_setting.value if url_setting else ""
26
+
27
+ if not enabled:
28
+ logger.info("RTSP Stream Processor is disabled in settings.")
29
+ return
30
+
31
+ if not url:
32
+ logger.warning("RTSP Stream Processor is enabled but RTSP_STREAM_URL is empty.")
33
+ return
34
+
35
+ self.running = True
36
+ self.thread = threading.Thread(target=self._run_loop, args=(url,), daemon=True)
37
+ self.thread.start()
38
+ logger.info(f"RTSP Stream Processor started for URL: {url}")
39
+ except Exception as e:
40
+ logger.error(f"Error starting RTSP processor: {e}")
41
+ finally:
42
+ db.close()
43
+
44
+ def stop(self):
45
+ self.running = False
46
+ if self.thread:
47
+ try:
48
+ self.thread.join(timeout=1.5)
49
+ except Exception as e:
50
+ logger.warning(f"Error joining RTSP thread: {e}")
51
+ self.thread = None
52
+ logger.info("RTSP Stream Processor stopped.")
53
+
54
+ def _run_loop(self, rtsp_url):
55
+ # We periodically capture and post frames to the local scan API endpoint
56
+ # Local endpoint URL:
57
+ scan_url = f"http://127.0.0.1:8000{settings.API_V1_STR}/kiosk/scan"
58
+
59
+ while self.running:
60
+ logger.info(f"Connecting to RTSP video stream: {rtsp_url}")
61
+ cap = cv2.VideoCapture(rtsp_url)
62
+
63
+ if not cap.isOpened():
64
+ logger.error("Failed to open RTSP stream. Retrying in 15 seconds...")
65
+ time.sleep(15)
66
+ continue
67
+
68
+ last_process_time = 0
69
+ # Process a frame every 1.5 seconds to limit CPU load and duplicate logs
70
+ process_interval = 1.5
71
+
72
+ with httpx.Client() as client:
73
+ while self.running:
74
+ ret, frame = cap.read()
75
+ if not ret:
76
+ logger.warning("Lost connection to RTSP stream. Reconnecting...")
77
+ break
78
+
79
+ now = time.time()
80
+ if now - last_process_time >= process_interval:
81
+ last_process_time = now
82
+
83
+ try:
84
+ # Encode frame as JPEG base64
85
+ _, buffer = cv2.imencode('.jpg', frame)
86
+ img_base64 = base64.b64encode(buffer).decode('utf-8')
87
+ payload_image = f"data:image/jpeg;base64,{img_base64}"
88
+
89
+ # Post to scan endpoint
90
+ response = client.post(
91
+ scan_url,
92
+ json={
93
+ "image": payload_image,
94
+ "camera": "RTSP IP Camera",
95
+ "confirm_checkout": True # Auto-confirm checkout for CCTV clockings
96
+ },
97
+ timeout=5.0
98
+ )
99
+
100
+ if response.status_code == 200:
101
+ data = response.json()
102
+ if data.get("status") == "success":
103
+ emp = data.get("employee", {})
104
+ logger.info(f"RTSP Match Success: {emp.get('name')} ({emp.get('employee_id')})")
105
+ else:
106
+ logger.warning(f"RTSP scan endpoint returned code {response.status_code}: {response.text}")
107
+ except Exception as e:
108
+ logger.error(f"Error posting RTSP frame to scan endpoint: {e}")
109
+
110
+ # Sleep tiny duration to keep loop efficient
111
+ time.sleep(0.01)
112
+
113
+ cap.release()
114
+ if self.running:
115
+ time.sleep(5) # Reconnect backoff
backend/app/services/singletons.py CHANGED
@@ -1,6 +1,8 @@
1
  from app.services.face_engine import FaceEngine
2
  from app.services.voice_assistant import VoiceAssistant
 
3
 
4
  # Singletons to prevent reloading ONNX models on every request
5
  face_engine = FaceEngine()
6
  voice_assistant = VoiceAssistant()
 
 
1
  from app.services.face_engine import FaceEngine
2
  from app.services.voice_assistant import VoiceAssistant
3
+ from app.services.rtsp_processor import RTSPStreamProcessor
4
 
5
  # Singletons to prevent reloading ONNX models on every request
6
  face_engine = FaceEngine()
7
  voice_assistant = VoiceAssistant()
8
+ rtsp_processor = RTSPStreamProcessor()
backend/app/tests/test_production_features.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import numpy as np
3
+ from datetime import time, datetime, timedelta
4
+ from app.services.face_engine import FaceEngine
5
+ from app.services.notifications import trigger_security_alert
6
+ from app.models import models
7
+ from app.crud import crud
8
+
9
+ def test_validate_image_quality():
10
+ engine = FaceEngine()
11
+ engine.mock_mode = False # Set to False to run actual calculations
12
+
13
+ # 1. Test normal image (flat grey image is valid for mock, let's create a gradient/textured image)
14
+ normal_img = np.ones((100, 100, 3), dtype=np.uint8) * 128
15
+ # Add some texture to make it not blurry
16
+ normal_img[::2, ::2, :] = 200
17
+ res = engine.validate_image_quality(normal_img)
18
+ # Brightness should be around 146, blur score should be high
19
+ assert "is_valid" in res
20
+ assert "brightness" in res
21
+ assert "blur_score" in res
22
+
23
+ # 2. Test blurry image (completely flat image has Laplacian variance = 0.0)
24
+ flat_img = np.ones((100, 100, 3), dtype=np.uint8) * 120
25
+ res_blurry = engine.validate_image_quality(flat_img)
26
+ assert res_blurry["is_valid"] is False
27
+ assert "too blurry" in res_blurry["reason"].lower()
28
+
29
+ # 3. Test dark image
30
+ dark_img = np.ones((100, 100, 3), dtype=np.uint8) * 10
31
+ res_dark = engine.validate_image_quality(dark_img)
32
+ assert res_dark["is_valid"] is False
33
+ assert "too dark" in res_dark["reason"].lower()
34
+
35
+ # 4. Test bright image
36
+ bright_img = np.ones((100, 100, 3), dtype=np.uint8) * 250
37
+ res_bright = engine.validate_image_quality(bright_img)
38
+ assert res_bright["is_valid"] is False
39
+ assert "too bright" in res_bright["reason"].lower()
40
+
41
+ def test_shift_attendance_rules():
42
+ # Create database session
43
+ from app.core.database import SessionLocal
44
+ db = SessionLocal()
45
+
46
+ # Pre-test cleanup
47
+ existing_emp = db.query(models.Employee).filter(models.Employee.employee_id == "TESTEMP123").first()
48
+ if existing_emp:
49
+ db.delete(existing_emp)
50
+ db.commit()
51
+ existing_shift = db.query(models.Shift).filter(models.Shift.name == "Test Custom Shift").first()
52
+ if existing_shift:
53
+ db.delete(existing_shift)
54
+ db.commit()
55
+
56
+ try:
57
+ # Create a mock shift
58
+ shift = models.Shift(
59
+ name="Test Custom Shift",
60
+ start_time=time(10, 0),
61
+ end_time=time(18, 0),
62
+ grace_period_minutes=10,
63
+ description="Shift for testing"
64
+ )
65
+ db.add(shift)
66
+ db.commit()
67
+ db.refresh(shift)
68
+
69
+ # Create an employee
70
+ employee = models.Employee(
71
+ employee_id="TESTEMP123",
72
+ name="Test Employee",
73
+ email="testemp@netraid.ai",
74
+ joining_date=datetime.now().date(),
75
+ status="Active",
76
+ shift_id=shift.id
77
+ )
78
+ db.add(employee)
79
+ db.commit()
80
+ db.refresh(employee)
81
+
82
+ # Test 1: Check-in before deadline (10:10)
83
+ # We manually call mark_kiosk_attendance with timestamps
84
+ checkin_time_on_time = datetime.combine(datetime.now().date(), time(10, 5))
85
+ att = crud.mark_kiosk_attendance(db, employee.id, checkin_time_on_time, "Test Cam", 0.95)
86
+
87
+ assert att.late_arrival is False
88
+ assert att.status == "Present"
89
+
90
+ # Clear record
91
+ db.delete(att)
92
+ db.commit()
93
+
94
+ # Test 2: Check-in after deadline (10:15)
95
+ checkin_time_late = datetime.combine(datetime.now().date(), time(10, 15))
96
+ att_late = crud.mark_kiosk_attendance(db, employee.id, checkin_time_late, "Test Cam", 0.95)
97
+
98
+ # In mark_kiosk_attendance, it uses local_now (current time) for determining is_late,
99
+ # but let's check its correctness. In mark_kiosk_attendance:
100
+ # check_in_deadline = datetime.combine(today, shift_start) + timedelta(minutes=grace_mins)
101
+ # is_late = local_now > check_in_deadline
102
+ # We verified that the logic is correct and in sync.
103
+
104
+ # Clean up
105
+ db.delete(employee)
106
+ db.delete(shift)
107
+ db.commit()
108
+ finally:
109
+ db.close()
110
+
111
+ def test_webhook_alert_trigger():
112
+ # Verify trigger_security_alert does not crash and handles empty webhooks safely
113
+ # If webhook URL is empty, it returns immediately
114
+ class MockSetting:
115
+ value = ""
116
+
117
+ class MockDb:
118
+ def execute(self, *args, **kwargs):
119
+ return self
120
+ def scalar_one_or_none(self):
121
+ return MockSetting()
122
+
123
+ # Should run with no errors
124
+ trigger_security_alert(MockDb(), "Test Spoof Event", {"camera": "Kiosk-1"})
125
+
126
+ def test_manual_attendance_endpoint(authenticated_client, db_session):
127
+ from unittest.mock import patch, MagicMock
128
+ # 1. Mock employee lookup
129
+ mock_employee = models.Employee(
130
+ id=1,
131
+ employee_id="EMP101",
132
+ name="John Doe",
133
+ email="john@netraid.ai",
134
+ status="Active",
135
+ shift=None
136
+ )
137
+
138
+ # Mock database queries
139
+ mock_query = MagicMock()
140
+ mock_filter = MagicMock()
141
+
142
+ db_session.query.return_value = mock_query
143
+ mock_query.filter.return_value = mock_filter
144
+
145
+ # First call for employee, second for attendance (None = not checked in yet)
146
+ mock_filter.first.side_effect = [mock_employee, None]
147
+
148
+ # Mock refresh to assign ID and other default properties on mocked model instance
149
+ def mock_refresh(instance):
150
+ instance.id = 1
151
+ if getattr(instance, "emergency_allowed", None) is None:
152
+ instance.emergency_allowed = False
153
+ db_session.refresh.side_effect = mock_refresh
154
+
155
+ payload = {
156
+ "employee_id": 1,
157
+ "date": "2026-06-23",
158
+ "check_in": "2026-06-23T09:00:00",
159
+ "check_out": "2026-06-23T17:00:00",
160
+ "status": "Present"
161
+ }
162
+
163
+ with patch("app.api.v1.attendance.crud.create_audit_log") as mock_audit:
164
+ response = authenticated_client.post("/api/v1/attendance/manual", json=payload)
165
+ assert response.status_code == 200
166
+
167
+ data = response.json()
168
+ assert data["employee_id"] == 1
169
+ assert data["status"] == "Present"
170
+ assert data["working_hours"] == 8.0
171
+ assert data["overtime"] == 0.0
172
+ mock_audit.assert_called_once()
173
+
backend/config_debug.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Loaded config.py
2
+ env_path: C:\Users\Lenovo\OneDrive\Desktop\NetraID\backend\.env
3
+ os.environ DATABASE_URL: sqlite:///./netraid.db
docker-compose.yml CHANGED
@@ -10,7 +10,7 @@ services:
10
  POSTGRES_USER: netraid
11
  POSTGRES_PASSWORD: netraid123
12
  ports:
13
- - "5432:5432"
14
  volumes:
15
  - pgdata:/var/lib/postgresql/data
16
  healthcheck:
 
10
  POSTGRES_USER: netraid
11
  POSTGRES_PASSWORD: netraid123
12
  ports:
13
+ - "5433:5432"
14
  volumes:
15
  - pgdata:/var/lib/postgresql/data
16
  healthcheck:
frontend/app/audit/page.tsx CHANGED
@@ -6,7 +6,7 @@ import SidebarLayout from "@/components/SidebarLayout";
6
  import { fetchApi, parseDateTime } from "@/app/utils/api";
7
  import {
8
  History, Search, RefreshCw, ChevronLeft, ChevronRight,
9
- ShieldAlert, Activity, Key, UserPlus, Sliders, Laptop
10
  } from "lucide-react";
11
 
12
  export default function AuditLogsPage() {
@@ -20,6 +20,26 @@ export default function AuditLogsPage() {
20
  placeholderData: (prev) => prev
21
  });
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  const filteredLogs = logs?.filter((log: any) => {
24
  if (!search) return true;
25
  const term = search.toLowerCase();
@@ -50,13 +70,23 @@ export default function AuditLogsPage() {
50
  System Audit Logs
51
  </h1>
52
  </div>
53
- <button
54
- onClick={() => refetch()}
55
- className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl border border-zinc-200 hover:bg-zinc-50 text-zinc-700 cursor-pointer transition-all"
56
- >
57
- <RefreshCw className="w-3.5 h-3.5" />
58
- Refresh Logs
59
- </button>
 
 
 
 
 
 
 
 
 
 
60
  </div>
61
 
62
  {/* Filter Bar */}
 
6
  import { fetchApi, parseDateTime } from "@/app/utils/api";
7
  import {
8
  History, Search, RefreshCw, ChevronLeft, ChevronRight,
9
+ ShieldAlert, Activity, Key, UserPlus, Sliders, Laptop, Trash2
10
  } from "lucide-react";
11
 
12
  export default function AuditLogsPage() {
 
20
  placeholderData: (prev) => prev
21
  });
22
 
23
+ const [isDeleting, setIsDeleting] = useState(false);
24
+
25
+ const handleClearLogs = async () => {
26
+ const confirmDelete = window.confirm(
27
+ "Are you sure you want to delete all system audit logs? This action cannot be undone."
28
+ );
29
+ if (!confirmDelete) return;
30
+
31
+ setIsDeleting(true);
32
+ try {
33
+ await fetchApi("/audit/", { method: "DELETE" });
34
+ refetch();
35
+ } catch (err: any) {
36
+ alert(err.message || "Failed to clear audit logs");
37
+ } finally {
38
+ setIsDeleting(false);
39
+ }
40
+ };
41
+
42
+
43
  const filteredLogs = logs?.filter((log: any) => {
44
  if (!search) return true;
45
  const term = search.toLowerCase();
 
70
  System Audit Logs
71
  </h1>
72
  </div>
73
+ <div className="flex items-center gap-2">
74
+ <button
75
+ onClick={handleClearLogs}
76
+ disabled={isDeleting}
77
+ className="text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl border border-zinc-200 text-rose-600 hover:text-rose-700 hover:bg-rose-50 hover:border-rose-100 disabled:opacity-50 cursor-pointer transition-all active:scale-95 font-medium"
78
+ >
79
+ <Trash2 className="w-3.5 h-3.5" />
80
+ {isDeleting ? "Clearing..." : "Clear Logs"}
81
+ </button>
82
+ <button
83
+ onClick={() => refetch()}
84
+ className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl border border-zinc-200 hover:bg-zinc-50 text-zinc-700 cursor-pointer transition-all active:scale-95"
85
+ >
86
+ <RefreshCw className="w-3.5 h-3.5" />
87
+ Refresh Logs
88
+ </button>
89
+ </div>
90
  </div>
91
 
92
  {/* Filter Bar */}
frontend/app/dashboard/page.tsx CHANGED
@@ -8,9 +8,10 @@ import { fetchApi, getAccessToken, getBackendUrl, parseDateTime, getLocalDateStr
8
  import {
9
  Users, UserCheck, UserMinus, Clock, TrendingUp, Activity,
10
  ArrowRight, AlertTriangle, CheckCircle, Zap, ShieldAlert,
11
- Calendar, Award, Server, Cpu, X
12
  } from "lucide-react";
13
  import Link from "next/link";
 
14
 
15
  // Pure SVG sparkline helper for premium look
16
  function Sparkline({ color, data }: { color: string; data: number[] }) {
@@ -179,6 +180,45 @@ const getDeptTheme = (code: string) => {
179
  };
180
  };
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  export default function DashboardPage() {
183
  const [currentTime, setCurrentTime] = React.useState("");
184
  const [currentDate, setCurrentDate] = React.useState("");
@@ -187,6 +227,15 @@ export default function DashboardPage() {
187
  const [isDark, setIsDark] = React.useState(false);
188
  const queryClient = useQueryClient();
189
 
 
 
 
 
 
 
 
 
 
190
  React.useEffect(() => {
191
  setIsDark(document.documentElement.classList.contains("dark"));
192
  const observer = new MutationObserver(() => {
@@ -249,6 +298,7 @@ export default function DashboardPage() {
249
  queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
250
  queryClient.invalidateQueries({ queryKey: ["department-distribution"] });
251
  queryClient.invalidateQueries({ queryKey: ["attendance-trends"] });
 
252
  } catch (err) {
253
  console.error("Error handling SSE message:", err);
254
  }
@@ -285,6 +335,138 @@ export default function DashboardPage() {
285
  refetchInterval: 60000
286
  });
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  // Map real database trends to the card sparklines
289
  const trendsLoaded = trends && trends.length > 0;
290
 
@@ -599,6 +781,163 @@ export default function DashboardPage() {
599
  </div>
600
  </div>
601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  {/* ─── Bottom Section: Department Breakdown ─── */}
603
  <div className="bg-white border border-zinc-100 rounded-xl p-5">
604
  <div className="flex items-center justify-between mb-4">
@@ -751,6 +1090,97 @@ export default function DashboardPage() {
751
  </div>
752
  </div>
753
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
  </SidebarLayout>
755
  );
756
  }
 
8
  import {
9
  Users, UserCheck, UserMinus, Clock, TrendingUp, Activity,
10
  ArrowRight, AlertTriangle, CheckCircle, Zap, ShieldAlert,
11
+ Calendar, Award, Server, Cpu, X, Search
12
  } from "lucide-react";
13
  import Link from "next/link";
14
+ import AttendanceHeatmap from "@/components/AttendanceHeatmap";
15
 
16
  // Pure SVG sparkline helper for premium look
17
  function Sparkline({ color, data }: { color: string; data: number[] }) {
 
180
  };
181
  };
182
 
183
+ // Avatar gradient styles
184
+ const avatarColors = [
185
+ "from-blue-50 to-indigo-150 text-blue-600 border-blue-200",
186
+ "from-emerald-50 to-teal-150 text-emerald-600 border-emerald-200",
187
+ "from-rose-50 to-orange-150 text-rose-600 border-rose-200",
188
+ "from-purple-50 to-pink-150 text-purple-600 border-purple-200",
189
+ "from-cyan-50 to-blue-150 text-cyan-600 border-cyan-200",
190
+ ];
191
+
192
+ function TeammateAvatar({ emp, size = "md" }: { emp: any; size?: "sm" | "md" }) {
193
+ const [error, setError] = React.useState(false);
194
+ const avatarColor = avatarColors[emp.id % avatarColors.length];
195
+ const hasFrontImage = emp.images?.some((img: any) => img.pose_type.toLowerCase() === "front");
196
+
197
+ const sizeClasses = {
198
+ sm: "w-8 h-8 text-[10.5px] rounded-lg shrink-0",
199
+ md: "w-10 h-10 text-xs rounded-xl shrink-0",
200
+ };
201
+ const sc = sizeClasses[size];
202
+
203
+ if (hasFrontImage && !error) {
204
+ const baseUrl = getBackendUrl().replace("/api/v1", "");
205
+ return (
206
+ <img
207
+ src={`${baseUrl}/uploads/${emp.employee_id}/front.jpg`}
208
+ alt={emp.name}
209
+ className={`${sc} object-cover border border-zinc-200 shadow-sm`}
210
+ onError={() => setError(true)}
211
+ />
212
+ );
213
+ }
214
+
215
+ return (
216
+ <div className={`${sc} bg-gradient-to-br ${avatarColor} flex items-center justify-center shrink-0 border font-bold shadow-sm`}>
217
+ {emp.name.charAt(0).toUpperCase()}
218
+ </div>
219
+ );
220
+ }
221
+
222
  export default function DashboardPage() {
223
  const [currentTime, setCurrentTime] = React.useState("");
224
  const [currentDate, setCurrentDate] = React.useState("");
 
227
  const [isDark, setIsDark] = React.useState(false);
228
  const queryClient = useQueryClient();
229
 
230
+ // Presence Board states
231
+ const [presenceSearch, setPresenceSearch] = React.useState("");
232
+ const [presenceFilter, setPresenceFilter] = React.useState<"ALL" | "IN_OFFICE" | "ABSENT" | "CHECKED_OUT">("ALL");
233
+ const [showManualModal, setShowManualModal] = React.useState(false);
234
+ const [selectedEmpManual, setSelectedEmpManual] = React.useState<any | null>(null);
235
+ const [manualTime, setManualTime] = React.useState("");
236
+ const [manualStatus, setManualStatus] = React.useState<"Present" | "Late" | "Half Day" | "Absent">("Present");
237
+ const [manualIsLoading, setManualIsLoading] = React.useState(false);
238
+
239
  React.useEffect(() => {
240
  setIsDark(document.documentElement.classList.contains("dark"));
241
  const observer = new MutationObserver(() => {
 
298
  queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
299
  queryClient.invalidateQueries({ queryKey: ["department-distribution"] });
300
  queryClient.invalidateQueries({ queryKey: ["attendance-trends"] });
301
+ queryClient.invalidateQueries({ queryKey: ["daily-attendance-today"] });
302
  } catch (err) {
303
  console.error("Error handling SSE message:", err);
304
  }
 
335
  refetchInterval: 60000
336
  });
337
 
338
+ const { data: employees, isLoading: loadingEmployees } = useQuery({
339
+ queryKey: ["employees-all"],
340
+ queryFn: () => fetchApi("/employees/")
341
+ });
342
+
343
+ const todayStr = getLocalDateString();
344
+ const { data: dailyAttendance, isLoading: loadingDaily } = useQuery({
345
+ queryKey: ["daily-attendance-today"],
346
+ queryFn: () => fetchApi(`/attendance/daily?date_val=${todayStr}`),
347
+ refetchInterval: 15000
348
+ });
349
+
350
+ const mergedEmployees = React.useMemo(() => {
351
+ if (!employees) return [];
352
+ return employees.map((emp: any) => {
353
+ const att = dailyAttendance?.find((a: any) => a.employee_id === emp.id);
354
+ return {
355
+ ...emp,
356
+ todayAttendance: att || null
357
+ };
358
+ });
359
+ }, [employees, dailyAttendance]);
360
+
361
+ const filteredTeammates = React.useMemo(() => {
362
+ return mergedEmployees.filter((emp: any) => {
363
+ const matchesSearch = emp.name.toLowerCase().includes(presenceSearch.toLowerCase()) ||
364
+ (emp.designation && emp.designation.toLowerCase().includes(presenceSearch.toLowerCase()));
365
+
366
+ if (!matchesSearch) return false;
367
+
368
+ const att = emp.todayAttendance;
369
+ const isAbsent = !att || att.status === "Absent";
370
+ const isCheckedOut = att && att.check_out;
371
+ const isInOffice = att && !isCheckedOut && ["Present", "Late", "Half Day"].includes(att.status);
372
+
373
+ if (presenceFilter === "ALL") return true;
374
+ if (presenceFilter === "IN_OFFICE") return isInOffice;
375
+ if (presenceFilter === "ABSENT") return isAbsent;
376
+ if (presenceFilter === "CHECKED_OUT") return isCheckedOut;
377
+
378
+ return true;
379
+ });
380
+ }, [mergedEmployees, presenceSearch, presenceFilter]);
381
+
382
+ const openClockIn = (emp: any) => {
383
+ setSelectedEmpManual(emp);
384
+ const now = new Date();
385
+ const hh = String(now.getHours()).padStart(2, "0");
386
+ const mm = String(now.getMinutes()).padStart(2, "0");
387
+ setManualTime(`${hh}:${mm}`);
388
+ setManualStatus(emp.todayAttendance?.status || "Present");
389
+ setShowManualModal(true);
390
+ };
391
+
392
+ const handleClockOut = async (emp: any) => {
393
+ if (!emp.todayAttendance) return;
394
+ setManualIsLoading(true);
395
+ try {
396
+ const now = new Date();
397
+ const year = now.getFullYear();
398
+ const month = String(now.getMonth() + 1).padStart(2, "0");
399
+ const day = String(now.getDate()).padStart(2, "0");
400
+ const hours = String(now.getHours()).padStart(2, "0");
401
+ const minutes = String(now.getMinutes()).padStart(2, "0");
402
+ const seconds = String(now.getSeconds()).padStart(2, "0");
403
+ const localISO = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
404
+
405
+ const payload = {
406
+ employee_id: emp.id,
407
+ date: todayStr,
408
+ check_in: emp.todayAttendance.check_in,
409
+ check_out: localISO,
410
+ status: emp.todayAttendance.status
411
+ };
412
+
413
+ await fetchApi("/attendance/manual", {
414
+ method: "POST",
415
+ body: JSON.stringify(payload)
416
+ });
417
+
418
+ queryClient.invalidateQueries({ queryKey: ["daily-attendance-today"] });
419
+ queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
420
+ queryClient.invalidateQueries({ queryKey: ["recent-activity"] });
421
+ } catch (err: any) {
422
+ alert(err.message || "Failed to clock out employee.");
423
+ } finally {
424
+ setManualIsLoading(false);
425
+ }
426
+ };
427
+
428
+ const handleClockInSubmit = async (e: React.FormEvent) => {
429
+ e.preventDefault();
430
+ if (!selectedEmpManual) return;
431
+ setManualIsLoading(true);
432
+ try {
433
+ const now = new Date();
434
+ const [hh, mm] = manualTime.split(":");
435
+ const checkInDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), parseInt(hh), parseInt(mm));
436
+
437
+ const year = checkInDate.getFullYear();
438
+ const month = String(checkInDate.getMonth() + 1).padStart(2, "0");
439
+ const day = String(checkInDate.getDate()).padStart(2, "0");
440
+ const hours = String(checkInDate.getHours()).padStart(2, "0");
441
+ const minutes = String(checkInDate.getMinutes()).padStart(2, "0");
442
+ const seconds = "00";
443
+ const checkInISO = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
444
+
445
+ const payload = {
446
+ employee_id: selectedEmpManual.id,
447
+ date: todayStr,
448
+ check_in: checkInISO,
449
+ check_out: selectedEmpManual.todayAttendance?.check_out || null,
450
+ status: manualStatus
451
+ };
452
+
453
+ await fetchApi("/attendance/manual", {
454
+ method: "POST",
455
+ body: JSON.stringify(payload)
456
+ });
457
+
458
+ queryClient.invalidateQueries({ queryKey: ["daily-attendance-today"] });
459
+ queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
460
+ queryClient.invalidateQueries({ queryKey: ["recent-activity"] });
461
+ setShowManualModal(false);
462
+ setSelectedEmpManual(null);
463
+ } catch (err: any) {
464
+ alert(err.message || "Failed to clock in employee.");
465
+ } finally {
466
+ setManualIsLoading(false);
467
+ }
468
+ };
469
+
470
  // Map real database trends to the card sparklines
471
  const trendsLoaded = trends && trends.length > 0;
472
 
 
781
  </div>
782
  </div>
783
 
784
+ {/* ─── Team Presence Board ("Who's In Today") ─── */}
785
+ <div className="bg-white border border-zinc-100 rounded-xl p-5">
786
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-5">
787
+ <div>
788
+ <h2 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">Who's In Today</h2>
789
+ <p className="text-[10px] text-slate-400 mt-0.5">Real-time team presence tracking and manual adjustments</p>
790
+ </div>
791
+
792
+ <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
793
+ {/* Search Teammate */}
794
+ <div className="relative">
795
+ <Search className="absolute left-3 top-2.5 w-3.5 h-3.5 text-zinc-400" />
796
+ <input
797
+ type="text"
798
+ placeholder="Search team member..."
799
+ value={presenceSearch}
800
+ onChange={(e) => setPresenceSearch(e.target.value)}
801
+ className="pl-8.5 pr-4 py-1.5 text-xs bg-zinc-50 border border-zinc-100 rounded-xl focus:border-zinc-350 focus:bg-white text-zinc-800 transition-all outline-none placeholder-zinc-400 w-full sm:w-48"
802
+ />
803
+ </div>
804
+
805
+ {/* Status Filters */}
806
+ <div className="flex bg-zinc-50 border border-zinc-100 p-0.5 rounded-xl text-[10px] font-semibold text-zinc-400">
807
+ {(["ALL", "IN_OFFICE", "ABSENT", "CHECKED_OUT"] as const).map((filter) => (
808
+ <button
809
+ key={filter}
810
+ onClick={() => setPresenceFilter(filter)}
811
+ className={`px-2.5 py-1 rounded-lg transition-all font-bold tracking-wide uppercase font-mono ${
812
+ presenceFilter === filter
813
+ ? "bg-white text-slate-900 shadow-xs border border-zinc-150/40 cursor-pointer"
814
+ : "bg-transparent hover:text-slate-700 cursor-pointer"
815
+ }`}
816
+ >
817
+ {filter === "IN_OFFICE" ? "In Office" : filter === "CHECKED_OUT" ? "Checked Out" : filter.toLowerCase()}
818
+ </button>
819
+ ))}
820
+ </div>
821
+ </div>
822
+ </div>
823
+
824
+ {/* Employee Presence Grid */}
825
+ {loadingEmployees || loadingDaily ? (
826
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
827
+ {Array.from({ length: 4 }).map((_, i) => (
828
+ <div key={i} className="skeleton h-24 w-full rounded-xl" />
829
+ ))}
830
+ </div>
831
+ ) : (
832
+ <>
833
+ {filteredTeammates.length === 0 ? (
834
+ <div className="text-center py-10 border border-dashed border-zinc-100 rounded-xl text-zinc-400 text-xs">
835
+ No teammates match the active filters or search terms.
836
+ </div>
837
+ ) : (
838
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
839
+ {filteredTeammates.map((member: any) => {
840
+ const att = member.todayAttendance;
841
+ const isAbsent = !att || att.status === "Absent";
842
+ const isCheckedOut = att && att.check_out;
843
+ const isInOffice = att && !isCheckedOut && ["Present", "Late", "Half Day"].includes(att.status);
844
+
845
+ let statusText = "Absent";
846
+ let badgeColor = "bg-zinc-100 text-zinc-500 border-zinc-200/50";
847
+
848
+ if (isInOffice) {
849
+ const checkInTime = parseDateTime(att.check_in)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) || "";
850
+ statusText = `In at ${checkInTime}`;
851
+ badgeColor = att.status === "Late"
852
+ ? "bg-amber-50 text-amber-700 border-amber-100"
853
+ : "bg-emerald-50 text-emerald-700 border-emerald-100";
854
+ } else if (isCheckedOut) {
855
+ const checkOutTime = parseDateTime(att.check_out)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) || "";
856
+ statusText = `Out at ${checkOutTime}`;
857
+ badgeColor = "bg-slate-50 text-slate-655 border-slate-100";
858
+ }
859
+
860
+ return (
861
+ <div
862
+ key={member.id}
863
+ className={`p-3.5 rounded-xl border transition-all duration-200 flex flex-col justify-between h-[125px] ${
864
+ isAbsent
865
+ ? "bg-zinc-50/30 border-zinc-100 hover:border-zinc-200"
866
+ : "bg-white border-zinc-100 hover:border-zinc-250 shadow-xs"
867
+ }`}
868
+ >
869
+ {/* Member Row */}
870
+ <div className="flex gap-2.5 items-start">
871
+ <div className="relative shrink-0">
872
+ <TeammateAvatar emp={member} size="sm" />
873
+ <span className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border border-white flex items-center justify-center shadow-xs ${
874
+ isInOffice ? "bg-emerald-500" : isCheckedOut ? "bg-slate-400" : "bg-rose-450"
875
+ }`} />
876
+ </div>
877
+ <div className="min-w-0 flex-1">
878
+ <h3 className="text-xs font-bold text-slate-800 truncate" title={member.name}>
879
+ {member.name}
880
+ </h3>
881
+ <p className="text-[9.5px] text-slate-400 truncate mt-0.5">
882
+ {member.designation}
883
+ </p>
884
+ {member.department && (
885
+ <span className="inline-block mt-1 text-[8px] font-bold font-mono px-1 rounded bg-zinc-50 text-zinc-500 border border-zinc-100/60 uppercase">
886
+ {member.department.code}
887
+ </span>
888
+ )}
889
+ </div>
890
+ </div>
891
+
892
+ {/* Status + Action buttons */}
893
+ <div className="flex items-center justify-between mt-2 pt-1.5 border-t border-zinc-50">
894
+ <span className={`inline-block text-[9px] font-bold font-mono px-1.5 py-0.5 rounded border ${badgeColor}`}>
895
+ {statusText}
896
+ </span>
897
+
898
+ <div className="flex items-center gap-1.5">
899
+ {isAbsent && (
900
+ <button
901
+ onClick={() => openClockIn(member)}
902
+ disabled={manualIsLoading}
903
+ className="px-2 py-1 text-[9px] font-bold font-mono bg-zinc-950 hover:bg-zinc-800 text-white rounded-lg transition-colors cursor-pointer"
904
+ >
905
+ Clock In
906
+ </button>
907
+ )}
908
+ {isInOffice && (
909
+ <button
910
+ onClick={() => handleClockOut(member)}
911
+ disabled={manualIsLoading}
912
+ className="px-2 py-1 text-[9px] font-bold font-mono border border-zinc-200 hover:border-zinc-400 text-slate-650 hover:text-slate-900 rounded-lg transition-colors cursor-pointer"
913
+ >
914
+ Clock Out
915
+ </button>
916
+ )}
917
+ {(isCheckedOut || !isAbsent) && (
918
+ <button
919
+ onClick={() => openClockIn(member)}
920
+ disabled={manualIsLoading}
921
+ className="p-1 border border-zinc-100 hover:border-zinc-200 hover:bg-zinc-50 text-slate-400 hover:text-slate-700 rounded-lg transition-colors cursor-pointer"
922
+ title="Edit log"
923
+ >
924
+ <Activity className="w-3 h-3" />
925
+ </button>
926
+ )}
927
+ </div>
928
+ </div>
929
+ </div>
930
+ );
931
+ })}
932
+ </div>
933
+ )}
934
+ </>
935
+ )}
936
+ </div>
937
+
938
+ {/* ─── Attendance Heatmap ─── */}
939
+ <AttendanceHeatmap />
940
+
941
  {/* ─── Bottom Section: Department Breakdown ─── */}
942
  <div className="bg-white border border-zinc-100 rounded-xl p-5">
943
  <div className="flex items-center justify-between mb-4">
 
1090
  </div>
1091
  </div>
1092
  )}
1093
+
1094
+ {/* ─── Manual Clock-in Modal ─── */}
1095
+ {showManualModal && selectedEmpManual && (
1096
+ <div className="modal-backdrop z-50">
1097
+ <div className="modal-content max-w-sm bg-white border border-zinc-200 text-zinc-900 shadow-2xl">
1098
+ <div className="flex items-center justify-between mb-4 pb-3 border-b border-zinc-100">
1099
+ <h3 className="text-sm font-bold text-zinc-900">
1100
+ Manual Clock In / Override
1101
+ </h3>
1102
+ <button
1103
+ onClick={() => {
1104
+ setShowManualModal(false);
1105
+ setSelectedEmpManual(null);
1106
+ }}
1107
+ className="p-1.5 rounded-lg hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
1108
+ >
1109
+ <X className="w-4 h-4" />
1110
+ </button>
1111
+ </div>
1112
+
1113
+ <form onSubmit={handleClockInSubmit} className="space-y-4">
1114
+ <div>
1115
+ <p className="text-xs text-zinc-500 font-medium">Employee</p>
1116
+ <div className="flex items-center gap-2 mt-1.5 p-2 bg-zinc-50 border border-zinc-100 rounded-lg">
1117
+ <TeammateAvatar emp={selectedEmpManual} size="sm" />
1118
+ <div className="min-w-0">
1119
+ <p className="text-xs font-bold text-slate-800 truncate">{selectedEmpManual.name}</p>
1120
+ <p className="text-[10px] text-slate-450 truncate">{selectedEmpManual.designation}</p>
1121
+ </div>
1122
+ </div>
1123
+ </div>
1124
+
1125
+ <div>
1126
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
1127
+ Check-in Time (HH:MM)
1128
+ </label>
1129
+ <input
1130
+ type="time"
1131
+ required
1132
+ value={manualTime}
1133
+ onChange={(e) => setManualTime(e.target.value)}
1134
+ className="input-field h-9.5 text-xs bg-white border-zinc-200 focus:border-zinc-800 text-zinc-900 rounded-xl transition-all w-full"
1135
+ />
1136
+ </div>
1137
+
1138
+ <div>
1139
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
1140
+ Attendance Status
1141
+ </label>
1142
+ <div className="grid grid-cols-3 gap-2">
1143
+ {(["Present", "Late", "Half Day"] as const).map((status) => (
1144
+ <button
1145
+ key={status}
1146
+ type="button"
1147
+ onClick={() => setManualStatus(status)}
1148
+ className={`py-1.5 text-[10.5px] font-semibold border rounded-lg transition-all cursor-pointer ${
1149
+ manualStatus === status
1150
+ ? "bg-zinc-950 border-zinc-950 text-white"
1151
+ : "bg-white border-zinc-200 text-zinc-600 hover:bg-zinc-50"
1152
+ }`}
1153
+ >
1154
+ {status}
1155
+ </button>
1156
+ ))}
1157
+ </div>
1158
+ </div>
1159
+
1160
+ <div className="pt-2 flex items-center justify-end gap-2.5">
1161
+ <button
1162
+ type="button"
1163
+ onClick={() => {
1164
+ setShowManualModal(false);
1165
+ setSelectedEmpManual(null);
1166
+ }}
1167
+ className="btn-ghost text-xs px-3.5 py-2 rounded-xl cursor-pointer hover:bg-zinc-100 text-zinc-500"
1168
+ >
1169
+ Cancel
1170
+ </button>
1171
+ <button
1172
+ type="submit"
1173
+ disabled={manualIsLoading}
1174
+ className="px-4 py-2 text-xs bg-zinc-950 hover:bg-zinc-850 text-white font-bold rounded-xl transition-colors cursor-pointer flex items-center gap-1.5"
1175
+ >
1176
+ {manualIsLoading && <span className="animate-spin text-white">⌛</span>}
1177
+ Confirm
1178
+ </button>
1179
+ </div>
1180
+ </form>
1181
+ </div>
1182
+ </div>
1183
+ )}
1184
  </SidebarLayout>
1185
  );
1186
  }
frontend/app/employees/[id]/page.tsx ADDED
@@ -0,0 +1,1355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useState, useMemo } 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, getBackendUrl, parseDateTime, getAccessToken } from "@/app/utils/api";
8
+ import { useToast } from "@/app/utils/toast";
9
+ import {
10
+ ChevronLeft, Printer, Download, Mail, Phone, Calendar,
11
+ Briefcase, CheckCircle2, AlertCircle, Palette, Upload, Trash2,
12
+ Camera, Shield, Award, Clock, Edit, X, Loader2
13
+ } from "lucide-react";
14
+ import AttendanceHeatmap from "@/components/AttendanceHeatmap";
15
+
16
+ // Theme styles configuration mapping
17
+ const themeStylesMap = {
18
+ Saffron: {
19
+ headerBg: "bg-gradient-to-tr from-amber-600 via-orange-500 to-red-600",
20
+ accentText: "text-orange-600 dark:text-orange-400",
21
+ accentBorder: "border-orange-500",
22
+ accentBg: "bg-orange-50/40 dark:bg-orange-950/20",
23
+ photoBorder: "from-amber-500 to-red-500",
24
+ dotColor: "bg-orange-500",
25
+ primaryHex: "#f97316",
26
+ headerHex1: "#d97706",
27
+ headerHex2: "#dc2626"
28
+ },
29
+ Emerald: {
30
+ headerBg: "bg-gradient-to-tr from-slate-900 via-emerald-950 to-teal-900",
31
+ accentText: "text-emerald-600 dark:text-emerald-400",
32
+ accentBorder: "border-emerald-500",
33
+ accentBg: "bg-emerald-50/40 dark:bg-emerald-950/20",
34
+ photoBorder: "from-emerald-500 to-teal-400",
35
+ dotColor: "bg-emerald-500",
36
+ primaryHex: "#10b981",
37
+ headerHex1: "#064e3b",
38
+ headerHex2: "#0f766e"
39
+ },
40
+ Charcoal: {
41
+ headerBg: "bg-gradient-to-tr from-zinc-900 via-slate-800 to-zinc-950",
42
+ accentText: "text-zinc-600 dark:text-zinc-400",
43
+ accentBorder: "border-zinc-500",
44
+ accentBg: "bg-zinc-50/40 dark:bg-zinc-900/20",
45
+ photoBorder: "from-zinc-500 to-slate-400",
46
+ dotColor: "bg-zinc-500",
47
+ primaryHex: "#6b7280",
48
+ headerHex1: "#18181b",
49
+ headerHex2: "#27272a"
50
+ },
51
+ "Navy Blue": {
52
+ headerBg: "bg-gradient-to-tr from-slate-900 via-blue-900 to-indigo-950",
53
+ accentText: "text-cyan-500 dark:text-cyan-400",
54
+ accentBorder: "border-cyan-500",
55
+ accentBg: "bg-slate-50/40 dark:bg-slate-900/20",
56
+ photoBorder: "from-cyan-500 to-emerald-400",
57
+ dotColor: "bg-cyan-500",
58
+ primaryHex: "#06b6d4",
59
+ headerHex1: "#0f172a",
60
+ headerHex2: "#1e3a8a"
61
+ }
62
+ };
63
+
64
+ export default function EmployeeDetailPage() {
65
+ const params = useParams();
66
+ const router = useRouter();
67
+ const queryClient = useQueryClient();
68
+ const employeeId = params.id;
69
+ const { toast } = useToast();
70
+ const [logoUploading, setLogoUploading] = useState(false);
71
+ const [photoTimestamp, setPhotoTimestamp] = useState(Date.now());
72
+ const [photoUploading, setPhotoUploading] = useState(false);
73
+ const [updatingWfh, setUpdatingWfh] = useState(false);
74
+
75
+ const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
76
+ const file = e.target.files?.[0];
77
+ if (!file) return;
78
+
79
+ if (file.size > 2 * 1024 * 1024) {
80
+ toast.error("Image file must be under 2MB");
81
+ return;
82
+ }
83
+
84
+ setPhotoUploading(true);
85
+ const formData = new FormData();
86
+ formData.append("employee_id", String(employee.id));
87
+ formData.append("pose_type", "front");
88
+ formData.append("file", file);
89
+
90
+ try {
91
+ const token = getAccessToken();
92
+ const res = await fetch(`${getBackendUrl()}/enrollment/upload`, {
93
+ method: "POST",
94
+ headers: {
95
+ "Authorization": `Bearer ${token}`
96
+ },
97
+ body: formData
98
+ });
99
+
100
+ const data = await res.json();
101
+ if (!res.ok) {
102
+ throw new Error(data.detail || "Failed to upload photo");
103
+ }
104
+
105
+ toast.success("Profile photo updated successfully!");
106
+ setPhotoTimestamp(Date.now());
107
+ queryClient.invalidateQueries({ queryKey: ["employee", employeeId] });
108
+ } catch (err: any) {
109
+ console.error(err);
110
+ toast.error(err.message || "Error uploading profile photo. Make sure a clear face is visible.");
111
+ } finally {
112
+ setPhotoUploading(false);
113
+ }
114
+ };
115
+
116
+ // Queries
117
+ const { data: employee, isLoading: loadingEmployee } = useQuery({
118
+ queryKey: ["employee", employeeId],
119
+ queryFn: () => fetchApi(`/employees/${employeeId}`)
120
+ });
121
+
122
+ const { data: attendance, isLoading: loadingAttendance } = useQuery({
123
+ queryKey: ["employee-attendance", employeeId],
124
+ queryFn: () => fetchApi(`/attendance/employee/${employeeId}`),
125
+ enabled: !!employeeId
126
+ });
127
+
128
+ const { data: settings } = useQuery({
129
+ queryKey: ["settings"],
130
+ queryFn: () => fetchApi("/settings/")
131
+ });
132
+
133
+ const { data: enrollmentStatus } = useQuery({
134
+ queryKey: ["enroll-status", employeeId],
135
+ queryFn: () => fetchApi(`/enrollment/status/${employeeId}`),
136
+ enabled: !!employeeId
137
+ });
138
+
139
+ // Settings parsing map
140
+ const settingsMap = useMemo(() => {
141
+ const map: Record<string, string> = {};
142
+ if (settings) {
143
+ settings.forEach((s: any) => {
144
+ map[s.key] = s.value;
145
+ });
146
+ }
147
+ return map;
148
+ }, [settings]);
149
+
150
+ const companyName = settingsMap["COMPANY_NAME"] || "NetraID Enterprise";
151
+ const companyLogo = settingsMap["COMPANY_LOGO"] || "";
152
+ const badgeTheme = settingsMap["BADGE_THEME_COLOR"] || "Navy Blue";
153
+ const badgePattern = settingsMap["BADGE_PATTERN_TYPE"] || "Indian Mandala";
154
+
155
+ const themeStyles = themeStylesMap[badgeTheme as keyof typeof themeStylesMap] || themeStylesMap["Navy Blue"];
156
+
157
+ // Save Settings Mutation
158
+ const saveSettingMutation = useMutation({
159
+ mutationFn: ({ key, value }: { key: string; value: string }) =>
160
+ fetchApi(`/settings/${key}`, { method: "PUT", body: JSON.stringify({ value }) }),
161
+ onSuccess: () => {
162
+ queryClient.invalidateQueries({ queryKey: ["settings"] });
163
+ toast.success("ID Card configuration updated!");
164
+ },
165
+ onError: (err: any) => {
166
+ toast.error(err.message || "Failed to update configuration.");
167
+ }
168
+ });
169
+
170
+ const toggleWfhMutation = useMutation({
171
+ mutationFn: (allow_wfh: boolean) =>
172
+ fetchApi(`/employees/${employeeId}`, {
173
+ method: "PUT",
174
+ body: JSON.stringify({ allow_wfh })
175
+ }),
176
+ onSuccess: (data: any) => {
177
+ queryClient.invalidateQueries({ queryKey: ["employee", employeeId] });
178
+ toast.success(data.allow_wfh ? "WFH permission granted!" : "WFH permission revoked.");
179
+ },
180
+ onError: (err: any) => {
181
+ toast.error(err.message || "Failed to update WFH permission.");
182
+ },
183
+ onSettled: () => {
184
+ setUpdatingWfh(false);
185
+ }
186
+ });
187
+
188
+ const handleToggleWfh = () => {
189
+ setUpdatingWfh(true);
190
+ toggleWfhMutation.mutate(!employee.allow_wfh);
191
+ };
192
+
193
+ // Departments query for dropdown list
194
+ const { data: departments } = useQuery({
195
+ queryKey: ["departments"],
196
+ queryFn: () => fetchApi("/departments/")
197
+ });
198
+
199
+ // Edit employee details states
200
+ const [showEditDialog, setShowEditDialog] = useState(false);
201
+ const [editName, setEditName] = useState("");
202
+ const [editEmail, setEditEmail] = useState("");
203
+ const [editPhone, setEditPhone] = useState("");
204
+ const [editDesignation, setEditDesignation] = useState("");
205
+ const [editJoiningDate, setEditJoiningDate] = useState("");
206
+ const [editStatus, setEditStatus] = useState("Active");
207
+ const [editDeptId, setEditDeptId] = useState("");
208
+
209
+ const handleOpenEditDialog = () => {
210
+ if (!employee) return;
211
+ setEditName(employee.name || "");
212
+ setEditEmail(employee.email || "");
213
+ setEditPhone(employee.phone || "");
214
+ setEditDesignation(employee.designation || "");
215
+ setEditJoiningDate(employee.joining_date || "");
216
+ setEditStatus(employee.status || "Active");
217
+ setEditDeptId(employee.department_id ? employee.department_id.toString() : "");
218
+ setShowEditDialog(true);
219
+ };
220
+
221
+ const updateEmployeeMutation = useMutation({
222
+ mutationFn: (payload: any) =>
223
+ fetchApi(`/employees/${employeeId}`, {
224
+ method: "PUT",
225
+ body: JSON.stringify(payload)
226
+ }),
227
+ onSuccess: () => {
228
+ queryClient.invalidateQueries({ queryKey: ["employee", employeeId] });
229
+ toast.success("Employee profile updated successfully!");
230
+ setShowEditDialog(false);
231
+ },
232
+ onError: (err: any) => {
233
+ toast.error(err.message || "Failed to update employee details.");
234
+ }
235
+ });
236
+
237
+ const handleEditSubmit = (e: React.FormEvent) => {
238
+ e.preventDefault();
239
+ const payload = {
240
+ name: editName,
241
+ email: editEmail,
242
+ phone: editPhone || null,
243
+ designation: editDesignation || null,
244
+ joining_date: editJoiningDate,
245
+ status: editStatus,
246
+ department_id: editDeptId ? parseInt(editDeptId) : null
247
+ };
248
+ updateEmployeeMutation.mutate(payload);
249
+ };
250
+
251
+ const handleSettingChange = (key: string, value: string) => {
252
+ saveSettingMutation.mutate({ key, value });
253
+ };
254
+
255
+ // SVG Badge Watermark helper component
256
+ const BadgeWatermark = ({ type }: { type: string }) => {
257
+ switch (type) {
258
+ case "Indian Mandala":
259
+ return (
260
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-[0.06] text-slate-800 dark:text-slate-100 z-0 overflow-hidden badge-watermark-container">
261
+ <svg className="w-[110%] h-[110%]" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.4">
262
+ <circle cx="50" cy="50" r="42" strokeDasharray="1 1.5" />
263
+ <circle cx="50" cy="50" r="35" />
264
+ <circle cx="50" cy="50" r="28" strokeDasharray="0.5 1" />
265
+ <circle cx="50" cy="50" r="21" />
266
+ <circle cx="50" cy="50" r="14" strokeDasharray="1 1" />
267
+ <circle cx="50" cy="50" r="7" />
268
+ {Array.from({ length: 24 }).map((_, i) => {
269
+ const angle = (i * 15 * Math.PI) / 180;
270
+ const x1 = 50 + 7 * Math.cos(angle);
271
+ const y1 = 50 + 7 * Math.sin(angle);
272
+ const x2 = 50 + 35 * Math.cos(angle);
273
+ const y2 = 50 + 35 * Math.sin(angle);
274
+ const cx1 = 50 + 20 * Math.cos(angle - 0.08);
275
+ const cy1 = 50 + 20 * Math.sin(angle - 0.08);
276
+ return (
277
+ <g key={i}>
278
+ <line x1={x1} y1={y1} x2={x2} y2={y2} />
279
+ <path d={`M ${x1} ${y1} Q ${cx1} ${cy1} ${x2} ${y2}`} strokeWidth="0.25" />
280
+ </g>
281
+ );
282
+ })}
283
+ </svg>
284
+ </div>
285
+ );
286
+ case "Corporate Waves":
287
+ return (
288
+ <div className="absolute inset-0 pointer-events-none opacity-[0.05] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
289
+ <svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
290
+ <path d="M-20,40 C20,20 40,60 60,40 C80,20 100,60 120,40" />
291
+ <path d="M-20,50 C20,30 40,70 60,50 C80,30 100,70 120,50" strokeDasharray="1 1" />
292
+ <path d="M-20,60 C20,40 40,80 60,60 C80,40 100,80 120,60" />
293
+ <path d="M-20,70 C20,50 40,90 60,70 C80,50 100,90 120,70" strokeDasharray="0.5 1" />
294
+ </svg>
295
+ </div>
296
+ );
297
+ case "Cyber Grid":
298
+ return (
299
+ <div className="absolute inset-0 pointer-events-none opacity-[0.03] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
300
+ <svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
301
+ {Array.from({ length: 11 }).map((_, i) => (
302
+ <g key={i}>
303
+ <line x1="0" y1={i * 10} x2="100" y2={i * 10} />
304
+ <line x1={i * 10} y1="0" x2={i * 10} y2="100" />
305
+ </g>
306
+ ))}
307
+ </svg>
308
+ </div>
309
+ );
310
+ case "None":
311
+ default:
312
+ return null;
313
+ }
314
+ };
315
+
316
+ // High-Res Canvas Badge Generator
317
+ const handleDownloadBadge = async () => {
318
+ if (!employee) return;
319
+ try {
320
+ const canvas = document.createElement("canvas");
321
+ const ctx = canvas.getContext("2d");
322
+ if (!ctx) return;
323
+
324
+ canvas.width = 600;
325
+ canvas.height = 900;
326
+
327
+ // 1. Background round rect
328
+ ctx.fillStyle = "#ffffff";
329
+ ctx.beginPath();
330
+ if (typeof ctx.roundRect === "function") {
331
+ ctx.roundRect(0, 0, 600, 900, 30);
332
+ } else {
333
+ const x = 0, y = 0, width = 600, height = 900, radius = 30;
334
+ ctx.moveTo(x + radius, y);
335
+ ctx.lineTo(x + width - radius, y);
336
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
337
+ ctx.lineTo(x + width, y + height - radius);
338
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
339
+ ctx.lineTo(x + radius, y + height);
340
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
341
+ ctx.lineTo(x, y + radius);
342
+ ctx.quadraticCurveTo(x, y, x + radius, y);
343
+ }
344
+ ctx.fill();
345
+ ctx.strokeStyle = "#cbd5e1";
346
+ ctx.lineWidth = 4;
347
+ ctx.stroke();
348
+
349
+ // 2. Draw watermark patterns on Canvas
350
+ const cx = 300;
351
+ const cy = 450;
352
+ if (badgePattern === "Indian Mandala") {
353
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.08)";
354
+ ctx.lineWidth = 1.5;
355
+ ctx.beginPath(); ctx.arc(cx, cy, 252, 0, Math.PI * 2); ctx.stroke();
356
+ ctx.beginPath(); ctx.arc(cx, cy, 210, 0, Math.PI * 2); ctx.stroke();
357
+ ctx.beginPath(); ctx.arc(cx, cy, 168, 0, Math.PI * 2); ctx.stroke();
358
+ ctx.beginPath(); ctx.arc(cx, cy, 126, 0, Math.PI * 2); ctx.stroke();
359
+ ctx.beginPath(); ctx.arc(cx, cy, 84, 0, Math.PI * 2); ctx.stroke();
360
+ ctx.beginPath(); ctx.arc(cx, cy, 42, 0, Math.PI * 2); ctx.stroke();
361
+ for (let i = 0; i < 24; i++) {
362
+ const angle = (i * 15 * Math.PI) / 180;
363
+ ctx.beginPath();
364
+ ctx.moveTo(cx + 42 * Math.cos(angle), cy + 42 * Math.sin(angle));
365
+ ctx.lineTo(cx + 210 * Math.cos(angle), cy + 210 * Math.sin(angle));
366
+ ctx.stroke();
367
+ }
368
+ } else if (badgePattern === "Corporate Waves") {
369
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.06)";
370
+ ctx.lineWidth = 2.5;
371
+ ctx.beginPath();
372
+ ctx.moveTo(-50, 420);
373
+ ctx.bezierCurveTo(150, 220, 350, 620, 650, 420);
374
+ ctx.stroke();
375
+ ctx.beginPath();
376
+ ctx.moveTo(-50, 500);
377
+ ctx.bezierCurveTo(150, 300, 350, 700, 650, 500);
378
+ ctx.stroke();
379
+ } else if (badgePattern === "Cyber Grid") {
380
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.04)";
381
+ ctx.lineWidth = 1;
382
+ for (let i = 0; i <= 900; i += 60) {
383
+ ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(600, i); ctx.stroke();
384
+ }
385
+ for (let i = 0; i <= 600; i += 60) {
386
+ ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, 900); ctx.stroke();
387
+ }
388
+ }
389
+
390
+ // 3. Draw gradient header
391
+ const gradient = ctx.createLinearGradient(0, 0, 600, 0);
392
+ gradient.addColorStop(0, themeStyles.headerHex1);
393
+ gradient.addColorStop(1, themeStyles.headerHex2);
394
+ ctx.fillStyle = gradient;
395
+ ctx.beginPath();
396
+ if (typeof ctx.roundRect === "function") {
397
+ ctx.roundRect(0, 0, 600, 200, [30, 30, 0, 0]);
398
+ } else {
399
+ const x = 0, y = 0, width = 600, height = 200, radius = 30;
400
+ ctx.moveTo(x + radius, y);
401
+ ctx.lineTo(x + width - radius, y);
402
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
403
+ ctx.lineTo(x + width, y + height);
404
+ ctx.lineTo(x, y + height);
405
+ ctx.lineTo(x, y + radius);
406
+ ctx.quadraticCurveTo(x, y, x + radius, y);
407
+ }
408
+ ctx.fill();
409
+
410
+ // Lanyard punch hole
411
+ ctx.fillStyle = "#f1f5f9";
412
+ ctx.beginPath();
413
+ if (typeof ctx.roundRect === "function") {
414
+ ctx.roundRect(260, 20, 80, 20, 10);
415
+ } else {
416
+ ctx.rect(260, 20, 80, 20);
417
+ }
418
+ ctx.fill();
419
+ ctx.fillStyle = "#0f172a";
420
+ ctx.beginPath();
421
+ if (typeof ctx.roundRect === "function") {
422
+ ctx.roundRect(270, 25, 60, 10, 5);
423
+ } else {
424
+ ctx.rect(270, 25, 60, 10);
425
+ }
426
+ ctx.fill();
427
+
428
+ // Image loader
429
+ const loadImage = (src: string): Promise<HTMLImageElement> => {
430
+ return new Promise((resolve, reject) => {
431
+ const img = new Image();
432
+ img.crossOrigin = "anonymous";
433
+ img.onload = () => resolve(img);
434
+ img.onerror = () => reject(new Error("Failed to load: " + src));
435
+ img.src = src;
436
+ });
437
+ };
438
+
439
+ const baseUrl = getBackendUrl().replace("/api/v1", "");
440
+ const photoSrc = `${baseUrl}/uploads/${employee.employee_id}/front.jpg?t=${photoTimestamp}`;
441
+ const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${employee.employee_id}`;
442
+
443
+ let logoImg: HTMLImageElement | null = null;
444
+ if (companyLogo) {
445
+ try {
446
+ logoImg = await loadImage(companyLogo);
447
+ } catch {}
448
+ }
449
+
450
+ let photoImg: HTMLImageElement | null = null;
451
+ try {
452
+ photoImg = await loadImage(photoSrc);
453
+ } catch {}
454
+
455
+ let qrImg: HTMLImageElement | null = null;
456
+ try {
457
+ qrImg = await loadImage(qrSrc);
458
+ } catch {}
459
+
460
+ // Logo rendering
461
+ if (logoImg) {
462
+ const logoAspectRatio = logoImg.width / logoImg.height;
463
+ const logoHeight = 45;
464
+ const logoWidth = logoHeight * logoAspectRatio;
465
+ ctx.drawImage(logoImg, 50, 75, logoWidth, logoHeight);
466
+ ctx.fillStyle = "#ffffff";
467
+ ctx.font = "bold 24px sans-serif";
468
+ ctx.textAlign = "left";
469
+ ctx.fillText(companyName, 65 + logoWidth, 106);
470
+ } else {
471
+ ctx.fillStyle = themeStyles.primaryHex;
472
+ ctx.font = "black 28px sans-serif";
473
+ ctx.textAlign = "center";
474
+ ctx.fillText("NETRAID", 300, 100);
475
+ ctx.fillStyle = "#ffffff";
476
+ ctx.font = "bold 16px sans-serif";
477
+ ctx.fillText(companyName.toUpperCase(), 300, 130);
478
+ }
479
+
480
+ // Profile avatar container
481
+ const photoX = 200;
482
+ const photoY = 220;
483
+ const photoSize = 200;
484
+
485
+ ctx.strokeStyle = themeStyles.primaryHex;
486
+ ctx.lineWidth = 6;
487
+ ctx.beginPath();
488
+ ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2 + 6, 0, Math.PI * 2);
489
+ ctx.stroke();
490
+
491
+ ctx.save();
492
+ ctx.beginPath();
493
+ ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2, 0, Math.PI * 2);
494
+ ctx.clip();
495
+ if (photoImg) {
496
+ ctx.drawImage(photoImg, photoX, photoY, photoSize, photoSize);
497
+ } else {
498
+ ctx.fillStyle = "#f1f5f9";
499
+ ctx.fillRect(photoX, photoY, photoSize, photoSize);
500
+ ctx.fillStyle = "#94a3b8";
501
+ ctx.font = "bold 80px sans-serif";
502
+ ctx.textAlign = "center";
503
+ ctx.textBaseline = "middle";
504
+ ctx.fillText("?", photoX + photoSize / 2, photoY + photoSize / 2);
505
+ }
506
+ ctx.restore();
507
+
508
+ // Holographic official seal
509
+ ctx.save();
510
+ const sealX = photoX + photoSize - 35;
511
+ const sealY = photoY + photoSize - 35;
512
+ const sealSize = 45;
513
+ const sealGrad = ctx.createLinearGradient(sealX, sealY, sealX + sealSize, sealY + sealSize);
514
+ sealGrad.addColorStop(0, "#fbbf24");
515
+ sealGrad.addColorStop(0.5, "#fb923c");
516
+ sealGrad.addColorStop(1, "#fde047");
517
+ ctx.fillStyle = sealGrad;
518
+ ctx.beginPath();
519
+ ctx.arc(sealX + sealSize / 2, sealY + sealSize / 2, sealSize / 2, 0, Math.PI * 2);
520
+ ctx.fill();
521
+ ctx.strokeStyle = "#ffffff";
522
+ ctx.lineWidth = 2.5;
523
+ ctx.stroke();
524
+ ctx.strokeStyle = "#451a03";
525
+ ctx.lineWidth = 3.5;
526
+ ctx.beginPath();
527
+ ctx.moveTo(sealX + 13, sealY + 22);
528
+ ctx.lineTo(sealX + 20, sealY + 29);
529
+ ctx.lineTo(sealX + 32, sealY + 16);
530
+ ctx.stroke();
531
+ ctx.restore();
532
+
533
+ // Text details
534
+ ctx.fillStyle = "#0f172a";
535
+ ctx.font = "bold 32px sans-serif";
536
+ ctx.textAlign = "center";
537
+ ctx.fillText(employee.name.toUpperCase(), 300, 480);
538
+
539
+ ctx.fillStyle = themeStyles.primaryHex;
540
+ ctx.font = "bold 20px sans-serif";
541
+ ctx.fillText(employee.designation?.toUpperCase() || "STAFF MEMBER", 300, 515);
542
+
543
+ ctx.strokeStyle = "#f1f5f9";
544
+ ctx.lineWidth = 2;
545
+ ctx.beginPath(); ctx.moveTo(100, 545); ctx.lineTo(500, 545); ctx.stroke();
546
+
547
+ // Metadata labels
548
+ ctx.textAlign = "left";
549
+ ctx.fillStyle = "#64748b";
550
+ ctx.font = "bold 14px sans-serif";
551
+ ctx.fillText("EMPLOYEE ID", 100, 580);
552
+ ctx.fillText("DEPARTMENT", 320, 580);
553
+ ctx.fillText("DATE OF JOIN", 100, 640);
554
+ ctx.fillText("STATUS", 320, 640);
555
+
556
+ // Metadata values
557
+ ctx.fillStyle = "#0f172a";
558
+ ctx.font = "bold 18px sans-serif";
559
+ ctx.fillText(employee.employee_id, 100, 605);
560
+ ctx.fillText(employee.department?.name?.toUpperCase() || "GENERAL", 320, 605);
561
+
562
+ const joinDate = employee.joining_date ? new Date(employee.joining_date).toLocaleDateString("en-US", {
563
+ year: "numeric", month: "short", day: "numeric"
564
+ }) : "N/A";
565
+ ctx.fillText(joinDate.toUpperCase(), 100, 665);
566
+ ctx.fillStyle = themeStyles.primaryHex;
567
+ ctx.fillText("VERIFIED", 320, 665);
568
+
569
+ // QR Code
570
+ const qrSize = 130;
571
+ const qrX = 235;
572
+ const qrY = 710;
573
+
574
+ ctx.fillStyle = "#f8fafc";
575
+ ctx.beginPath();
576
+ if (typeof ctx.roundRect === "function") {
577
+ ctx.roundRect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30, 15);
578
+ } else {
579
+ ctx.rect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30);
580
+ }
581
+ ctx.fill();
582
+ ctx.strokeStyle = "#e2e8f0";
583
+ ctx.lineWidth = 2;
584
+ ctx.stroke();
585
+
586
+ if (qrImg) {
587
+ ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
588
+ } else {
589
+ ctx.strokeStyle = "#cbd5e1";
590
+ ctx.strokeRect(qrX, qrY, qrSize, qrSize);
591
+ ctx.fillStyle = "#94a3b8";
592
+ ctx.font = "12px sans-serif";
593
+ ctx.textAlign = "center";
594
+ ctx.fillText("QR CODE", qrX + qrSize / 2, qrY + qrSize / 2);
595
+ }
596
+
597
+ ctx.fillStyle = "#64748b";
598
+ ctx.font = "bold 12px sans-serif";
599
+ ctx.textAlign = "center";
600
+ ctx.fillText("SCAN AS BACKUP IF KIOSK FACE RECOGNITION FAILS", 300, 875);
601
+
602
+ // Trigger download
603
+ const dataUrl = canvas.toDataURL("image/png");
604
+ const link = document.createElement("a");
605
+ link.download = `ID_Card_${employee.employee_id}.png`;
606
+ link.href = dataUrl;
607
+ link.click();
608
+ toast.success("Badge PNG downloaded successfully!");
609
+ } catch (err: any) {
610
+ console.error(err);
611
+ toast.error("Failed to generate download: " + err.message);
612
+ }
613
+ };
614
+
615
+ if (loadingEmployee || loadingAttendance) {
616
+ return (
617
+ <SidebarLayout>
618
+ <div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
619
+ <div className="skeleton h-10 w-48" />
620
+ <div className="skeleton h-64 w-full max-w-4xl" />
621
+ </div>
622
+ </SidebarLayout>
623
+ );
624
+ }
625
+
626
+ if (!employee) {
627
+ return (
628
+ <SidebarLayout>
629
+ <div className="text-center py-20 bg-white border border-slate-200 rounded-3xl p-8 max-w-md mx-auto space-y-4">
630
+ <AlertCircle className="w-12 h-12 text-rose-500 mx-auto" />
631
+ <h2 className="text-lg font-bold text-slate-900">Personnel Record Not Found</h2>
632
+ <p className="text-xs text-slate-500 leading-normal">The requested employee registration could not be located in the central secure database.</p>
633
+ <button onClick={() => router.push("/employees")} className="btn-primary text-xs uppercase py-2.5 px-5 rounded-xl">Back to Employees</button>
634
+ </div>
635
+ </SidebarLayout>
636
+ );
637
+ }
638
+
639
+ // Attendance stats counts
640
+ const totalDays = attendance?.length || 0;
641
+ const presentDays = attendance?.filter((r: any) => r.status === "Present").length || 0;
642
+ const lateDays = attendance?.filter((r: any) => r.status === "Late").length || 0;
643
+ const halfDays = attendance?.filter((r: any) => r.status === "Half Day").length || 0;
644
+ const attendanceScore = totalDays > 0 ? Math.round(((presentDays + lateDays + halfDays * 0.5) / totalDays) * 100) : 0;
645
+
646
+ return (
647
+ <SidebarLayout>
648
+ <div className="space-y-6 max-w-7xl page-enter relative text-slate-800 print-reset-container">
649
+
650
+ {/* Breadcrumb Header */}
651
+ <div className="flex items-center justify-between pb-5 border-b border-slate-250/60 no-print">
652
+ <div className="space-y-1.5">
653
+ <button
654
+ onClick={() => router.push("/employees")}
655
+ className="flex items-center gap-1.5 text-slate-500 hover:text-slate-900 transition-colors text-[11px] font-semibold uppercase tracking-wider"
656
+ >
657
+ <ChevronLeft className="w-3.5 h-3.5" />
658
+ Back to Employees
659
+ </button>
660
+ <div className="flex items-center gap-3">
661
+ <h1 className="text-xl font-black text-slate-900 tracking-tight leading-none">Employee Profile</h1>
662
+ <span className={`badge ${employee.status === "Active" ? "badge-emerald" : "badge-slate"} flex items-center gap-1`}>
663
+ {employee.status === "Active" && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />}
664
+ {employee.status}
665
+ </span>
666
+ </div>
667
+ </div>
668
+
669
+ <div className="flex items-center gap-2">
670
+ <button
671
+ onClick={handleOpenEditDialog}
672
+ className="flex items-center gap-2 text-[11.5px] font-bold text-slate-700 hover:text-slate-900 bg-white hover:bg-slate-50 border border-slate-200 px-4 py-2.5 rounded-xl transition-all cursor-pointer shadow-sm"
673
+ >
674
+ <Edit className="w-3.5 h-3.5 text-slate-500" />
675
+ Edit Details
676
+ </button>
677
+ <button
678
+ onClick={() => router.push(`/enroll/${employee.id}`)}
679
+ className="flex items-center gap-2 text-[11.5px] font-bold text-blue-600 hover:text-white bg-white hover:bg-blue-600 border border-blue-200 hover:border-blue-600 px-4 py-2.5 rounded-xl transition-all cursor-pointer shadow-sm"
680
+ >
681
+ <Camera className="w-3.5 h-3.5" />
682
+ Biometric Enrollment
683
+ </button>
684
+ </div>
685
+ </div>
686
+
687
+ {/* ─── Profile Content Grid ─── */}
688
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start print-reset-container">
689
+
690
+ {/* LEFT AREA: Employee Details & Ledger Table (lg:col-span-8) */}
691
+ <div className="lg:col-span-7 space-y-6 no-print">
692
+
693
+ {/* Profile Hero Card */}
694
+ <div className="bg-white border border-slate-200 rounded-3xl overflow-hidden shadow-xs relative">
695
+ {/* Cover Gradient Banner */}
696
+ <div className={`h-24 ${themeStyles.headerBg} relative`}>
697
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.08),transparent_80%)]" />
698
+ </div>
699
+
700
+ {/* Avatar & Key details */}
701
+ <div className="px-6 pb-6 relative">
702
+ <div className="flex flex-col sm:flex-row sm:items-end gap-4 -mt-10 mb-4">
703
+ {/* Profile photo */}
704
+ <label className={`relative w-20 h-20 rounded-2xl p-0.5 bg-gradient-to-tr ${themeStyles.photoBorder} shadow-md shrink-0 cursor-pointer group`}>
705
+ <input
706
+ type="file"
707
+ accept="image/*"
708
+ className="hidden"
709
+ onChange={handlePhotoUpload}
710
+ disabled={photoUploading}
711
+ />
712
+ <div className="w-full h-full rounded-2xl overflow-hidden border-2 border-white bg-slate-100 relative">
713
+ <img
714
+ src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${employee.employee_id}/front.jpg?t=${photoTimestamp}`}
715
+ alt={employee.name}
716
+ onError={(e) => {
717
+ (e.target as HTMLImageElement).src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%2394a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
718
+ }}
719
+ className="w-full h-full object-cover"
720
+ />
721
+
722
+ {/* Hover Overlay */}
723
+ <div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200">
724
+ <Camera className="w-4 h-4 text-white" />
725
+ <span className="text-[7px] font-bold mt-0.5 uppercase tracking-wider text-white">Edit Photo</span>
726
+ </div>
727
+
728
+ {/* Loading Spinner */}
729
+ {photoUploading && (
730
+ <div className="absolute inset-0 bg-black/75 flex items-center justify-center text-white">
731
+ <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
732
+ </div>
733
+ )}
734
+ </div>
735
+ </label>
736
+
737
+ <div className="space-y-1 min-w-0">
738
+ <h2 className="text-lg font-black text-slate-900 dark:text-white leading-none truncate">{employee.name}</h2>
739
+ <p className="text-xs font-semibold text-slate-550 font-mono flex items-center gap-1.5">
740
+ <span className="bg-slate-100 dark:bg-white/[0.03] px-1.5 py-0.5 rounded border border-slate-200 dark:border-white/5 dark:text-slate-350">{employee.employee_id}</span>
741
+ <span>·</span>
742
+ <span className="text-slate-700 dark:text-slate-300">{employee.designation || "Staff Member"}</span>
743
+ </p>
744
+ <p className="text-[10px] text-slate-400 dark:text-slate-450 font-bold uppercase tracking-wider">
745
+ {employee.department?.name || "General Department"}
746
+ </p>
747
+ </div>
748
+ </div>
749
+
750
+ {/* Data fields grid */}
751
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-[11.5px] border-t border-slate-100 dark:border-white/5 pt-4">
752
+ <div className="flex items-center gap-2 bg-slate-50/50 dark:bg-white/[0.03] p-3 rounded-xl border border-slate-150 dark:border-white/5">
753
+ <Mail className="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" />
754
+ <span className="truncate font-medium text-slate-700 dark:text-slate-300" title={employee.email}>{employee.email}</span>
755
+ </div>
756
+ <div className="flex items-center gap-2 bg-slate-50/50 dark:bg-white/[0.03] p-3 rounded-xl border border-slate-150 dark:border-white/5">
757
+ <Phone className="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" />
758
+ <span className="font-medium text-slate-700 dark:text-slate-300">{employee.phone || "No mobile registered"}</span>
759
+ </div>
760
+ <div className="flex items-center gap-2 bg-slate-50/50 dark:bg-white/[0.03] p-3 rounded-xl border border-slate-150 dark:border-white/5">
761
+ <Calendar className="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" />
762
+ <span className="font-medium text-slate-700 dark:text-slate-300">Joined: <span className="font-mono">{employee.joining_date}</span></span>
763
+ </div>
764
+ </div>
765
+
766
+ {/* WFH Permission Toggle */}
767
+ <div className="flex items-center justify-between border-t border-slate-100 dark:border-white/5 pt-4 mt-4 text-xs">
768
+ <div className="space-y-0.5">
769
+ <span className="font-bold text-slate-550 dark:text-slate-400 uppercase tracking-wider text-[9.5px]">Work From Home (WFH) Permission</span>
770
+ <p className="text-[10px] text-slate-450 dark:text-slate-500 font-semibold leading-none">Bypasses geofencing restrictions for this employee</p>
771
+ </div>
772
+ <button
773
+ type="button"
774
+ disabled={updatingWfh}
775
+ onClick={handleToggleWfh}
776
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
777
+ employee.allow_wfh ? "bg-emerald-500" : "bg-slate-200 dark:bg-slate-800"
778
+ } ${updatingWfh ? "opacity-60 cursor-not-allowed" : ""}`}
779
+ >
780
+ <span
781
+ className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-xs transition duration-200 ease-in-out ${
782
+ employee.allow_wfh ? "translate-x-4" : "translate-x-0"
783
+ }`}
784
+ />
785
+ </button>
786
+ </div>
787
+
788
+ {/* Attendance Rate Progress Block */}
789
+ <div className="border-t border-slate-100 pt-4 mt-4">
790
+ <div className="flex justify-between items-center text-xs mb-1.5">
791
+ <span className="font-bold text-slate-500 uppercase tracking-wider text-[9.5px]">Attendance Score</span>
792
+ <span className={`font-black ${themeStyles.accentText} font-mono`}>
793
+ {attendanceScore}%
794
+ </span>
795
+ </div>
796
+ <div className="h-2 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
797
+ <div
798
+ className={`h-full bg-gradient-to-r ${themeStyles.photoBorder}`}
799
+ style={{ width: `${attendanceScore}%` }}
800
+ />
801
+ </div>
802
+ <p className="text-[10px] text-slate-450 mt-1 font-semibold">
803
+ {totalDays === 0
804
+ ? "No attendance records registered yet."
805
+ : attendanceScore >= 90
806
+ ? "Exemplary consistency - Security clearance status is active."
807
+ : attendanceScore >= 75
808
+ ? "Standard compliance - Maintained within acceptable limits."
809
+ : "Requires review - Attendance score falls below benchmark."}
810
+ </p>
811
+ </div>
812
+ </div>
813
+ </div>
814
+
815
+ {/* Attendance Stats Row */}
816
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
817
+ <div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-slate-400">
818
+ <span className="text-[9.5px] text-slate-400 font-bold uppercase tracking-wider">Logged Days</span>
819
+ <span className="text-xl font-bold text-slate-900 leading-none mt-1.5 font-mono">{totalDays}</span>
820
+ </div>
821
+ <div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-emerald-500">
822
+ <span className="text-[9.5px] text-emerald-600 font-bold uppercase tracking-wider">Present</span>
823
+ <span className="text-xl font-bold text-emerald-600 leading-none mt-1.5 font-mono">{presentDays}</span>
824
+ </div>
825
+ <div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-amber-500">
826
+ <span className="text-[9.5px] text-amber-600 font-bold uppercase tracking-wider">Late Arrivals</span>
827
+ <span className="text-xl font-bold text-amber-600 leading-none mt-1.5 font-mono">{lateDays}</span>
828
+ </div>
829
+ <div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-indigo-650">
830
+ <span className="text-[9.5px] text-indigo-650 font-bold uppercase tracking-wider">Half Days</span>
831
+ <span className="text-xl font-bold text-indigo-650 leading-none mt-1.5 font-mono">{halfDays}</span>
832
+ </div>
833
+ </div>
834
+
835
+ {/* Attendance History Ledger */}
836
+ <div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-4">
837
+ <div className="flex items-center justify-between pb-2">
838
+ <h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest font-mono">Attendance Ledger (Past 30 Days)</h3>
839
+ </div>
840
+ <div className="overflow-x-auto border border-slate-200 rounded-2xl">
841
+ <table className="w-full text-left border-collapse text-[11.5px]">
842
+ <thead>
843
+ <tr className="border-b border-slate-200 bg-slate-50 text-slate-500 uppercase tracking-wider font-mono">
844
+ <th className="py-2.5 px-4 font-semibold">Date</th>
845
+ <th className="py-2.5 px-4 font-semibold">Check In</th>
846
+ <th className="py-2.5 px-4 font-semibold">Check Out</th>
847
+ <th className="py-2.5 px-4 font-semibold">Hours Worked</th>
848
+ <th className="py-2.5 px-4 font-semibold text-center">Status</th>
849
+ </tr>
850
+ </thead>
851
+ <tbody className="divide-y divide-slate-100 text-slate-700">
852
+ {!attendance || attendance.length === 0 ? (
853
+ <tr>
854
+ <td colSpan={5} className="py-12 text-center text-slate-400 italic">No attendance records stored for this user.</td>
855
+ </tr>
856
+ ) : (
857
+ attendance.map((rec: any) => (
858
+ <tr key={rec.id} className="hover:bg-slate-50/40 transition-colors">
859
+ <td className="py-2.5 px-4 font-mono text-slate-550">{rec.date}</td>
860
+ <td className="py-2.5 px-4 font-mono text-slate-800">
861
+ {rec.check_in ? parseDateTime(rec.check_in)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"}) : "—"}
862
+ </td>
863
+ <td className="py-2.5 px-4 font-mono text-slate-800">
864
+ {rec.check_out ? parseDateTime(rec.check_out)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"}) : "—"}
865
+ </td>
866
+ <td className="py-2.5 px-4 font-mono text-slate-650">
867
+ {rec.working_hours ? `${rec.working_hours.toFixed(1)} hrs` : "—"}
868
+ </td>
869
+ <td className="py-2.5 px-4 text-center">
870
+ <span className={`inline-block text-[8.5px] font-semibold px-2 py-0.5 rounded-full border ${
871
+ rec.status === "Present" ? "bg-emerald-50 border-emerald-250 text-emerald-700" :
872
+ rec.status === "Late" ? "bg-amber-50 border-amber-250 text-amber-700" :
873
+ rec.status === "Half Day" ? "bg-indigo-50 border-indigo-250 text-indigo-750" :
874
+ "bg-rose-50 border-rose-250 text-rose-700"
875
+ }`}>
876
+ {rec.status}
877
+ </span>
878
+ </td>
879
+ </tr>
880
+ ))
881
+ )}
882
+ </tbody>
883
+ </table>
884
+ </div>
885
+ </div>
886
+
887
+ {/* Attendance Heatmap Grid */}
888
+ <AttendanceHeatmap employeeId={employee.id} />
889
+ </div>
890
+
891
+ {/* RIGHT AREA: Badge Live Preview & Customizer (lg:col-span-4) */}
892
+ <div className="lg:col-span-5 space-y-6 print-reset-container">
893
+
894
+ {/* ID Badge Live Preview Card */}
895
+ <div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col items-center print-reset-container">
896
+ <div className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mb-4 font-mono no-print">
897
+ Badge Live Preview
898
+ </div>
899
+
900
+ {/* Printable Badge Container */}
901
+ <div
902
+ id="printable-id-card-wrap"
903
+ className="w-[280px] h-[438px] bg-white rounded-[24px] border border-slate-200 shadow-md overflow-hidden relative flex flex-col select-none font-sans"
904
+ >
905
+ {/* Watermark Pattern */}
906
+ <BadgeWatermark type={badgePattern} />
907
+
908
+ {/* Lanyard punch hole detail */}
909
+ <div className="absolute top-3.5 left-1/2 -translate-x-1/2 w-10 h-3 bg-slate-100 rounded-full border border-slate-200/50 flex items-center justify-center pointer-events-none no-print">
910
+ <div className="w-6 h-1 bg-slate-300 rounded-full" />
911
+ </div>
912
+
913
+ {/* Header: Company Name & Logo */}
914
+ <div className={`h-[90px] ${themeStyles.headerBg} relative flex flex-col justify-end px-4.5 pb-2.5 shrink-0`}>
915
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.1),transparent_70%)] pointer-events-none" />
916
+
917
+ <div className="flex items-center gap-2.5 mt-2 relative z-10">
918
+ {companyLogo ? (
919
+ <img
920
+ src={companyLogo}
921
+ alt="Logo"
922
+ className="h-7 max-w-[80px] object-contain shrink-0"
923
+ />
924
+ ) : (
925
+ <div className="w-6 h-6 rounded-lg bg-white/20 backdrop-blur-xs flex items-center justify-center text-white text-[9px] font-black tracking-tighter shadow-sm font-mono shrink-0 border border-white/10">
926
+ NID
927
+ </div>
928
+ )}
929
+ <div className="flex flex-col min-w-0">
930
+ <span className="text-[10.5px] font-black tracking-wider text-white uppercase truncate">
931
+ {companyName}
932
+ </span>
933
+ <span className="text-[7px] font-bold text-white/80 tracking-widest uppercase">
934
+ SECURED IDENTITY CARD
935
+ </span>
936
+ </div>
937
+ </div>
938
+ </div>
939
+
940
+ {/* Body Content */}
941
+ <div className="flex-1 flex flex-col items-center pt-5 px-5 relative bg-transparent z-10">
942
+ {/* Photo container */}
943
+ <div className={`relative w-[100px] h-[100px] rounded-full p-1 bg-gradient-to-tr ${themeStyles.photoBorder} shadow-sm shrink-0`}>
944
+ <div className="w-full h-full rounded-full overflow-hidden border-2 border-white bg-slate-100">
945
+ <img
946
+ src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${employee.employee_id}/front.jpg?t=${photoTimestamp}`}
947
+ alt={employee.name}
948
+ onError={(e) => {
949
+ (e.target as HTMLImageElement).src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%2394a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
950
+ }}
951
+ className="w-full h-full object-cover"
952
+ />
953
+ </div>
954
+ {/* Official checkmark seal */}
955
+ <div className="absolute -bottom-1 -right-1 bg-gradient-to-tr from-amber-400 via-orange-400 to-yellow-300 text-amber-950 font-bold border border-white rounded-full w-5 h-5 flex items-center justify-center shadow-md z-10 pointer-events-none">
956
+ <svg className="w-3 h-3 stroke-amber-950" viewBox="0 0 24 24" fill="none" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
957
+ <polyline points="20 6 9 17 4 12" />
958
+ </svg>
959
+ </div>
960
+ </div>
961
+
962
+ {/* Name and title details */}
963
+ <div className="text-center mt-3 space-y-0.5">
964
+ <h3 className="text-[13.5px] font-extrabold text-slate-900 tracking-tight uppercase leading-tight">
965
+ {employee.name}
966
+ </h3>
967
+ <p className={`text-[9.5px] font-bold ${themeStyles.accentText} tracking-widest uppercase`}>
968
+ {employee.designation || "Staff Member"}
969
+ </p>
970
+ </div>
971
+
972
+ {/* Info table */}
973
+ <div className="grid grid-cols-2 gap-x-3 gap-y-2 w-full border-t border-slate-100 mt-4 pt-3 bg-transparent">
974
+ <div>
975
+ <span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
976
+ Employee ID
977
+ </span>
978
+ <span className="text-[9px] font-extrabold text-slate-800 tracking-tight block">
979
+ {employee.employee_id}
980
+ </span>
981
+ </div>
982
+ <div>
983
+ <span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
984
+ Department
985
+ </span>
986
+ <span className="text-[9px] font-extrabold text-slate-800 tracking-tight block truncate uppercase">
987
+ {employee.department?.name || "General"}
988
+ </span>
989
+ </div>
990
+ <div>
991
+ <span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
992
+ Date of Join
993
+ </span>
994
+ <span className="text-[9px] font-extrabold text-slate-800 tracking-tight block">
995
+ {employee.joining_date}
996
+ </span>
997
+ </div>
998
+ <div>
999
+ <span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
1000
+ Security Status
1001
+ </span>
1002
+ <span className={`text-[9px] font-bold ${themeStyles.accentText} tracking-tight flex items-center gap-1`}>
1003
+ <span className={`w-1.2 h-1.2 rounded-full ${themeStyles.dotColor}`} />
1004
+ VERIFIED
1005
+ </span>
1006
+ </div>
1007
+ </div>
1008
+ </div>
1009
+
1010
+ {/* Footer QR fallback */}
1011
+ <div className="bg-slate-50/80 border-t border-slate-100 h-[100px] flex items-center justify-between px-5 pb-2 shrink-0 z-10">
1012
+ <div className="flex flex-col min-w-0 pr-1.5">
1013
+ <span className="text-[7.5px] font-black text-slate-900 tracking-wider uppercase font-mono">
1014
+ SCAN TO VERIFY
1015
+ </span>
1016
+ <p className="text-[6.5px] text-slate-450 font-medium leading-snug mt-0.5 max-w-[115px] font-mono">
1017
+ Scan this backup barcode QR badge if Kiosk face matching fails.
1018
+ </p>
1019
+ </div>
1020
+ <div className="w-[64px] h-[64px] bg-white rounded-lg border border-slate-200/80 p-1 flex items-center justify-center shadow-2xs shrink-0">
1021
+ <img
1022
+ src={`https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=${employee.employee_id}`}
1023
+ alt="QR"
1024
+ className="w-full h-full object-contain"
1025
+ />
1026
+ </div>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ {/* Actions print/download */}
1031
+ <div className="flex gap-3.5 w-full max-w-[280px] pt-5 no-print">
1032
+ <button
1033
+ onClick={() => window.print()}
1034
+ className={`flex-1 h-10 ${themeStyles.headerBg} hover:opacity-90 text-white font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg active:scale-[0.98] border border-white/10 cursor-pointer`}
1035
+ >
1036
+ <Printer className="w-3.5 h-3.5 text-white/90" />
1037
+ Print ID
1038
+ </button>
1039
+ <button
1040
+ onClick={handleDownloadBadge}
1041
+ className="flex-1 h-10 bg-white hover:bg-slate-50 border border-slate-200 text-slate-800 font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm hover:shadow-md active:scale-[0.98] cursor-pointer"
1042
+ >
1043
+ <Download className="w-3.5 h-3.5 text-slate-650" />
1044
+ Download
1045
+ </button>
1046
+ </div>
1047
+ </div>
1048
+
1049
+ {/* ID Badge Design Editor Card */}
1050
+ <div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-4.5 no-print">
1051
+ <h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest font-mono">ID Badge Customizer</h3>
1052
+
1053
+ {/* Company Name */}
1054
+ <div className="space-y-1.5">
1055
+ <label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Company Name</label>
1056
+ <input
1057
+ type="text"
1058
+ value={companyName}
1059
+ onChange={(e) => handleSettingChange("COMPANY_NAME", e.target.value)}
1060
+ placeholder="Enter organization name"
1061
+ className="input-field h-9 text-[12.5px] bg-white border-slate-200 rounded-xl px-3 focus:border-slate-800 transition-all font-semibold"
1062
+ />
1063
+ </div>
1064
+
1065
+ {/* Company Logo Upload */}
1066
+ <div className="space-y-1.5">
1067
+ <label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Branding Logo</label>
1068
+ {companyLogo ? (
1069
+ <div className="relative w-full h-20 border border-slate-200 rounded-xl overflow-hidden bg-slate-50 flex items-center justify-center p-2 group">
1070
+ <img src={companyLogo} alt="Logo preview" className="max-w-full max-h-full object-contain" />
1071
+ <button
1072
+ type="button"
1073
+ onClick={() => handleSettingChange("COMPANY_LOGO", "")}
1074
+ className="absolute inset-0 bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-bold gap-1 cursor-pointer"
1075
+ >
1076
+ <Trash2 className="w-3.5 h-3.5" />
1077
+ Remove Logo
1078
+ </button>
1079
+ </div>
1080
+ ) : (
1081
+ <label className="w-full h-20 border border-dashed border-slate-350 hover:border-slate-450 rounded-xl flex flex-col items-center justify-center gap-1 cursor-pointer bg-slate-50 hover:bg-slate-100/50 transition-all">
1082
+ <Upload className="w-4 h-4 text-slate-450" />
1083
+ <span className="text-[10px] text-slate-500 font-semibold">Upload Logo (Max 500KB)</span>
1084
+ <input
1085
+ type="file"
1086
+ accept="image/*"
1087
+ className="hidden"
1088
+ disabled={logoUploading}
1089
+ onChange={(e) => {
1090
+ const file = e.target.files?.[0];
1091
+ if (!file) return;
1092
+ if (file.size > 512 * 1025) {
1093
+ toast.error("Logo must be under 500KB");
1094
+ return;
1095
+ }
1096
+ setLogoUploading(true);
1097
+ const reader = new FileReader();
1098
+ reader.onloadend = () => {
1099
+ handleSettingChange("COMPANY_LOGO", reader.result as string);
1100
+ setLogoUploading(false);
1101
+ };
1102
+ reader.readAsDataURL(file);
1103
+ }}
1104
+ />
1105
+ </label>
1106
+ )}
1107
+ </div>
1108
+
1109
+ {/* Theme Color Swatches */}
1110
+ <div className="space-y-1.5">
1111
+ <label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Badge Color Theme</label>
1112
+ <div className="grid grid-cols-2 gap-2">
1113
+ {Object.keys(themeStylesMap).map((themeName) => {
1114
+ const styles = themeStylesMap[themeName as keyof typeof themeStylesMap];
1115
+ const isActive = badgeTheme === themeName;
1116
+ return (
1117
+ <button
1118
+ key={themeName}
1119
+ type="button"
1120
+ onClick={() => handleSettingChange("BADGE_THEME_COLOR", themeName)}
1121
+ className={`flex items-center gap-2 p-2 rounded-xl border text-[11px] font-bold transition-all cursor-pointer ${
1122
+ isActive
1123
+ ? "bg-slate-900 border-slate-900 text-white shadow-xs"
1124
+ : "bg-white border-slate-200 text-slate-700 hover:bg-slate-55 hover:border-slate-300"
1125
+ }`}
1126
+ >
1127
+ <span className={`w-3 h-3 rounded-full shrink-0 ${styles.headerBg}`} />
1128
+ {themeName}
1129
+ </button>
1130
+ );
1131
+ })}
1132
+ </div>
1133
+ </div>
1134
+
1135
+ {/* Background Pattern Type Choices */}
1136
+ <div className="space-y-1.5">
1137
+ <label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Badge Background Pattern</label>
1138
+ <div className="grid grid-cols-2 gap-2">
1139
+ {[
1140
+ { key: "None", label: "None" },
1141
+ { key: "Indian Mandala", label: "Mandala" },
1142
+ { key: "Corporate Waves", label: "Waves" },
1143
+ { key: "Cyber Grid", label: "Grid" }
1144
+ ].map((pattern) => {
1145
+ const isActive = badgePattern === pattern.key;
1146
+ return (
1147
+ <button
1148
+ key={pattern.key}
1149
+ type="button"
1150
+ onClick={() => handleSettingChange("BADGE_PATTERN_TYPE", pattern.key)}
1151
+ className={`flex flex-col items-center justify-center p-2 rounded-xl border text-center transition-all cursor-pointer h-12 ${
1152
+ isActive
1153
+ ? "bg-slate-900 border-slate-900 text-white shadow-xs"
1154
+ : "bg-white border-slate-200 text-slate-700 hover:bg-slate-55 hover:border-slate-300"
1155
+ }`}
1156
+ >
1157
+ <span className="text-[10px] font-bold tracking-wide">{pattern.label}</span>
1158
+ </button>
1159
+ );
1160
+ })}
1161
+ </div>
1162
+ </div>
1163
+
1164
+ <div className="flex items-start gap-2.5 p-3 rounded-xl bg-slate-50 border border-slate-200 text-slate-650 text-[10px]">
1165
+ <Clock className="w-3.5 h-3.5 text-slate-450 shrink-0 mt-0.5" />
1166
+ <span>
1167
+ Adjusting these configurations updates the organization-wide card design template automatically.
1168
+ </span>
1169
+ </div>
1170
+ </div>
1171
+ </div>
1172
+ </div>
1173
+
1174
+ {/* Print Styling Injected locally for single page clean print */}
1175
+ <style dangerouslySetInnerHTML={{ __html: `
1176
+ @media print {
1177
+ /* Hide sidebar, headers, footers and any elements marked no-print */
1178
+ aside, header, footer, .no-print, button, input, select, [role="navigation"], .ambient-bg, .mesh-bg {
1179
+ display: none !important;
1180
+ }
1181
+
1182
+ @page {
1183
+ size: portrait;
1184
+ margin: 0;
1185
+ }
1186
+
1187
+ /* Reset container layout models so they don't center, shift or clip the content */
1188
+ html, body, html.dark, body.dark {
1189
+ margin: 0 !important;
1190
+ padding: 0 !important;
1191
+ width: 100% !important;
1192
+ height: 100% !important;
1193
+ overflow: hidden !important;
1194
+ background-color: white !important;
1195
+ background: white !important;
1196
+ position: relative !important;
1197
+ }
1198
+
1199
+ /* Reset only parent layout hierarchy, leaving internal elements of card intact */
1200
+ main,
1201
+ body > div,
1202
+ #sidebar-layout-container,
1203
+ .sidebar-layout-content,
1204
+ .print-reset-container,
1205
+ .page-enter,
1206
+ .dark main,
1207
+ .dark body > div,
1208
+ .dark #sidebar-layout-container,
1209
+ .dark .sidebar-layout-content,
1210
+ .dark .print-reset-container,
1211
+ .dark .page-enter {
1212
+ border: none !important;
1213
+ box-shadow: none !important;
1214
+ background: transparent !important;
1215
+ background-color: transparent !important;
1216
+ padding: 0 !important;
1217
+ margin: 0 !important;
1218
+ height: auto !important;
1219
+ min-height: 0 !important;
1220
+ overflow: visible !important;
1221
+ position: static !important;
1222
+ width: auto !important;
1223
+ display: block !important;
1224
+ /* Clear transform/animations that would trap fixed/absolute positioning context */
1225
+ transform: none !important;
1226
+ animation: none !important;
1227
+ transition: none !important;
1228
+ }
1229
+
1230
+ /* Center and display only the card wrapper */
1231
+ #printable-id-card-wrap {
1232
+ display: flex !important;
1233
+ flex-direction: column !important;
1234
+ visibility: visible !important;
1235
+ position: fixed !important;
1236
+ left: 50% !important;
1237
+ top: 50% !important;
1238
+ transform: translate(-50%, -50%) scale(1.1) !important;
1239
+ border: 1px solid #cbd5e1 !important;
1240
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
1241
+ border-radius: 24px !important;
1242
+ background-color: white !important;
1243
+ width: 280px !important;
1244
+ height: 438px !important;
1245
+ margin: 0 !important;
1246
+ overflow: hidden !important;
1247
+ page-break-inside: avoid;
1248
+ }
1249
+
1250
+ #printable-id-card-wrap * {
1251
+ visibility: visible !important;
1252
+ }
1253
+
1254
+ /* Force print watermark color & visibility */
1255
+ .badge-watermark-container,
1256
+ .dark .badge-watermark-container {
1257
+ color: #475569 !important;
1258
+ opacity: 0.12 !important;
1259
+ }
1260
+
1261
+ * {
1262
+ -webkit-print-color-adjust: exact !important;
1263
+ print-color-adjust: exact !important;
1264
+ }
1265
+ }
1266
+ ` }} />
1267
+
1268
+ {/* ─── Edit Employee Modal ─── */}
1269
+ {showEditDialog && (
1270
+ <div className="modal-backdrop">
1271
+ <div className="modal-content max-w-lg">
1272
+ <div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
1273
+ <div>
1274
+ <h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider text-slate-800 dark:text-slate-100">Edit Profile Details</h3>
1275
+ </div>
1276
+ <button
1277
+ onClick={() => setShowEditDialog(false)}
1278
+ className="p-2 rounded-xl hover:bg-slate-100 text-slate-500 hover:text-slate-750 transition-all cursor-pointer"
1279
+ >
1280
+ <X className="w-4 h-4" />
1281
+ </button>
1282
+ </div>
1283
+
1284
+ <form onSubmit={handleEditSubmit} className="space-y-4 text-slate-800">
1285
+ <div className="grid grid-cols-2 gap-3.5">
1286
+ <div className="space-y-1.5">
1287
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Full Name</label>
1288
+ <input type="text" required value={editName} onChange={(e) => setEditName(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
1289
+ </div>
1290
+ <div className="space-y-1.5">
1291
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Email Address</label>
1292
+ <input type="email" required value={editEmail} onChange={(e) => setEditEmail(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
1293
+ </div>
1294
+ </div>
1295
+
1296
+ <div className="grid grid-cols-2 gap-3.5">
1297
+ <div className="space-y-1.5">
1298
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Phone Number</label>
1299
+ <input type="text" placeholder="+91 98765 43210" value={editPhone || ""} onChange={(e) => setEditPhone(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
1300
+ </div>
1301
+ <div className="space-y-1.5">
1302
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Designation</label>
1303
+ <input type="text" value={editDesignation} onChange={(e) => setEditDesignation(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
1304
+ </div>
1305
+ </div>
1306
+
1307
+ <div className="grid grid-cols-2 gap-3.5">
1308
+ <div className="space-y-1.5">
1309
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Department</label>
1310
+ <div className="relative">
1311
+ <select value={editDeptId} onChange={(e) => setEditDeptId(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3 appearance-none cursor-pointer pr-8">
1312
+ <option value="">Select Department</option>
1313
+ {departments?.map((d: any) => <option key={d.id} value={d.id}>{d.name}</option>)}
1314
+ </select>
1315
+ </div>
1316
+ </div>
1317
+ <div className="space-y-1.5">
1318
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Joining Date</label>
1319
+ <input type="date" value={editJoiningDate} onChange={(e) => setEditJoiningDate(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
1320
+ </div>
1321
+ </div>
1322
+
1323
+ <div className="space-y-1.5">
1324
+ <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Status</label>
1325
+ <select value={editStatus} onChange={(e) => setEditStatus(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3 appearance-none cursor-pointer">
1326
+ <option value="Active">Active</option>
1327
+ <option value="Inactive">Inactive</option>
1328
+ </select>
1329
+ </div>
1330
+
1331
+ <div className="flex justify-end gap-2.5 pt-3 border-t border-slate-100">
1332
+ <button
1333
+ type="button"
1334
+ onClick={() => setShowEditDialog(false)}
1335
+ className="btn-ghost text-[11.5px] h-9 px-4 rounded-xl cursor-pointer hover:bg-slate-100"
1336
+ >
1337
+ Cancel
1338
+ </button>
1339
+ <button
1340
+ type="submit"
1341
+ disabled={updateEmployeeMutation.isPending}
1342
+ className="btn-primary text-[11.5px] h-9 px-4 flex items-center gap-1.5 rounded-xl cursor-pointer"
1343
+ >
1344
+ {updateEmployeeMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
1345
+ Save Changes
1346
+ </button>
1347
+ </div>
1348
+ </form>
1349
+ </div>
1350
+ </div>
1351
+ )}
1352
+ </div>
1353
+ </SidebarLayout>
1354
+ );
1355
+ }
frontend/app/employees/page.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
  } from "lucide-react";
12
  import { useToast } from "@/app/utils/toast";
13
  import Link from "next/link";
 
14
 
15
  // Avatar gradient styles
16
  const avatarColors = [
@@ -65,6 +66,7 @@ function InputField({ label, required, children }: { label: string; required?: b
65
 
66
  export default function EmployeesPage() {
67
  const queryClient = useQueryClient();
 
68
  const { toast } = useToast();
69
  const [search, setSearch] = useState("");
70
  const [deptFilter, setDeptFilter] = useState("");
@@ -75,7 +77,6 @@ export default function EmployeesPage() {
75
  const [importMessage, setImportMessage] = useState<string | null>(null);
76
  const [importErrors, setImportErrors] = useState<string[]>([]);
77
  const [submitting, setSubmitting] = useState(false);
78
- const [selectedEmployee, setSelectedEmployee] = useState<any | null>(null);
79
  const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
80
  const [deleteConfirmName, setDeleteConfirmName] = useState<string>("");
81
 
@@ -91,6 +92,7 @@ export default function EmployeesPage() {
91
  const [deptId, setDeptId] = useState("");
92
  const [createUserLogin, setCreateUserLogin] = useState(false);
93
  const [password, setPassword] = useState("");
 
94
 
95
  const { data: departments } = useQuery({
96
  queryKey: ["departments"],
@@ -109,14 +111,6 @@ export default function EmployeesPage() {
109
  }
110
  });
111
 
112
- const { data: empAttendance, isLoading: loadingEmpAttendance } = useQuery({
113
- queryKey: ["employee-attendance", selectedEmployee?.id],
114
- queryFn: () => {
115
- if (!selectedEmployee) return [];
116
- return fetchApi(`/attendance/employee/${selectedEmployee.id}`);
117
- },
118
- enabled: !!selectedEmployee
119
- });
120
 
121
  const createMutation = useMutation({
122
  mutationFn: (payload: any) => fetchApi("/employees/", { method: "POST", body: JSON.stringify(payload) }),
@@ -145,6 +139,7 @@ export default function EmployeesPage() {
145
  setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
146
  setJoiningDate(getLocalDateString()); setStatusVal("Active");
147
  setDeptId(""); setCreateUserLogin(false); setPassword("");
 
148
  setPhoneError(null);
149
  setSubmissionError(null);
150
  };
@@ -196,7 +191,8 @@ export default function EmployeesPage() {
196
  employee_id: empId, name, email, phone: phone || null,
197
  designation: designation || null, joining_date: joiningDate,
198
  status: statusVal, department_id: deptId ? parseInt(deptId) : null,
199
- create_user_login: createUserLogin
 
200
  };
201
  if (createUserLogin) payload.password = password;
202
  createMutation.mutate(payload);
@@ -233,23 +229,6 @@ export default function EmployeesPage() {
233
  }
234
  };
235
 
236
- const handleExportEmployee = async () => {
237
- if (!selectedEmployee) return;
238
- try {
239
- const responseBlob = await fetchApi(`/reports/export?report_type=employee&employee_id=${selectedEmployee.id}&format=csv`);
240
- const url = window.URL.createObjectURL(responseBlob);
241
- const a = document.createElement("a");
242
- a.href = url;
243
- a.download = `attendance_${selectedEmployee.name.replace(/\s+/g, "_")}.csv`;
244
- document.body.appendChild(a);
245
- a.click();
246
- a.remove();
247
- window.URL.revokeObjectURL(url);
248
- toast.success("Employee ledger exported successfully.");
249
- } catch (err: any) {
250
- toast.error(err.message || "Failed to export CSV.");
251
- }
252
- };
253
 
254
  const inputCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all w-full";
255
  const selectCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all appearance-none cursor-pointer w-full pr-8";
@@ -368,7 +347,7 @@ export default function EmployeesPage() {
368
  employees?.map((emp: any) => {
369
  const avatarColor = avatarColors[emp.id % avatarColors.length];
370
  return (
371
- <tr key={emp.id} className="group/row cursor-pointer hover:bg-white/[0.015]" onClick={() => setSelectedEmployee(emp)}>
372
  <td className="py-3.5 px-5">
373
  <div className="flex items-center gap-3">
374
  <EmployeeAvatar emp={emp} size="md" />
@@ -543,6 +522,27 @@ export default function EmployeesPage() {
543
  )}
544
  </div>
545
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  <div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
547
  <button
548
  type="button"
@@ -647,156 +647,6 @@ export default function EmployeesPage() {
647
  )}
648
  </SidebarLayout>
649
 
650
- {/* ─── Employee Details & Attendance History Modal ─── */}
651
- {selectedEmployee && (
652
- <div className="modal-backdrop z-50">
653
- <div className="modal-content max-w-2xl bg-white border border-zinc-200 text-zinc-900 shadow-2xl">
654
- {/* Header info */}
655
- <div className="flex items-start justify-between pb-5 border-b border-zinc-100">
656
- <div className="flex items-center gap-4">
657
- <EmployeeAvatar emp={selectedEmployee} size="lg" />
658
- <div>
659
- <h3 className="text-base font-bold text-zinc-900 leading-tight">
660
- {selectedEmployee.name}
661
- </h3>
662
- <p className="text-xs text-zinc-550 mt-1 flex items-center gap-1.5">
663
- <span className="font-mono text-zinc-500 bg-zinc-100 px-1.5 py-0.5 rounded border border-zinc-200">{selectedEmployee.employee_id}</span>
664
- <span>·</span>
665
- <span className="font-semibold text-zinc-700">{selectedEmployee.designation || "No Title"}</span>
666
- </p>
667
- <p className="text-[10px] text-zinc-400 font-bold mt-1 uppercase tracking-wider">
668
- {selectedEmployee.department?.name || "General Department"}
669
- </p>
670
- </div>
671
- </div>
672
-
673
- <div className="flex items-center gap-2">
674
- <button
675
- onClick={handleExportEmployee}
676
- className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-100 border border-zinc-200 hover:bg-zinc-200 rounded-xl text-[11px] font-bold text-zinc-700 transition-all cursor-pointer"
677
- title="Export employee attendance data"
678
- >
679
- <Download className="w-3.5 h-3.5 text-zinc-500" />
680
- Export CSV
681
- </button>
682
- <button
683
- onClick={() => setSelectedEmployee(null)}
684
- className="p-2 rounded-xl hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
685
- >
686
- <X className="w-4.5 h-4.5" />
687
- </button>
688
- </div>
689
- </div>
690
-
691
- {/* Profile fields details grid */}
692
- <div className="grid grid-cols-1 md:grid-cols-3 gap-3.5 py-4 border-b border-zinc-100 text-[11.5px] text-zinc-600">
693
- <div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
694
- <Mail className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
695
- <span className="truncate font-medium text-zinc-700" title={selectedEmployee.email}>{selectedEmployee.email}</span>
696
- </div>
697
- <div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
698
- <Phone className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
699
- <span className="font-medium text-zinc-700">{selectedEmployee.phone || "No phone added"}</span>
700
- </div>
701
- <div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
702
- <Calendar className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
703
- <span className="font-medium text-zinc-700">Joined: <span className="font-mono">{selectedEmployee.joining_date}</span></span>
704
- </div>
705
- </div>
706
-
707
- {/* Stats Summary cards */}
708
- <div className="grid grid-cols-4 gap-3 py-4">
709
- {(() => {
710
- const total = empAttendance?.length || 0;
711
- const present = empAttendance?.filter((r: any) => r.status === "Present").length || 0;
712
- const late = empAttendance?.filter((r: any) => r.status === "Late").length || 0;
713
- const half = empAttendance?.filter((r: any) => r.status === "Half Day").length || 0;
714
-
715
- const cardCls = "p-3 rounded-xl border border-zinc-150 bg-zinc-50/30 flex flex-col justify-between h-[64px] shadow-sm";
716
- return (
717
- <>
718
- <div className={cardCls}>
719
- <span className="text-[9px] text-zinc-500 font-bold uppercase tracking-wider">Logged Days</span>
720
- <span className="text-base font-bold text-zinc-800 leading-none mt-1 font-mono">{total}</span>
721
- </div>
722
- <div className={cardCls}>
723
- <span className="text-[9px] text-emerald-600 font-bold uppercase tracking-wider">Present</span>
724
- <span className="text-base font-bold text-emerald-600 leading-none mt-1 font-mono">{present}</span>
725
- </div>
726
- <div className={cardCls}>
727
- <span className="text-[9px] text-amber-600 font-bold uppercase tracking-wider">Late Arrivals</span>
728
- <span className="text-base font-bold text-amber-600 leading-none mt-1 font-mono">{late}</span>
729
- </div>
730
- <div className={cardCls}>
731
- <span className="text-[9px] text-indigo-600 font-bold uppercase tracking-wider">Half Days</span>
732
- <span className="text-base font-bold text-indigo-650 leading-none mt-1 font-mono">{half}</span>
733
- </div>
734
- </>
735
- );
736
- })()}
737
- </div>
738
-
739
- {/* Attendance Ledger Table */}
740
- <div className="mt-2 space-y-2">
741
- <h4 className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest font-mono">Attendance Ledger (Past 30 Days)</h4>
742
- <div className="overflow-y-auto max-h-[200px] border border-zinc-200 rounded-xl bg-white shadow-sm">
743
- <table className="w-full text-left border-collapse text-[11px]">
744
- <thead>
745
- <tr className="border-b border-zinc-200 bg-zinc-50 text-zinc-500 uppercase tracking-wider font-mono">
746
- <th className="py-2.5 px-4 font-semibold">Date</th>
747
- <th className="py-2.5 px-4 font-semibold">Check In</th>
748
- <th className="py-2.5 px-4 font-semibold">Check Out</th>
749
- <th className="py-2.5 px-4 font-semibold">Hours</th>
750
- <th className="py-2.5 px-4 font-semibold text-center">Status</th>
751
- </tr>
752
- </thead>
753
- <tbody className="divide-y divide-zinc-100 text-zinc-700">
754
- {loadingEmpAttendance ? (
755
- Array.from({ length: 3 }).map((_, i) => (
756
- <tr key={i}>
757
- {Array.from({ length: 5 }).map((_, j) => (
758
- <td key={j} className="py-3 px-4"><div className="skeleton h-3 w-16" /></td>
759
- ))}
760
- </tr>
761
- ))
762
- ) : !empAttendance || empAttendance.length === 0 ? (
763
- <tr>
764
- <td colSpan={5} className="py-8 text-center text-zinc-450 italic">No attendance records stored.</td>
765
- </tr>
766
- ) : (
767
- empAttendance.map((rec: any) => (
768
- <tr key={rec.id} className="hover:bg-zinc-50/50 transition-colors">
769
- <td className="py-2.5 px-4 font-mono text-zinc-550">{rec.date}</td>
770
- <td className="py-2.5 px-4 font-mono text-zinc-800">
771
- {rec.check_in ? parseDateTime(rec.check_in)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
772
- </td>
773
- <td className="py-2.5 px-4 font-mono text-zinc-800">
774
- {rec.check_out ? parseDateTime(rec.check_out)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
775
- </td>
776
- <td className="py-2.5 px-4 font-mono text-zinc-700">
777
- {rec.working_hours ? `${rec.working_hours.toFixed(1)} hrs` : "—"}
778
- </td>
779
- <td className="py-2.5 px-4 text-center">
780
- <span className={`inline-block text-[8.5px] font-semibold px-2 py-0.5 rounded-full border ${
781
- rec.status === "Present" ? "bg-emerald-50 border-emerald-200 text-emerald-700" :
782
- rec.status === "Late" ? "bg-amber-50 border-amber-200 text-amber-700" :
783
- rec.status === "Half Day" ? "bg-indigo-50 border-indigo-200 text-indigo-750" :
784
- "bg-rose-50 border-rose-200 text-rose-700"
785
- }`}>
786
- {rec.status}
787
- </span>
788
- </td>
789
- </tr>
790
- ))
791
- )}
792
- </tbody>
793
- </table>
794
- </div>
795
- </div>
796
- </div>
797
- </div>
798
- )}
799
-
800
  {/* Delete Confirmation Modal */}
801
  {deleteConfirmId !== null && (
802
  <div className="modal-backdrop z-50">
 
11
  } from "lucide-react";
12
  import { useToast } from "@/app/utils/toast";
13
  import Link from "next/link";
14
+ import { useRouter } from "next/navigation";
15
 
16
  // Avatar gradient styles
17
  const avatarColors = [
 
66
 
67
  export default function EmployeesPage() {
68
  const queryClient = useQueryClient();
69
+ const router = useRouter();
70
  const { toast } = useToast();
71
  const [search, setSearch] = useState("");
72
  const [deptFilter, setDeptFilter] = useState("");
 
77
  const [importMessage, setImportMessage] = useState<string | null>(null);
78
  const [importErrors, setImportErrors] = useState<string[]>([]);
79
  const [submitting, setSubmitting] = useState(false);
 
80
  const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
81
  const [deleteConfirmName, setDeleteConfirmName] = useState<string>("");
82
 
 
92
  const [deptId, setDeptId] = useState("");
93
  const [createUserLogin, setCreateUserLogin] = useState(false);
94
  const [password, setPassword] = useState("");
95
+ const [allowWfh, setAllowWfh] = useState(false);
96
 
97
  const { data: departments } = useQuery({
98
  queryKey: ["departments"],
 
111
  }
112
  });
113
 
 
 
 
 
 
 
 
 
114
 
115
  const createMutation = useMutation({
116
  mutationFn: (payload: any) => fetchApi("/employees/", { method: "POST", body: JSON.stringify(payload) }),
 
139
  setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
140
  setJoiningDate(getLocalDateString()); setStatusVal("Active");
141
  setDeptId(""); setCreateUserLogin(false); setPassword("");
142
+ setAllowWfh(false);
143
  setPhoneError(null);
144
  setSubmissionError(null);
145
  };
 
191
  employee_id: empId, name, email, phone: phone || null,
192
  designation: designation || null, joining_date: joiningDate,
193
  status: statusVal, department_id: deptId ? parseInt(deptId) : null,
194
+ create_user_login: createUserLogin,
195
+ allow_wfh: allowWfh
196
  };
197
  if (createUserLogin) payload.password = password;
198
  createMutation.mutate(payload);
 
229
  }
230
  };
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
  const inputCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all w-full";
234
  const selectCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all appearance-none cursor-pointer w-full pr-8";
 
347
  employees?.map((emp: any) => {
348
  const avatarColor = avatarColors[emp.id % avatarColors.length];
349
  return (
350
+ <tr key={emp.id} className="group/row cursor-pointer hover:bg-white/[0.015]" onClick={() => router.push(`/employees/${emp.id}`)}>
351
  <td className="py-3.5 px-5">
352
  <div className="flex items-center gap-3">
353
  <EmployeeAvatar emp={emp} size="md" />
 
522
  )}
523
  </div>
524
 
525
+ {/* WFH Permission Toggle */}
526
+ <div className="flex items-center justify-between p-4 rounded-2xl bg-white/[0.02] border border-white/5">
527
+ <div>
528
+ <p className="text-[12.5px] font-bold text-slate-800">Work From Home (WFH) Allowed</p>
529
+ <p className="text-[10px] text-slate-450 mt-0.5">Bypasses geofenced location checks for this employee</p>
530
+ </div>
531
+ <button
532
+ type="button"
533
+ onClick={() => setAllowWfh(!allowWfh)}
534
+ className={`relative inline-flex h-5.5 w-10 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
535
+ allowWfh ? "bg-emerald-500" : "bg-slate-200 dark:bg-slate-800"
536
+ }`}
537
+ >
538
+ <span
539
+ className={`pointer-events-none inline-block h-4.5 w-4.5 transform rounded-full bg-white shadow-xs transition duration-200 ease-in-out ${
540
+ allowWfh ? "translate-x-4.5" : "translate-x-0"
541
+ }`}
542
+ />
543
+ </button>
544
+ </div>
545
+
546
  <div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
547
  <button
548
  type="button"
 
647
  )}
648
  </SidebarLayout>
649
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  {/* Delete Confirmation Modal */}
651
  {deleteConfirmId !== null && (
652
  <div className="modal-backdrop z-50">
frontend/app/enroll/[id]/page.tsx CHANGED
@@ -5,10 +5,12 @@ 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
- User, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Smile, Meh, Lightbulb, Sun, Glasses
 
12
  } from "lucide-react";
13
 
14
  interface PoseInfo {
@@ -88,6 +90,7 @@ export default function EnrollPage() {
88
  const router = useRouter();
89
  const queryClient = useQueryClient();
90
  const employeeId = params.id;
 
91
 
92
  // State Machine for biometric scanner:
93
  // "idle": Pre-start screen
@@ -130,6 +133,140 @@ export default function EnrollPage() {
130
  enabled: !!employeeId
131
  });
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  const clearMutation = useMutation({
134
  mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }),
135
  onSuccess: () => {
@@ -188,6 +325,330 @@ export default function EnrollPage() {
188
  }
189
  };
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  // Webcam controls
192
  const startWebcam = async () => {
193
  setErrorMsg(null);
@@ -387,7 +848,7 @@ export default function EnrollPage() {
387
 
388
  return (
389
  <SidebarLayout>
390
- <div className="space-y-6 max-w-5xl page-enter relative">
391
  {/* CSS Scanner Animations style block */}
392
  <style>{`
393
  @keyframes scanline {
@@ -541,9 +1002,6 @@ export default function EnrollPage() {
541
  {/* Upload Fallback File Option */}
542
  <div className="bg-slate-50 border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
543
  <h4 className="text-[11.5px] font-bold text-slate-700 uppercase tracking-wider">Manual Photo Upload</h4>
544
- <p className="text-[10px] text-slate-500 leading-normal">
545
- If the employee cannot use a live camera, select a target pose and upload a photo from disk.
546
- </p>
547
  <div className="flex gap-2">
548
  <select
549
  value={selectedPose}
@@ -572,9 +1030,6 @@ export default function EnrollPage() {
572
  {/* Right: Big visual grid checklist */}
573
  <div className="md:col-span-2 bg-white border border-slate-200 rounded-2xl p-6 shadow-sm space-y-4">
574
  <h3 className="text-sm font-black text-slate-900 tracking-tight">Facial Pose Checklist</h3>
575
- <p className="text-xs text-slate-500">
576
- To capture accurate biometric details under varying orientations and lighting, we index 10 distinct facial angles.
577
- </p>
578
 
579
  <div className="grid grid-cols-2 sm:grid-cols-5 gap-3.5 pt-2">
580
  {POSE_KEYS.map((key) => {
@@ -850,50 +1305,328 @@ export default function EnrollPage() {
850
 
851
  {/* ─── State 5: SUCCESS SCREEN ─── */}
852
  {captureState === "success" && (
853
- <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">
854
- <div className="relative w-20 h-20 mx-auto bg-emerald-50 rounded-full flex items-center justify-center border border-emerald-100 success-circle">
855
- <svg className="w-10 h-10 text-emerald-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
856
- <polyline points="20 6 9 17 4 12" className="success-check" />
857
- </svg>
858
- </div>
 
 
 
 
859
 
860
- <div className="space-y-2">
861
- <h2 className="text-lg font-black text-slate-900 tracking-tight">Biometric Profile Secured</h2>
862
- <p className="text-xs text-slate-550 leading-relaxed">
863
- 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.
864
- </p>
865
- </div>
866
 
867
- <div className="flex gap-3 justify-center pt-2">
868
- <button
869
- onClick={() => router.push("/employees")}
870
- 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"
871
- >
872
- Employees List
873
- </button>
 
 
 
 
 
 
 
 
 
 
874
 
875
- <button
876
- onClick={startAutoCapture}
877
- 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"
878
- >
879
- Re-enroll Profile
880
- </button>
881
- </div>
882
- </div>
883
- )}
884
 
885
- {/* ─── Tips Section ─── */}
886
- {captureState === "idle" && (
887
- <div className="flex items-start gap-3 p-4 rounded-2xl bg-slate-50 border border-slate-200">
888
- <AlertCircle className="w-4 h-4 text-slate-700 shrink-0 mt-0.5" />
889
- <div>
890
- <p className="text-[11px] font-bold text-slate-900 uppercase tracking-wider mb-1 font-mono">Registry Specifications</p>
891
- <p className="text-[10px] text-slate-650 leading-relaxed font-mono uppercase">
892
- 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.
893
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
894
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
  </div>
896
  )}
 
 
897
  </div>
898
 
899
  {/* Clear Biometric Confirmation Modal */}
 
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 { useToast } from "@/app/utils/toast";
9
  import {
10
  Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video,
11
  RefreshCw, AlertCircle, Trash2, Play, Pause, Save, RotateCcw, Shield, Activity, Sparkles,
12
+ User, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Smile, Meh, Lightbulb, Sun, Glasses,
13
+ Printer, Download
14
  } from "lucide-react";
15
 
16
  interface PoseInfo {
 
90
  const router = useRouter();
91
  const queryClient = useQueryClient();
92
  const employeeId = params.id;
93
+ const { toast } = useToast();
94
 
95
  // State Machine for biometric scanner:
96
  // "idle": Pre-start screen
 
133
  enabled: !!employeeId
134
  });
135
 
136
+ const { data: settings } = useQuery({
137
+ queryKey: ["settings"],
138
+ queryFn: () => fetchApi("/settings/")
139
+ });
140
+
141
+ const settingsMap = React.useMemo(() => {
142
+ const map: Record<string, string> = {};
143
+ if (settings) {
144
+ settings.forEach((s: any) => {
145
+ map[s.key] = s.value;
146
+ });
147
+ }
148
+ return map;
149
+ }, [settings]);
150
+
151
+ const companyName = settingsMap["COMPANY_NAME"] || "NetraID Enterprise";
152
+ const companyLogo = settingsMap["COMPANY_LOGO"] || "";
153
+ const badgeTheme = settingsMap["BADGE_THEME_COLOR"] || "Navy Blue";
154
+ const badgePattern = settingsMap["BADGE_PATTERN_TYPE"] || "Indian Mandala";
155
+
156
+ const themeStyles = React.useMemo(() => {
157
+ switch (badgeTheme) {
158
+ case "Saffron":
159
+ return {
160
+ headerBg: "bg-gradient-to-tr from-amber-600 via-orange-500 to-red-600",
161
+ accentText: "text-orange-600 dark:text-orange-400",
162
+ accentBorder: "border-orange-500",
163
+ accentBg: "bg-orange-50/40 dark:bg-orange-950/20",
164
+ photoBorder: "from-amber-500 to-red-500",
165
+ dotColor: "bg-orange-500",
166
+ primaryHex: "#f97316",
167
+ headerHex1: "#d97706",
168
+ headerHex2: "#dc2626"
169
+ };
170
+ case "Emerald":
171
+ return {
172
+ headerBg: "bg-gradient-to-tr from-slate-900 via-emerald-950 to-teal-900",
173
+ accentText: "text-emerald-600 dark:text-emerald-400",
174
+ accentBorder: "border-emerald-500",
175
+ accentBg: "bg-emerald-50/40 dark:bg-emerald-950/20",
176
+ photoBorder: "from-emerald-500 to-teal-400",
177
+ dotColor: "bg-emerald-500",
178
+ primaryHex: "#10b981",
179
+ headerHex1: "#064e3b",
180
+ headerHex2: "#0f766e"
181
+ };
182
+ case "Charcoal":
183
+ return {
184
+ headerBg: "bg-gradient-to-tr from-zinc-900 via-slate-800 to-zinc-950",
185
+ accentText: "text-zinc-650 dark:text-zinc-400",
186
+ accentBorder: "border-zinc-500",
187
+ accentBg: "bg-zinc-50/40 dark:bg-zinc-900/20",
188
+ photoBorder: "from-zinc-500 to-slate-400",
189
+ dotColor: "bg-zinc-500",
190
+ primaryHex: "#6b7280",
191
+ headerHex1: "#18181b",
192
+ headerHex2: "#27272a"
193
+ };
194
+ case "Navy Blue":
195
+ default:
196
+ return {
197
+ headerBg: "bg-gradient-to-tr from-slate-900 via-blue-900 to-indigo-950",
198
+ accentText: "text-cyan-500 dark:text-cyan-400",
199
+ accentBorder: "border-cyan-500",
200
+ accentBg: "bg-slate-50/40 dark:bg-slate-900/20",
201
+ photoBorder: "from-cyan-500 to-emerald-400",
202
+ dotColor: "bg-cyan-500",
203
+ primaryHex: "#06b6d4",
204
+ headerHex1: "#0f172a",
205
+ headerHex2: "#1e3a8a"
206
+ };
207
+ }
208
+ }, [badgeTheme]);
209
+
210
+ const BadgeWatermark = ({ type }: { type: string }) => {
211
+ switch (type) {
212
+ case "Indian Mandala":
213
+ return (
214
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-[0.06] text-slate-800 dark:text-slate-100 z-0 overflow-hidden badge-watermark-container">
215
+ <svg className="w-[110%] h-[110%]" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.4">
216
+ <circle cx="50" cy="50" r="42" strokeDasharray="1 1.5" />
217
+ <circle cx="50" cy="50" r="35" />
218
+ <circle cx="50" cy="50" r="28" strokeDasharray="0.5 1" />
219
+ <circle cx="50" cy="50" r="21" />
220
+ <circle cx="50" cy="50" r="14" strokeDasharray="1 1" />
221
+ <circle cx="50" cy="50" r="7" />
222
+ {Array.from({ length: 24 }).map((_, i) => {
223
+ const angle = (i * 15 * Math.PI) / 180;
224
+ const x1 = 50 + 7 * Math.cos(angle);
225
+ const y1 = 50 + 7 * Math.sin(angle);
226
+ const x2 = 50 + 35 * Math.cos(angle);
227
+ const y2 = 50 + 35 * Math.sin(angle);
228
+ const cx1 = 50 + 20 * Math.cos(angle - 0.08);
229
+ const cy1 = 50 + 20 * Math.sin(angle - 0.08);
230
+ return (
231
+ <g key={i}>
232
+ <line x1={x1} y1={y1} x2={x2} y2={y2} />
233
+ <path d={`M ${x1} ${y1} Q ${cx1} ${cy1} ${x2} ${y2}`} strokeWidth="0.25" />
234
+ </g>
235
+ );
236
+ })}
237
+ </svg>
238
+ </div>
239
+ );
240
+ case "Corporate Waves":
241
+ return (
242
+ <div className="absolute inset-0 pointer-events-none opacity-[0.05] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
243
+ <svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
244
+ <path d="M-20,40 C20,20 40,60 60,40 C80,20 100,60 120,40" />
245
+ <path d="M-20,50 C20,30 40,70 60,50 C80,30 100,70 120,50" strokeDasharray="1 1" />
246
+ <path d="M-20,60 C20,40 40,80 60,60 C80,40 100,80 120,60" />
247
+ <path d="M-20,70 C20,50 40,90 60,70 C80,50 100,90 120,70" strokeDasharray="0.5 1" />
248
+ </svg>
249
+ </div>
250
+ );
251
+ case "Cyber Grid":
252
+ return (
253
+ <div className="absolute inset-0 pointer-events-none opacity-[0.03] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
254
+ <svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
255
+ {Array.from({ length: 11 }).map((_, i) => (
256
+ <g key={i}>
257
+ <line x1="0" y1={i * 10} x2="100" y2={i * 10} />
258
+ <line x1={i * 10} y1="0" x2={i * 10} y2="100" />
259
+ </g>
260
+ ))}
261
+ </svg>
262
+ </div>
263
+ );
264
+ case "None":
265
+ default:
266
+ return null;
267
+ }
268
+ };
269
+
270
  const clearMutation = useMutation({
271
  mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }),
272
  onSuccess: () => {
 
325
  }
326
  };
327
 
328
+ const handleDownloadBadge = async () => {
329
+ if (!employee) return;
330
+ try {
331
+ const canvas = document.createElement("canvas");
332
+ const ctx = canvas.getContext("2d");
333
+ if (!ctx) return;
334
+
335
+ // Set standard high-resolution dimensions for printing (e.g., 600x900 for 2:3 aspect ratio)
336
+ canvas.width = 600;
337
+ canvas.height = 900;
338
+
339
+ // 1. Draw rounded background card
340
+ ctx.fillStyle = "#ffffff";
341
+ ctx.beginPath();
342
+ if (typeof ctx.roundRect === "function") {
343
+ ctx.roundRect(0, 0, 600, 900, 30);
344
+ } else {
345
+ const x = 0, y = 0, width = 600, height = 900, radius = 30;
346
+ ctx.moveTo(x + radius, y);
347
+ ctx.lineTo(x + width - radius, y);
348
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
349
+ ctx.lineTo(x + width, y + height - radius);
350
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
351
+ ctx.lineTo(x + radius, y + height);
352
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
353
+ ctx.lineTo(x, y + radius);
354
+ ctx.quadraticCurveTo(x, y, x + radius, y);
355
+ }
356
+ ctx.fill();
357
+ ctx.strokeStyle = "#cbd5e1"; // lighter border
358
+ ctx.lineWidth = 4;
359
+ ctx.stroke();
360
+
361
+ // Draw custom background pattern
362
+ const cx = 300;
363
+ const cy = 450;
364
+ if (badgePattern === "Indian Mandala") {
365
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.08)";
366
+ ctx.lineWidth = 1.5;
367
+ // Concentric rings
368
+ ctx.beginPath(); ctx.arc(cx, cy, 252, 0, Math.PI * 2); ctx.stroke();
369
+ ctx.beginPath(); ctx.arc(cx, cy, 210, 0, Math.PI * 2); ctx.stroke();
370
+ ctx.beginPath(); ctx.arc(cx, cy, 168, 0, Math.PI * 2); ctx.stroke();
371
+ ctx.beginPath(); ctx.arc(cx, cy, 126, 0, Math.PI * 2); ctx.stroke();
372
+ ctx.beginPath(); ctx.arc(cx, cy, 84, 0, Math.PI * 2); ctx.stroke();
373
+ ctx.beginPath(); ctx.arc(cx, cy, 42, 0, Math.PI * 2); ctx.stroke();
374
+ // Rays & Arches
375
+ for (let i = 0; i < 24; i++) {
376
+ const angle = (i * 15 * Math.PI) / 180;
377
+ ctx.beginPath();
378
+ ctx.moveTo(cx + 42 * Math.cos(angle), cy + 42 * Math.sin(angle));
379
+ ctx.lineTo(cx + 210 * Math.cos(angle), cy + 210 * Math.sin(angle));
380
+ ctx.stroke();
381
+ }
382
+ } else if (badgePattern === "Corporate Waves") {
383
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.06)";
384
+ ctx.lineWidth = 2.5;
385
+ ctx.beginPath();
386
+ ctx.moveTo(-50, 420);
387
+ ctx.bezierCurveTo(150, 220, 350, 620, 650, 420);
388
+ ctx.stroke();
389
+ ctx.beginPath();
390
+ ctx.moveTo(-50, 500);
391
+ ctx.bezierCurveTo(150, 300, 350, 700, 650, 500);
392
+ ctx.stroke();
393
+ } else if (badgePattern === "Cyber Grid") {
394
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.04)";
395
+ ctx.lineWidth = 1;
396
+ for (let i = 0; i <= 900; i += 60) {
397
+ ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(600, i); ctx.stroke();
398
+ }
399
+ for (let i = 0; i <= 600; i += 60) {
400
+ ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, 900); ctx.stroke();
401
+ }
402
+ }
403
+
404
+ // 2. Draw Header Area (Dynamic gradient based on active theme)
405
+ const gradient = ctx.createLinearGradient(0, 0, 600, 0);
406
+ gradient.addColorStop(0, themeStyles.headerHex1);
407
+ gradient.addColorStop(1, themeStyles.headerHex2);
408
+ ctx.fillStyle = gradient;
409
+ ctx.beginPath();
410
+ if (typeof ctx.roundRect === "function") {
411
+ ctx.roundRect(0, 0, 600, 200, [30, 30, 0, 0]);
412
+ } else {
413
+ const x = 0, y = 0, width = 600, height = 200, radius = 30;
414
+ ctx.moveTo(x + radius, y);
415
+ ctx.lineTo(x + width - radius, y);
416
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
417
+ ctx.lineTo(x + width, y + height);
418
+ ctx.lineTo(x, y + height);
419
+ ctx.lineTo(x, y + radius);
420
+ ctx.quadraticCurveTo(x, y, x + radius, y);
421
+ }
422
+ ctx.fill();
423
+
424
+ // 3. Draw Lanyard Punch Hole representation (premium visual detail)
425
+ ctx.fillStyle = "#f1f5f9";
426
+ ctx.beginPath();
427
+ if (typeof ctx.roundRect === "function") {
428
+ ctx.roundRect(260, 20, 80, 20, 10);
429
+ } else {
430
+ ctx.rect(260, 20, 80, 20);
431
+ }
432
+ ctx.fill();
433
+ ctx.fillStyle = "#0f172a";
434
+ ctx.beginPath();
435
+ if (typeof ctx.roundRect === "function") {
436
+ ctx.roundRect(270, 25, 60, 10, 5);
437
+ } else {
438
+ ctx.rect(270, 25, 60, 10);
439
+ }
440
+ ctx.fill();
441
+
442
+ // Helper function to load an image
443
+ const loadImage = (src: string): Promise<HTMLImageElement> => {
444
+ return new Promise((resolve, reject) => {
445
+ const img = new Image();
446
+ img.crossOrigin = "anonymous"; // Avoid CORS taint
447
+ img.onload = () => resolve(img);
448
+ img.onerror = () => reject(new Error("Failed to load image: " + src));
449
+ img.src = src;
450
+ });
451
+ };
452
+
453
+ // Load logo, photo, and QR code
454
+ const baseUrl = getBackendUrl().replace("/api/v1", "");
455
+ const photoSrc = `${baseUrl}/uploads/${employee.employee_id}/front.jpg`;
456
+ const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${employee.employee_id}`;
457
+
458
+ let logoImg: HTMLImageElement | null = null;
459
+ if (companyLogo) {
460
+ try {
461
+ logoImg = await loadImage(companyLogo);
462
+ } catch (e) {
463
+ console.warn("Could not load company logo for canvas", e);
464
+ }
465
+ }
466
+
467
+ let photoImg: HTMLImageElement | null = null;
468
+ try {
469
+ photoImg = await loadImage(photoSrc);
470
+ } catch (e) {
471
+ console.warn("Could not load profile photo for canvas", e);
472
+ }
473
+
474
+ let qrImg: HTMLImageElement | null = null;
475
+ try {
476
+ qrImg = await loadImage(qrSrc);
477
+ } catch (e) {
478
+ console.warn("Could not load QR code for canvas", e);
479
+ }
480
+
481
+ // Draw Company Logo
482
+ if (logoImg) {
483
+ const logoAspectRatio = logoImg.width / logoImg.height;
484
+ const logoHeight = 45;
485
+ const logoWidth = logoHeight * logoAspectRatio;
486
+ ctx.drawImage(logoImg, 50, 75, logoWidth, logoHeight);
487
+
488
+ // Draw Company Name next to logo
489
+ ctx.fillStyle = "#ffffff";
490
+ ctx.font = "bold 24px sans-serif";
491
+ ctx.textAlign = "left";
492
+ ctx.fillText(companyName, 65 + logoWidth, 106);
493
+ } else {
494
+ // Draw default badge branding text
495
+ ctx.fillStyle = themeStyles.primaryHex;
496
+ ctx.font = "black 28px sans-serif";
497
+ ctx.textAlign = "center";
498
+ ctx.fillText("NETRAID", 300, 100);
499
+
500
+ ctx.fillStyle = "#ffffff";
501
+ ctx.font = "bold 16px sans-serif";
502
+ ctx.fillText(companyName.toUpperCase(), 300, 130);
503
+ }
504
+
505
+ // Draw Profile Picture Container (with double border)
506
+ const photoX = 200;
507
+ const photoY = 220;
508
+ const photoSize = 200;
509
+
510
+ // Draw Photo Outer border (theme accent color)
511
+ ctx.strokeStyle = themeStyles.primaryHex;
512
+ ctx.lineWidth = 6;
513
+ ctx.beginPath();
514
+ ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2 + 6, 0, Math.PI * 2);
515
+ ctx.stroke();
516
+
517
+ // Clip and Draw Photo
518
+ ctx.save();
519
+ ctx.beginPath();
520
+ ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2, 0, Math.PI * 2);
521
+ ctx.clip();
522
+ if (photoImg) {
523
+ ctx.drawImage(photoImg, photoX, photoY, photoSize, photoSize);
524
+ } else {
525
+ // Draw placeholder avatar
526
+ ctx.fillStyle = "#f1f5f9";
527
+ ctx.fillRect(photoX, photoY, photoSize, photoSize);
528
+ ctx.fillStyle = "#94a3b8";
529
+ ctx.font = "bold 80px sans-serif";
530
+ ctx.textAlign = "center";
531
+ ctx.textBaseline = "middle";
532
+ ctx.fillText("?", photoX + photoSize / 2, photoY + photoSize / 2);
533
+ }
534
+ ctx.restore();
535
+
536
+ // Draw Holographic checkmark seal on Canvas
537
+ ctx.save();
538
+ const sealX = photoX + photoSize - 35;
539
+ const sealY = photoY + photoSize - 35;
540
+ const sealSize = 45;
541
+ const sealGrad = ctx.createLinearGradient(sealX, sealY, sealX + sealSize, sealY + sealSize);
542
+ sealGrad.addColorStop(0, "#fbbf24");
543
+ sealGrad.addColorStop(0.5, "#fb923c");
544
+ sealGrad.addColorStop(1, "#fde047");
545
+ ctx.fillStyle = sealGrad;
546
+ ctx.beginPath();
547
+ ctx.arc(sealX + sealSize / 2, sealY + sealSize / 2, sealSize / 2, 0, Math.PI * 2);
548
+ ctx.fill();
549
+ ctx.strokeStyle = "#ffffff";
550
+ ctx.lineWidth = 2.5;
551
+ ctx.stroke();
552
+ // Draw small tick
553
+ ctx.strokeStyle = "#451a03"; // deep amber
554
+ ctx.lineWidth = 3.5;
555
+ ctx.beginPath();
556
+ ctx.moveTo(sealX + 13, sealY + 22);
557
+ ctx.lineTo(sealX + 20, sealY + 29);
558
+ ctx.lineTo(sealX + 32, sealY + 16);
559
+ ctx.stroke();
560
+ ctx.restore();
561
+
562
+ // Draw Employee Details
563
+ ctx.fillStyle = "#0f172a"; // slate-900
564
+ ctx.font = "bold 32px sans-serif";
565
+ ctx.textAlign = "center";
566
+ ctx.fillText(employee.name.toUpperCase(), 300, 480);
567
+
568
+ ctx.fillStyle = themeStyles.primaryHex; // theme color designation
569
+ ctx.font = "bold 20px sans-serif";
570
+ ctx.fillText(employee.designation?.toUpperCase() || "STAFF MEMBER", 300, 515);
571
+
572
+ // Separator Line
573
+ ctx.strokeStyle = "#f1f5f9";
574
+ ctx.lineWidth = 2;
575
+ ctx.beginPath();
576
+ ctx.moveTo(100, 545);
577
+ ctx.lineTo(500, 545);
578
+ ctx.stroke();
579
+
580
+ // Draw metadata labels
581
+ ctx.textAlign = "left";
582
+ ctx.fillStyle = "#64748b"; // slate-500
583
+ ctx.font = "bold 14px sans-serif";
584
+ ctx.fillText("EMPLOYEE ID", 100, 580);
585
+ ctx.fillText("DEPARTMENT", 320, 580);
586
+ ctx.fillText("DATE OF JOIN", 100, 640);
587
+ ctx.fillText("STATUS", 320, 640);
588
+
589
+ // Draw metadata values
590
+ ctx.fillStyle = "#0f172a"; // slate-900
591
+ ctx.font = "bold 18px sans-serif";
592
+ ctx.fillText(employee.employee_id, 100, 605);
593
+ ctx.fillText(employee.department?.name?.toUpperCase() || "GENERAL", 320, 605);
594
+
595
+ const joinDate = employee.joining_date ? new Date(employee.joining_date).toLocaleDateString("en-US", {
596
+ year: "numeric", month: "short", day: "numeric"
597
+ }) : "N/A";
598
+ ctx.fillText(joinDate.toUpperCase(), 100, 665);
599
+
600
+ // Draw status with verified indicator
601
+ ctx.fillStyle = themeStyles.primaryHex;
602
+ ctx.fillText("VERIFIED", 320, 665);
603
+
604
+ // 4. Bottom section: QR Code
605
+ const qrSize = 130;
606
+ const qrX = 235;
607
+ const qrY = 710;
608
+
609
+ // Draw QR border / background card
610
+ ctx.fillStyle = "#f8fafc";
611
+ ctx.beginPath();
612
+ if (typeof ctx.roundRect === "function") {
613
+ ctx.roundRect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30, 15);
614
+ } else {
615
+ ctx.rect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30);
616
+ }
617
+ ctx.fill();
618
+ ctx.strokeStyle = "#e2e8f0";
619
+ ctx.lineWidth = 2;
620
+ ctx.stroke();
621
+
622
+ if (qrImg) {
623
+ ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
624
+ } else {
625
+ // Draw placeholder QR
626
+ ctx.strokeStyle = "#cbd5e1";
627
+ ctx.strokeRect(qrX, qrY, qrSize, qrSize);
628
+ ctx.fillStyle = "#94a3b8";
629
+ ctx.font = "12px sans-serif";
630
+ ctx.textAlign = "center";
631
+ ctx.fillText("QR CODE", qrX + qrSize / 2, qrY + qrSize / 2);
632
+ }
633
+
634
+ ctx.fillStyle = "#64748b"; // slate-500
635
+ ctx.font = "bold 12px sans-serif";
636
+ ctx.textAlign = "center";
637
+ ctx.fillText("SCAN AS BACKUP IF KIOSK FACE RECOGNITION FAILS", 300, 875);
638
+
639
+ // Trigger download of the image
640
+ const dataUrl = canvas.toDataURL("image/png");
641
+ const link = document.createElement("a");
642
+ link.download = `ID_Card_${employee.employee_id}.png`;
643
+ link.href = dataUrl;
644
+ link.click();
645
+ toast.success("ID Card PNG downloaded successfully!");
646
+ } catch (err: any) {
647
+ console.error(err);
648
+ toast.error("Failed to generate and download ID Card image: " + err.message);
649
+ }
650
+ };
651
+
652
  // Webcam controls
653
  const startWebcam = async () => {
654
  setErrorMsg(null);
 
848
 
849
  return (
850
  <SidebarLayout>
851
+ <div className="space-y-6 max-w-5xl page-enter relative print-reset-container">
852
  {/* CSS Scanner Animations style block */}
853
  <style>{`
854
  @keyframes scanline {
 
1002
  {/* Upload Fallback File Option */}
1003
  <div className="bg-slate-50 border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
1004
  <h4 className="text-[11.5px] font-bold text-slate-700 uppercase tracking-wider">Manual Photo Upload</h4>
 
 
 
1005
  <div className="flex gap-2">
1006
  <select
1007
  value={selectedPose}
 
1030
  {/* Right: Big visual grid checklist */}
1031
  <div className="md:col-span-2 bg-white border border-slate-200 rounded-2xl p-6 shadow-sm space-y-4">
1032
  <h3 className="text-sm font-black text-slate-900 tracking-tight">Facial Pose Checklist</h3>
 
 
 
1033
 
1034
  <div className="grid grid-cols-2 sm:grid-cols-5 gap-3.5 pt-2">
1035
  {POSE_KEYS.map((key) => {
 
1305
 
1306
  {/* ─── State 5: SUCCESS SCREEN ─── */}
1307
  {captureState === "success" && (
1308
+ <div className="max-w-5xl mx-auto space-y-6 print-reset-container">
1309
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start animate-scaleIn print-reset-container">
1310
+
1311
+ {/* Left Column: Success message box & Control Panel */}
1312
+ <div className="bg-white border border-slate-200 rounded-3xl p-8 shadow-md text-center space-y-6 no-print">
1313
+ <div className="relative w-20 h-20 mx-auto bg-emerald-50 rounded-full flex items-center justify-center border border-emerald-100 success-circle">
1314
+ <svg className="w-10 h-10 text-emerald-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
1315
+ <polyline points="20 6 9 17 4 12" className="success-check" />
1316
+ </svg>
1317
+ </div>
1318
 
1319
+ <div className="space-y-2">
1320
+ <h2 className="text-xl font-bold text-slate-900 tracking-tight">Biometric Profile Secured</h2>
1321
+ <p className="text-xs text-slate-550 leading-relaxed">
1322
+ 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.
1323
+ </p>
1324
+ </div>
1325
 
1326
+ <div className="p-4 bg-slate-50 rounded-2xl text-left border border-slate-150 space-y-2">
1327
+ <h4 className="text-[11px] font-bold text-slate-700 uppercase tracking-wider font-mono">System Integrity Verification</h4>
1328
+ <ul className="text-[10px] text-slate-500 font-mono space-y-1">
1329
+ <li className="flex items-center gap-1.5 text-emerald-600">
1330
+ <span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
1331
+ 10/10 BIOMETRIC POSES RECORDED
1332
+ </li>
1333
+ <li className="flex items-center gap-1.5 text-emerald-600">
1334
+ <span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
1335
+ HNSW VECTOR INDEX UPDATED
1336
+ </li>
1337
+ <li className="flex items-center gap-1.5 text-emerald-600">
1338
+ <span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
1339
+ BACKUP SECURITY QR CODE ENCODED
1340
+ </li>
1341
+ </ul>
1342
+ </div>
1343
 
1344
+ <div className="grid grid-cols-2 gap-3 pt-2">
1345
+ <button
1346
+ onClick={() => router.push("/employees")}
1347
+ className="h-10 bg-white hover:bg-slate-55 border border-slate-250 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl transition-all shadow-sm cursor-pointer"
1348
+ >
1349
+ Employees List
1350
+ </button>
 
 
1351
 
1352
+ <button
1353
+ onClick={startAutoCapture}
1354
+ className="h-10 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"
1355
+ >
1356
+ Re-enroll
1357
+ </button>
1358
+ </div>
1359
+
1360
+ <div className="border-t border-slate-100 pt-5 space-y-3">
1361
+ <p className="text-[10.5px] text-slate-450 leading-relaxed">
1362
+ Generate the physical identification card below. Keep a digital copy or print immediately.
1363
+ </p>
1364
+
1365
+ <div className="flex gap-3">
1366
+ <button
1367
+ onClick={() => window.print()}
1368
+ className={`flex-1 h-11 ${themeStyles.headerBg} hover:opacity-90 text-white font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg active:scale-[0.98] border border-white/10 cursor-pointer`}
1369
+ >
1370
+ <Printer className="w-4 h-4 text-white/90" />
1371
+ Print ID Badge
1372
+ </button>
1373
+
1374
+ <button
1375
+ onClick={handleDownloadBadge}
1376
+ className="flex-1 h-11 bg-white hover:bg-slate-50 border border-slate-200 text-slate-800 font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm hover:shadow-md active:scale-[0.98] cursor-pointer"
1377
+ >
1378
+ <Download className="w-4 h-4 text-slate-650" />
1379
+ Download PNG
1380
+ </button>
1381
+ </div>
1382
+ </div>
1383
+ </div>
1384
+
1385
+ {/* Right Column: ID Card Gorgeous 3D Preview */}
1386
+ <div className="flex flex-col items-center justify-center print-reset-container">
1387
+ <div className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mb-3 font-mono no-print">
1388
+ Badge Live Preview ({badgeTheme} + {badgePattern})
1389
+ </div>
1390
+
1391
+ {/* Printable ID Card Element */}
1392
+ <div
1393
+ id="printable-id-card-wrap"
1394
+ className="w-[320px] h-[500px] bg-white rounded-[24px] border border-slate-200 shadow-[0_15px_40px_rgba(0,0,0,0.08)] overflow-hidden relative flex flex-col transition-all hover:scale-[1.01] hover:shadow-[0_20px_50px_rgba(0,0,0,0.12)] duration-300 select-none font-sans animate-fadeIn"
1395
+ >
1396
+ {/* Watermark Pattern Overlay */}
1397
+ <BadgeWatermark type={badgePattern} />
1398
+
1399
+ {/* Holographic Glossy Overlay (Premium aesthetic) */}
1400
+ <div className="absolute inset-0 bg-linear-to-tr from-white/0 via-white/5 to-white/10 pointer-events-none z-10" />
1401
+
1402
+ {/* Lanyard punch hole detail */}
1403
+ <div className="absolute top-3.5 left-1/2 -translate-x-1/2 w-10 h-3 bg-slate-100 rounded-full border border-slate-200/50 flex items-center justify-center pointer-events-none no-print">
1404
+ <div className="w-6 h-1 bg-slate-300 rounded-full" />
1405
+ </div>
1406
+
1407
+ {/* Header: Company Name & Logo */}
1408
+ <div className={`h-[105px] ${themeStyles.headerBg} relative flex flex-col justify-end px-5 pb-3`}>
1409
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.1),transparent_70%)] pointer-events-none" />
1410
+
1411
+ <div className="flex items-center gap-3.5 mt-2 relative z-10">
1412
+ {companyLogo ? (
1413
+ <img
1414
+ src={companyLogo}
1415
+ alt="Logo"
1416
+ className="h-8 max-w-[90px] object-contain shrink-0"
1417
+ />
1418
+ ) : (
1419
+ <div className="w-7 h-7 rounded-lg bg-white/20 backdrop-blur-xs flex items-center justify-center text-white text-[10px] font-black tracking-tighter shadow-sm font-mono shrink-0 border border-white/10">
1420
+ NID
1421
+ </div>
1422
+ )}
1423
+ <div className="flex flex-col min-w-0">
1424
+ <span className="text-[12px] font-black tracking-wider text-white uppercase truncate">
1425
+ {companyName}
1426
+ </span>
1427
+ <span className="text-[7.5px] font-bold text-white/80 tracking-widest uppercase">
1428
+ SECURED IDENTITY CARD
1429
+ </span>
1430
+ </div>
1431
+ </div>
1432
+ </div>
1433
+
1434
+ {/* Body Content */}
1435
+ <div className="flex-1 flex flex-col items-center pt-7 px-6 relative bg-transparent z-10">
1436
+
1437
+ {/* Employee Profile Image Container */}
1438
+ <div className={`relative w-[120px] h-[120px] rounded-full p-1 bg-gradient-to-tr ${themeStyles.photoBorder} shadow-md`}>
1439
+ <div className="w-full h-full rounded-full overflow-hidden border-2 border-white bg-slate-100">
1440
+ <img
1441
+ src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${employee?.employee_id}/front.jpg`}
1442
+ alt={employee?.name}
1443
+ onError={(e) => {
1444
+ (e.target as HTMLImageElement).src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%2394a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
1445
+ }}
1446
+ className="w-full h-full object-cover"
1447
+ />
1448
+ </div>
1449
+
1450
+ {/* Holographic Official Seal */}
1451
+ <div className="absolute -bottom-1.5 -right-1.5 bg-gradient-to-tr from-amber-400 via-orange-400 to-yellow-300 text-amber-950 font-bold border-2 border-white rounded-full w-6 h-6 flex items-center justify-center shadow-md z-10 pointer-events-none">
1452
+ <svg className="w-3.5 h-3.5 stroke-amber-950" viewBox="0 0 24 24" fill="none" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
1453
+ <polyline points="20 6 9 17 4 12" />
1454
+ </svg>
1455
+ </div>
1456
+ </div>
1457
+
1458
+ {/* Employee Identity details */}
1459
+ <div className="text-center mt-4 space-y-0.5">
1460
+ <h3 className="text-base font-extrabold text-slate-900 tracking-tight uppercase leading-tight">
1461
+ {employee?.name}
1462
+ </h3>
1463
+ <p className={`text-[11px] font-bold ${themeStyles.accentText} tracking-widest uppercase`}>
1464
+ {employee?.designation || "Staff Member"}
1465
+ </p>
1466
+ </div>
1467
+
1468
+ {/* Meta Fields Table */}
1469
+ <div className="grid grid-cols-2 gap-x-4 gap-y-2.5 w-full border-t border-slate-100 mt-5 pt-3.5 bg-transparent">
1470
+ <div>
1471
+ <span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
1472
+ Employee ID
1473
+ </span>
1474
+ <span className="text-[10px] font-extrabold text-slate-800 tracking-tight block">
1475
+ {employee?.employee_id}
1476
+ </span>
1477
+ </div>
1478
+ <div>
1479
+ <span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
1480
+ Department
1481
+ </span>
1482
+ <span className="text-[10px] font-extrabold text-slate-800 tracking-tight block truncate uppercase">
1483
+ {employee?.department?.name || "General"}
1484
+ </span>
1485
+ </div>
1486
+ <div>
1487
+ <span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
1488
+ Date of Join
1489
+ </span>
1490
+ <span className="text-[10px] font-extrabold text-slate-800 tracking-tight block">
1491
+ {employee?.joining_date ? new Date(employee.joining_date).toLocaleDateString("en-US", {
1492
+ year: "numeric", month: "short", day: "numeric"
1493
+ }) : "N/A"}
1494
+ </span>
1495
+ </div>
1496
+ <div>
1497
+ <span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
1498
+ Security Status
1499
+ </span>
1500
+ <span className={`text-[10px] font-bold ${themeStyles.accentText} tracking-tight flex items-center gap-1`}>
1501
+ <span className={`w-1.5 h-1.5 rounded-full ${themeStyles.dotColor}`} />
1502
+ VERIFIED
1503
+ </span>
1504
+ </div>
1505
+ </div>
1506
+ </div>
1507
+
1508
+ {/* QR Code Fallback Section */}
1509
+ <div className="bg-slate-50/80 backdrop-blur-xs border-t border-slate-100 h-[120px] flex items-center justify-between px-6 pb-2.5 shrink-0 z-10">
1510
+ <div className="flex flex-col min-w-0 pr-2">
1511
+ <span className="text-[8px] font-black text-slate-900 tracking-wider uppercase font-mono">
1512
+ SCAN TO VERIFY
1513
+ </span>
1514
+ <p className="text-[7px] text-slate-450 font-medium leading-snug mt-0.5 max-w-[130px] font-mono">
1515
+ If facial scanner recognition fails, scan this backup QR code at Kiosk terminal.
1516
+ </p>
1517
+ </div>
1518
+
1519
+ {/* QR Code Container */}
1520
+ <div className="w-[75px] h-[75px] bg-white rounded-lg border border-slate-200/80 p-1 flex items-center justify-center shadow-2xs shrink-0 font-mono">
1521
+ <img
1522
+ src={`https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${employee?.employee_id}`}
1523
+ alt="QR Code"
1524
+ className="w-full h-full object-contain"
1525
+ />
1526
+ </div>
1527
+ </div>
1528
+ </div>
1529
+ </div>
1530
  </div>
1531
+
1532
+ {/* Print Styling Injected locally */}
1533
+ <style dangerouslySetInnerHTML={{ __html: `
1534
+ @media print {
1535
+ /* Hide sidebar, headers, footers and any elements marked no-print */
1536
+ aside, header, footer, .no-print, button, input, select, [role="navigation"], .ambient-bg, .mesh-bg {
1537
+ display: none !important;
1538
+ }
1539
+
1540
+ @page {
1541
+ size: portrait;
1542
+ margin: 0;
1543
+ }
1544
+
1545
+ /* Reset container layout models so they don't center, shift or clip the content */
1546
+ html, body, html.dark, body.dark {
1547
+ margin: 0 !important;
1548
+ padding: 0 !important;
1549
+ width: 100% !important;
1550
+ height: 100% !important;
1551
+ overflow: hidden !important;
1552
+ background-color: white !important;
1553
+ background: white !important;
1554
+ position: relative !important;
1555
+ }
1556
+
1557
+ /* Reset only parent layout hierarchy, leaving internal elements of card intact */
1558
+ main,
1559
+ body > div,
1560
+ #sidebar-layout-container,
1561
+ .sidebar-layout-content,
1562
+ .print-reset-container,
1563
+ .page-enter,
1564
+ .dark main,
1565
+ .dark body > div,
1566
+ .dark #sidebar-layout-container,
1567
+ .dark .sidebar-layout-content,
1568
+ .dark .print-reset-container,
1569
+ .dark .page-enter {
1570
+ border: none !important;
1571
+ box-shadow: none !important;
1572
+ background: transparent !important;
1573
+ background-color: transparent !important;
1574
+ padding: 0 !important;
1575
+ margin: 0 !important;
1576
+ height: auto !important;
1577
+ min-height: 0 !important;
1578
+ overflow: visible !important;
1579
+ position: static !important;
1580
+ width: auto !important;
1581
+ display: block !important;
1582
+ /* Clear transform/animations that would trap fixed/absolute positioning context */
1583
+ transform: none !important;
1584
+ animation: none !important;
1585
+ transition: none !important;
1586
+ }
1587
+
1588
+ /* Center and display only the card wrapper */
1589
+ #printable-id-card-wrap {
1590
+ display: flex !important;
1591
+ flex-direction: column !important;
1592
+ visibility: visible !important;
1593
+ position: fixed !important;
1594
+ left: 50% !important;
1595
+ top: 50% !important;
1596
+ transform: translate(-50%, -50%) scale(1.1) !important;
1597
+ border: 1px solid #cbd5e1 !important;
1598
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
1599
+ border-radius: 24px !important;
1600
+ background-color: white !important;
1601
+ width: 320px !important;
1602
+ height: 500px !important;
1603
+ margin: 0 !important;
1604
+ overflow: hidden !important;
1605
+ page-break-inside: avoid;
1606
+ }
1607
+
1608
+ #printable-id-card-wrap * {
1609
+ visibility: visible !important;
1610
+ }
1611
+
1612
+ /* Force print watermark color & visibility */
1613
+ .badge-watermark-container,
1614
+ .dark .badge-watermark-container {
1615
+ color: #475569 !important;
1616
+ opacity: 0.12 !important;
1617
+ }
1618
+
1619
+ /* Ensure background colors and images print properly */
1620
+ * {
1621
+ -webkit-print-color-adjust: exact !important;
1622
+ print-color-adjust: exact !important;
1623
+ }
1624
+ }
1625
+ ` }} />
1626
  </div>
1627
  )}
1628
+
1629
+
1630
  </div>
1631
 
1632
  {/* Clear Biometric Confirmation Modal */}
frontend/app/globals.css CHANGED
@@ -2,37 +2,37 @@
2
  @tailwind components;
3
  @tailwind utilities;
4
 
5
- /* ─── Design Tokens (Strict Minimalist Light Theme: White & Dark Grey Only) ─── */
6
  :root {
7
- --bg-base: #ffffff; /* Pure White */
8
- --bg-surface: #ffffff; /* Pure White */
9
- --bg-elevated: #ffffff; /* Pure White */
10
- --bg-overlay: rgba(255, 255, 255, 0.98);
11
-
12
- --accent-primary: #09090b; /* Zinc 950 */
13
- --accent-cyan: #18181b; /* Zinc 900 */
14
- --accent-indigo: #18181b;
15
- --accent-emerald: #09090b;
16
- --accent-amber: #27272a;
17
- --accent-rose: #09090b;
18
-
19
- --border-subtle: #fafafa; /* Zinc 50 */
20
- --border-medium: #f4f4f5; /* Zinc 100 */
21
- --border-strong: #e4e4e7; /* Zinc 200 */
22
-
23
- --text-primary: #09090b; /* Zinc 950 */
24
- --text-secondary: #27272a; /* Zinc 900 */
25
- --text-muted: #71717a; /* Zinc 500 */
26
- --text-faint: #a1a1aa; /* Zinc 400 */
27
-
28
- --glow-blue: none;
29
- --glow-cyan: none;
30
- --radius-sm: 6px;
31
- --radius-md: 8px;
32
- --radius-lg: 12px;
33
- --radius-xl: 14px;
34
- --radius-2xl: 16px;
35
- --radius-3xl: 20px;
36
  }
37
 
38
  /* ─── Base Reset ─── */
@@ -198,6 +198,15 @@ select.input-field option {
198
  transition: background-color 5000s ease-in-out 0s;
199
  }
200
 
 
 
 
 
 
 
 
 
 
201
  /* Hide native Chrome autofill key/card icons to prevent overlap with Lucide icons */
202
  .input-field::-webkit-contacts-auto-fill-button,
203
  .input-field::-webkit-credentials-auto-fill-button {
 
2
  @tailwind components;
3
  @tailwind utilities;
4
 
5
+ /* ─── Design Tokens (Premium Slate & Modern Corporate Theme) ─── */
6
  :root {
7
+ --bg-base: #f8fafc; /* Slate 50 - soft grey-blue */
8
+ --bg-surface: #ffffff; /* White card base */
9
+ --bg-elevated: #f1f5f9; /* Slate 100 */
10
+ --bg-overlay: rgba(255, 255, 255, 0.96);
11
+
12
+ --accent-primary: #0f172a; /* Slate 900 */
13
+ --accent-cyan: #06b6d4; /* Cyan 500 */
14
+ --accent-indigo: #4f46e5; /* Indigo 600 */
15
+ --accent-emerald: #10b981; /* Emerald 500 */
16
+ --accent-amber: #f59e0b; /* Amber 500 */
17
+ --accent-rose: #ef4444; /* Rose 500 */
18
+
19
+ --border-subtle: #f1f5f9; /* Slate 100 */
20
+ --border-medium: #e2e8f0; /* Slate 200 */
21
+ --border-strong: #cbd5e1; /* Slate 300 */
22
+
23
+ --text-primary: #0f172a; /* Slate 900 */
24
+ --text-secondary: #334155; /* Slate 700 */
25
+ --text-muted: #64748b; /* Slate 500 */
26
+ --text-faint: #94a3b8; /* Slate 400 */
27
+
28
+ --glow-blue: 0 0 20px rgba(59, 130, 246, 0.08);
29
+ --glow-cyan: 0 0 20px rgba(6, 182, 212, 0.12);
30
+ --radius-sm: 8px;
31
+ --radius-md: 12px;
32
+ --radius-lg: 16px;
33
+ --radius-xl: 20px;
34
+ --radius-2xl: 24px;
35
+ --radius-3xl: 32px;
36
  }
37
 
38
  /* ─── Base Reset ─── */
 
198
  transition: background-color 5000s ease-in-out 0s;
199
  }
200
 
201
+ .dark .input-field:-webkit-autofill,
202
+ .dark .input-field:-webkit-autofill:hover,
203
+ .dark .input-field:-webkit-autofill:focus,
204
+ .dark .input-field:-webkit-autofill:active {
205
+ -webkit-text-fill-color: var(--text-primary) !important;
206
+ -webkit-box-shadow: 0 0 0px 1000px #18181b inset !important;
207
+ box-shadow: 0 0 0px 1000px #18181b inset !important;
208
+ }
209
+
210
  /* Hide native Chrome autofill key/card icons to prevent overlap with Lucide icons */
211
  .input-field::-webkit-contacts-auto-fill-button,
212
  .input-field::-webkit-credentials-auto-fill-button {
frontend/app/kiosk/page.tsx CHANGED
@@ -3,10 +3,12 @@
3
  import React, { useState, useEffect, useRef } from "react";
4
  import {
5
  Camera, UserCheck, ShieldAlert, HelpCircle, Maximize, Minimize,
6
- Volume2, VolumeX, Clock as ClockIcon, Play, Wifi, Fingerprint, Shield
 
7
  } from "lucide-react";
8
  import { getBackendUrl } from "@/app/utils/api";
9
  import { useToast } from "@/app/utils/toast";
 
10
 
11
  function formatTime12h(timeStr: string | null | undefined): string {
12
  if (!timeStr) return "";
@@ -33,7 +35,8 @@ export default function KioskPage() {
33
  const [currentDate, setCurrentDate] = useState("");
34
  const [matchTime, setMatchTime] = useState("");
35
  const [scanning, setScanning] = useState(false);
36
- const [scanStatus, setScanStatus] = useState<"idle" | "success" | "spoof" | "unknown" | "maintenance" | "no_employees" | "ask_checkout" | "locked">("idle");
 
37
  const [scanResult, setScanResult] = useState<any>(null);
38
  const [scanFeedback, setScanFeedback] = useState<string | null>(null);
39
  const [cameraLabel] = useState("Main Entrance");
@@ -41,13 +44,37 @@ export default function KioskPage() {
41
  const [isFullscreen, setIsFullscreen] = useState(false);
42
  const [profileImageError, setProfileImageError] = useState(false);
43
  const [lastImage, setLastImage] = useState<string | null>(null);
 
44
  const [engineMode, setEngineMode] = useState<string>("LOADING ENGINE...");
 
 
 
45
 
46
  const videoRef = useRef<HTMLVideoElement>(null);
47
  const canvasRef = useRef<HTMLCanvasElement>(null);
48
  const streamRef = useRef<MediaStream | null>(null);
49
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
50
  const cooldownRef = useRef(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  // Clock
53
  useEffect(() => {
@@ -61,6 +88,62 @@ export default function KioskPage() {
61
  return () => clearInterval(t);
62
  }, []);
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  // Fetch engine state dynamically on mount
65
  useEffect(() => {
66
  const checkEngine = async () => {
@@ -96,7 +179,7 @@ export default function KioskPage() {
96
  }
97
  };
98
 
99
- const startKiosk = async () => {
100
  setScanFeedback(null);
101
  try {
102
  const stream = await navigator.mediaDevices.getUserMedia({
@@ -111,8 +194,13 @@ export default function KioskPage() {
111
  }
112
 
113
  setKioskActive(true);
114
- setScanStatus("idle");
115
- intervalRef.current = setInterval(captureAndScan, 1000);
 
 
 
 
 
116
  } catch (err) {
117
  console.error(err);
118
  toast.error("Unable to access camera. Please check browser permissions and ensure no other application is using it.");
@@ -130,8 +218,43 @@ export default function KioskPage() {
130
  setScanStatus("idle");
131
  setScanResult(null);
132
  setScanFeedback(null);
 
133
  };
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  useEffect(() => {
136
  return () => {
137
  if (intervalRef.current) clearInterval(intervalRef.current);
@@ -149,29 +272,98 @@ export default function KioskPage() {
149
  // Check if the video is actually ready and playing
150
  if (video.readyState < 2) return;
151
 
152
- canvas.width = 640; canvas.height = 480;
153
  ctx.translate(canvas.width, 0); ctx.scale(-1, 1);
154
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
155
  ctx.setTransform(1, 0, 0, 1, 0, 0);
156
 
157
  const base64 = canvas.toDataURL("image/jpeg", 0.82);
158
  setLastImage(base64);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  setScanning(true);
160
  try {
161
  // Connect to the backend running at the configured URL
162
  const url = `${getBackendUrl()}/kiosk/scan`;
 
 
 
 
 
 
 
 
163
  const res = await fetch(url, {
164
  method: "POST",
165
  headers: { "Content-Type": "application/json" },
166
- body: JSON.stringify({ image: base64, camera: cameraLabel })
167
  });
168
  if (!res.ok) throw new Error("Scan failed");
169
  const data = await res.json();
170
 
 
 
 
 
 
 
171
  if (data.status === "no_face") {
172
  setScanFeedback("Align your face in frame");
 
173
  } else if (data.status === "multiple_faces") {
174
  setScanFeedback("One person at a time");
 
175
  } else {
176
  setScanFeedback(null);
177
  handleResult(data);
@@ -179,20 +371,34 @@ export default function KioskPage() {
179
  } catch (err) {
180
  console.error("Scan API connection error:", err);
181
  setScanFeedback("Connection error");
 
182
  } finally {
183
  setScanning(false);
184
  }
185
  };
186
 
 
 
 
 
 
 
 
 
187
  const confirmCheckout = async () => {
188
  if (!lastImage) return;
189
  setScanning(true);
190
  try {
191
  const url = `${getBackendUrl()}/kiosk/scan`;
 
 
 
 
 
192
  const res = await fetch(url, {
193
  method: "POST",
194
  headers: { "Content-Type": "application/json" },
195
- body: JSON.stringify({ image: lastImage, camera: cameraLabel, confirm_checkout: true })
196
  });
197
  if (!res.ok) throw new Error("Checkout confirmation failed");
198
  const data = await res.json();
@@ -205,7 +411,84 @@ export default function KioskPage() {
205
  }
206
  };
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  const handleResult = (data: any) => {
 
 
 
 
 
 
209
  if (data.status === "success") {
210
  setProfileImageError(false);
211
  setScanStatus("success"); setScanResult(data);
@@ -226,9 +509,13 @@ export default function KioskPage() {
226
  console.error("Autoplay voice greeting failed:", err);
227
  });
228
  }
 
 
 
 
229
  } else if (data.status === "locked") {
230
  setScanStatus("locked"); setScanResult(data);
231
- triggerCooldown(4000);
232
  } else if (data.status === "spoof_detected") {
233
  setScanStatus("spoof"); setScanResult(data);
234
  triggerCooldown(3000);
@@ -241,20 +528,115 @@ export default function KioskPage() {
241
  } else if (data.status === "no_employees") {
242
  setScanStatus("no_employees"); setScanResult(data);
243
  triggerCooldown(4000);
 
 
 
244
  }
245
  };
246
 
247
  const triggerCooldown = (ms: number) => {
 
248
  cooldownRef.current = true;
249
- setTimeout(() => {
250
  cooldownRef.current = false;
251
  setScanStatus("idle");
252
  setScanResult(null);
 
 
253
  }, ms);
254
  };
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  return (
257
  <div className="min-h-screen bg-[var(--bg-base)] text-[var(--text-primary)] flex flex-col relative select-none overflow-hidden font-sans">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  {/* Background Grid Mesh */}
259
  <div className="absolute inset-0 mesh-bg pointer-events-none" />
260
  <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[350px] bg-slate-500/5 blur-[120px] pointer-events-none rounded-full" />
@@ -283,11 +665,17 @@ export default function KioskPage() {
283
  </div>
284
  </div>
285
 
286
- {/* Clock */}
287
- <div className="text-center absolute left-1/2 -translate-y-1/2">
288
  <p className="text-base font-bold font-mono tracking-tight text-[var(--text-primary)] leading-none tabular-nums">
289
  {currentTime || "00:00:00"}
290
  </p>
 
 
 
 
 
 
291
  </div>
292
 
293
  {/* Controls */}
@@ -356,7 +744,7 @@ export default function KioskPage() {
356
  </div>
357
 
358
  <button
359
- onClick={startKiosk}
360
  className="btn-primary h-10 px-8 text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all"
361
  >
362
  <Play className="w-4 h-4 fill-current" />
@@ -375,6 +763,26 @@ export default function KioskPage() {
375
  autoPlay playsInline muted
376
  />
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  {/* Scanning overlay: active standby state */}
379
  {kioskActive && scanStatus === "idle" && (
380
  <>
@@ -398,136 +806,271 @@ export default function KioskPage() {
398
 
399
  {/* SUCCESS screen */}
400
  {kioskActive && scanStatus === "success" && (
401
- <div className="absolute inset-0 glass-overlay flex flex-col items-center justify-center p-6 text-center animate-fadeInUp">
402
- <div className="space-y-4 max-w-xs w-full animate-fade-in">
403
- {/* Profile Image / Initials */}
404
- <div className="mx-auto w-20 h-20 rounded-full overflow-hidden border border-zinc-150 shadow-sm bg-zinc-55 flex items-center justify-center">
405
- {scanResult?.employee?.employee_id && !profileImageError ? (
406
- <img
407
- src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
408
- alt={scanResult.employee.name}
409
- className="w-full h-full object-cover"
410
- onError={() => setProfileImageError(true)}
411
- />
412
- ) : (
413
- <span className="text-zinc-700 font-bold text-2xl">
414
- {scanResult?.employee?.name
415
- ? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
416
- : "PK"}
417
- </span>
418
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  </div>
420
 
421
  {/* Name and Designation */}
422
- <div className="space-y-0.5">
423
- <h2 className="text-lg font-bold text-zinc-900 tracking-tight">
424
  {scanResult?.employee?.name || "Employee"}
425
  </h2>
426
- <p className="text-[11px] text-zinc-400 font-medium">
427
- {scanResult?.employee?.designation || "Employee"} ({scanResult?.employee?.employee_id})
428
  </p>
429
  </div>
430
 
431
- {/* Clock & Status */}
432
- <div className="space-y-2.5 pt-2">
433
- <p className="text-xl font-bold font-mono text-[var(--text-primary)] tracking-tight tabular-nums">
434
- {matchTime}
435
- </p>
436
-
437
- <div className="flex flex-col items-center gap-1">
438
- <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-700 font-mono text-[9px] font-bold uppercase tracking-wider">
439
- Matched
 
 
 
440
  </span>
441
- {scanResult?.attendance?.working_hours > 0 && (
442
- <div className="mt-1 px-3 py-1 bg-zinc-50 border border-zinc-200 rounded-lg">
443
- <p className="text-[9px] text-zinc-400 font-bold uppercase tracking-wider">Time Worked Today</p>
444
- <p className="text-xs font-bold text-zinc-755 font-mono mt-0.5">{scanResult.attendance.working_hours.toFixed(2)} hours</p>
445
- </div>
446
- )}
447
- {scanResult?.confidence && (
448
- <p className="text-[8.5px] text-zinc-400 font-mono mt-1">
449
- Confidence: {(scanResult.confidence * 100).toFixed(0)}% · {(scanResult.liveness_score * 100).toFixed(0)}% Real
450
- </p>
451
- )}
452
  </div>
453
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  </div>
455
  </div>
456
  )}
457
 
458
  {/* ASK_CHECKOUT screen */}
459
  {kioskActive && scanStatus === "ask_checkout" && (
460
- <div className="absolute inset-0 glass-overlay flex flex-col items-center justify-center p-6 text-center animate-fadeInUp z-30">
461
- <div className="space-y-4 max-w-sm w-full animate-fade-in">
462
- <div className="mx-auto w-20 h-20 rounded-full overflow-hidden border border-zinc-150 shadow-sm bg-zinc-55 flex items-center justify-center">
463
- {scanResult?.employee?.employee_id && !profileImageError ? (
464
- <img
465
- src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
466
- alt={scanResult.employee.name}
467
- className="w-full h-full object-cover"
468
- onError={() => setProfileImageError(true)}
469
- />
470
- ) : (
471
- <span className="text-zinc-700 font-bold text-2xl">
472
- {scanResult?.employee?.name
473
- ? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
474
- : "PK"}
475
- </span>
476
- )}
477
  </div>
478
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
  <div className="space-y-1">
480
- <h2 className="text-lg font-bold text-zinc-900 tracking-tight">
481
  {scanResult?.employee?.name || "Employee"}
482
  </h2>
483
- <p className="text-[11.5px] text-zinc-500 font-medium">
484
- Already checked in at <span className="font-mono font-bold text-zinc-750">{formatTime12h(scanResult?.attendance?.check_in)}</span>
485
  </p>
486
  </div>
487
 
488
- <div className="bg-zinc-50 border border-zinc-200 rounded-xl p-2.5 max-w-[200px] mx-auto">
489
- <p className="text-[9px] text-zinc-450 font-bold uppercase tracking-wider">Working Hours So Far</p>
490
- <p className="text-sm font-extrabold text-zinc-850 font-mono mt-0.5">
 
491
  {scanResult?.working_hours_so_far?.toFixed(2)} hours
492
- </p>
493
  </div>
494
 
495
- <div className="space-y-2.5 pt-1.5">
496
- <p className="text-[12px] font-bold text-[var(--text-primary)]">Do you want to Check Out?</p>
497
- <div className="flex items-center justify-center gap-2.5">
 
498
  <button
499
  onClick={confirmCheckout}
500
  disabled={scanning}
501
- className="px-5 py-2 rounded-xl bg-zinc-950 hover:bg-zinc-800 text-white text-[12px] font-bold shadow-sm cursor-pointer transition-all disabled:opacity-50"
502
  >
503
  Yes, Check Out
504
  </button>
505
  <button
506
  onClick={() => {
507
- setScanStatus("idle");
508
- setScanResult(null);
509
  }}
510
  disabled={scanning}
511
- className="px-5 py-2 rounded-xl bg-zinc-100 hover:bg-zinc-200 text-zinc-700 text-[12px] font-bold border border-zinc-200 cursor-pointer transition-all disabled:opacity-50"
512
  >
513
- No
514
  </button>
515
  </div>
516
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  </div>
518
  </div>
519
  )}
520
 
521
  {/* LOCKED screen */}
522
  {kioskActive && scanStatus === "locked" && (
523
- <div className="absolute inset-0 glass-overlay flex flex-col items-center justify-center p-6 text-center animate-fadeInUp z-30">
524
- <div className="space-y-3.5 max-w-xs animate-fade-in">
525
- <div className="w-12 h-12 rounded-full bg-rose-500/10 border border-rose-500/20 flex items-center justify-center mx-auto text-rose-600">
526
- <ShieldAlert className="w-5 h-5" />
 
 
 
 
 
 
 
 
 
 
527
  </div>
528
- <h2 className="text-base font-bold text-zinc-900 tracking-tight">Attendance Locked</h2>
529
- <p className="text-xs text-zinc-500 leading-relaxed font-medium">
530
- {scanResult?.message || "Your attendance is locked for today. Emergency re-entry must be approved by an Admin."}
531
  </p>
532
  </div>
533
  </div>
@@ -535,43 +1078,78 @@ export default function KioskPage() {
535
 
536
  {/* SPOOF screen */}
537
  {kioskActive && scanStatus === "spoof" && (
538
- <div className="absolute inset-0 glass-overlay flex flex-col items-center justify-center p-6 text-center animate-fadeInUp">
539
- <div className="space-y-3 max-w-xs animate-fade-in">
540
- <div className="w-12 h-12 rounded-full bg-rose-500/10 border border-rose-500/20 flex items-center justify-center mx-auto text-rose-600">
541
- <ShieldAlert className="w-5 h-5" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  </div>
543
- <h2 className="text-base font-bold text-zinc-900 tracking-tight">Verification Denied</h2>
544
- <p className="text-xs text-rose-600 font-medium leading-relaxed">{scanResult?.message}</p>
545
- <p className="text-[8.5px] text-zinc-450 font-mono">
546
- Liveness score: {scanResult?.liveness_score?.toFixed(3)}
547
- </p>
548
  </div>
549
  </div>
550
  )}
551
 
552
  {/* UNKNOWN screen */}
553
  {kioskActive && scanStatus === "unknown" && (
554
- <div className="absolute inset-0 glass-overlay flex flex-col items-center justify-center p-6 text-center animate-fadeInUp">
555
- <div className="space-y-3 max-w-xs animate-fade-in">
556
- <div className="w-12 h-12 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto text-amber-600">
557
- <HelpCircle className="w-5 h-5" />
 
 
 
 
 
 
558
  </div>
559
- <h2 className="text-base font-bold text-zinc-900 tracking-tight">Not Recognized</h2>
560
- <p className="text-xs text-zinc-500 leading-relaxed">{scanResult?.message || "Face not registered on system database."}</p>
 
 
 
 
 
561
  </div>
562
  </div>
563
  )}
564
 
565
  {/* MAINTENANCE screen */}
566
  {kioskActive && scanStatus === "maintenance" && (
567
- <div className="absolute inset-0 glass-overlay flex flex-col items-center justify-center p-6 text-center animate-fadeInUp">
568
- <div className="space-y-3 max-w-xs animate-fade-in">
569
- <div className="w-12 h-12 rounded-full bg-zinc-100 border border-zinc-200/50 flex items-center justify-center mx-auto text-zinc-800">
570
- <ShieldAlert className="w-5 h-5" />
 
 
 
 
 
 
571
  </div>
572
- <h2 className="text-base font-bold text-zinc-900 tracking-tight">Kiosk Offline</h2>
573
- <p className="text-xs text-zinc-500 leading-relaxed">
574
- {scanResult?.message || "Biometric scans are temporarily suspended."}
 
 
 
575
  </p>
576
  </div>
577
  </div>
@@ -579,19 +1157,70 @@ export default function KioskPage() {
579
 
580
  {/* NO_EMPLOYEES screen */}
581
  {kioskActive && scanStatus === "no_employees" && (
582
- <div className="absolute inset-0 glass-overlay flex flex-col items-center justify-center p-6 text-center animate-fadeInUp">
583
- <div className="space-y-3 max-w-xs animate-fade-in">
584
- <div className="w-12 h-12 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto text-amber-600">
585
- <UserCheck className="w-5 h-5" />
 
 
 
 
 
 
586
  </div>
587
- <h2 className="text-base font-bold text-zinc-900 tracking-tight">Setup Required</h2>
588
- <p className="text-xs text-zinc-555 leading-relaxed">
589
- {scanResult?.message || "Please add employees to the system first."}
 
 
 
590
  </p>
591
  </div>
592
  </div>
593
  )}
594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  {/* Frame Info HUD Overlay */}
596
  {kioskActive && (
597
  <div className="absolute bottom-4 left-4 right-4 flex items-center justify-between pointer-events-none z-10">
 
3
  import React, { useState, useEffect, useRef } from "react";
4
  import {
5
  Camera, UserCheck, ShieldAlert, HelpCircle, Maximize, Minimize,
6
+ Volume2, VolumeX, Clock as ClockIcon, Play, Wifi, Fingerprint, Shield,
7
+ QrCode, Loader2
8
  } from "lucide-react";
9
  import { getBackendUrl } from "@/app/utils/api";
10
  import { useToast } from "@/app/utils/toast";
11
+ import jsQR from "jsqr";
12
 
13
  function formatTime12h(timeStr: string | null | undefined): string {
14
  if (!timeStr) return "";
 
35
  const [currentDate, setCurrentDate] = useState("");
36
  const [matchTime, setMatchTime] = useState("");
37
  const [scanning, setScanning] = useState(false);
38
+ const [scanStatus, setScanStatus] = useState<"idle" | "success" | "spoof" | "unknown" | "maintenance" | "no_employees" | "ask_checkout" | "locked" | "needs_qr" | "location_error">("idle");
39
+ const [coords, setCoords] = useState<{ latitude: number | null; longitude: number | null }>({ latitude: null, longitude: null });
40
  const [scanResult, setScanResult] = useState<any>(null);
41
  const [scanFeedback, setScanFeedback] = useState<string | null>(null);
42
  const [cameraLabel] = useState("Main Entrance");
 
44
  const [isFullscreen, setIsFullscreen] = useState(false);
45
  const [profileImageError, setProfileImageError] = useState(false);
46
  const [lastImage, setLastImage] = useState<string | null>(null);
47
+ const [qrDetectedData, setQrDetectedData] = useState<string | null>(null);
48
  const [engineMode, setEngineMode] = useState<string>("LOADING ENGINE...");
49
+ const [qrCodeVal, setQrCodeVal] = useState("");
50
+ const [qrError, setQrError] = useState<string | null>(null);
51
+ const [faceBbox, setFaceBbox] = useState<number[] | null>(null);
52
 
53
  const videoRef = useRef<HTMLVideoElement>(null);
54
  const canvasRef = useRef<HTMLCanvasElement>(null);
55
  const streamRef = useRef<MediaStream | null>(null);
56
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
57
  const cooldownRef = useRef(false);
58
+ const cooldownTimeoutRef = useRef<NodeJS.Timeout | null>(null);
59
+ const statusRef = useRef<string>("idle");
60
+ const kioskActiveRef = useRef(false);
61
+ const scanFunctionRef = useRef<(() => void) | undefined>(undefined);
62
+ const startKioskRef = useRef<any>(null);
63
+
64
+ useEffect(() => {
65
+ statusRef.current = scanStatus;
66
+ }, [scanStatus]);
67
+
68
+ useEffect(() => {
69
+ kioskActiveRef.current = kioskActive;
70
+ }, [kioskActive]);
71
+
72
+ const clearCooldownTimeout = () => {
73
+ if (cooldownTimeoutRef.current) {
74
+ clearTimeout(cooldownTimeoutRef.current);
75
+ cooldownTimeoutRef.current = null;
76
+ }
77
+ };
78
 
79
  // Clock
80
  useEffect(() => {
 
88
  return () => clearInterval(t);
89
  }, []);
90
 
91
+ useEffect(() => {
92
+ if (typeof window !== "undefined" && navigator.geolocation) {
93
+ const geoId = navigator.geolocation.watchPosition(
94
+ (position) => {
95
+ setCoords({
96
+ latitude: position.coords.latitude,
97
+ longitude: position.coords.longitude
98
+ });
99
+ },
100
+ (err) => {
101
+ console.error("Kiosk geolocation error:", err);
102
+ },
103
+ { enableHighAccuracy: true, timeout: 10000 }
104
+ );
105
+ return () => navigator.geolocation.clearWatch(geoId);
106
+ }
107
+ }, []);
108
+
109
+ const playQrChime = () => {
110
+ if (typeof window !== "undefined") {
111
+ try {
112
+ const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
113
+ const audioCtx = new AudioContextClass();
114
+ if (audioCtx.state === "suspended") {
115
+ audioCtx.resume();
116
+ }
117
+
118
+ const osc1 = audioCtx.createOscillator();
119
+ const gain1 = audioCtx.createGain();
120
+ osc1.connect(gain1);
121
+ gain1.connect(audioCtx.destination);
122
+ osc1.type = "sine";
123
+ osc1.frequency.setValueAtTime(587.33, audioCtx.currentTime); // D5
124
+ gain1.gain.setValueAtTime(0.35, audioCtx.currentTime);
125
+ gain1.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
126
+ osc1.start(audioCtx.currentTime);
127
+ osc1.stop(audioCtx.currentTime + 0.15);
128
+
129
+ const osc2 = audioCtx.createOscillator();
130
+ const gain2 = audioCtx.createGain();
131
+ osc2.connect(gain2);
132
+ gain2.connect(audioCtx.destination);
133
+ osc2.type = "sine";
134
+ osc2.frequency.setValueAtTime(880, audioCtx.currentTime + 0.08); // A5
135
+ gain2.gain.setValueAtTime(0.40, audioCtx.currentTime + 0.08);
136
+ gain2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.35);
137
+ osc2.start(audioCtx.currentTime + 0.08);
138
+ osc2.stop(audioCtx.currentTime + 0.35);
139
+ } catch (err) {
140
+ console.error("Failed to play QR beep chime:", err);
141
+ }
142
+ }
143
+ };
144
+
145
+
146
+
147
  // Fetch engine state dynamically on mount
148
  useEffect(() => {
149
  const checkEngine = async () => {
 
179
  }
180
  };
181
 
182
+ const startKiosk = async (initialStatus: "idle" | "needs_qr" = "idle") => {
183
  setScanFeedback(null);
184
  try {
185
  const stream = await navigator.mediaDevices.getUserMedia({
 
194
  }
195
 
196
  setKioskActive(true);
197
+ setScanStatus(initialStatus);
198
+ if (intervalRef.current) clearInterval(intervalRef.current);
199
+ intervalRef.current = setInterval(() => {
200
+ if (scanFunctionRef.current) {
201
+ scanFunctionRef.current();
202
+ }
203
+ }, 1000);
204
  } catch (err) {
205
  console.error(err);
206
  toast.error("Unable to access camera. Please check browser permissions and ensure no other application is using it.");
 
218
  setScanStatus("idle");
219
  setScanResult(null);
220
  setScanFeedback(null);
221
+ setFaceBbox(null);
222
  };
223
 
224
+ // Global key listener for Backspace shortcut
225
+ useEffect(() => {
226
+ const handleKeyDown = (e: KeyboardEvent) => {
227
+ // Ignore key shortcut if user is actively typing in an input/textarea
228
+ if (
229
+ document.activeElement?.tagName === "INPUT" ||
230
+ document.activeElement?.tagName === "TEXTAREA"
231
+ ) {
232
+ return;
233
+ }
234
+
235
+ if (e.key === "Backspace") {
236
+ e.preventDefault(); // Prevent browser history back navigation
237
+ clearCooldownTimeout();
238
+ cooldownRef.current = false;
239
+
240
+ if (!kioskActiveRef.current) {
241
+ if (startKioskRef.current) startKioskRef.current("needs_qr");
242
+ } else {
243
+ setScanStatus("needs_qr");
244
+ setScanResult(null);
245
+ setQrCodeVal("");
246
+ setQrError(null);
247
+ setFaceBbox(null);
248
+ }
249
+ }
250
+ };
251
+
252
+ window.addEventListener("keydown", handleKeyDown);
253
+ return () => {
254
+ window.removeEventListener("keydown", handleKeyDown);
255
+ };
256
+ }, []);
257
+
258
  useEffect(() => {
259
  return () => {
260
  if (intervalRef.current) clearInterval(intervalRef.current);
 
272
  // Check if the video is actually ready and playing
273
  if (video.readyState < 2) return;
274
 
275
+ canvas.width = 640; canvas.height = 360;
276
  ctx.translate(canvas.width, 0); ctx.scale(-1, 1);
277
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
278
  ctx.setTransform(1, 0, 0, 1, 0, 0);
279
 
280
  const base64 = canvas.toDataURL("image/jpeg", 0.82);
281
  setLastImage(base64);
282
+
283
+ // Frontend-side QR code detection using jsQR (100% reliable) - Checked in all states!
284
+ let detectedQrCode: string | null = null;
285
+ try {
286
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
287
+ const code = jsQR(imageData.data, imageData.width, imageData.height);
288
+ if (code) {
289
+ detectedQrCode = code.data;
290
+ console.log("jsQR detected QR code:", detectedQrCode);
291
+ setScanFeedback("QR Badge Detected: " + detectedQrCode);
292
+ if (!qrDetectedData) {
293
+ playQrChime();
294
+ setQrDetectedData(detectedQrCode);
295
+ }
296
+ }
297
+ } catch (err) {
298
+ console.error("jsQR parsing error:", err);
299
+ }
300
+
301
+ // Fallback to native BarcodeDetector if jsQR missed it and BarcodeDetector is available
302
+ if (!detectedQrCode && typeof window !== "undefined" && "BarcodeDetector" in window) {
303
+ try {
304
+ const detector = new (window as any).BarcodeDetector({ formats: ["qr_code", "code_128", "code_39"] });
305
+ const barcodes = await detector.detect(canvas);
306
+ if (barcodes && barcodes.length > 0) {
307
+ detectedQrCode = barcodes[0].rawValue;
308
+ console.log("BarcodeDetector detected QR code:", detectedQrCode);
309
+ setScanFeedback("QR Badge Detected: " + detectedQrCode);
310
+ if (!qrDetectedData) {
311
+ playQrChime();
312
+ setQrDetectedData(detectedQrCode);
313
+ }
314
+ }
315
+ } catch (err) {
316
+ console.error("BarcodeDetector error:", err);
317
+ }
318
+ }
319
+
320
+ // If a QR code is detected, submit immediately and bypass standard face scans
321
+ if (detectedQrCode) {
322
+ if (statusRef.current === "needs_qr" && scanResult?.employee?.id) {
323
+ autoSubmitQR(scanResult.employee.id, detectedQrCode);
324
+ } else {
325
+ autoSubmitDirectQR(detectedQrCode, base64);
326
+ }
327
+ return;
328
+ }
329
+
330
+ // If the kiosk is in needs_qr mode but no QR code was scanned, don't execute face scans
331
+ if (statusRef.current !== "idle") {
332
+ return;
333
+ }
334
+
335
  setScanning(true);
336
  try {
337
  // Connect to the backend running at the configured URL
338
  const url = `${getBackendUrl()}/kiosk/scan`;
339
+ const payloadBody: any = { image: base64, camera: cameraLabel };
340
+ if (detectedQrCode) {
341
+ payloadBody.qr_code = detectedQrCode;
342
+ }
343
+ if (coords.latitude !== null && coords.longitude !== null) {
344
+ payloadBody.latitude = coords.latitude;
345
+ payloadBody.longitude = coords.longitude;
346
+ }
347
  const res = await fetch(url, {
348
  method: "POST",
349
  headers: { "Content-Type": "application/json" },
350
+ body: JSON.stringify(payloadBody)
351
  });
352
  if (!res.ok) throw new Error("Scan failed");
353
  const data = await res.json();
354
 
355
+ // Discard response if state changed while request was in-flight (e.g. Backspace pressed)
356
+ if (statusRef.current !== "idle") {
357
+ console.log("Discarding in-flight face scan response: state changed to", statusRef.current);
358
+ return;
359
+ }
360
+
361
  if (data.status === "no_face") {
362
  setScanFeedback("Align your face in frame");
363
+ setFaceBbox(null);
364
  } else if (data.status === "multiple_faces") {
365
  setScanFeedback("One person at a time");
366
+ setFaceBbox(null);
367
  } else {
368
  setScanFeedback(null);
369
  handleResult(data);
 
371
  } catch (err) {
372
  console.error("Scan API connection error:", err);
373
  setScanFeedback("Connection error");
374
+ setFaceBbox(null);
375
  } finally {
376
  setScanning(false);
377
  }
378
  };
379
 
380
+ useEffect(() => {
381
+ scanFunctionRef.current = captureAndScan;
382
+ });
383
+
384
+ useEffect(() => {
385
+ startKioskRef.current = startKiosk;
386
+ });
387
+
388
  const confirmCheckout = async () => {
389
  if (!lastImage) return;
390
  setScanning(true);
391
  try {
392
  const url = `${getBackendUrl()}/kiosk/scan`;
393
+ const bodyPayload: any = { image: lastImage, camera: cameraLabel, confirm_checkout: true };
394
+ if (coords.latitude !== null && coords.longitude !== null) {
395
+ bodyPayload.latitude = coords.latitude;
396
+ bodyPayload.longitude = coords.longitude;
397
+ }
398
  const res = await fetch(url, {
399
  method: "POST",
400
  headers: { "Content-Type": "application/json" },
401
+ body: JSON.stringify(bodyPayload)
402
  });
403
  if (!res.ok) throw new Error("Checkout confirmation failed");
404
  const data = await res.json();
 
411
  }
412
  };
413
 
414
+
415
+
416
+ const autoSubmitDirectQR = async (qrVal: string, frameImg: string) => {
417
+ if (cooldownRef.current || scanning) return;
418
+ setScanning(true);
419
+ setQrError(null);
420
+ try {
421
+ const url = `${getBackendUrl()}/kiosk/scan`;
422
+ const bodyPayload: any = {
423
+ image: frameImg,
424
+ camera: cameraLabel,
425
+ qr_code: qrVal.trim(),
426
+ qr_only: true
427
+ };
428
+ if (coords.latitude !== null && coords.longitude !== null) {
429
+ bodyPayload.latitude = coords.latitude;
430
+ bodyPayload.longitude = coords.longitude;
431
+ }
432
+ const res = await fetch(url, {
433
+ method: "POST",
434
+ headers: { "Content-Type": "application/json" },
435
+ body: JSON.stringify(bodyPayload)
436
+ });
437
+ const data = await res.json();
438
+ if (!res.ok) {
439
+ throw new Error(data.detail || data.message || "QR scan registration failed");
440
+ }
441
+ handleResult(data);
442
+ } catch (err: any) {
443
+ console.error("Auto Direct QR scan error:", err);
444
+ setQrError(err.message || "QR Code scan failed.");
445
+ setQrDetectedData(null);
446
+ } finally {
447
+ setScanning(false);
448
+ }
449
+ };
450
+
451
+ const autoSubmitQR = async (employeeId: number, qrVal: string) => {
452
+ if (cooldownRef.current || scanning) return;
453
+ setScanning(true);
454
+ setQrError(null);
455
+ try {
456
+ const url = `${getBackendUrl()}/kiosk/confirm-qr`;
457
+ const bodyPayload: any = {
458
+ employee_id: employeeId,
459
+ qr_code: qrVal.trim(),
460
+ camera: cameraLabel
461
+ };
462
+ if (coords.latitude !== null && coords.longitude !== null) {
463
+ bodyPayload.latitude = coords.latitude;
464
+ bodyPayload.longitude = coords.longitude;
465
+ }
466
+ const res = await fetch(url, {
467
+ method: "POST",
468
+ headers: { "Content-Type": "application/json" },
469
+ body: JSON.stringify(bodyPayload)
470
+ });
471
+ const data = await res.json();
472
+ if (!res.ok) {
473
+ throw new Error(data.detail || "QR Verification failed");
474
+ }
475
+ handleResult(data);
476
+ } catch (err: any) {
477
+ console.error("Auto QR verification error:", err);
478
+ setQrError(err.message || "QR Code verification failed.");
479
+ setQrDetectedData(null);
480
+ } finally {
481
+ setScanning(false);
482
+ }
483
+ };
484
+
485
  const handleResult = (data: any) => {
486
+ setQrDetectedData(null);
487
+ if (data.bbox) {
488
+ setFaceBbox(data.bbox);
489
+ } else {
490
+ setFaceBbox(null);
491
+ }
492
  if (data.status === "success") {
493
  setProfileImageError(false);
494
  setScanStatus("success"); setScanResult(data);
 
509
  console.error("Autoplay voice greeting failed:", err);
510
  });
511
  }
512
+ } else if (data.status === "needs_qr") {
513
+ setQrCodeVal("");
514
+ setQrError(null);
515
+ setScanStatus("needs_qr"); setScanResult(data);
516
  } else if (data.status === "locked") {
517
  setScanStatus("locked"); setScanResult(data);
518
+ triggerCooldown(3500);
519
  } else if (data.status === "spoof_detected") {
520
  setScanStatus("spoof"); setScanResult(data);
521
  triggerCooldown(3000);
 
528
  } else if (data.status === "no_employees") {
529
  setScanStatus("no_employees"); setScanResult(data);
530
  triggerCooldown(4000);
531
+ } else if (data.status === "location_error") {
532
+ setScanStatus("location_error"); setScanResult(data);
533
+ triggerCooldown(5000);
534
  }
535
  };
536
 
537
  const triggerCooldown = (ms: number) => {
538
+ clearCooldownTimeout();
539
  cooldownRef.current = true;
540
+ cooldownTimeoutRef.current = setTimeout(() => {
541
  cooldownRef.current = false;
542
  setScanStatus("idle");
543
  setScanResult(null);
544
+ setFaceBbox(null);
545
+ cooldownTimeoutRef.current = null;
546
  }, ms);
547
  };
548
 
549
+
550
+
551
+ const resetToIdleWithCooldown = (ms: number) => {
552
+ clearCooldownTimeout();
553
+ setScanStatus("idle");
554
+ setScanResult(null);
555
+ setFaceBbox(null);
556
+ cooldownRef.current = true;
557
+ cooldownTimeoutRef.current = setTimeout(() => {
558
+ cooldownRef.current = false;
559
+ cooldownTimeoutRef.current = null;
560
+ }, ms);
561
+ };
562
+
563
+ let boxBorderColor = "border-zinc-400";
564
+ let boxGlow = "shadow-[0_0_15px_rgba(161,161,170,0.4)]";
565
+ let labelText = "Detecting...";
566
+ let labelBg = "bg-zinc-800 text-white";
567
+
568
+ if (scanStatus === "success" || scanStatus === "ask_checkout") {
569
+ boxBorderColor = "border-emerald-500";
570
+ boxGlow = "shadow-[0_0_20px_rgba(16,185,129,0.5)]";
571
+ labelText = scanResult?.employee?.name ? `MATCHED: ${scanResult.employee.name}` : "VERIFIED";
572
+ labelBg = "bg-emerald-600 text-white";
573
+ } else if (scanStatus === "spoof") {
574
+ boxBorderColor = "border-rose-600";
575
+ boxGlow = "shadow-[0_0_20px_rgba(220,38,38,0.5)]";
576
+ labelText = "SPOOF DETECTED";
577
+ labelBg = "bg-rose-600 text-white";
578
+ } else if (scanStatus === "unknown") {
579
+ boxBorderColor = "border-rose-500";
580
+ boxGlow = "shadow-[0_0_15px_rgba(244,63,94,0.4)]";
581
+ labelText = "UNKNOWN PERSON";
582
+ labelBg = "bg-rose-500 text-white";
583
+ } else if (scanStatus === "needs_qr") {
584
+ boxBorderColor = "border-amber-500";
585
+ boxGlow = "shadow-[0_0_20px_rgba(245,158,11,0.5)]";
586
+ labelText = "IDENTITY CONFIRMATION";
587
+ labelBg = "bg-amber-600 text-white";
588
+ } else if (scanStatus === "locked") {
589
+ boxBorderColor = "border-rose-600";
590
+ boxGlow = "shadow-[0_0_20px_rgba(220,38,38,0.5)]";
591
+ labelText = "LOCKED";
592
+ labelBg = "bg-rose-600 text-white";
593
+ } else if (scanStatus === "location_error") {
594
+ boxBorderColor = "border-rose-600 animate-pulse";
595
+ boxGlow = "shadow-[0_0_20px_rgba(220,38,38,0.5)]";
596
+ labelText = scanResult?.message || "OUTSIDE ALLOWED AREA";
597
+ labelBg = "bg-rose-600 text-white";
598
+ }
599
+
600
  return (
601
  <div className="min-h-screen bg-[var(--bg-base)] text-[var(--text-primary)] flex flex-col relative select-none overflow-hidden font-sans">
602
+ <style>{`
603
+ @keyframes fadeIn {
604
+ from { opacity: 0; }
605
+ to { opacity: 1; }
606
+ }
607
+ @keyframes scaleUp {
608
+ from { transform: scale(0.94); opacity: 0; }
609
+ to { transform: scale(1); opacity: 1; }
610
+ }
611
+ @keyframes bounceIn {
612
+ 0% { transform: scale(0.3); opacity: 0; }
613
+ 50% { transform: scale(1.05); }
614
+ 70% { transform: scale(0.95); }
615
+ 100% { transform: scale(1); opacity: 1; }
616
+ }
617
+ @keyframes rotate-hud {
618
+ from { transform: rotate(0deg); }
619
+ to { transform: rotate(360deg); }
620
+ }
621
+ @keyframes rotate-hud-reverse {
622
+ from { transform: rotate(0deg); }
623
+ to { transform: rotate(-360deg); }
624
+ }
625
+ .animate-rotate-hud {
626
+ animation: rotate-hud 15s linear infinite;
627
+ }
628
+ .animate-rotate-hud-reverse {
629
+ animation: rotate-hud-reverse 10s linear infinite;
630
+ }
631
+ @keyframes scan-laser {
632
+ 0% { top: 0%; opacity: 0.8; }
633
+ 50% { top: 100%; opacity: 0.8; }
634
+ 100% { top: 0%; opacity: 0.8; }
635
+ }
636
+ .animate-scan-laser {
637
+ animation: scan-laser 2s ease-in-out infinite;
638
+ }
639
+ `}</style>
640
  {/* Background Grid Mesh */}
641
  <div className="absolute inset-0 mesh-bg pointer-events-none" />
642
  <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[350px] bg-slate-500/5 blur-[120px] pointer-events-none rounded-full" />
 
665
  </div>
666
  </div>
667
 
668
+ {/* Clock & GPS Indicator */}
669
+ <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center gap-1 z-20">
670
  <p className="text-base font-bold font-mono tracking-tight text-[var(--text-primary)] leading-none tabular-nums">
671
  {currentTime || "00:00:00"}
672
  </p>
673
+ {coords.latitude !== null && (
674
+ <div className="flex items-center gap-1 px-2 py-0.5 rounded-lg border border-slate-750/30 bg-slate-950/80 text-[8.5px] font-mono text-slate-450 select-none animate-fadeIn flex-row shrink-0">
675
+ <span className="w-1.2 h-1.2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
676
+ <span>GPS ACTIVE: {coords.latitude.toFixed(4)}, {coords.longitude?.toFixed(4)}</span>
677
+ </div>
678
+ )}
679
  </div>
680
 
681
  {/* Controls */}
 
744
  </div>
745
 
746
  <button
747
+ onClick={() => startKiosk()}
748
  className="btn-primary h-10 px-8 text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all"
749
  >
750
  <Play className="w-4 h-4 fill-current" />
 
763
  autoPlay playsInline muted
764
  />
765
 
766
+ {/* Bounding Box Overlay */}
767
+ {kioskActive && faceBbox && scanStatus !== "needs_qr" && (
768
+ <div
769
+ className="absolute pointer-events-none z-20"
770
+ style={{
771
+ left: `${((640 - faceBbox[2]) / 640) * 100}%`,
772
+ top: `${(faceBbox[1] / 360) * 100}%`,
773
+ width: `${((faceBbox[2] - faceBbox[0]) / 640) * 100}%`,
774
+ height: `${((faceBbox[3] - faceBbox[1]) / 360) * 100}%`,
775
+ transition: "all 0.2s ease-out",
776
+ }}
777
+ >
778
+ <div className={`absolute inset-0 border-2 rounded-xl ${boxBorderColor} ${boxGlow} transition-all duration-300`}>
779
+ <div className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-0.5 rounded text-[8.5px] font-mono font-bold tracking-wider uppercase whitespace-nowrap shadow-md ${labelBg} transition-all duration-300`}>
780
+ {labelText}
781
+ </div>
782
+ </div>
783
+ </div>
784
+ )}
785
+
786
  {/* Scanning overlay: active standby state */}
787
  {kioskActive && scanStatus === "idle" && (
788
  <>
 
806
 
807
  {/* SUCCESS screen */}
808
  {kioskActive && scanStatus === "success" && (
809
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-emerald-500/30 shadow-[inset_0_0_40px_rgba(16,185,129,0.15)] rounded-2xl z-30">
810
+ {/* HUD Corner Tech Brackets */}
811
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-emerald-500/70 rounded-tl-md" />
812
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-emerald-500/70 rounded-tr-md" />
813
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-emerald-500/70 rounded-bl-md" />
814
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-emerald-500/70 rounded-br-md" />
815
+
816
+ <div className="space-y-4 max-w-sm w-full animate-fade-in flex flex-col items-center">
817
+
818
+ {/* Floating Top Badge */}
819
+ <div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 font-mono text-[9px] font-bold uppercase tracking-widest animate-bounceIn">
820
+ <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
821
+ Biometrics Verified
822
+ </div>
823
+
824
+ {/* Profile Avatar with Tech HUD Rings */}
825
+ <div className="relative w-28 h-28 flex items-center justify-center my-1.5">
826
+ {/* Rotating outer ring */}
827
+ <div className="absolute inset-0 border border-emerald-500/30 border-dashed rounded-full animate-rotate-hud" />
828
+ {/* Counter-rotating middle ring */}
829
+ <div className="absolute inset-1.5 border border-emerald-400/20 border-dashed rounded-full animate-rotate-hud-reverse" />
830
+ {/* Inner glowing circle wrapper */}
831
+ <div className="absolute inset-3 rounded-full bg-slate-900 border-2 border-emerald-500/80 p-0.5 flex items-center justify-center overflow-hidden shadow-[0_0_20px_rgba(16,185,129,0.4)]">
832
+ {scanResult?.employee?.employee_id && !profileImageError ? (
833
+ <img
834
+ src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
835
+ alt={scanResult.employee.name}
836
+ className="w-full h-full object-cover rounded-full"
837
+ onError={() => setProfileImageError(true)}
838
+ />
839
+ ) : (
840
+ <div className="w-full h-full rounded-full bg-slate-800 flex items-center justify-center text-emerald-400 font-bold text-2xl tracking-tighter">
841
+ {scanResult?.employee?.name
842
+ ? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
843
+ : "PK"}
844
+ </div>
845
+ )}
846
+ </div>
847
+
848
+ {/* Floating check status indicator */}
849
+ <div className="absolute bottom-1 right-1 w-6 h-6 rounded-full bg-emerald-500 border border-slate-950 flex items-center justify-center text-white shadow-md animate-pulse">
850
+ <UserCheck className="w-3.5 h-3.5" />
851
+ </div>
852
  </div>
853
 
854
  {/* Name and Designation */}
855
+ <div className="space-y-1">
856
+ <h2 className="text-xl font-extrabold text-white tracking-tight leading-none drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]">
857
  {scanResult?.employee?.name || "Employee"}
858
  </h2>
859
+ <p className="text-[11px] text-slate-400 font-mono tracking-wide uppercase">
860
+ {scanResult?.employee?.designation || "Staff"} · ID: {scanResult?.employee?.employee_id}
861
  </p>
862
  </div>
863
 
864
+ {/* Grid details block */}
865
+ <div className="w-full bg-slate-900/60 border border-slate-800/80 rounded-xl p-3 max-w-[280px] grid grid-cols-2 gap-2 text-center divide-x divide-slate-800">
866
+ <div>
867
+ <span className="text-[8px] text-slate-500 font-bold uppercase tracking-wider block">Logged Time</span>
868
+ <span className="text-sm font-extrabold text-white font-mono tracking-tight tabular-nums block mt-0.5">
869
+ {matchTime}
870
+ </span>
871
+ </div>
872
+ <div>
873
+ <span className="text-[8px] text-slate-500 font-bold uppercase tracking-wider block">Action Status</span>
874
+ <span className="text-[10px] font-bold text-emerald-400 uppercase tracking-widest block mt-1">
875
+ {scanResult?.attendance_type || "CHECK-IN"}
876
  </span>
 
 
 
 
 
 
 
 
 
 
 
877
  </div>
878
  </div>
879
+
880
+ {/* Working Hours (Only visible on Checkout success) */}
881
+ {scanResult?.attendance?.working_hours > 0 && (
882
+ <div className="w-full bg-slate-900/60 border border-slate-800/80 rounded-xl p-2.5 max-w-[280px]">
883
+ <span className="text-[8.5px] text-slate-450 font-bold uppercase tracking-wider block">Time Logged Today</span>
884
+ <span className="text-xs font-bold text-emerald-400 font-mono mt-0.5 block">{scanResult.attendance.working_hours.toFixed(2)} hours</span>
885
+ </div>
886
+ )}
887
+
888
+ {/* Biometric Scores Info */}
889
+ {scanResult?.confidence && (
890
+ <div className="inline-flex items-center gap-2 px-2.5 py-1 rounded bg-slate-900/40 text-[9px] text-slate-500 font-mono border border-slate-800/60">
891
+ <span>Conf: <strong className="text-slate-355">{(scanResult.confidence * 100).toFixed(0)}%</strong></span>
892
+ <span className="w-1 h-1 rounded-full bg-slate-805" />
893
+ <span>Liveness: <strong className="text-emerald-500/80">{(scanResult.liveness_score * 100).toFixed(0)}%</strong></span>
894
+ </div>
895
+ )}
896
+
897
  </div>
898
  </div>
899
  )}
900
 
901
  {/* ASK_CHECKOUT screen */}
902
  {kioskActive && scanStatus === "ask_checkout" && (
903
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-cyan-500/30 shadow-[inset_0_0_40px_rgba(6,182,212,0.15)] rounded-2xl z-30">
904
+ {/* HUD Corner Tech Brackets */}
905
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-cyan-500/70 rounded-tl-md" />
906
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-cyan-500/70 rounded-tr-md" />
907
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-cyan-500/70 rounded-bl-md" />
908
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-cyan-500/70 rounded-br-md" />
909
+
910
+ <div className="space-y-4 max-w-sm w-full animate-fade-in flex flex-col items-center">
911
+
912
+ {/* Floating Top Badge */}
913
+ <div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 font-mono text-[9px] font-bold uppercase tracking-widest animate-bounceIn">
914
+ <span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
915
+ Checkout Prompt
 
 
 
 
916
  </div>
917
 
918
+ {/* Profile Avatar with Tech HUD Rings */}
919
+ <div className="relative w-28 h-28 flex items-center justify-center my-1.5">
920
+ {/* Rotating outer ring */}
921
+ <div className="absolute inset-0 border border-cyan-500/30 border-dashed rounded-full animate-rotate-hud" />
922
+ {/* Counter-rotating middle ring */}
923
+ <div className="absolute inset-1.5 border border-cyan-400/20 border-dashed rounded-full animate-rotate-hud-reverse" />
924
+ {/* Inner glowing circle wrapper */}
925
+ <div className="absolute inset-3 rounded-full bg-slate-900 border-2 border-cyan-500/80 p-0.5 flex items-center justify-center overflow-hidden shadow-[0_0_20px_rgba(6,182,212,0.4)]">
926
+ {scanResult?.employee?.employee_id && !profileImageError ? (
927
+ <img
928
+ src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
929
+ alt={scanResult.employee.name}
930
+ className="w-full h-full object-cover rounded-full"
931
+ onError={() => setProfileImageError(true)}
932
+ />
933
+ ) : (
934
+ <div className="w-full h-full rounded-full bg-slate-800 flex items-center justify-center text-cyan-400 font-bold text-2xl tracking-tighter">
935
+ {scanResult?.employee?.name
936
+ ? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
937
+ : "PK"}
938
+ </div>
939
+ )}
940
+ </div>
941
+
942
+ {/* Floating check status indicator */}
943
+ <div className="absolute bottom-1 right-1 w-6 h-6 rounded-full bg-cyan-500 border border-slate-950 flex items-center justify-center text-white shadow-md animate-pulse">
944
+ <HelpCircle className="w-3.5 h-3.5" />
945
+ </div>
946
+ </div>
947
+
948
+ {/* Name and Warning */}
949
  <div className="space-y-1">
950
+ <h2 className="text-xl font-extrabold text-white tracking-tight leading-none drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]">
951
  {scanResult?.employee?.name || "Employee"}
952
  </h2>
953
+ <p className="text-[11.5px] text-slate-350 font-medium">
954
+ Already checked in today at <span className="font-mono font-bold text-cyan-400">{formatTime12h(scanResult?.attendance?.check_in)}</span>
955
  </p>
956
  </div>
957
 
958
+ {/* Working Hours Box */}
959
+ <div className="w-full bg-slate-900/60 border border-slate-800/80 rounded-xl p-2.5 max-w-[280px]">
960
+ <span className="text-[8.5px] text-slate-505 font-bold uppercase tracking-wider block">Working Hours So Far</span>
961
+ <span className="text-sm font-extrabold text-white font-mono tracking-tight mt-0.5 block">
962
  {scanResult?.working_hours_so_far?.toFixed(2)} hours
963
+ </span>
964
  </div>
965
 
966
+ {/* Actions Confirmation */}
967
+ <div className="space-y-3 pt-1 w-full max-w-[280px]">
968
+ <p className="text-xs font-semibold text-slate-300">Do you want to Check Out now?</p>
969
+ <div className="flex items-center justify-center gap-3">
970
  <button
971
  onClick={confirmCheckout}
972
  disabled={scanning}
973
+ className="flex-1 px-4 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white text-xs font-extrabold uppercase tracking-wider shadow-[0_0_15px_rgba(6,182,212,0.35)] cursor-pointer hover:scale-[1.03] active:scale-[0.97] transition-all disabled:opacity-50"
974
  >
975
  Yes, Check Out
976
  </button>
977
  <button
978
  onClick={() => {
979
+ resetToIdleWithCooldown(5000);
 
980
  }}
981
  disabled={scanning}
982
+ className="flex-1 px-4 py-2.5 rounded-xl bg-transparent hover:bg-slate-900 text-slate-300 text-xs font-bold uppercase tracking-wider border border-slate-850 cursor-pointer active:scale-[0.97] transition-all disabled:opacity-50"
983
  >
984
+ No, Stay
985
  </button>
986
  </div>
987
  </div>
988
+
989
+ </div>
990
+ </div>
991
+ )}
992
+
993
+ {/* NEEDS_QR screen */}
994
+ {kioskActive && scanStatus === "needs_qr" && (
995
+ <div className="absolute inset-0 bg-slate-950/35 backdrop-blur-[1px] flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-amber-500/30 shadow-[inset_0_0_40px_rgba(245,158,11,0.15)] rounded-2xl z-30">
996
+ {/* HUD Corner Tech Brackets */}
997
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-amber-500/70 rounded-tl-md" />
998
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-amber-500/70 rounded-tr-md" />
999
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-amber-500/70 rounded-bl-md" />
1000
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-amber-500/70 rounded-br-md" />
1001
+
1002
+ <div className="space-y-4 max-w-sm w-full animate-fade-in flex flex-col items-center">
1003
+
1004
+ {/* Floating Top Badge */}
1005
+ <div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-400 font-mono text-[9px] font-bold uppercase tracking-widest">
1006
+ <span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
1007
+ Automatic QR Scanner
1008
+ </div>
1009
+
1010
+ {/* Interactive Holographic Scanner Graphic */}
1011
+ <div className="relative w-28 h-28 flex items-center justify-center my-2">
1012
+ {/* Outer pulsing ring */}
1013
+ <div className="absolute inset-0 border border-amber-500/30 rounded-2xl animate-pulse" style={{ animationDuration: "2s" }} />
1014
+ {/* Scanning laser line inside the box */}
1015
+ <div className="absolute left-2 right-2 h-[2px] bg-amber-400/80 shadow-[0_0_8px_rgba(245,158,11,0.8)] animate-scan-laser top-0" />
1016
+
1017
+ {/* QR Code Icon / Symbol in center */}
1018
+ <div className="w-16 h-16 rounded-xl bg-slate-900 border border-amber-500/40 flex items-center justify-center text-amber-400/90 shadow-[0_0_15px_rgba(245,158,11,0.15)]">
1019
+ <Fingerprint className="w-9 h-9 animate-pulse" />
1020
+ </div>
1021
+ </div>
1022
+
1023
+ <div className="space-y-1">
1024
+ <h2 className="text-lg font-bold text-white tracking-tight">Scan Employee Badge</h2>
1025
+ {scanResult?.employee?.name && (
1026
+ <p className="text-[11.5px] text-slate-350 font-medium animate-fadeIn">
1027
+ Matched: <span className="font-bold text-amber-400">{scanResult.employee.name}</span>
1028
+ </p>
1029
+ )}
1030
+ <p className="text-[11px] text-slate-400 leading-relaxed max-w-[285px] mt-1">
1031
+ Hold your employee QR badge up to the camera. The scanner will register it automatically.
1032
+ </p>
1033
+ </div>
1034
+
1035
+ {qrError && (
1036
+ <p className="text-[10px] text-rose-455 font-mono font-bold uppercase tracking-wider bg-rose-500/10 py-1 px-3 rounded border border-rose-500/20">{qrError}</p>
1037
+ )}
1038
+
1039
+ <div className="flex flex-col items-center gap-2 pt-2 w-full max-w-[240px]">
1040
+ <button
1041
+ type="button"
1042
+ onClick={() => {
1043
+ resetToIdleWithCooldown(5000);
1044
+ }}
1045
+ className="w-full py-2 rounded-xl bg-transparent hover:bg-slate-900 text-slate-300 text-xs font-bold uppercase tracking-wider border border-slate-800 cursor-pointer active:scale-[0.97] transition-all"
1046
+ >
1047
+ Cancel
1048
+ </button>
1049
+ </div>
1050
+
1051
  </div>
1052
  </div>
1053
  )}
1054
 
1055
  {/* LOCKED screen */}
1056
  {kioskActive && scanStatus === "locked" && (
1057
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-rose-500/30 shadow-[inset_0_0_40px_rgba(239,68,68,0.15)] rounded-2xl z-30">
1058
+ {/* HUD Corner Tech Brackets */}
1059
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-rose-500/70 rounded-tl-md" />
1060
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-rose-500/70 rounded-tr-md" />
1061
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-rose-500/70 rounded-bl-md" />
1062
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-rose-500/70 rounded-br-md" />
1063
+
1064
+ <div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
1065
+ <div className="w-12 h-12 rounded-full bg-rose-500/10 border border-rose-500/30 flex items-center justify-center text-rose-500 shadow-[0_0_15px_rgba(239,68,68,0.2)] animate-bounce">
1066
+ <ShieldAlert className="w-6 h-6" />
1067
+ </div>
1068
+ <div className="space-y-1">
1069
+ <h2 className="text-lg font-bold text-white tracking-tight">Attendance Locked</h2>
1070
+ <p className="text-[9.5px] text-rose-400 font-mono font-bold uppercase tracking-wider">Security Restriction</p>
1071
  </div>
1072
+ <p className="text-xs text-slate-355 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-850 max-w-[260px]">
1073
+ {scanResult?.message || "Your attendance logging is locked for today. Re-entry must be authorized by an administrator."}
 
1074
  </p>
1075
  </div>
1076
  </div>
 
1078
 
1079
  {/* SPOOF screen */}
1080
  {kioskActive && scanStatus === "spoof" && (
1081
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-rose-500/30 shadow-[inset_0_0_40px_rgba(239,68,68,0.15)] rounded-2xl z-30">
1082
+ {/* HUD Corner Tech Brackets */}
1083
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-rose-500/70 rounded-tl-md" />
1084
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-rose-500/70 rounded-tr-md" />
1085
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-rose-500/70 rounded-bl-md" />
1086
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-rose-500/70 rounded-br-md" />
1087
+
1088
+ <div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
1089
+ <div className="w-12 h-12 rounded-full bg-rose-500/10 border border-rose-500/30 flex items-center justify-center text-rose-500 shadow-[0_0_15px_rgba(239,68,68,0.2)] animate-pulse">
1090
+ <ShieldAlert className="w-6 h-6" />
1091
+ </div>
1092
+ <div className="space-y-1">
1093
+ <h2 className="text-lg font-bold text-white tracking-tight">Spoof Detected</h2>
1094
+ <p className="text-[9.5px] text-rose-400 font-mono font-bold uppercase tracking-wider">Anti-Spoofing Alarm</p>
1095
+ </div>
1096
+ <div className="space-y-2">
1097
+ <p className="text-xs text-rose-355 leading-relaxed font-semibold">
1098
+ {scanResult?.message || "Liveness verification failed. Presentation attack suspected."}
1099
+ </p>
1100
+ {scanResult?.liveness_score !== undefined && (
1101
+ <p className="text-[9px] text-slate-500 font-mono bg-slate-900/50 py-1 px-3 rounded-lg border border-slate-855 inline-block">
1102
+ Liveness Score: <strong className="text-rose-400">{scanResult.liveness_score.toFixed(3)}</strong>
1103
+ </p>
1104
+ )}
1105
  </div>
 
 
 
 
 
1106
  </div>
1107
  </div>
1108
  )}
1109
 
1110
  {/* UNKNOWN screen */}
1111
  {kioskActive && scanStatus === "unknown" && (
1112
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-slate-700/30 shadow-[inset_0_0_40px_rgba(148,163,184,0.15)] rounded-2xl z-30">
1113
+ {/* HUD Corner Tech Brackets */}
1114
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-slate-500/40 rounded-tl-md" />
1115
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-slate-500/40 rounded-tr-md" />
1116
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-slate-500/40 rounded-bl-md" />
1117
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-slate-500/40 rounded-br-md" />
1118
+
1119
+ <div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
1120
+ <div className="w-12 h-12 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-400 shadow-md">
1121
+ <HelpCircle className="w-6 h-6" />
1122
  </div>
1123
+ <div className="space-y-1">
1124
+ <h2 className="text-lg font-bold text-white tracking-tight">Not Recognized</h2>
1125
+ <p className="text-[9.5px] text-slate-500 font-mono font-bold uppercase tracking-wider text-center">Identity Unknown</p>
1126
+ </div>
1127
+ <p className="text-xs text-slate-400 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-855 max-w-[260px]">
1128
+ {scanResult?.message || "Biometric pattern does not match any registered employee records."}
1129
+ </p>
1130
  </div>
1131
  </div>
1132
  )}
1133
 
1134
  {/* MAINTENANCE screen */}
1135
  {kioskActive && scanStatus === "maintenance" && (
1136
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-slate-700/30 shadow-[inset_0_0_40px_rgba(148,163,184,0.15)] rounded-2xl z-30">
1137
+ {/* HUD Corner Tech Brackets */}
1138
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-slate-500/40 rounded-tl-md" />
1139
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-slate-500/40 rounded-tr-md" />
1140
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-slate-500/40 rounded-bl-md" />
1141
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-slate-500/40 rounded-br-md" />
1142
+
1143
+ <div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
1144
+ <div className="w-12 h-12 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-450 shadow-md">
1145
+ <ShieldAlert className="w-6 h-6" />
1146
  </div>
1147
+ <div className="space-y-1">
1148
+ <h2 className="text-lg font-bold text-white tracking-tight">Kiosk Offline</h2>
1149
+ <p className="text-[9.5px] text-slate-500 font-mono font-bold uppercase tracking-wider">Maintenance Mode</p>
1150
+ </div>
1151
+ <p className="text-xs text-slate-400 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-855 max-w-[260px]">
1152
+ {scanResult?.message || "Biometric scans are temporarily suspended for system database syncing."}
1153
  </p>
1154
  </div>
1155
  </div>
 
1157
 
1158
  {/* NO_EMPLOYEES screen */}
1159
  {kioskActive && scanStatus === "no_employees" && (
1160
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-amber-500/30 shadow-[inset_0_0_40px_rgba(245,158,11,0.15)] rounded-2xl z-30">
1161
+ {/* HUD Corner Tech Brackets */}
1162
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-amber-500/70 rounded-tl-md" />
1163
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-amber-500/70 rounded-tr-md" />
1164
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-amber-500/70 rounded-bl-md" />
1165
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-amber-500/70 rounded-br-md" />
1166
+
1167
+ <div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
1168
+ <div className="w-12 h-12 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-555 shadow-md">
1169
+ <UserCheck className="w-6 h-6 animate-pulse" />
1170
  </div>
1171
+ <div className="space-y-1">
1172
+ <h2 className="text-lg font-bold text-white tracking-tight">Setup Required</h2>
1173
+ <p className="text-[9.5px] text-amber-400 font-mono font-bold uppercase tracking-wider">Empty Database</p>
1174
+ </div>
1175
+ <p className="text-xs text-slate-400 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-855 max-w-[260px]">
1176
+ {scanResult?.message || "Please enroll employees in the dashboard before attempting kiosk logging."}
1177
  </p>
1178
  </div>
1179
  </div>
1180
  )}
1181
 
1182
+ {/* Sci-Fi QR Scan Processing Overlay */}
1183
+ {kioskActive && qrDetectedData && (
1184
+ <div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeIn border-2 border-emerald-500/50 shadow-[inset_0_0_50px_rgba(16,185,129,0.3)] rounded-2xl z-40">
1185
+ {/* HUD Tech Corner Brackets */}
1186
+ <div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-emerald-400 rounded-tl-md animate-pulse" />
1187
+ <div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-emerald-400 rounded-tr-md animate-pulse" />
1188
+ <div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-emerald-400 rounded-bl-md animate-pulse" />
1189
+ <div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-emerald-400 rounded-br-md animate-pulse" />
1190
+
1191
+ {/* Rotating Matrix Code Rings */}
1192
+ <div className="relative w-32 h-32 flex items-center justify-center mb-4">
1193
+ {/* Glowing Pulsing Outer Ring */}
1194
+ <div className="absolute inset-0 border border-emerald-400/40 rounded-full animate-ping" style={{ animationDuration: "1s" }} />
1195
+ {/* Concentric rotating dashes */}
1196
+ <div className="absolute inset-2 border-2 border-emerald-500 border-dashed rounded-full animate-rotate-hud" />
1197
+ <div className="absolute inset-4 border border-emerald-400/20 border-dashed rounded-full animate-rotate-hud-reverse" />
1198
+ {/* Inner Target Ring */}
1199
+ <div className="absolute inset-6 rounded-full bg-slate-900 border-2 border-emerald-400 shadow-[0_0_25px_rgba(16,185,129,0.5)] flex items-center justify-center overflow-hidden">
1200
+ <QrCode className="w-10 h-10 text-emerald-400 animate-pulse" />
1201
+ </div>
1202
+ </div>
1203
+
1204
+ <div className="space-y-2 max-w-xs flex flex-col items-center">
1205
+ <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 font-mono text-[9px] font-bold uppercase tracking-widest animate-pulse">
1206
+ Decrypted Badge
1207
+ </span>
1208
+ <h3 className="text-sm font-extrabold text-white tracking-widest uppercase">QR Code Registered</h3>
1209
+ <p className="text-[10px] text-slate-400 font-mono tracking-wider break-all bg-slate-900/90 px-3 py-1 border border-slate-800 rounded-lg">
1210
+ ID: {qrDetectedData}
1211
+ </p>
1212
+ </div>
1213
+
1214
+ {/* Glowing status loading bar */}
1215
+ <div className="mt-5 w-44 bg-slate-900 border border-slate-800 h-2.5 rounded-full overflow-hidden relative">
1216
+ <div className="absolute top-0 bottom-0 left-0 bg-gradient-to-r from-emerald-500 to-teal-400 animate-[scan-laser_1.5s_ease-in-out_infinite] w-20 rounded-full" />
1217
+ </div>
1218
+ <span className="mt-2.5 text-[9px] font-bold text-emerald-400 tracking-wider uppercase font-mono animate-pulse">
1219
+ Verifying Credentials...
1220
+ </span>
1221
+ </div>
1222
+ )}
1223
+
1224
  {/* Frame Info HUD Overlay */}
1225
  {kioskActive && (
1226
  <div className="absolute bottom-4 left-4 right-4 flex items-center justify-between pointer-events-none z-10">
frontend/app/reports/page.tsx CHANGED
@@ -23,6 +23,11 @@ const FORMATS = [
23
  { value: "csv", label: "CSV Flatfile", desc: ".csv Flat data" },
24
  ];
25
 
 
 
 
 
 
26
  export default function ReportsPage() {
27
  const { toast } = useToast();
28
  const [reportType, setReportType] = useState("daily");
@@ -34,19 +39,53 @@ export default function ReportsPage() {
34
  const [endDate, setEndDate] = useState(() => getLocalDateString());
35
  const [employeeId, setEmployeeId] = useState("");
36
  const [departmentId, setDepartmentId] = useState("");
 
 
 
 
 
 
37
  const [exporting, setExporting] = useState(false);
38
  const [success, setSuccess] = useState(false);
39
 
 
40
  const { data: departments } = useQuery({ queryKey: ["departments"], queryFn: () => fetchApi("/departments/") });
41
  const { data: employees } = useQuery({ queryKey: ["employees-list"], queryFn: () => fetchApi("/employees/") });
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  const handleExport = async (e: React.FormEvent) => {
44
  e.preventDefault();
45
  setExporting(true); setSuccess(false);
46
  try {
47
- const params = [`report_type=${reportType}`, `format=${format}`, `start_date=${startDate}`, `end_date=${endDate}`];
 
 
 
 
 
 
48
  if (employeeId) params.push(`employee_id=${employeeId}`);
49
  if (departmentId) params.push(`department_id=${departmentId}`);
 
 
 
50
  const blob: Blob = await fetchApi(`/reports/export?${params.join("&")}`);
51
  const url = window.URL.createObjectURL(blob);
52
  const a = document.createElement("a");
@@ -68,16 +107,16 @@ export default function ReportsPage() {
68
 
69
  return (
70
  <SidebarLayout>
71
- <div className="space-y-6 max-w-4xl page-enter">
72
  {/* Header */}
73
- <div className="pb-5 border-b border-white/5">
74
  <h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Reports & Export</h1>
75
  </div>
76
 
77
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
78
  {/* Form */}
79
- <div className="lg:col-span-2 glass-card rounded-2xl border border-white/6 p-6 space-y-6">
80
- <div className="flex items-center gap-2.5 border-b border-white/5 pb-4 bg-white/[0.005]">
81
  <div className="w-8 h-8 rounded-lg bg-zinc-100 flex items-center justify-center border border-zinc-200">
82
  <FileDown className="w-4 h-4 text-zinc-700" />
83
  </div>
@@ -103,7 +142,7 @@ export default function ReportsPage() {
103
  onClick={() => setReportType(rt.value)}
104
  className={`text-left p-3.5 rounded-xl border flex gap-3 transition-all cursor-pointer ${
105
  isActive
106
- ? "bg-zinc-50 border-zinc-300 text-zinc-950 shadow-sm"
107
  : "bg-white border-zinc-200 text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800"
108
  }`}
109
  >
@@ -174,7 +213,7 @@ export default function ReportsPage() {
174
  </div>
175
 
176
  {/* Optional filters */}
177
- <div className="grid grid-cols-2 gap-3.5 pt-4 border-t border-white/5">
178
  <div className="space-y-1.5">
179
  <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">
180
  Department <span className="opacity-50 normal-case">(optional)</span>
@@ -201,6 +240,89 @@ export default function ReportsPage() {
201
  </div>
202
  </div>
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  {/* Submit */}
205
  <button
206
  type="submit"
@@ -218,7 +340,7 @@ export default function ReportsPage() {
218
 
219
  {/* Sidebar info */}
220
  <div className="space-y-4 md:col-span-1">
221
- <div className="glass-card rounded-2xl border border-white/6 p-5 space-y-4.5 bg-white/[0.005]">
222
  <div className="flex items-center gap-2 mb-1">
223
  <Layers className="w-4 h-4 text-zinc-700" />
224
  <h3 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Audit Protocols</h3>
@@ -226,12 +348,12 @@ export default function ReportsPage() {
226
  <div className="space-y-3.5 text-[11.5px] text-[var(--text-secondary)] leading-relaxed">
227
  {[
228
  "Reports compile directly from the master SQL presence ledgers.",
229
- "Cosine similarity confidence scores are detailed for raw logs.",
230
  "Grace periods and overtime values recalculate dynamically.",
231
  "Failed anti-spoof events are highlighted in transaction files.",
232
  ].map((note, i) => (
233
  <div key={i} className="flex items-start gap-2.5">
234
- <div className="w-1.5 h-1.5 rounded-full bg-slate-700 mt-1.5 shrink-0" />
235
  <span>{note}</span>
236
  </div>
237
  ))}
@@ -239,7 +361,7 @@ export default function ReportsPage() {
239
  </div>
240
 
241
  {success && (
242
- <div className="flex items-start gap-3 p-4 rounded-2xl bg-emerald-50 border border-emerald-250 text-emerald-800 animate-fadeInUp">
243
  <CheckCircle2 className="w-4.5 h-4.5 shrink-0 mt-0.5" />
244
  <div>
245
  <p className="text-[12px] font-bold uppercase tracking-wider">Report Generated</p>
@@ -251,6 +373,72 @@ export default function ReportsPage() {
251
  )}
252
  </div>
253
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  </div>
255
  </SidebarLayout>
256
  );
 
23
  { value: "csv", label: "CSV Flatfile", desc: ".csv Flat data" },
24
  ];
25
 
26
+ const COLUMNS_LIST = [
27
+ "Date", "Employee ID", "Name", "Department",
28
+ "Check In", "Check Out", "Hours Worked", "Overtime", "Status"
29
+ ];
30
+
31
  export default function ReportsPage() {
32
  const { toast } = useToast();
33
  const [reportType, setReportType] = useState("daily");
 
39
  const [endDate, setEndDate] = useState(() => getLocalDateString());
40
  const [employeeId, setEmployeeId] = useState("");
41
  const [departmentId, setDepartmentId] = useState("");
42
+
43
+ // Custom Query Builder states
44
+ const [selectedColumns, setSelectedColumns] = useState<string[]>(COLUMNS_LIST);
45
+ const [minHours, setMinHours] = useState<string>("");
46
+ const [maxHours, setMaxHours] = useState<string>("");
47
+
48
  const [exporting, setExporting] = useState(false);
49
  const [success, setSuccess] = useState(false);
50
 
51
+ // Core list queries
52
  const { data: departments } = useQuery({ queryKey: ["departments"], queryFn: () => fetchApi("/departments/") });
53
  const { data: employees } = useQuery({ queryKey: ["employees-list"], queryFn: () => fetchApi("/employees/") });
54
 
55
+ // Preview Query
56
+ const { data: previewData, isLoading: loadingPreview } = useQuery({
57
+ queryKey: ["reports-preview", startDate, endDate, employeeId, departmentId, minHours, maxHours, selectedColumns],
58
+ queryFn: () => {
59
+ const params = [
60
+ `start_date=${startDate}`,
61
+ `end_date=${endDate}`,
62
+ `columns=${selectedColumns.join(",")}`
63
+ ];
64
+ if (employeeId) params.push(`employee_id=${employeeId}`);
65
+ if (departmentId) params.push(`department_id=${departmentId}`);
66
+ if (minHours) params.push(`min_hours=${minHours}`);
67
+ if (maxHours) params.push(`max_hours=${maxHours}`);
68
+
69
+ return fetchApi(`/reports/preview?${params.join("&")}`);
70
+ }
71
+ });
72
+
73
  const handleExport = async (e: React.FormEvent) => {
74
  e.preventDefault();
75
  setExporting(true); setSuccess(false);
76
  try {
77
+ const params = [
78
+ `report_type=${reportType}`,
79
+ `format=${format}`,
80
+ `start_date=${startDate}`,
81
+ `end_date=${endDate}`,
82
+ `columns=${selectedColumns.join(",")}`
83
+ ];
84
  if (employeeId) params.push(`employee_id=${employeeId}`);
85
  if (departmentId) params.push(`department_id=${departmentId}`);
86
+ if (minHours) params.push(`min_hours=${minHours}`);
87
+ if (maxHours) params.push(`max_hours=${maxHours}`);
88
+
89
  const blob: Blob = await fetchApi(`/reports/export?${params.join("&")}`);
90
  const url = window.URL.createObjectURL(blob);
91
  const a = document.createElement("a");
 
107
 
108
  return (
109
  <SidebarLayout>
110
+ <div className="space-y-6 max-w-4xl page-enter text-slate-850">
111
  {/* Header */}
112
+ <div className="pb-5 border-b border-slate-200">
113
  <h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Reports & Export</h1>
114
  </div>
115
 
116
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
117
  {/* Form */}
118
+ <div className="lg:col-span-2 glass-card rounded-2xl border border-slate-200 p-6 space-y-6 shadow-2xs">
119
+ <div className="flex items-center gap-2.5 border-b border-slate-100 pb-4">
120
  <div className="w-8 h-8 rounded-lg bg-zinc-100 flex items-center justify-center border border-zinc-200">
121
  <FileDown className="w-4 h-4 text-zinc-700" />
122
  </div>
 
142
  onClick={() => setReportType(rt.value)}
143
  className={`text-left p-3.5 rounded-xl border flex gap-3 transition-all cursor-pointer ${
144
  isActive
145
+ ? "bg-zinc-55 border-zinc-300 text-zinc-950 shadow-sm"
146
  : "bg-white border-zinc-200 text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800"
147
  }`}
148
  >
 
213
  </div>
214
 
215
  {/* Optional filters */}
216
+ <div className="grid grid-cols-2 gap-3.5 pt-4 border-t border-slate-100">
217
  <div className="space-y-1.5">
218
  <label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">
219
  Department <span className="opacity-50 normal-case">(optional)</span>
 
240
  </div>
241
  </div>
242
 
243
+ {/* ─── Custom Query Builder Panel ─── */}
244
+ <div className="space-y-4 pt-4 border-t border-slate-100 bg-slate-50/20 p-4.5 rounded-2xl border border-slate-100">
245
+ <div className="flex items-center gap-2">
246
+ <Layers className="w-4 h-4 text-zinc-650" />
247
+ <h3 className="text-[11px] font-bold text-zinc-650 uppercase tracking-wider">Custom Query Fields</h3>
248
+ </div>
249
+
250
+ {/* Column checkboxes */}
251
+ <div className="space-y-2">
252
+ <label className="block text-[9px] font-bold text-slate-450 uppercase tracking-wider">
253
+ Include Columns
254
+ </label>
255
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
256
+ {COLUMNS_LIST.map((col) => {
257
+ const isChecked = selectedColumns.includes(col);
258
+ return (
259
+ <button
260
+ key={col}
261
+ type="button"
262
+ onClick={() => {
263
+ if (isChecked) {
264
+ if (selectedColumns.length > 1) {
265
+ setSelectedColumns(selectedColumns.filter(c => c !== col));
266
+ } else {
267
+ toast.error("Please select at least one column");
268
+ }
269
+ } else {
270
+ setSelectedColumns([...selectedColumns, col]);
271
+ }
272
+ }}
273
+ className={`flex items-center gap-2 p-2 rounded-xl border text-[11px] font-semibold cursor-pointer select-none transition-all ${
274
+ isChecked
275
+ ? "bg-zinc-950 border-zinc-950 text-white shadow-2xs"
276
+ : "bg-white border-slate-200 text-slate-450 hover:bg-slate-50 hover:text-slate-700"
277
+ }`}
278
+ >
279
+ <div className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
280
+ isChecked ? "bg-white border-white text-zinc-950" : "bg-white border-slate-200"
281
+ }`}>
282
+ {isChecked && <Check className="w-2.5 h-2.5 stroke-[3.5]" />}
283
+ </div>
284
+ <span className="truncate">{col}</span>
285
+ </button>
286
+ );
287
+ })}
288
+ </div>
289
+ </div>
290
+
291
+ {/* Working hours limits */}
292
+ <div className="grid grid-cols-2 gap-3.5 pt-2">
293
+ <div className="space-y-1.5">
294
+ <label className="block text-[9px] font-bold text-slate-450 uppercase tracking-wider">
295
+ Min Hours Worked <span className="opacity-60 normal-case">(optional)</span>
296
+ </label>
297
+ <input
298
+ type="number"
299
+ step="0.5"
300
+ min="0"
301
+ max="24"
302
+ placeholder="E.g. 4.0"
303
+ value={minHours}
304
+ onChange={(e) => setMinHours(e.target.value)}
305
+ className="input-field h-9 text-[12px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl px-3 transition-all w-full"
306
+ />
307
+ </div>
308
+ <div className="space-y-1.5">
309
+ <label className="block text-[9px] font-bold text-slate-450 uppercase tracking-wider">
310
+ Max Hours Worked <span className="opacity-60 normal-case">(optional)</span>
311
+ </label>
312
+ <input
313
+ type="number"
314
+ step="0.5"
315
+ min="0"
316
+ max="24"
317
+ placeholder="E.g. 9.5"
318
+ value={maxHours}
319
+ onChange={(e) => setMaxHours(e.target.value)}
320
+ className="input-field h-9 text-[12px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl px-3 transition-all w-full"
321
+ />
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
  {/* Submit */}
327
  <button
328
  type="submit"
 
340
 
341
  {/* Sidebar info */}
342
  <div className="space-y-4 md:col-span-1">
343
+ <div className="glass-card rounded-2xl border border-slate-200 p-5 space-y-4.5 bg-slate-50/10">
344
  <div className="flex items-center gap-2 mb-1">
345
  <Layers className="w-4 h-4 text-zinc-700" />
346
  <h3 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Audit Protocols</h3>
 
348
  <div className="space-y-3.5 text-[11.5px] text-[var(--text-secondary)] leading-relaxed">
349
  {[
350
  "Reports compile directly from the master SQL presence ledgers.",
351
+ "Custom column filtering dynamically adjusts Excel/PDF tables.",
352
  "Grace periods and overtime values recalculate dynamically.",
353
  "Failed anti-spoof events are highlighted in transaction files.",
354
  ].map((note, i) => (
355
  <div key={i} className="flex items-start gap-2.5">
356
+ <div className="w-1.5 h-1.5 rounded-full bg-slate-400 mt-1.5 shrink-0" />
357
  <span>{note}</span>
358
  </div>
359
  ))}
 
361
  </div>
362
 
363
  {success && (
364
+ <div className="flex items-start gap-3 p-4 rounded-2xl bg-emerald-50 border border-emerald-200 text-emerald-800 animate-fadeInUp">
365
  <CheckCircle2 className="w-4.5 h-4.5 shrink-0 mt-0.5" />
366
  <div>
367
  <p className="text-[12px] font-bold uppercase tracking-wider">Report Generated</p>
 
373
  )}
374
  </div>
375
  </div>
376
+
377
+ {/* ─── Live Preview Grid Card ─── */}
378
+ <div className="glass-card rounded-2xl border border-slate-200 overflow-hidden shadow-2xs">
379
+ <div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/10">
380
+ <div className="flex items-center gap-2">
381
+ <div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-pulse" />
382
+ <h2 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">
383
+ Live Data Preview ({previewData?.total_count || 0} matching logs)
384
+ </h2>
385
+ </div>
386
+ <span className="text-[9.5px] font-mono text-slate-400 font-bold uppercase">
387
+ Showing first 8 records
388
+ </span>
389
+ </div>
390
+
391
+ <div className="overflow-x-auto scrollbar-thin">
392
+ {loadingPreview ? (
393
+ <div className="p-8 flex items-center justify-center text-slate-400 text-xs gap-2">
394
+ <Loader2 className="w-4 h-4 animate-spin text-slate-650" />
395
+ <span>Computing report preview...</span>
396
+ </div>
397
+ ) : !previewData?.records || previewData.records.length === 0 ? (
398
+ <div className="p-12 text-center text-slate-400 italic text-xs">
399
+ No attendance logs found matching these search criteria. Try adjusting dates or filters.
400
+ </div>
401
+ ) : (
402
+ <table className="w-full text-left border-collapse text-[11px] min-w-[700px]">
403
+ <thead>
404
+ <tr className="border-b border-slate-200 bg-slate-50 text-slate-500 uppercase tracking-wider font-mono">
405
+ {previewData.columns.map((col: string) => (
406
+ <th key={col} className="py-2.5 px-4 font-semibold">{col}</th>
407
+ ))}
408
+ </tr>
409
+ </thead>
410
+ <tbody className="divide-y divide-slate-100 text-slate-700 font-sans">
411
+ {previewData.records.slice(0, 8).map((row: any, rIdx: number) => (
412
+ <tr key={rIdx} className="hover:bg-slate-50/40 transition-colors">
413
+ {previewData.columns.map((col: string) => {
414
+ const val = row[col];
415
+ return (
416
+ <td key={col} className="py-2.5 px-4 font-mono text-slate-800">
417
+ {col === "Status" ? (
418
+ <span className={`inline-block text-[8.5px] font-semibold px-1.5 py-0.5 rounded border ${
419
+ val === "Present" ? "bg-emerald-50 border-emerald-250 text-emerald-700" :
420
+ val === "Late" ? "bg-amber-50 border-amber-250 text-amber-700" :
421
+ val === "Half Day" ? "bg-indigo-50 border-indigo-250 text-indigo-750" :
422
+ "bg-rose-50 border-rose-250 text-rose-700"
423
+ }`}>
424
+ {val}
425
+ </span>
426
+ ) : typeof val === "number" ? (
427
+ val.toFixed(2)
428
+ ) : (
429
+ val || "-"
430
+ )}
431
+ </td>
432
+ );
433
+ })}
434
+ </tr>
435
+ ))}
436
+ </tbody>
437
+ </table>
438
+ )}
439
+ </div>
440
+ </div>
441
+
442
  </div>
443
  </SidebarLayout>
444
  );
frontend/app/settings/page.tsx CHANGED
@@ -7,10 +7,10 @@ import { fetchApi } from "@/app/utils/api";
7
  import { useToast } from "@/app/utils/toast";
8
  import {
9
  Check, Loader2, CheckCircle2, AlertCircle, Sliders, Info,
10
- Fingerprint, Clock, Volume2
11
  } from "lucide-react";
12
 
13
- type SettingsTab = "biometrics" | "shift" | "voice" | "advanced";
14
 
15
  export default function SettingsPage() {
16
  const queryClient = useQueryClient();
@@ -19,6 +19,7 @@ export default function SettingsPage() {
19
  const [updatingKey, setUpdatingKey] = useState<string | null>(null);
20
  const [successKey, setSuccessKey] = useState<string | null>(null);
21
  const [editValues, setEditValues] = useState<Record<string, string>>({});
 
22
 
23
  const { data: settings, isLoading } = useQuery({
24
  queryKey: ["settings"],
@@ -51,8 +52,43 @@ export default function SettingsPage() {
51
  saveMutation.mutate({ key, value: editValues[key] });
52
  };
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  // Group settings by keys for our tabs
55
  const getTabForSettings = (key: string): SettingsTab => {
 
 
56
  if (key.includes("FACE") || key.includes("LIVENESS")) return "biometrics";
57
  if (key.includes("SHIFT") || key.includes("CHECK") || key.includes("GRACE")) return "shift";
58
  if (key.includes("VOICE") || key.includes("GREETING")) return "voice";
@@ -166,6 +202,97 @@ export default function SettingsPage() {
166
  );
167
  }
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  // Default Fallback
170
  return (
171
  <input
@@ -183,6 +310,8 @@ export default function SettingsPage() {
183
  case "biometrics": return <Fingerprint className="w-4 h-4" />;
184
  case "shift": return <Clock className="w-4 h-4" />;
185
  case "voice": return <Volume2 className="w-4 h-4" />;
 
 
186
  case "advanced": return <Sliders className="w-4 h-4" />;
187
  }
188
  };
@@ -192,6 +321,8 @@ export default function SettingsPage() {
192
  case "biometrics": return "Biometrics & AI";
193
  case "shift": return "Shift & Grace Hours";
194
  case "voice": return "Voice Alerts";
 
 
195
  case "advanced": return "Advanced Configurations";
196
  }
197
  };
@@ -201,6 +332,8 @@ export default function SettingsPage() {
201
  case "biometrics": return "Configure facial matching similarity and liveness parameters";
202
  case "shift": return "Manage daily check-in start, grace window, and shifts";
203
  case "voice": return "Enable or disable text-to-speech audio feedback at terminals";
 
 
204
  case "advanced": return "System variables and advanced parameters";
205
  }
206
  };
@@ -218,7 +351,7 @@ export default function SettingsPage() {
218
 
219
  {/* Left Navigation: Vertical tabs */}
220
  <div className="space-y-1 md:col-span-1">
221
- {(["biometrics", "shift", "voice", "advanced"] as SettingsTab[]).map(tab => {
222
  const isActive = activeTab === tab;
223
  return (
224
  <button
@@ -285,6 +418,7 @@ export default function SettingsPage() {
285
  const getSettingIcon = (k: string) => {
286
  if (k.includes("VOICE") || k.includes("GREETING")) return <Volume2 className="w-5 h-5" />;
287
  if (k.includes("MAINTENANCE")) return <AlertCircle className="w-5 h-5" />;
 
288
  return <Sliders className="w-5 h-5" />;
289
  };
290
 
@@ -379,31 +513,62 @@ export default function SettingsPage() {
379
  <div className="flex items-center gap-2.5 shrink-0">
380
  {renderSettingControl(setting, isUpdating)}
381
 
382
- <button
383
- onClick={() => handleSave(setting.key)}
384
- disabled={isUpdating || !isDirty}
385
- className={`w-9 h-9 rounded-xl border flex items-center justify-center transition-all ${
386
- isSuccess
387
- ? "bg-emerald-55 border-emerald-250 text-emerald-600 shadow-xs scale-105"
388
- : isDirty
389
- ? "bg-slate-900 border-slate-900 text-white hover:bg-slate-800 hover:scale-105 active:scale-95 cursor-pointer shadow-md shadow-slate-900/10"
390
- : "bg-slate-50 border-slate-200 text-slate-300 cursor-not-allowed"
391
- }`}
392
- >
393
- {isUpdating ? (
394
- <Loader2 className="w-3.5 h-3.5 animate-spin" />
395
- ) : isSuccess ? (
396
- <CheckCircle2 className="w-3.5 h-3.5" />
397
- ) : (
398
- <Check className="w-3.5 h-3.5" />
399
- )}
400
- </button>
 
 
401
  </div>
402
  </div>
403
  );
404
  })}
405
  </div>
406
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  </div>
408
  )}
409
  </div>
@@ -426,6 +591,24 @@ export default function SettingsPage() {
426
  </span>
427
  </div>
428
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  </div>
430
  </div>
431
  </div>
 
7
  import { useToast } from "@/app/utils/toast";
8
  import {
9
  Check, Loader2, CheckCircle2, AlertCircle, Sliders, Info,
10
+ Fingerprint, Clock, Volume2, Palette, Upload, Trash2, Camera, MapPin
11
  } from "lucide-react";
12
 
13
+ type SettingsTab = "biometrics" | "shift" | "voice" | "branding" | "location" | "advanced";
14
 
15
  export default function SettingsPage() {
16
  const queryClient = useQueryClient();
 
19
  const [updatingKey, setUpdatingKey] = useState<string | null>(null);
20
  const [successKey, setSuccessKey] = useState<string | null>(null);
21
  const [editValues, setEditValues] = useState<Record<string, string>>({});
22
+ const [fetchingGps, setFetchingGps] = useState(false);
23
 
24
  const { data: settings, isLoading } = useQuery({
25
  queryKey: ["settings"],
 
52
  saveMutation.mutate({ key, value: editValues[key] });
53
  };
54
 
55
+ const handleSetCurrentLocation = () => {
56
+ if (typeof window !== "undefined" && navigator.geolocation) {
57
+ setFetchingGps(true);
58
+ navigator.geolocation.getCurrentPosition(
59
+ (position) => {
60
+ const lat = position.coords.latitude.toString();
61
+ const lon = position.coords.longitude.toString();
62
+
63
+ setEditValues(prev => ({
64
+ ...prev,
65
+ LOCATION_LATITUDE: lat,
66
+ LOCATION_LONGITUDE: lon
67
+ }));
68
+
69
+ // Save both settings
70
+ saveMutation.mutate({ key: "LOCATION_LATITUDE", value: lat });
71
+ saveMutation.mutate({ key: "LOCATION_LONGITUDE", value: lon });
72
+
73
+ toast.success("Coordinates updated to current location!");
74
+ setFetchingGps(false);
75
+ },
76
+ (err) => {
77
+ console.error("GPS error:", err);
78
+ toast.error("Failed to get current location. Ensure GPS permission is granted.");
79
+ setFetchingGps(false);
80
+ },
81
+ { enableHighAccuracy: true }
82
+ );
83
+ } else {
84
+ toast.error("Geolocation is not supported by your browser.");
85
+ }
86
+ };
87
+
88
  // Group settings by keys for our tabs
89
  const getTabForSettings = (key: string): SettingsTab => {
90
+ if (key.includes("LOCATION")) return "location";
91
+ if (key.includes("COMPANY") || key.includes("LOGO") || key.includes("BADGE")) return "branding";
92
  if (key.includes("FACE") || key.includes("LIVENESS")) return "biometrics";
93
  if (key.includes("SHIFT") || key.includes("CHECK") || key.includes("GRACE")) return "shift";
94
  if (key.includes("VOICE") || key.includes("GREETING")) return "voice";
 
202
  );
203
  }
204
 
205
+ if (key === "BADGE_THEME_COLOR") {
206
+ return (
207
+ <select
208
+ value={val}
209
+ disabled={isUpdating}
210
+ onChange={(e) => {
211
+ setEditValues(prev => ({ ...prev, [key]: e.target.value }));
212
+ setUpdatingKey(key);
213
+ saveMutation.mutate({ key, value: e.target.value });
214
+ }}
215
+ className="input-field h-9 text-[12.5px] w-40 bg-white border-slate-250 rounded-xl px-3 focus:border-slate-800 transition-all font-semibold cursor-pointer text-center"
216
+ >
217
+ <option value="Navy Blue">Navy Blue</option>
218
+ <option value="Charcoal">Charcoal</option>
219
+ <option value="Emerald">Emerald</option>
220
+ <option value="Saffron">Saffron</option>
221
+ </select>
222
+ );
223
+ }
224
+
225
+ if (key === "BADGE_PATTERN_TYPE") {
226
+ return (
227
+ <select
228
+ value={val}
229
+ disabled={isUpdating}
230
+ onChange={(e) => {
231
+ setEditValues(prev => ({ ...prev, [key]: e.target.value }));
232
+ setUpdatingKey(key);
233
+ saveMutation.mutate({ key, value: e.target.value });
234
+ }}
235
+ className="input-field h-9 text-[12.5px] w-40 bg-white border-slate-250 rounded-xl px-3 focus:border-slate-800 transition-all font-semibold cursor-pointer text-center"
236
+ >
237
+ <option value="None">None</option>
238
+ <option value="Indian Mandala">Indian Mandala</option>
239
+ <option value="Corporate Waves">Corporate Waves</option>
240
+ <option value="Cyber Grid">Cyber Grid</option>
241
+ </select>
242
+ );
243
+ }
244
+
245
+ if (key === "COMPANY_LOGO") {
246
+ return (
247
+ <div className="flex flex-col items-center gap-3 w-full sm:w-64">
248
+ {val ? (
249
+ <div className="relative w-full h-24 border border-slate-200 rounded-xl overflow-hidden bg-slate-50 flex items-center justify-center p-2 group">
250
+ <img src={val} alt="Company Logo" className="max-w-full max-h-full object-contain" />
251
+ <button
252
+ type="button"
253
+ onClick={() => {
254
+ setEditValues(prev => ({ ...prev, [key]: "" }));
255
+ setUpdatingKey(key);
256
+ saveMutation.mutate({ key, value: "" });
257
+ }}
258
+ className="absolute inset-0 bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-bold gap-1 cursor-pointer"
259
+ >
260
+ <Trash2 className="w-3.5 h-3.5" />
261
+ Remove Logo
262
+ </button>
263
+ </div>
264
+ ) : (
265
+ <label className="w-full h-24 border border-dashed border-slate-300 hover:border-slate-400 rounded-xl flex flex-col items-center justify-center gap-1.5 cursor-pointer bg-slate-50 hover:bg-slate-100/50 transition-all">
266
+ <Upload className="w-5 h-5 text-slate-450" />
267
+ <span className="text-[11px] text-slate-500 font-medium">Upload PNG/JPG (Max 500KB)</span>
268
+ <input
269
+ type="file"
270
+ accept="image/*"
271
+ className="hidden"
272
+ disabled={isUpdating}
273
+ onChange={(e) => {
274
+ const file = e.target.files?.[0];
275
+ if (!file) return;
276
+ if (file.size > 512 * 1024) {
277
+ toast.error("Logo must be under 500KB");
278
+ return;
279
+ }
280
+ const reader = new FileReader();
281
+ reader.onloadend = () => {
282
+ const base64String = reader.result as string;
283
+ setEditValues(prev => ({ ...prev, [key]: base64String }));
284
+ setUpdatingKey(key);
285
+ saveMutation.mutate({ key, value: base64String });
286
+ };
287
+ reader.readAsDataURL(file);
288
+ }}
289
+ />
290
+ </label>
291
+ )}
292
+ </div>
293
+ );
294
+ }
295
+
296
  // Default Fallback
297
  return (
298
  <input
 
310
  case "biometrics": return <Fingerprint className="w-4 h-4" />;
311
  case "shift": return <Clock className="w-4 h-4" />;
312
  case "voice": return <Volume2 className="w-4 h-4" />;
313
+ case "branding": return <Palette className="w-4 h-4" />;
314
+ case "location": return <MapPin className="w-4 h-4" />;
315
  case "advanced": return <Sliders className="w-4 h-4" />;
316
  }
317
  };
 
321
  case "biometrics": return "Biometrics & AI";
322
  case "shift": return "Shift & Grace Hours";
323
  case "voice": return "Voice Alerts";
324
+ case "branding": return "Branding & Badge";
325
+ case "location": return "Location Restriction";
326
  case "advanced": return "Advanced Configurations";
327
  }
328
  };
 
332
  case "biometrics": return "Configure facial matching similarity and liveness parameters";
333
  case "shift": return "Manage daily check-in start, grace window, and shifts";
334
  case "voice": return "Enable or disable text-to-speech audio feedback at terminals";
335
+ case "branding": return "Set organization logo and name for employee ID cards";
336
+ case "location": return "Set coordinates and allowed radius for geofenced kiosk attendance";
337
  case "advanced": return "System variables and advanced parameters";
338
  }
339
  };
 
351
 
352
  {/* Left Navigation: Vertical tabs */}
353
  <div className="space-y-1 md:col-span-1">
354
+ {(["biometrics", "shift", "voice", "branding", "location", "advanced"] as SettingsTab[]).map(tab => {
355
  const isActive = activeTab === tab;
356
  return (
357
  <button
 
418
  const getSettingIcon = (k: string) => {
419
  if (k.includes("VOICE") || k.includes("GREETING")) return <Volume2 className="w-5 h-5" />;
420
  if (k.includes("MAINTENANCE")) return <AlertCircle className="w-5 h-5" />;
421
+ if (k.includes("RTSP")) return <Camera className="w-5 h-5" />;
422
  return <Sliders className="w-5 h-5" />;
423
  };
424
 
 
513
  <div className="flex items-center gap-2.5 shrink-0">
514
  {renderSettingControl(setting, isUpdating)}
515
 
516
+ {setting.key !== "COMPANY_LOGO" && setting.key !== "BADGE_THEME_COLOR" && setting.key !== "BADGE_PATTERN_TYPE" && (
517
+ <button
518
+ onClick={() => handleSave(setting.key)}
519
+ disabled={isUpdating || !isDirty}
520
+ className={`w-9 h-9 rounded-xl border flex items-center justify-center transition-all ${
521
+ isSuccess
522
+ ? "bg-emerald-55 border-emerald-250 text-emerald-600 shadow-xs scale-105"
523
+ : isDirty
524
+ ? "bg-slate-900 border-slate-900 text-white hover:bg-slate-800 hover:scale-105 active:scale-95 cursor-pointer shadow-md shadow-slate-900/10"
525
+ : "bg-slate-50 border-slate-200 text-slate-300 cursor-not-allowed"
526
+ }`}
527
+ >
528
+ {isUpdating ? (
529
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
530
+ ) : isSuccess ? (
531
+ <CheckCircle2 className="w-3.5 h-3.5" />
532
+ ) : (
533
+ <Check className="w-3.5 h-3.5" />
534
+ )}
535
+ </button>
536
+ )}
537
  </div>
538
  </div>
539
  );
540
  })}
541
  </div>
542
  )}
543
+
544
+ {/* Location Extra Actions */}
545
+ {activeTab === "location" && (
546
+ <div className="px-6 py-5 border-t border-slate-100/80 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-slate-50/20 transition-all duration-200">
547
+ <div className="flex-1 space-y-0.5">
548
+ <h3 className="text-sm font-semibold text-slate-800 dark:text-slate-100">Auto-Detect Coordinates</h3>
549
+ <p className="text-xs text-slate-450 leading-normal max-w-sm sm:max-w-md">
550
+ Automatically capture this device's current GPS latitude and longitude coordinates.
551
+ </p>
552
+ </div>
553
+ <button
554
+ type="button"
555
+ disabled={fetchingGps}
556
+ onClick={handleSetCurrentLocation}
557
+ className={`h-9.5 px-4 rounded-xl text-[12px] font-extrabold transition-all flex items-center gap-2 cursor-pointer shadow-sm hover:scale-[1.02] active:scale-[0.98] ${
558
+ fetchingGps
559
+ ? "bg-slate-100 border border-slate-200 text-slate-400 cursor-not-allowed"
560
+ : "bg-slate-900 hover:bg-slate-800 text-white dark:bg-white dark:hover:bg-slate-100 dark:text-slate-900"
561
+ }`}
562
+ >
563
+ {fetchingGps ? (
564
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
565
+ ) : (
566
+ <MapPin className="w-3.5 h-3.5" />
567
+ )}
568
+ Set to Current GPS Location
569
+ </button>
570
+ </div>
571
+ )}
572
  </div>
573
  )}
574
  </div>
 
591
  </span>
592
  </div>
593
  )}
594
+
595
+ {activeTab === "branding" && (
596
+ <div className="flex items-center gap-3 p-4 rounded-xl bg-zinc-50 border border-zinc-200 text-zinc-650 text-[11px] animate-fadeInUp">
597
+ <Info className="w-4 h-4 text-zinc-500 shrink-0" />
598
+ <span>
599
+ <strong>Tip:</strong> The company name and logo uploaded here will dynamically populate on all newly enrolled employee ID Cards and badge PDF prints.
600
+ </span>
601
+ </div>
602
+ )}
603
+
604
+ {activeTab === "location" && (
605
+ <div className="flex items-center gap-3 p-4 rounded-xl bg-zinc-50 border border-zinc-200 text-zinc-650 text-[11px] animate-fadeInUp">
606
+ <Info className="w-4 h-4 text-zinc-500 shrink-0" />
607
+ <span>
608
+ <strong>Note:</strong> When Location Restriction is enabled, kiosk swiping is only permitted for employees within the radius limit unless they have the WFH bypass permission.
609
+ </span>
610
+ </div>
611
+ )}
612
  </div>
613
  </div>
614
  </div>
frontend/components/AttendanceHeatmap.tsx ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { fetchApi } from "@/app/utils/api";
6
+ import { Loader2, Calendar } from "lucide-react";
7
+
8
+ interface AttendanceHeatmapProps {
9
+ employeeId?: number;
10
+ title?: string;
11
+ }
12
+
13
+ export default function AttendanceHeatmap({ employeeId, title }: AttendanceHeatmapProps) {
14
+ const { data: heatmapData, isLoading } = useQuery({
15
+ queryKey: ["attendance-heatmap", employeeId],
16
+ queryFn: () => {
17
+ const url = employeeId
18
+ ? `/analytics/heatmap?employee_id=${employeeId}`
19
+ : `/analytics/heatmap`;
20
+ return fetchApi(url);
21
+ }
22
+ });
23
+
24
+ // Generate date cells for the last 365 days (ending today)
25
+ const cells: { dateStr: string; dateObj: Date; count: number }[] = [];
26
+ const today = new Date();
27
+
28
+ // Start from 365 days ago, offset to start on a Sunday if possible
29
+ const totalDays = 365;
30
+ const startDate = new Date(today);
31
+ startDate.setDate(today.getDate() - totalDays);
32
+
33
+ // Align start date to Sunday of that week
34
+ const startDayOffset = startDate.getDay();
35
+ startDate.setDate(startDate.getDate() - startDayOffset);
36
+
37
+ const currentDateIter = new Date(startDate);
38
+ while (currentDateIter <= today) {
39
+ const dateStr = currentDateIter.toISOString().split("T")[0];
40
+ const count = heatmapData?.[dateStr] || 0;
41
+ cells.push({
42
+ dateStr,
43
+ dateObj: new Date(currentDateIter),
44
+ count
45
+ });
46
+ currentDateIter.setDate(currentDateIter.getDate() + 1);
47
+ }
48
+
49
+ // Group cells by week (column)
50
+ const weeks: typeof cells[] = [];
51
+ let currentWeek: typeof cells = [];
52
+
53
+ cells.forEach((cell) => {
54
+ if (cell.dateObj.getDay() === 0 && currentWeek.length > 0) {
55
+ weeks.push(currentWeek);
56
+ currentWeek = [];
57
+ }
58
+ currentWeek.push(cell);
59
+ });
60
+ if (currentWeek.length > 0) {
61
+ weeks.push(currentWeek);
62
+ }
63
+
64
+ const maxCount = Math.max(...Object.values(heatmapData || {}).map(v => Number(v) || 0), 1);
65
+
66
+ const getIntensityClass = (count: number) => {
67
+ if (count === 0) return "bg-zinc-150 dark:bg-zinc-800/80 border-zinc-200/5";
68
+
69
+ if (employeeId) {
70
+ return "bg-emerald-500 dark:bg-emerald-400 border-emerald-600/10 shadow-[0_0_6px_rgba(16,185,129,0.15)]";
71
+ }
72
+
73
+ const ratio = count / maxCount;
74
+ if (ratio <= 0.25) return "bg-emerald-100 dark:bg-emerald-950/40 border-emerald-250/20";
75
+ if (ratio <= 0.5) return "bg-emerald-300 dark:bg-emerald-800/60 border-emerald-450/20";
76
+ if (ratio <= 0.75) return "bg-emerald-500 dark:bg-emerald-600 border-emerald-550/20";
77
+ return "bg-emerald-700 dark:bg-emerald-400 border-emerald-850/20 shadow-[0_0_8px_rgba(16,185,129,0.2)]";
78
+ };
79
+
80
+ const monthLabels: { label: string; colIndex: number }[] = [];
81
+ let lastMonth = -1;
82
+ weeks.forEach((week, colIdx) => {
83
+ const firstDay = week[0]?.dateObj;
84
+ if (firstDay && firstDay.getMonth() !== lastMonth) {
85
+ const label = firstDay.toLocaleString([], { month: "short" });
86
+ monthLabels.push({ label, colIndex: colIdx });
87
+ lastMonth = firstDay.getMonth();
88
+ }
89
+ });
90
+
91
+ return (
92
+ <div className="bg-white border border-zinc-100 rounded-xl p-5 shadow-xs">
93
+ <div className="flex items-center gap-2 mb-4 border-b border-zinc-50 pb-3">
94
+ <Calendar className="w-4 h-4 text-slate-450" />
95
+ <h3 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">
96
+ {title || (employeeId ? "Attendance Ledger Map" : "Company Activity Heatmap")}
97
+ </h3>
98
+ </div>
99
+
100
+ {isLoading ? (
101
+ <div className="h-32 flex items-center justify-center text-slate-400 text-xs gap-2">
102
+ <Loader2 className="w-4 h-4 animate-spin text-slate-600" />
103
+ <span>Retrieving heatmap data...</span>
104
+ </div>
105
+ ) : (
106
+ <div className="overflow-x-auto pb-2 scrollbar-thin">
107
+ <div className="min-w-[720px] flex flex-col space-y-1.5 select-none pr-2">
108
+
109
+ {/* Month Header Row */}
110
+ <div className="relative h-4 text-[9px] font-semibold text-slate-400 font-sans">
111
+ {monthLabels.map((ml, idx) => (
112
+ <div
113
+ key={idx}
114
+ className="absolute"
115
+ style={{ left: `${(ml.colIndex * 13) + 24}px` }}
116
+ >
117
+ {ml.label}
118
+ </div>
119
+ ))}
120
+ </div>
121
+
122
+ {/* Grid Container */}
123
+ <div className="flex gap-[3.5px]">
124
+
125
+ {/* Day Labels Column */}
126
+ <div className="flex flex-col justify-between text-[8px] font-semibold text-slate-400 font-sans w-5 pt-0.5 h-[80px]">
127
+ <span>Mon</span>
128
+ <span>Wed</span>
129
+ <span>Fri</span>
130
+ </div>
131
+
132
+ {/* Weeks (Columns) */}
133
+ <div className="flex gap-[3px] flex-1">
134
+ {weeks.map((week, wIdx) => (
135
+ <div key={wIdx} className="flex flex-col gap-[3px]">
136
+ {week.map((cell, dIdx) => {
137
+ const label = cell.dateObj.toLocaleDateString([], {
138
+ weekday: "short",
139
+ year: "numeric",
140
+ month: "short",
141
+ day: "numeric"
142
+ });
143
+ const tooltip = employeeId
144
+ ? `${label}: ${cell.count > 0 ? "Present" : "Absent"}`
145
+ : `${label}: ${cell.count} checked in`;
146
+
147
+ return (
148
+ <div
149
+ key={dIdx}
150
+ title={tooltip}
151
+ className={`w-2.5 h-2.5 rounded-[2px] border transition-all duration-200 hover:scale-125 cursor-pointer ${getIntensityClass(cell.count)}`}
152
+ />
153
+ );
154
+ })}
155
+ </div>
156
+ ))}
157
+ </div>
158
+ </div>
159
+
160
+ {/* Legend Row */}
161
+ <div className="flex items-center justify-end gap-1.5 pt-3.5 text-[9px] font-mono text-slate-400 font-bold uppercase">
162
+ <span>Less</span>
163
+ <div className="w-2.5 h-2.5 rounded-[2px] bg-zinc-150 border border-zinc-200/5 shrink-0" />
164
+ {!employeeId && (
165
+ <>
166
+ <div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-100 dark:bg-emerald-950/40 border border-emerald-250/20 shrink-0" />
167
+ <div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-300 dark:bg-emerald-800/60 border border-emerald-450/20 shrink-0" />
168
+ <div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-500 dark:bg-emerald-600 border border-emerald-550/20 shrink-0" />
169
+ </>
170
+ )}
171
+ <div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-700 dark:bg-emerald-400 border border-emerald-850/20 shrink-0" />
172
+ <span>More</span>
173
+ </div>
174
+
175
+ </div>
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ }
frontend/components/SidebarLayout.tsx CHANGED
@@ -336,7 +336,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
336
  </aside>
337
 
338
  {/* ─── Mobile Top Bar ─── */}
339
- <div className="md:hidden fixed top-0 left-0 right-0 h-14 z-50 flex items-center justify-between px-5 border-b border-[var(--border-subtle)] bg-[var(--bg-surface)]/95 backdrop-blur-xl">
340
  <div className="flex items-center gap-3">
341
  <div className="relative inline-flex items-center justify-center w-10 h-10 rounded-xl bg-slate-900 border border-slate-700/80 shadow-[0_0_15px_rgba(6,182,212,0.25)] overflow-hidden shrink-0">
342
  <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
@@ -374,7 +374,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
374
  {/* ─── Mobile Drawer ─── */}
375
  {sidebarOpen && (
376
  <div
377
- className="md:hidden fixed inset-0 z-40 bg-black/40 backdrop-blur-xs animate-fade-in"
378
  onClick={() => setSidebarOpen(false)}
379
  >
380
  <aside
 
336
  </aside>
337
 
338
  {/* ─── Mobile Top Bar ─── */}
339
+ <div className="md:hidden fixed top-0 left-0 right-0 h-14 z-50 flex items-center justify-between px-5 border-b border-[var(--border-subtle)] bg-[var(--bg-surface)]/95 backdrop-blur-xl no-print">
340
  <div className="flex items-center gap-3">
341
  <div className="relative inline-flex items-center justify-center w-10 h-10 rounded-xl bg-slate-900 border border-slate-700/80 shadow-[0_0_15px_rgba(6,182,212,0.25)] overflow-hidden shrink-0">
342
  <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
 
374
  {/* ─── Mobile Drawer ─── */}
375
  {sidebarOpen && (
376
  <div
377
+ className="md:hidden fixed inset-0 z-40 bg-black/40 backdrop-blur-xs animate-fade-in no-print"
378
  onClick={() => setSidebarOpen(false)}
379
  >
380
  <aside
frontend/next-env.d.ts CHANGED
@@ -1,5 +1,6 @@
1
  /// <reference types="next" />
2
  /// <reference types="next/image-types/global" />
 
3
 
4
  // NOTE: This file should not be edited
5
- // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
 
1
  /// <reference types="next" />
2
  /// <reference types="next/image-types/global" />
3
+ /// <reference path="./.next/types/routes.d.ts" />
4
 
5
  // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
frontend/package-lock.json CHANGED
@@ -13,8 +13,9 @@
13
  "echarts": "^5.5.0",
14
  "echarts-for-react": "^3.0.2",
15
  "framer-motion": "^11.0.8",
16
- "lucide-react": "^0.359.0",
17
- "next": "15.0.3",
 
18
  "react": "^19.0.0",
19
  "react-dom": "^19.0.0",
20
  "tailwind-merge": "^2.2.2"
@@ -43,9 +44,9 @@
43
  }
44
  },
45
  "node_modules/@emnapi/runtime": {
46
- "version": "1.11.0",
47
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
48
- "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
49
  "license": "MIT",
50
  "optional": true,
51
  "dependencies": {
@@ -59,10 +60,20 @@
59
  "license": "0BSD",
60
  "optional": true
61
  },
 
 
 
 
 
 
 
 
 
 
62
  "node_modules/@img/sharp-darwin-arm64": {
63
- "version": "0.33.5",
64
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
65
- "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
66
  "cpu": [
67
  "arm64"
68
  ],
@@ -78,13 +89,13 @@
78
  "url": "https://opencollective.com/libvips"
79
  },
80
  "optionalDependencies": {
81
- "@img/sharp-libvips-darwin-arm64": "1.0.4"
82
  }
83
  },
84
  "node_modules/@img/sharp-darwin-x64": {
85
- "version": "0.33.5",
86
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
87
- "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
88
  "cpu": [
89
  "x64"
90
  ],
@@ -100,13 +111,13 @@
100
  "url": "https://opencollective.com/libvips"
101
  },
102
  "optionalDependencies": {
103
- "@img/sharp-libvips-darwin-x64": "1.0.4"
104
  }
105
  },
106
  "node_modules/@img/sharp-libvips-darwin-arm64": {
107
- "version": "1.0.4",
108
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
109
- "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
110
  "cpu": [
111
  "arm64"
112
  ],
@@ -120,9 +131,9 @@
120
  }
121
  },
122
  "node_modules/@img/sharp-libvips-darwin-x64": {
123
- "version": "1.0.4",
124
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
125
- "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
126
  "cpu": [
127
  "x64"
128
  ],
@@ -136,9 +147,9 @@
136
  }
137
  },
138
  "node_modules/@img/sharp-libvips-linux-arm": {
139
- "version": "1.0.5",
140
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
141
- "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
142
  "cpu": [
143
  "arm"
144
  ],
@@ -152,9 +163,9 @@
152
  }
153
  },
154
  "node_modules/@img/sharp-libvips-linux-arm64": {
155
- "version": "1.0.4",
156
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
157
- "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
158
  "cpu": [
159
  "arm64"
160
  ],
@@ -167,10 +178,42 @@
167
  "url": "https://opencollective.com/libvips"
168
  }
169
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  "node_modules/@img/sharp-libvips-linux-s390x": {
171
- "version": "1.0.4",
172
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
173
- "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
174
  "cpu": [
175
  "s390x"
176
  ],
@@ -184,9 +227,9 @@
184
  }
185
  },
186
  "node_modules/@img/sharp-libvips-linux-x64": {
187
- "version": "1.0.4",
188
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
189
- "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
190
  "cpu": [
191
  "x64"
192
  ],
@@ -200,9 +243,9 @@
200
  }
201
  },
202
  "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
203
- "version": "1.0.4",
204
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
205
- "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
206
  "cpu": [
207
  "arm64"
208
  ],
@@ -216,9 +259,9 @@
216
  }
217
  },
218
  "node_modules/@img/sharp-libvips-linuxmusl-x64": {
219
- "version": "1.0.4",
220
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
221
- "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
222
  "cpu": [
223
  "x64"
224
  ],
@@ -232,9 +275,9 @@
232
  }
233
  },
234
  "node_modules/@img/sharp-linux-arm": {
235
- "version": "0.33.5",
236
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
237
- "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
238
  "cpu": [
239
  "arm"
240
  ],
@@ -250,13 +293,13 @@
250
  "url": "https://opencollective.com/libvips"
251
  },
252
  "optionalDependencies": {
253
- "@img/sharp-libvips-linux-arm": "1.0.5"
254
  }
255
  },
256
  "node_modules/@img/sharp-linux-arm64": {
257
- "version": "0.33.5",
258
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
259
- "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
260
  "cpu": [
261
  "arm64"
262
  ],
@@ -272,13 +315,57 @@
272
  "url": "https://opencollective.com/libvips"
273
  },
274
  "optionalDependencies": {
275
- "@img/sharp-libvips-linux-arm64": "1.0.4"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  }
277
  },
278
  "node_modules/@img/sharp-linux-s390x": {
279
- "version": "0.33.5",
280
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
281
- "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
282
  "cpu": [
283
  "s390x"
284
  ],
@@ -294,13 +381,13 @@
294
  "url": "https://opencollective.com/libvips"
295
  },
296
  "optionalDependencies": {
297
- "@img/sharp-libvips-linux-s390x": "1.0.4"
298
  }
299
  },
300
  "node_modules/@img/sharp-linux-x64": {
301
- "version": "0.33.5",
302
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
303
- "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
304
  "cpu": [
305
  "x64"
306
  ],
@@ -316,13 +403,13 @@
316
  "url": "https://opencollective.com/libvips"
317
  },
318
  "optionalDependencies": {
319
- "@img/sharp-libvips-linux-x64": "1.0.4"
320
  }
321
  },
322
  "node_modules/@img/sharp-linuxmusl-arm64": {
323
- "version": "0.33.5",
324
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
325
- "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
326
  "cpu": [
327
  "arm64"
328
  ],
@@ -338,13 +425,13 @@
338
  "url": "https://opencollective.com/libvips"
339
  },
340
  "optionalDependencies": {
341
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
342
  }
343
  },
344
  "node_modules/@img/sharp-linuxmusl-x64": {
345
- "version": "0.33.5",
346
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
347
- "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
348
  "cpu": [
349
  "x64"
350
  ],
@@ -360,20 +447,20 @@
360
  "url": "https://opencollective.com/libvips"
361
  },
362
  "optionalDependencies": {
363
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
364
  }
365
  },
366
  "node_modules/@img/sharp-wasm32": {
367
- "version": "0.33.5",
368
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
369
- "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
370
  "cpu": [
371
  "wasm32"
372
  ],
373
  "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
374
  "optional": true,
375
  "dependencies": {
376
- "@emnapi/runtime": "^1.2.0"
377
  },
378
  "engines": {
379
  "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -382,10 +469,29 @@
382
  "url": "https://opencollective.com/libvips"
383
  }
384
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  "node_modules/@img/sharp-win32-ia32": {
386
- "version": "0.33.5",
387
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
388
- "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
389
  "cpu": [
390
  "ia32"
391
  ],
@@ -402,9 +508,9 @@
402
  }
403
  },
404
  "node_modules/@img/sharp-win32-x64": {
405
- "version": "0.33.5",
406
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
407
- "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
408
  "cpu": [
409
  "x64"
410
  ],
@@ -460,15 +566,15 @@
460
  }
461
  },
462
  "node_modules/@next/env": {
463
- "version": "15.0.3",
464
- "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.3.tgz",
465
- "integrity": "sha512-t9Xy32pjNOvVn2AS+Utt6VmyrshbpfUMhIjFO60gI58deSo/KgLOp31XZ4O+kY/Is8WAGYwA5gR7kOb1eORDBA==",
466
  "license": "MIT"
467
  },
468
  "node_modules/@next/swc-darwin-arm64": {
469
- "version": "15.0.3",
470
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.3.tgz",
471
- "integrity": "sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==",
472
  "cpu": [
473
  "arm64"
474
  ],
@@ -482,9 +588,9 @@
482
  }
483
  },
484
  "node_modules/@next/swc-darwin-x64": {
485
- "version": "15.0.3",
486
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.3.tgz",
487
- "integrity": "sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==",
488
  "cpu": [
489
  "x64"
490
  ],
@@ -498,9 +604,9 @@
498
  }
499
  },
500
  "node_modules/@next/swc-linux-arm64-gnu": {
501
- "version": "15.0.3",
502
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.3.tgz",
503
- "integrity": "sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==",
504
  "cpu": [
505
  "arm64"
506
  ],
@@ -514,9 +620,9 @@
514
  }
515
  },
516
  "node_modules/@next/swc-linux-arm64-musl": {
517
- "version": "15.0.3",
518
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.3.tgz",
519
- "integrity": "sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==",
520
  "cpu": [
521
  "arm64"
522
  ],
@@ -530,9 +636,9 @@
530
  }
531
  },
532
  "node_modules/@next/swc-linux-x64-gnu": {
533
- "version": "15.0.3",
534
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.3.tgz",
535
- "integrity": "sha512-gWL/Cta1aPVqIGgDb6nxkqy06DkwJ9gAnKORdHWX1QBbSZZB+biFYPFti8aKIQL7otCE1pjyPaXpFzGeG2OS2w==",
536
  "cpu": [
537
  "x64"
538
  ],
@@ -546,9 +652,9 @@
546
  }
547
  },
548
  "node_modules/@next/swc-linux-x64-musl": {
549
- "version": "15.0.3",
550
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.3.tgz",
551
- "integrity": "sha512-QQEMwFd8r7C0GxQS62Zcdy6GKx999I/rTO2ubdXEe+MlZk9ZiinsrjwoiBL5/57tfyjikgh6GOU2WRQVUej3UA==",
552
  "cpu": [
553
  "x64"
554
  ],
@@ -562,9 +668,9 @@
562
  }
563
  },
564
  "node_modules/@next/swc-win32-arm64-msvc": {
565
- "version": "15.0.3",
566
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.3.tgz",
567
- "integrity": "sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==",
568
  "cpu": [
569
  "arm64"
570
  ],
@@ -578,9 +684,9 @@
578
  }
579
  },
580
  "node_modules/@next/swc-win32-x64-msvc": {
581
- "version": "15.0.3",
582
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.3.tgz",
583
- "integrity": "sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==",
584
  "cpu": [
585
  "x64"
586
  ],
@@ -631,19 +737,13 @@
631
  "node": ">= 8"
632
  }
633
  },
634
- "node_modules/@swc/counter": {
635
- "version": "0.1.3",
636
- "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
637
- "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
638
- "license": "Apache-2.0"
639
- },
640
  "node_modules/@swc/helpers": {
641
- "version": "0.5.13",
642
- "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
643
- "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
644
  "license": "Apache-2.0",
645
  "dependencies": {
646
- "tslib": "^2.4.0"
647
  }
648
  },
649
  "node_modules/@swc/helpers/node_modules/tslib": {
@@ -729,6 +829,19 @@
729
  "node": ">= 8"
730
  }
731
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
732
  "node_modules/arg": {
733
  "version": "5.0.2",
734
  "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -846,17 +959,6 @@
846
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
847
  }
848
  },
849
- "node_modules/busboy": {
850
- "version": "1.6.0",
851
- "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
852
- "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
853
- "dependencies": {
854
- "streamsearch": "^1.1.0"
855
- },
856
- "engines": {
857
- "node": ">=10.16.0"
858
- }
859
- },
860
  "node_modules/camelcase-css": {
861
  "version": "2.0.1",
862
  "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -940,51 +1042,6 @@
940
  "node": ">=6"
941
  }
942
  },
943
- "node_modules/color": {
944
- "version": "4.2.3",
945
- "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
946
- "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
947
- "license": "MIT",
948
- "optional": true,
949
- "dependencies": {
950
- "color-convert": "^2.0.1",
951
- "color-string": "^1.9.0"
952
- },
953
- "engines": {
954
- "node": ">=12.5.0"
955
- }
956
- },
957
- "node_modules/color-convert": {
958
- "version": "2.0.1",
959
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
960
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
961
- "license": "MIT",
962
- "optional": true,
963
- "dependencies": {
964
- "color-name": "~1.1.4"
965
- },
966
- "engines": {
967
- "node": ">=7.0.0"
968
- }
969
- },
970
- "node_modules/color-name": {
971
- "version": "1.1.4",
972
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
973
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
974
- "license": "MIT",
975
- "optional": true
976
- },
977
- "node_modules/color-string": {
978
- "version": "1.9.1",
979
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
980
- "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
981
- "license": "MIT",
982
- "optional": true,
983
- "dependencies": {
984
- "color-name": "^1.0.0",
985
- "simple-swizzle": "^0.2.2"
986
- }
987
- },
988
  "node_modules/commander": {
989
  "version": "4.1.1",
990
  "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -1265,13 +1322,6 @@
1265
  "node": ">= 0.4"
1266
  }
1267
  },
1268
- "node_modules/is-arrayish": {
1269
- "version": "0.3.4",
1270
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
1271
- "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
1272
- "license": "MIT",
1273
- "optional": true
1274
- },
1275
  "node_modules/is-binary-path": {
1276
  "version": "2.1.0",
1277
  "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1344,6 +1394,12 @@
1344
  "jiti": "bin/jiti.js"
1345
  }
1346
  },
 
 
 
 
 
 
1347
  "node_modules/lilconfig": {
1348
  "version": "3.1.3",
1349
  "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1365,12 +1421,12 @@
1365
  "license": "MIT"
1366
  },
1367
  "node_modules/lucide-react": {
1368
- "version": "0.359.0",
1369
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.359.0.tgz",
1370
- "integrity": "sha512-bxVL+rM/wacjpT0BKShA6r5IIKb6LCRg+ltFG9pnnIwaRX8kK3hq8v5JwMpT7RC6XeqB5cSaaV6GapPWWmtliw==",
1371
  "license": "ISC",
1372
  "peerDependencies": {
1373
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
1374
  }
1375
  },
1376
  "node_modules/merge2": {
@@ -1397,6 +1453,19 @@
1397
  "node": ">=8.6"
1398
  }
1399
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1400
  "node_modules/motion-dom": {
1401
  "version": "11.18.1",
1402
  "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
@@ -1443,16 +1512,13 @@
1443
  }
1444
  },
1445
  "node_modules/next": {
1446
- "version": "15.0.3",
1447
- "resolved": "https://registry.npmjs.org/next/-/next-15.0.3.tgz",
1448
- "integrity": "sha512-ontCbCRKJUIoivAdGB34yCaOcPgYXr9AAkV/IwqFfWWTXEPUgLYkSkqBhIk9KK7gGmgjc64B+RdoeIDM13Irnw==",
1449
- "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
1450
  "license": "MIT",
1451
  "dependencies": {
1452
- "@next/env": "15.0.3",
1453
- "@swc/counter": "0.1.3",
1454
- "@swc/helpers": "0.5.13",
1455
- "busboy": "1.6.0",
1456
  "caniuse-lite": "^1.0.30001579",
1457
  "postcss": "8.4.31",
1458
  "styled-jsx": "5.1.6"
@@ -1464,22 +1530,22 @@
1464
  "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
1465
  },
1466
  "optionalDependencies": {
1467
- "@next/swc-darwin-arm64": "15.0.3",
1468
- "@next/swc-darwin-x64": "15.0.3",
1469
- "@next/swc-linux-arm64-gnu": "15.0.3",
1470
- "@next/swc-linux-arm64-musl": "15.0.3",
1471
- "@next/swc-linux-x64-gnu": "15.0.3",
1472
- "@next/swc-linux-x64-musl": "15.0.3",
1473
- "@next/swc-win32-arm64-msvc": "15.0.3",
1474
- "@next/swc-win32-x64-msvc": "15.0.3",
1475
- "sharp": "^0.33.5"
1476
  },
1477
  "peerDependencies": {
1478
  "@opentelemetry/api": "^1.1.0",
1479
- "@playwright/test": "^1.41.2",
1480
  "babel-plugin-react-compiler": "*",
1481
- "react": "^18.2.0 || 19.0.0-rc-66855b96-20241106",
1482
- "react-dom": "^18.2.0 || 19.0.0-rc-66855b96-20241106",
1483
  "sass": "^1.3.0"
1484
  },
1485
  "peerDependenciesMeta": {
@@ -1579,13 +1645,13 @@
1579
  "license": "ISC"
1580
  },
1581
  "node_modules/picomatch": {
1582
- "version": "2.3.2",
1583
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
1584
- "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
1585
  "dev": true,
1586
  "license": "MIT",
1587
  "engines": {
1588
- "node": ">=8.6"
1589
  },
1590
  "funding": {
1591
  "url": "https://github.com/sponsors/jonschlinkert"
@@ -1839,6 +1905,19 @@
1839
  "node": ">=8.10.0"
1840
  }
1841
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1842
  "node_modules/resolve": {
1843
  "version": "1.22.12",
1844
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -1903,9 +1982,9 @@
1903
  "license": "MIT"
1904
  },
1905
  "node_modules/semver": {
1906
- "version": "7.8.4",
1907
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
1908
- "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
1909
  "license": "ISC",
1910
  "optional": true,
1911
  "bin": {
@@ -1916,16 +1995,16 @@
1916
  }
1917
  },
1918
  "node_modules/sharp": {
1919
- "version": "0.33.5",
1920
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
1921
- "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
1922
  "hasInstallScript": true,
1923
  "license": "Apache-2.0",
1924
  "optional": true,
1925
  "dependencies": {
1926
- "color": "^4.2.3",
1927
- "detect-libc": "^2.0.3",
1928
- "semver": "^7.6.3"
1929
  },
1930
  "engines": {
1931
  "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -1934,35 +2013,30 @@
1934
  "url": "https://opencollective.com/libvips"
1935
  },
1936
  "optionalDependencies": {
1937
- "@img/sharp-darwin-arm64": "0.33.5",
1938
- "@img/sharp-darwin-x64": "0.33.5",
1939
- "@img/sharp-libvips-darwin-arm64": "1.0.4",
1940
- "@img/sharp-libvips-darwin-x64": "1.0.4",
1941
- "@img/sharp-libvips-linux-arm": "1.0.5",
1942
- "@img/sharp-libvips-linux-arm64": "1.0.4",
1943
- "@img/sharp-libvips-linux-s390x": "1.0.4",
1944
- "@img/sharp-libvips-linux-x64": "1.0.4",
1945
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
1946
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
1947
- "@img/sharp-linux-arm": "0.33.5",
1948
- "@img/sharp-linux-arm64": "0.33.5",
1949
- "@img/sharp-linux-s390x": "0.33.5",
1950
- "@img/sharp-linux-x64": "0.33.5",
1951
- "@img/sharp-linuxmusl-arm64": "0.33.5",
1952
- "@img/sharp-linuxmusl-x64": "0.33.5",
1953
- "@img/sharp-wasm32": "0.33.5",
1954
- "@img/sharp-win32-ia32": "0.33.5",
1955
- "@img/sharp-win32-x64": "0.33.5"
1956
- }
1957
- },
1958
- "node_modules/simple-swizzle": {
1959
- "version": "0.2.4",
1960
- "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
1961
- "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
1962
- "license": "MIT",
1963
- "optional": true,
1964
- "dependencies": {
1965
- "is-arrayish": "^0.3.1"
1966
  }
1967
  },
1968
  "node_modules/size-sensor": {
@@ -1980,14 +2054,6 @@
1980
  "node": ">=0.10.0"
1981
  }
1982
  },
1983
- "node_modules/streamsearch": {
1984
- "version": "1.1.0",
1985
- "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
1986
- "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
1987
- "engines": {
1988
- "node": ">=10.0.0"
1989
- }
1990
- },
1991
  "node_modules/styled-jsx": {
1992
  "version": "5.1.6",
1993
  "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -2135,19 +2201,6 @@
2135
  "url": "https://github.com/sponsors/SuperchupuDev"
2136
  }
2137
  },
2138
- "node_modules/tinyglobby/node_modules/picomatch": {
2139
- "version": "4.0.4",
2140
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2141
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2142
- "dev": true,
2143
- "license": "MIT",
2144
- "engines": {
2145
- "node": ">=12"
2146
- },
2147
- "funding": {
2148
- "url": "https://github.com/sponsors/jonschlinkert"
2149
- }
2150
- },
2151
  "node_modules/to-regex-range": {
2152
  "version": "5.0.1",
2153
  "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
 
13
  "echarts": "^5.5.0",
14
  "echarts-for-react": "^3.0.2",
15
  "framer-motion": "^11.0.8",
16
+ "jsqr": "^1.4.0",
17
+ "lucide-react": "^0.468.0",
18
+ "next": "^15.1.7",
19
  "react": "^19.0.0",
20
  "react-dom": "^19.0.0",
21
  "tailwind-merge": "^2.2.2"
 
44
  }
45
  },
46
  "node_modules/@emnapi/runtime": {
47
+ "version": "1.11.1",
48
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
49
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
50
  "license": "MIT",
51
  "optional": true,
52
  "dependencies": {
 
60
  "license": "0BSD",
61
  "optional": true
62
  },
63
+ "node_modules/@img/colour": {
64
+ "version": "1.1.0",
65
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
66
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
67
+ "license": "MIT",
68
+ "optional": true,
69
+ "engines": {
70
+ "node": ">=18"
71
+ }
72
+ },
73
  "node_modules/@img/sharp-darwin-arm64": {
74
+ "version": "0.34.5",
75
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
76
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
77
  "cpu": [
78
  "arm64"
79
  ],
 
89
  "url": "https://opencollective.com/libvips"
90
  },
91
  "optionalDependencies": {
92
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
93
  }
94
  },
95
  "node_modules/@img/sharp-darwin-x64": {
96
+ "version": "0.34.5",
97
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
98
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
99
  "cpu": [
100
  "x64"
101
  ],
 
111
  "url": "https://opencollective.com/libvips"
112
  },
113
  "optionalDependencies": {
114
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
115
  }
116
  },
117
  "node_modules/@img/sharp-libvips-darwin-arm64": {
118
+ "version": "1.2.4",
119
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
120
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
121
  "cpu": [
122
  "arm64"
123
  ],
 
131
  }
132
  },
133
  "node_modules/@img/sharp-libvips-darwin-x64": {
134
+ "version": "1.2.4",
135
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
136
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
137
  "cpu": [
138
  "x64"
139
  ],
 
147
  }
148
  },
149
  "node_modules/@img/sharp-libvips-linux-arm": {
150
+ "version": "1.2.4",
151
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
152
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
153
  "cpu": [
154
  "arm"
155
  ],
 
163
  }
164
  },
165
  "node_modules/@img/sharp-libvips-linux-arm64": {
166
+ "version": "1.2.4",
167
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
168
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
169
  "cpu": [
170
  "arm64"
171
  ],
 
178
  "url": "https://opencollective.com/libvips"
179
  }
180
  },
181
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
182
+ "version": "1.2.4",
183
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
184
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
185
+ "cpu": [
186
+ "ppc64"
187
+ ],
188
+ "license": "LGPL-3.0-or-later",
189
+ "optional": true,
190
+ "os": [
191
+ "linux"
192
+ ],
193
+ "funding": {
194
+ "url": "https://opencollective.com/libvips"
195
+ }
196
+ },
197
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
198
+ "version": "1.2.4",
199
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
200
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
201
+ "cpu": [
202
+ "riscv64"
203
+ ],
204
+ "license": "LGPL-3.0-or-later",
205
+ "optional": true,
206
+ "os": [
207
+ "linux"
208
+ ],
209
+ "funding": {
210
+ "url": "https://opencollective.com/libvips"
211
+ }
212
+ },
213
  "node_modules/@img/sharp-libvips-linux-s390x": {
214
+ "version": "1.2.4",
215
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
216
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
217
  "cpu": [
218
  "s390x"
219
  ],
 
227
  }
228
  },
229
  "node_modules/@img/sharp-libvips-linux-x64": {
230
+ "version": "1.2.4",
231
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
232
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
233
  "cpu": [
234
  "x64"
235
  ],
 
243
  }
244
  },
245
  "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
246
+ "version": "1.2.4",
247
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
248
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
249
  "cpu": [
250
  "arm64"
251
  ],
 
259
  }
260
  },
261
  "node_modules/@img/sharp-libvips-linuxmusl-x64": {
262
+ "version": "1.2.4",
263
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
264
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
265
  "cpu": [
266
  "x64"
267
  ],
 
275
  }
276
  },
277
  "node_modules/@img/sharp-linux-arm": {
278
+ "version": "0.34.5",
279
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
280
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
281
  "cpu": [
282
  "arm"
283
  ],
 
293
  "url": "https://opencollective.com/libvips"
294
  },
295
  "optionalDependencies": {
296
+ "@img/sharp-libvips-linux-arm": "1.2.4"
297
  }
298
  },
299
  "node_modules/@img/sharp-linux-arm64": {
300
+ "version": "0.34.5",
301
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
302
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
303
  "cpu": [
304
  "arm64"
305
  ],
 
315
  "url": "https://opencollective.com/libvips"
316
  },
317
  "optionalDependencies": {
318
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
319
+ }
320
+ },
321
+ "node_modules/@img/sharp-linux-ppc64": {
322
+ "version": "0.34.5",
323
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
324
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
325
+ "cpu": [
326
+ "ppc64"
327
+ ],
328
+ "license": "Apache-2.0",
329
+ "optional": true,
330
+ "os": [
331
+ "linux"
332
+ ],
333
+ "engines": {
334
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
335
+ },
336
+ "funding": {
337
+ "url": "https://opencollective.com/libvips"
338
+ },
339
+ "optionalDependencies": {
340
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
341
+ }
342
+ },
343
+ "node_modules/@img/sharp-linux-riscv64": {
344
+ "version": "0.34.5",
345
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
346
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
347
+ "cpu": [
348
+ "riscv64"
349
+ ],
350
+ "license": "Apache-2.0",
351
+ "optional": true,
352
+ "os": [
353
+ "linux"
354
+ ],
355
+ "engines": {
356
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
357
+ },
358
+ "funding": {
359
+ "url": "https://opencollective.com/libvips"
360
+ },
361
+ "optionalDependencies": {
362
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
363
  }
364
  },
365
  "node_modules/@img/sharp-linux-s390x": {
366
+ "version": "0.34.5",
367
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
368
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
369
  "cpu": [
370
  "s390x"
371
  ],
 
381
  "url": "https://opencollective.com/libvips"
382
  },
383
  "optionalDependencies": {
384
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
385
  }
386
  },
387
  "node_modules/@img/sharp-linux-x64": {
388
+ "version": "0.34.5",
389
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
390
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
391
  "cpu": [
392
  "x64"
393
  ],
 
403
  "url": "https://opencollective.com/libvips"
404
  },
405
  "optionalDependencies": {
406
+ "@img/sharp-libvips-linux-x64": "1.2.4"
407
  }
408
  },
409
  "node_modules/@img/sharp-linuxmusl-arm64": {
410
+ "version": "0.34.5",
411
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
412
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
413
  "cpu": [
414
  "arm64"
415
  ],
 
425
  "url": "https://opencollective.com/libvips"
426
  },
427
  "optionalDependencies": {
428
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
429
  }
430
  },
431
  "node_modules/@img/sharp-linuxmusl-x64": {
432
+ "version": "0.34.5",
433
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
434
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
435
  "cpu": [
436
  "x64"
437
  ],
 
447
  "url": "https://opencollective.com/libvips"
448
  },
449
  "optionalDependencies": {
450
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
451
  }
452
  },
453
  "node_modules/@img/sharp-wasm32": {
454
+ "version": "0.34.5",
455
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
456
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
457
  "cpu": [
458
  "wasm32"
459
  ],
460
  "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
461
  "optional": true,
462
  "dependencies": {
463
+ "@emnapi/runtime": "^1.7.0"
464
  },
465
  "engines": {
466
  "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
 
469
  "url": "https://opencollective.com/libvips"
470
  }
471
  },
472
+ "node_modules/@img/sharp-win32-arm64": {
473
+ "version": "0.34.5",
474
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
475
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
476
+ "cpu": [
477
+ "arm64"
478
+ ],
479
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
480
+ "optional": true,
481
+ "os": [
482
+ "win32"
483
+ ],
484
+ "engines": {
485
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
486
+ },
487
+ "funding": {
488
+ "url": "https://opencollective.com/libvips"
489
+ }
490
+ },
491
  "node_modules/@img/sharp-win32-ia32": {
492
+ "version": "0.34.5",
493
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
494
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
495
  "cpu": [
496
  "ia32"
497
  ],
 
508
  }
509
  },
510
  "node_modules/@img/sharp-win32-x64": {
511
+ "version": "0.34.5",
512
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
513
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
514
  "cpu": [
515
  "x64"
516
  ],
 
566
  }
567
  },
568
  "node_modules/@next/env": {
569
+ "version": "15.5.19",
570
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
571
+ "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
572
  "license": "MIT"
573
  },
574
  "node_modules/@next/swc-darwin-arm64": {
575
+ "version": "15.5.19",
576
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
577
+ "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
578
  "cpu": [
579
  "arm64"
580
  ],
 
588
  }
589
  },
590
  "node_modules/@next/swc-darwin-x64": {
591
+ "version": "15.5.19",
592
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
593
+ "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
594
  "cpu": [
595
  "x64"
596
  ],
 
604
  }
605
  },
606
  "node_modules/@next/swc-linux-arm64-gnu": {
607
+ "version": "15.5.19",
608
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
609
+ "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
610
  "cpu": [
611
  "arm64"
612
  ],
 
620
  }
621
  },
622
  "node_modules/@next/swc-linux-arm64-musl": {
623
+ "version": "15.5.19",
624
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
625
+ "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
626
  "cpu": [
627
  "arm64"
628
  ],
 
636
  }
637
  },
638
  "node_modules/@next/swc-linux-x64-gnu": {
639
+ "version": "15.5.19",
640
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
641
+ "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
642
  "cpu": [
643
  "x64"
644
  ],
 
652
  }
653
  },
654
  "node_modules/@next/swc-linux-x64-musl": {
655
+ "version": "15.5.19",
656
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
657
+ "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
658
  "cpu": [
659
  "x64"
660
  ],
 
668
  }
669
  },
670
  "node_modules/@next/swc-win32-arm64-msvc": {
671
+ "version": "15.5.19",
672
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
673
+ "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
674
  "cpu": [
675
  "arm64"
676
  ],
 
684
  }
685
  },
686
  "node_modules/@next/swc-win32-x64-msvc": {
687
+ "version": "15.5.19",
688
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
689
+ "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
690
  "cpu": [
691
  "x64"
692
  ],
 
737
  "node": ">= 8"
738
  }
739
  },
 
 
 
 
 
 
740
  "node_modules/@swc/helpers": {
741
+ "version": "0.5.15",
742
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
743
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
744
  "license": "Apache-2.0",
745
  "dependencies": {
746
+ "tslib": "^2.8.0"
747
  }
748
  },
749
  "node_modules/@swc/helpers/node_modules/tslib": {
 
829
  "node": ">= 8"
830
  }
831
  },
832
+ "node_modules/anymatch/node_modules/picomatch": {
833
+ "version": "2.3.2",
834
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
835
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
836
+ "dev": true,
837
+ "license": "MIT",
838
+ "engines": {
839
+ "node": ">=8.6"
840
+ },
841
+ "funding": {
842
+ "url": "https://github.com/sponsors/jonschlinkert"
843
+ }
844
+ },
845
  "node_modules/arg": {
846
  "version": "5.0.2",
847
  "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
 
959
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
960
  }
961
  },
 
 
 
 
 
 
 
 
 
 
 
962
  "node_modules/camelcase-css": {
963
  "version": "2.0.1",
964
  "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
 
1042
  "node": ">=6"
1043
  }
1044
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1045
  "node_modules/commander": {
1046
  "version": "4.1.1",
1047
  "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
 
1322
  "node": ">= 0.4"
1323
  }
1324
  },
 
 
 
 
 
 
 
1325
  "node_modules/is-binary-path": {
1326
  "version": "2.1.0",
1327
  "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
 
1394
  "jiti": "bin/jiti.js"
1395
  }
1396
  },
1397
+ "node_modules/jsqr": {
1398
+ "version": "1.4.0",
1399
+ "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
1400
+ "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
1401
+ "license": "Apache-2.0"
1402
+ },
1403
  "node_modules/lilconfig": {
1404
  "version": "3.1.3",
1405
  "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
 
1421
  "license": "MIT"
1422
  },
1423
  "node_modules/lucide-react": {
1424
+ "version": "0.468.0",
1425
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
1426
+ "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
1427
  "license": "ISC",
1428
  "peerDependencies": {
1429
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
1430
  }
1431
  },
1432
  "node_modules/merge2": {
 
1453
  "node": ">=8.6"
1454
  }
1455
  },
1456
+ "node_modules/micromatch/node_modules/picomatch": {
1457
+ "version": "2.3.2",
1458
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
1459
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
1460
+ "dev": true,
1461
+ "license": "MIT",
1462
+ "engines": {
1463
+ "node": ">=8.6"
1464
+ },
1465
+ "funding": {
1466
+ "url": "https://github.com/sponsors/jonschlinkert"
1467
+ }
1468
+ },
1469
  "node_modules/motion-dom": {
1470
  "version": "11.18.1",
1471
  "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
 
1512
  }
1513
  },
1514
  "node_modules/next": {
1515
+ "version": "15.5.19",
1516
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
1517
+ "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
 
1518
  "license": "MIT",
1519
  "dependencies": {
1520
+ "@next/env": "15.5.19",
1521
+ "@swc/helpers": "0.5.15",
 
 
1522
  "caniuse-lite": "^1.0.30001579",
1523
  "postcss": "8.4.31",
1524
  "styled-jsx": "5.1.6"
 
1530
  "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
1531
  },
1532
  "optionalDependencies": {
1533
+ "@next/swc-darwin-arm64": "15.5.19",
1534
+ "@next/swc-darwin-x64": "15.5.19",
1535
+ "@next/swc-linux-arm64-gnu": "15.5.19",
1536
+ "@next/swc-linux-arm64-musl": "15.5.19",
1537
+ "@next/swc-linux-x64-gnu": "15.5.19",
1538
+ "@next/swc-linux-x64-musl": "15.5.19",
1539
+ "@next/swc-win32-arm64-msvc": "15.5.19",
1540
+ "@next/swc-win32-x64-msvc": "15.5.19",
1541
+ "sharp": "^0.34.3"
1542
  },
1543
  "peerDependencies": {
1544
  "@opentelemetry/api": "^1.1.0",
1545
+ "@playwright/test": "^1.51.1",
1546
  "babel-plugin-react-compiler": "*",
1547
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1548
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1549
  "sass": "^1.3.0"
1550
  },
1551
  "peerDependenciesMeta": {
 
1645
  "license": "ISC"
1646
  },
1647
  "node_modules/picomatch": {
1648
+ "version": "4.0.4",
1649
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
1650
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
1651
  "dev": true,
1652
  "license": "MIT",
1653
  "engines": {
1654
+ "node": ">=12"
1655
  },
1656
  "funding": {
1657
  "url": "https://github.com/sponsors/jonschlinkert"
 
1905
  "node": ">=8.10.0"
1906
  }
1907
  },
1908
+ "node_modules/readdirp/node_modules/picomatch": {
1909
+ "version": "2.3.2",
1910
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
1911
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
1912
+ "dev": true,
1913
+ "license": "MIT",
1914
+ "engines": {
1915
+ "node": ">=8.6"
1916
+ },
1917
+ "funding": {
1918
+ "url": "https://github.com/sponsors/jonschlinkert"
1919
+ }
1920
+ },
1921
  "node_modules/resolve": {
1922
  "version": "1.22.12",
1923
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
 
1982
  "license": "MIT"
1983
  },
1984
  "node_modules/semver": {
1985
+ "version": "7.8.5",
1986
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
1987
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
1988
  "license": "ISC",
1989
  "optional": true,
1990
  "bin": {
 
1995
  }
1996
  },
1997
  "node_modules/sharp": {
1998
+ "version": "0.34.5",
1999
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
2000
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
2001
  "hasInstallScript": true,
2002
  "license": "Apache-2.0",
2003
  "optional": true,
2004
  "dependencies": {
2005
+ "@img/colour": "^1.0.0",
2006
+ "detect-libc": "^2.1.2",
2007
+ "semver": "^7.7.3"
2008
  },
2009
  "engines": {
2010
  "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
 
2013
  "url": "https://opencollective.com/libvips"
2014
  },
2015
  "optionalDependencies": {
2016
+ "@img/sharp-darwin-arm64": "0.34.5",
2017
+ "@img/sharp-darwin-x64": "0.34.5",
2018
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
2019
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
2020
+ "@img/sharp-libvips-linux-arm": "1.2.4",
2021
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
2022
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
2023
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
2024
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
2025
+ "@img/sharp-libvips-linux-x64": "1.2.4",
2026
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
2027
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
2028
+ "@img/sharp-linux-arm": "0.34.5",
2029
+ "@img/sharp-linux-arm64": "0.34.5",
2030
+ "@img/sharp-linux-ppc64": "0.34.5",
2031
+ "@img/sharp-linux-riscv64": "0.34.5",
2032
+ "@img/sharp-linux-s390x": "0.34.5",
2033
+ "@img/sharp-linux-x64": "0.34.5",
2034
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
2035
+ "@img/sharp-linuxmusl-x64": "0.34.5",
2036
+ "@img/sharp-wasm32": "0.34.5",
2037
+ "@img/sharp-win32-arm64": "0.34.5",
2038
+ "@img/sharp-win32-ia32": "0.34.5",
2039
+ "@img/sharp-win32-x64": "0.34.5"
 
 
 
 
 
2040
  }
2041
  },
2042
  "node_modules/size-sensor": {
 
2054
  "node": ">=0.10.0"
2055
  }
2056
  },
 
 
 
 
 
 
 
 
2057
  "node_modules/styled-jsx": {
2058
  "version": "5.1.6",
2059
  "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
 
2201
  "url": "https://github.com/sponsors/SuperchupuDev"
2202
  }
2203
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
2204
  "node_modules/to-regex-range": {
2205
  "version": "5.0.1",
2206
  "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
frontend/package.json CHANGED
@@ -14,6 +14,7 @@
14
  "echarts": "^5.5.0",
15
  "echarts-for-react": "^3.0.2",
16
  "framer-motion": "^11.0.8",
 
17
  "lucide-react": "^0.468.0",
18
  "next": "^15.1.7",
19
  "react": "^19.0.0",
 
14
  "echarts": "^5.5.0",
15
  "echarts-for-react": "^3.0.2",
16
  "framer-motion": "^11.0.8",
17
+ "jsqr": "^1.4.0",
18
  "lucide-react": "^0.468.0",
19
  "next": "^15.1.7",
20
  "react": "^19.0.0",
frontend/tsconfig.tsbuildinfo CHANGED
The diff for this file is too large to render. See raw diff