Pavanupadhyay27 commited on
Commit ·
411cf16
1
Parent(s): 15adfd0
Deploy NetraID backend
Browse files- backend/app/api/v1/employees.py +11 -0
- backend/app/api/v1/enrollment.py +20 -5
- backend/app/api/v1/kiosk.py +322 -90
- backend/app/core/config.py +4 -0
- backend/app/core/init_db.py +23 -1
- backend/app/crud/crud.py +15 -2
- backend/app/main.py +3 -1
- backend/app/models/models.py +2 -0
- backend/app/schemas/schemas.py +16 -1
- backend/app/services/face_engine.py +18 -10
- backend/app/tests/test_employees.py +7 -3
- frontend/app/attendance/page.tsx +106 -35
- frontend/app/audit/page.tsx +0 -1
- frontend/app/dashboard/page.tsx +149 -191
- frontend/app/employees/page.tsx +148 -14
- frontend/app/enroll/[id]/page.tsx +164 -46
- frontend/app/globals.css +379 -69
- frontend/app/kiosk/page.tsx +284 -140
- frontend/app/page.tsx +30 -38
- frontend/app/providers.tsx +17 -2
- frontend/app/reports/page.tsx +3 -5
- frontend/app/settings/page.tsx +3 -7
- frontend/app/utils/api.ts +3 -2
- frontend/app/utils/toast.tsx +110 -0
- frontend/components/SidebarLayout.tsx +94 -32
- frontend/tsconfig.tsbuildinfo +1 -0
backend/app/api/v1/employees.py
CHANGED
|
@@ -68,6 +68,17 @@ def create_employee(
|
|
| 68 |
existing_email = crud.get_employee_by_email(db, email=emp.email)
|
| 69 |
if existing_email:
|
| 70 |
raise HTTPException(status_code=400, detail="Employee email already exists")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
user_id = None
|
| 73 |
if emp.create_user_login:
|
|
|
|
| 68 |
existing_email = crud.get_employee_by_email(db, email=emp.email)
|
| 69 |
if existing_email:
|
| 70 |
raise HTTPException(status_code=400, detail="Employee email already exists")
|
| 71 |
+
|
| 72 |
+
# Check duplicate name (case-insensitive)
|
| 73 |
+
existing_name = crud.get_employee_by_name(db, name=emp.name)
|
| 74 |
+
if existing_name:
|
| 75 |
+
raise HTTPException(status_code=400, detail="An employee with this name is already registered")
|
| 76 |
+
|
| 77 |
+
# Check duplicate phone
|
| 78 |
+
if emp.phone:
|
| 79 |
+
existing_phone = crud.get_employee_by_phone(db, phone=emp.phone)
|
| 80 |
+
if existing_phone:
|
| 81 |
+
raise HTTPException(status_code=400, detail="This phone number is already registered to another employee")
|
| 82 |
|
| 83 |
user_id = None
|
| 84 |
if emp.create_user_login:
|
backend/app/api/v1/enrollment.py
CHANGED
|
@@ -58,11 +58,26 @@ async def upload_face_image(
|
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
# Align face (112x112)
|
| 68 |
aligned_face = face_engine.align_face(img, face["landmarks"])
|
|
|
|
| 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
|
| 63 |
+
|
| 64 |
+
liveness_threshold_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_THRESHOLD")
|
| 65 |
+
liveness_threshold = float(liveness_threshold_setting.value) if liveness_threshold_setting else 0.70
|
| 66 |
+
|
| 67 |
+
liveness_score, is_live = face_engine.check_liveness(img, face["bbox"], threshold=liveness_threshold)
|
| 68 |
+
|
| 69 |
+
# In enrollment we want to prevent spoofing. However, liveness models are calibrated for direct frontal views.
|
| 70 |
+
# Profile/tilted views (left, right, up, down) often yield lower liveness scores and cause false rejections.
|
| 71 |
+
# Therefore, we strictly enforce liveness on the "front" pose only, and bypass it for other poses.
|
| 72 |
+
if liveness_enabled and not is_live and not face_engine.mock_mode:
|
| 73 |
+
if pose_type.strip().lower() == "front":
|
| 74 |
+
raise HTTPException(status_code=400, detail=f"Liveness check failed ({liveness_score:.2f}). Please upload a real photo.")
|
| 75 |
+
else:
|
| 76 |
+
logger.warning(
|
| 77 |
+
f"Liveness check failed during enrollment for non-frontal pose '{pose_type}' "
|
| 78 |
+
f"(score: {liveness_score:.2f}, threshold: {liveness_threshold:.2f}). "
|
| 79 |
+
f"Bypassing check to prevent false rejection."
|
| 80 |
+
)
|
| 81 |
|
| 82 |
# Align face (112x112)
|
| 83 |
aligned_face = face_engine.align_face(img, face["landmarks"])
|
backend/app/api/v1/kiosk.py
CHANGED
|
@@ -35,6 +35,14 @@ def _publish_log(log_obj, employee=None):
|
|
| 35 |
"employee_id": employee.employee_id,
|
| 36 |
"designation": employee.designation,
|
| 37 |
"department": employee.department.name if employee.department else "General",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
payload = {
|
| 40 |
"id": log_obj.id,
|
|
@@ -44,6 +52,7 @@ def _publish_log(log_obj, employee=None):
|
|
| 44 |
"liveness_score": log_obj.liveness_score,
|
| 45 |
"is_spoof": log_obj.is_spoof,
|
| 46 |
"status": log_obj.status,
|
|
|
|
| 47 |
"employee": emp_data,
|
| 48 |
}
|
| 49 |
event_bus.publish_scan_event(payload)
|
|
@@ -53,6 +62,7 @@ def _publish_log(log_obj, employee=None):
|
|
| 53 |
class KioskScanRequest(BaseModel):
|
| 54 |
image: str = Field(..., description="Base64 encoded image frame (JPEG/PNG data URL)")
|
| 55 |
camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
|
|
|
|
| 56 |
|
| 57 |
@router.post("/scan")
|
| 58 |
def scan_face(
|
|
@@ -118,7 +128,7 @@ def scan_face(
|
|
| 118 |
landmarks = face["landmarks"]
|
| 119 |
|
| 120 |
# 3. Liveness Check
|
| 121 |
-
liveness_score, is_live = face_engine.check_liveness(img, bbox)
|
| 122 |
if not is_live and not face_engine.mock_mode:
|
| 123 |
# Save spoof log
|
| 124 |
log_entry = crud.create_attendance_log(
|
|
@@ -134,7 +144,7 @@ def scan_face(
|
|
| 134 |
_publish_log(log_entry)
|
| 135 |
return {
|
| 136 |
"status": "spoof_detected",
|
| 137 |
-
"message": "
|
| 138 |
"confidence": float(confidence),
|
| 139 |
"liveness_score": float(liveness_score),
|
| 140 |
"should_retry": False
|
|
@@ -152,28 +162,24 @@ def scan_face(
|
|
| 152 |
if not all_embeddings:
|
| 153 |
match_result = None
|
| 154 |
else:
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
if best_emb is not None:
|
| 175 |
-
match_result = (best_emb, best_dist)
|
| 176 |
-
else:
|
| 177 |
match_result = None
|
| 178 |
|
| 179 |
if not match_result:
|
|
@@ -248,74 +254,300 @@ def scan_face(
|
|
| 248 |
else:
|
| 249 |
_last_greeted_employee_id = employee.id
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
# Save log
|
| 261 |
-
log_entry = crud.create_attendance_log(
|
| 262 |
-
db=db,
|
| 263 |
-
employee_id=employee.id,
|
| 264 |
-
camera=payload.camera,
|
| 265 |
-
confidence=similarity,
|
| 266 |
-
liveness_score=liveness_score,
|
| 267 |
-
is_spoof=False,
|
| 268 |
-
status="Match Success",
|
| 269 |
-
timestamp=now
|
| 270 |
)
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
else:
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
@router.get("/tts")
|
| 321 |
def play_tts(text: str):
|
|
|
|
| 35 |
"employee_id": employee.employee_id,
|
| 36 |
"designation": employee.designation,
|
| 37 |
"department": employee.department.name if employee.department else "General",
|
| 38 |
+
"images": [
|
| 39 |
+
{
|
| 40 |
+
"id": img.id,
|
| 41 |
+
"file_path": img.file_path,
|
| 42 |
+
"pose_type": img.pose_type,
|
| 43 |
+
"created_at": img.created_at.isoformat() if img.created_at else None
|
| 44 |
+
} for img in employee.images
|
| 45 |
+
]
|
| 46 |
}
|
| 47 |
payload = {
|
| 48 |
"id": log_obj.id,
|
|
|
|
| 52 |
"liveness_score": log_obj.liveness_score,
|
| 53 |
"is_spoof": log_obj.is_spoof,
|
| 54 |
"status": log_obj.status,
|
| 55 |
+
"image_path": log_obj.image_path,
|
| 56 |
"employee": emp_data,
|
| 57 |
}
|
| 58 |
event_bus.publish_scan_event(payload)
|
|
|
|
| 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(
|
|
|
|
| 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(
|
|
|
|
| 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
|
|
|
|
| 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:
|
|
|
|
| 254 |
else:
|
| 255 |
_last_greeted_employee_id = employee.id
|
| 256 |
|
| 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_(
|
| 263 |
+
models.Attendance.employee_id == employee.id,
|
| 264 |
+
models.Attendance.date == now.date()
|
| 265 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
)
|
| 267 |
+
attendance_record = db.execute(stmt).scalar_one_or_none()
|
| 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 |
+
|
| 284 |
+
attendance_record = models.Attendance(
|
| 285 |
+
employee_id=employee.id,
|
| 286 |
+
date=now.date(),
|
| 287 |
+
check_in=now,
|
| 288 |
+
late_arrival=is_late,
|
| 289 |
+
status=status
|
| 290 |
+
)
|
| 291 |
+
db.add(attendance_record)
|
| 292 |
+
db.commit()
|
| 293 |
+
db.refresh(attendance_record)
|
| 294 |
+
|
| 295 |
+
# Save log
|
| 296 |
+
log_entry = crud.create_attendance_log(
|
| 297 |
+
db=db,
|
| 298 |
+
employee_id=employee.id,
|
| 299 |
+
camera=payload.camera,
|
| 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)
|
| 307 |
+
|
| 308 |
+
# Generate check-in greeting based on timing
|
| 309 |
+
current_hour = now.hour
|
| 310 |
+
if 5 <= current_hour < 12:
|
| 311 |
+
salutation = "Good Morning"
|
| 312 |
+
icon = "☀️"
|
| 313 |
+
elif 12 <= current_hour < 17:
|
| 314 |
+
salutation = "Good Afternoon"
|
| 315 |
+
icon = "🌤️"
|
| 316 |
+
else:
|
| 317 |
+
salutation = "Good Evening"
|
| 318 |
+
icon = "🌙"
|
| 319 |
+
|
| 320 |
+
greeting_text = f"Welcome {employee.name}. {salutation}. Attendance Recorded Successfully. Have a Great Day."
|
| 321 |
+
tts_url = f"{settings.API_V1_STR}/kiosk/tts?text={urllib.parse.quote(greeting_text)}" if (voice_enabled and should_greet) else None
|
| 322 |
+
|
| 323 |
+
return {
|
| 324 |
+
"status": "success",
|
| 325 |
+
"employee": {
|
| 326 |
+
"id": employee.id,
|
| 327 |
+
"employee_id": employee.employee_id,
|
| 328 |
+
"name": employee.name,
|
| 329 |
+
"designation": employee.designation,
|
| 330 |
+
"department": employee.department.name if employee.department else "General"
|
| 331 |
+
},
|
| 332 |
+
"attendance": {
|
| 333 |
+
"date": str(attendance_record.date),
|
| 334 |
+
"check_in": str(attendance_record.check_in.time().strftime("%H:%M:%S")) if attendance_record.check_in else None,
|
| 335 |
+
"check_out": None,
|
| 336 |
+
"status": attendance_record.status,
|
| 337 |
+
"working_hours": 0.0
|
| 338 |
+
},
|
| 339 |
+
"confidence": similarity,
|
| 340 |
+
"liveness_score": liveness_score,
|
| 341 |
+
"greeting": {
|
| 342 |
+
"title": f"Welcome, {employee.name}",
|
| 343 |
+
"subtitle": f"{salutation} {icon}",
|
| 344 |
+
"detail": "Attendance Recorded Successfully",
|
| 345 |
+
"closing": "Have a Great Day"
|
| 346 |
+
},
|
| 347 |
+
"tts_url": tts_url
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
else:
|
| 351 |
+
# --- Attendance record exists for today ---
|
| 352 |
+
if attendance_record.check_out is not None:
|
| 353 |
+
# Already checked out -> Locked!
|
| 354 |
+
# Check if emergency bypass is enabled
|
| 355 |
+
if getattr(attendance_record, "emergency_allowed", False):
|
| 356 |
+
# Emergency check-in!
|
| 357 |
+
attendance_record.check_out = None
|
| 358 |
+
attendance_record.emergency_allowed = False
|
| 359 |
+
db.commit()
|
| 360 |
+
|
| 361 |
+
log_entry = crud.create_attendance_log(
|
| 362 |
+
db=db,
|
| 363 |
+
employee_id=employee.id,
|
| 364 |
+
camera=payload.camera,
|
| 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)
|
| 372 |
+
|
| 373 |
+
current_hour = now.hour
|
| 374 |
+
if 5 <= current_hour < 12:
|
| 375 |
+
salutation = "Good Morning"
|
| 376 |
+
icon = "☀️"
|
| 377 |
+
elif 12 <= current_hour < 17:
|
| 378 |
+
salutation = "Good Afternoon"
|
| 379 |
+
icon = "🌤️"
|
| 380 |
+
else:
|
| 381 |
+
salutation = "Good Evening"
|
| 382 |
+
icon = "🌙"
|
| 383 |
+
|
| 384 |
+
greeting_text = f"Welcome {employee.name}. {salutation}. Emergency Attendance Recorded. Have a Great Day."
|
| 385 |
+
tts_url = f"{settings.API_V1_STR}/kiosk/tts?text={urllib.parse.quote(greeting_text)}" if (voice_enabled and should_greet) else None
|
| 386 |
+
|
| 387 |
+
return {
|
| 388 |
+
"status": "success",
|
| 389 |
+
"employee": {
|
| 390 |
+
"id": employee.id,
|
| 391 |
+
"employee_id": employee.employee_id,
|
| 392 |
+
"name": employee.name,
|
| 393 |
+
"designation": employee.designation,
|
| 394 |
+
"department": employee.department.name if employee.department else "General"
|
| 395 |
+
},
|
| 396 |
+
"attendance": {
|
| 397 |
+
"date": str(attendance_record.date),
|
| 398 |
+
"check_in": str(attendance_record.check_in.time().strftime("%H:%M:%S")) if attendance_record.check_in else None,
|
| 399 |
+
"check_out": None,
|
| 400 |
+
"status": attendance_record.status,
|
| 401 |
+
"working_hours": 0.0
|
| 402 |
+
},
|
| 403 |
+
"confidence": similarity,
|
| 404 |
+
"liveness_score": liveness_score,
|
| 405 |
+
"greeting": {
|
| 406 |
+
"title": f"Welcome back, {employee.name}",
|
| 407 |
+
"subtitle": f"{salutation} {icon}",
|
| 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!
|
| 415 |
+
log_entry = crud.create_attendance_log(
|
| 416 |
+
db=db,
|
| 417 |
+
employee_id=employee.id,
|
| 418 |
+
camera=payload.camera,
|
| 419 |
+
confidence=similarity,
|
| 420 |
+
liveness_score=liveness_score,
|
| 421 |
+
is_spoof=False,
|
| 422 |
+
status="Attendance Locked",
|
| 423 |
+
timestamp=now
|
| 424 |
+
)
|
| 425 |
+
_publish_log(log_entry, employee)
|
| 426 |
+
|
| 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:
|
| 434 |
+
# Checked in, but not checked out yet -> Prompt for check-out
|
| 435 |
+
if not payload.confirm_checkout:
|
| 436 |
+
# Ask user if they want to check out
|
| 437 |
+
# Calculate working hours so far
|
| 438 |
+
diff = now - attendance_record.check_in
|
| 439 |
+
hours = round(diff.total_seconds() / 3600.0, 2)
|
| 440 |
+
|
| 441 |
+
# Format: only say "Thank you" before checkout confirmation
|
| 442 |
+
greeting_text = "Thank you"
|
| 443 |
+
tts_url = f"{settings.API_V1_STR}/kiosk/tts?text={urllib.parse.quote(greeting_text)}" if (voice_enabled and should_greet) else None
|
| 444 |
+
|
| 445 |
+
return {
|
| 446 |
+
"status": "ask_checkout",
|
| 447 |
+
"employee": {
|
| 448 |
+
"id": employee.id,
|
| 449 |
+
"employee_id": employee.employee_id,
|
| 450 |
+
"name": employee.name,
|
| 451 |
+
"designation": employee.designation,
|
| 452 |
+
"department": employee.department.name if employee.department else "General"
|
| 453 |
+
},
|
| 454 |
+
"attendance": {
|
| 455 |
+
"date": str(attendance_record.date),
|
| 456 |
+
"check_in": str(attendance_record.check_in.time().strftime("%H:%M:%S")),
|
| 457 |
+
"status": attendance_record.status
|
| 458 |
+
},
|
| 459 |
+
"working_hours_so_far": hours,
|
| 460 |
+
"confidence": similarity,
|
| 461 |
+
"liveness_score": liveness_score,
|
| 462 |
+
"greeting": {
|
| 463 |
+
"title": "Identity Verified",
|
| 464 |
+
"subtitle": "Thank You",
|
| 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!
|
| 472 |
+
attendance_record.check_out = now
|
| 473 |
+
diff = now - attendance_record.check_in
|
| 474 |
+
hours = round(diff.total_seconds() / 3600.0, 2)
|
| 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"]:
|
| 493 |
+
attendance_record.status = "Present"
|
| 494 |
+
|
| 495 |
+
db.commit()
|
| 496 |
+
db.refresh(attendance_record)
|
| 497 |
+
|
| 498 |
+
log_entry = crud.create_attendance_log(
|
| 499 |
+
db=db,
|
| 500 |
+
employee_id=employee.id,
|
| 501 |
+
camera=payload.camera,
|
| 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)
|
| 509 |
+
|
| 510 |
+
# Play timing greeting after confirmation
|
| 511 |
+
current_hour = now.hour
|
| 512 |
+
if 5 <= current_hour < 12:
|
| 513 |
+
salutation = "Good Morning"
|
| 514 |
+
icon = "☀️"
|
| 515 |
+
elif 12 <= current_hour < 17:
|
| 516 |
+
salutation = "Good Afternoon"
|
| 517 |
+
icon = "🌤️"
|
| 518 |
+
else:
|
| 519 |
+
salutation = "Good Evening"
|
| 520 |
+
icon = "🌙"
|
| 521 |
+
|
| 522 |
+
greeting_text = f"Welcome {employee.name}. {salutation}. Checkout Recorded Successfully. Have a Relaxing Evening."
|
| 523 |
+
tts_url = f"{settings.API_V1_STR}/kiosk/tts?text={urllib.parse.quote(greeting_text)}" if (voice_enabled and should_greet) else None
|
| 524 |
+
|
| 525 |
+
return {
|
| 526 |
+
"status": "success",
|
| 527 |
+
"employee": {
|
| 528 |
+
"id": employee.id,
|
| 529 |
+
"employee_id": employee.employee_id,
|
| 530 |
+
"name": employee.name,
|
| 531 |
+
"designation": employee.designation,
|
| 532 |
+
"department": employee.department.name if employee.department else "General"
|
| 533 |
+
},
|
| 534 |
+
"attendance": {
|
| 535 |
+
"date": str(attendance_record.date),
|
| 536 |
+
"check_in": str(attendance_record.check_in.time().strftime("%H:%M:%S")),
|
| 537 |
+
"check_out": str(attendance_record.check_out.time().strftime("%H:%M:%S")),
|
| 538 |
+
"status": attendance_record.status,
|
| 539 |
+
"working_hours": hours
|
| 540 |
+
},
|
| 541 |
+
"confidence": similarity,
|
| 542 |
+
"liveness_score": liveness_score,
|
| 543 |
+
"greeting": {
|
| 544 |
+
"title": f"Goodbye, {employee.name}",
|
| 545 |
+
"subtitle": f"{salutation} {icon}",
|
| 546 |
+
"detail": "Checkout Recorded Successfully",
|
| 547 |
+
"closing": f"Worked: {hours} hours"
|
| 548 |
+
},
|
| 549 |
+
"tts_url": tts_url
|
| 550 |
+
}
|
| 551 |
|
| 552 |
@router.get("/tts")
|
| 553 |
def play_tts(text: str):
|
backend/app/core/config.py
CHANGED
|
@@ -54,6 +54,10 @@ class Settings(BaseSettings):
|
|
| 54 |
KIOSK_LIVENESS_THRESHOLD: float = 0.75
|
| 55 |
FORCE_MOCK_MODE: bool = False
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
# Paths
|
| 58 |
UPLOAD_DIR: str = "./uploads"
|
| 59 |
MODELS_DIR: str = "./models"
|
|
|
|
| 54 |
KIOSK_LIVENESS_THRESHOLD: float = 0.75
|
| 55 |
FORCE_MOCK_MODE: bool = False
|
| 56 |
|
| 57 |
+
# Performance Optimizations
|
| 58 |
+
LOW_MEMORY_MODE: bool = True # Set to False on VPS/Dedicated servers to run faster
|
| 59 |
+
ORT_INTRA_OP_NUM_THREADS: int = 0 # 0 = automatic core detection (faster)
|
| 60 |
+
|
| 61 |
# Paths
|
| 62 |
UPLOAD_DIR: str = "./uploads"
|
| 63 |
MODELS_DIR: str = "./models"
|
backend/app/core/init_db.py
CHANGED
|
@@ -19,6 +19,24 @@ def init_db(db: Session):
|
|
| 19 |
# Create all tables if they don't exist
|
| 20 |
logger.info("Creating all database tables if they do not exist...")
|
| 21 |
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# 1. Seed Roles
|
| 24 |
roles = [
|
|
@@ -57,6 +75,8 @@ def init_db(db: Session):
|
|
| 57 |
{"key": "GRACE_PERIOD_MINUTES", "value": "0", "description": "Grace period for check-ins in minutes"},
|
| 58 |
{"key": "KIOSK_FACE_THRESHOLD", "value": str(settings.KIOSK_FACE_THRESHOLD), "description": "Cosine similarity threshold for face match"},
|
| 59 |
{"key": "KIOSK_LIVENESS_THRESHOLD", "value": str(settings.KIOSK_LIVENESS_THRESHOLD), "description": "Softmax probability threshold for face liveness"},
|
|
|
|
|
|
|
| 60 |
{"key": "VOICE_GREETING_ENABLED", "value": "true", "description": "Enable voice greeting on successful attendance"},
|
| 61 |
{"key": "SYSTEM_MAINTENANCE_MODE", "value": "false", "description": "Toggle maintenance mode to suspend active check-ins"},
|
| 62 |
{"key": "MAX_ENROLLMENT_IMAGES", "value": "5", "description": "Maximum face images captured during registration"},
|
|
@@ -81,6 +101,8 @@ def init_db(db: Session):
|
|
| 81 |
)
|
| 82 |
crud.create_user(db, admin_create)
|
| 83 |
else:
|
| 84 |
-
logger.info(f"Super Admin user {admin_email} already exists.")
|
|
|
|
|
|
|
| 85 |
|
| 86 |
logger.info("Database initialization and seeding completed successfully.")
|
|
|
|
| 19 |
# Create all tables if they don't exist
|
| 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"))
|
| 26 |
+
except Exception:
|
| 27 |
+
db.rollback()
|
| 28 |
+
logger.info("Adding emergency_allowed column to attendance table...")
|
| 29 |
+
db.execute(text("ALTER TABLE attendance ADD COLUMN emergency_allowed BOOLEAN DEFAULT 0"))
|
| 30 |
+
db.commit()
|
| 31 |
+
|
| 32 |
+
# Ensure image_path column exists in attendance_logs table
|
| 33 |
+
try:
|
| 34 |
+
db.execute(text("SELECT image_path FROM attendance_logs LIMIT 1"))
|
| 35 |
+
except Exception:
|
| 36 |
+
db.rollback()
|
| 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 = [
|
|
|
|
| 75 |
{"key": "GRACE_PERIOD_MINUTES", "value": "0", "description": "Grace period for check-ins in minutes"},
|
| 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"},
|
|
|
|
| 101 |
)
|
| 102 |
crud.create_user(db, admin_create)
|
| 103 |
else:
|
| 104 |
+
logger.info(f"Super Admin user {admin_email} already exists. Syncing password with configuration...")
|
| 105 |
+
admin_user.hashed_password = crud.get_password_hash(settings.INITIAL_ADMIN_PASSWORD)
|
| 106 |
+
db.commit()
|
| 107 |
|
| 108 |
logger.info("Database initialization and seeding completed successfully.")
|
backend/app/crud/crud.py
CHANGED
|
@@ -100,6 +100,18 @@ def get_employee_by_uuid(db: Session, employee_id: str):
|
|
| 100 |
def get_employee_by_email(db: Session, email: str):
|
| 101 |
return db.execute(select(models.Employee).where(models.Employee.email == email)).scalar_one_or_none()
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def get_employees(
|
| 104 |
db: Session,
|
| 105 |
skip: int = 0,
|
|
@@ -259,7 +271,7 @@ def set_setting(db: Session, key: str, value: str, description: str = None):
|
|
| 259 |
return db_setting
|
| 260 |
|
| 261 |
# --- Attendance Logs CRUD ---
|
| 262 |
-
def create_attendance_log(db: Session, employee_id: int, camera: str, confidence: float, liveness_score: float, is_spoof: bool, status: str, timestamp: datetime = None):
|
| 263 |
if not timestamp:
|
| 264 |
timestamp = datetime.now()
|
| 265 |
log = models.AttendanceLog(
|
|
@@ -269,7 +281,8 @@ def create_attendance_log(db: Session, employee_id: int, camera: str, confidence
|
|
| 269 |
liveness_score=liveness_score,
|
| 270 |
is_spoof=is_spoof,
|
| 271 |
status=status,
|
| 272 |
-
timestamp=timestamp
|
|
|
|
| 273 |
)
|
| 274 |
db.add(log)
|
| 275 |
db.commit()
|
|
|
|
| 100 |
def get_employee_by_email(db: Session, email: str):
|
| 101 |
return db.execute(select(models.Employee).where(models.Employee.email == email)).scalar_one_or_none()
|
| 102 |
|
| 103 |
+
def get_employee_by_name(db: Session, name: str):
|
| 104 |
+
return db.execute(select(models.Employee).where(func.lower(models.Employee.name) == func.lower(name))).scalars().first()
|
| 105 |
+
|
| 106 |
+
def get_employee_by_phone(db: Session, phone: str):
|
| 107 |
+
import re
|
| 108 |
+
cleaned = re.sub(r'[\s\-()]', '', phone)
|
| 109 |
+
all_emps = db.execute(select(models.Employee).where(models.Employee.phone.isnot(None))).scalars().all()
|
| 110 |
+
for emp in all_emps:
|
| 111 |
+
if re.sub(r'[\s\-()]', '', emp.phone) == cleaned:
|
| 112 |
+
return emp
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
def get_employees(
|
| 116 |
db: Session,
|
| 117 |
skip: int = 0,
|
|
|
|
| 271 |
return db_setting
|
| 272 |
|
| 273 |
# --- Attendance Logs CRUD ---
|
| 274 |
+
def create_attendance_log(db: Session, employee_id: int, camera: str, confidence: float, liveness_score: float, is_spoof: bool, status: str, timestamp: datetime = None, image_path: str = None):
|
| 275 |
if not timestamp:
|
| 276 |
timestamp = datetime.now()
|
| 277 |
log = models.AttendanceLog(
|
|
|
|
| 281 |
liveness_score=liveness_score,
|
| 282 |
is_spoof=is_spoof,
|
| 283 |
status=status,
|
| 284 |
+
timestamp=timestamp,
|
| 285 |
+
image_path=image_path
|
| 286 |
)
|
| 287 |
db.add(log)
|
| 288 |
db.commit()
|
backend/app/main.py
CHANGED
|
@@ -95,10 +95,12 @@ def debug_db():
|
|
| 95 |
|
| 96 |
@app.get("/health", tags=["Status"])
|
| 97 |
def health_check():
|
|
|
|
| 98 |
return {
|
| 99 |
"status": "healthy",
|
| 100 |
"project": settings.PROJECT_NAME,
|
| 101 |
-
"version": "1.0.0"
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
|
|
|
|
| 95 |
|
| 96 |
@app.get("/health", tags=["Status"])
|
| 97 |
def health_check():
|
| 98 |
+
from app.services.singletons import face_engine
|
| 99 |
return {
|
| 100 |
"status": "healthy",
|
| 101 |
"project": settings.PROJECT_NAME,
|
| 102 |
+
"version": "1.0.0",
|
| 103 |
+
"mock_mode": face_engine.mock_mode
|
| 104 |
}
|
| 105 |
|
| 106 |
|
backend/app/models/models.py
CHANGED
|
@@ -134,6 +134,7 @@ class Attendance(Base):
|
|
| 134 |
early_departure = Column(Boolean, default=False)
|
| 135 |
overtime = Column(Float, default=0.0) # Calculated in hours
|
| 136 |
status = Column(String(20), default="Absent") # Present, Absent, Late, Half Day, Leave, Holiday, WFH
|
|
|
|
| 137 |
|
| 138 |
employee = relationship("Employee", back_populates="attendance_records")
|
| 139 |
|
|
@@ -148,6 +149,7 @@ class AttendanceLog(Base):
|
|
| 148 |
liveness_score = Column(Float, nullable=True)
|
| 149 |
is_spoof = Column(Boolean, default=False)
|
| 150 |
status = Column(String(50), nullable=False) # Match Success, Spoof Rejected, Unknown Person, Low Confidence
|
|
|
|
| 151 |
|
| 152 |
employee = relationship("Employee", back_populates="attendance_logs")
|
| 153 |
|
|
|
|
| 134 |
early_departure = Column(Boolean, default=False)
|
| 135 |
overtime = Column(Float, default=0.0) # Calculated in hours
|
| 136 |
status = Column(String(20), default="Absent") # Present, Absent, Late, Half Day, Leave, Holiday, WFH
|
| 137 |
+
emergency_allowed = Column(Boolean, default=False)
|
| 138 |
|
| 139 |
employee = relationship("Employee", back_populates="attendance_records")
|
| 140 |
|
|
|
|
| 149 |
liveness_score = Column(Float, nullable=True)
|
| 150 |
is_spoof = Column(Boolean, default=False)
|
| 151 |
status = Column(String(50), nullable=False) # Match Success, Spoof Rejected, Unknown Person, Low Confidence
|
| 152 |
+
image_path = Column(String(255), nullable=True)
|
| 153 |
|
| 154 |
employee = relationship("Employee", back_populates="attendance_logs")
|
| 155 |
|
backend/app/schemas/schemas.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
-
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
| 2 |
from typing import Optional, List
|
| 3 |
from datetime import datetime, date, time, timedelta
|
|
|
|
| 4 |
|
| 5 |
# Role Schemas
|
| 6 |
class RoleBase(BaseModel):
|
|
@@ -72,6 +73,17 @@ class EmployeeBase(BaseModel):
|
|
| 72 |
status: str = "Active"
|
| 73 |
department_id: Optional[int] = None
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
class EmployeeCreate(EmployeeBase):
|
| 76 |
create_user_login: bool = False
|
| 77 |
password: Optional[str] = None # Required if create_user_login is True
|
|
@@ -124,6 +136,7 @@ class AttendanceOut(AttendanceBase):
|
|
| 124 |
late_arrival: bool
|
| 125 |
early_departure: bool
|
| 126 |
overtime: float
|
|
|
|
| 127 |
employee: Optional[EmployeeOut] = None
|
| 128 |
model_config = ConfigDict(from_attributes=True)
|
| 129 |
|
|
@@ -131,6 +144,7 @@ class AttendanceUpdate(BaseModel):
|
|
| 131 |
check_in: Optional[datetime] = None
|
| 132 |
check_out: Optional[datetime] = None
|
| 133 |
status: Optional[str] = None
|
|
|
|
| 134 |
|
| 135 |
# Attendance Log Schemas
|
| 136 |
class AttendanceLogOut(BaseModel):
|
|
@@ -142,6 +156,7 @@ class AttendanceLogOut(BaseModel):
|
|
| 142 |
liveness_score: Optional[float] = None
|
| 143 |
is_spoof: bool
|
| 144 |
status: str
|
|
|
|
| 145 |
employee: Optional[EmployeeOut] = None
|
| 146 |
model_config = ConfigDict(from_attributes=True)
|
| 147 |
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
|
| 2 |
from typing import Optional, List
|
| 3 |
from datetime import datetime, date, time, timedelta
|
| 4 |
+
import re
|
| 5 |
|
| 6 |
# Role Schemas
|
| 7 |
class RoleBase(BaseModel):
|
|
|
|
| 73 |
status: str = "Active"
|
| 74 |
department_id: Optional[int] = None
|
| 75 |
|
| 76 |
+
@field_validator('phone')
|
| 77 |
+
@classmethod
|
| 78 |
+
def validate_indian_phone(cls, v: Optional[str]) -> Optional[str]:
|
| 79 |
+
if v is None or v.strip() == "":
|
| 80 |
+
return None
|
| 81 |
+
# Clean phone number (remove spaces, hyphens, brackets)
|
| 82 |
+
cleaned = re.sub(r'[\s\-()]', '', v)
|
| 83 |
+
if not re.match(r'^(?:\+91|91|0)?[6-9]\d{9}$', cleaned):
|
| 84 |
+
raise ValueError('Invalid Indian mobile number. Must be a 10-digit number starting with 6-9, optionally prefixed with +91, 91, or 0.')
|
| 85 |
+
return v
|
| 86 |
+
|
| 87 |
class EmployeeCreate(EmployeeBase):
|
| 88 |
create_user_login: bool = False
|
| 89 |
password: Optional[str] = None # Required if create_user_login is True
|
|
|
|
| 136 |
late_arrival: bool
|
| 137 |
early_departure: bool
|
| 138 |
overtime: float
|
| 139 |
+
emergency_allowed: bool = False
|
| 140 |
employee: Optional[EmployeeOut] = None
|
| 141 |
model_config = ConfigDict(from_attributes=True)
|
| 142 |
|
|
|
|
| 144 |
check_in: Optional[datetime] = None
|
| 145 |
check_out: Optional[datetime] = None
|
| 146 |
status: Optional[str] = None
|
| 147 |
+
emergency_allowed: Optional[bool] = None
|
| 148 |
|
| 149 |
# Attendance Log Schemas
|
| 150 |
class AttendanceLogOut(BaseModel):
|
|
|
|
| 156 |
liveness_score: Optional[float] = None
|
| 157 |
is_spoof: bool
|
| 158 |
status: str
|
| 159 |
+
image_path: Optional[str] = None
|
| 160 |
employee: Optional[EmployeeOut] = None
|
| 161 |
model_config = ConfigDict(from_attributes=True)
|
| 162 |
|
backend/app/services/face_engine.py
CHANGED
|
@@ -67,15 +67,22 @@ class FaceEngine:
|
|
| 67 |
def _init_sessions(self):
|
| 68 |
try:
|
| 69 |
import gc
|
| 70 |
-
# Initialize ONNX Runtime Inference Sessions with memory-optimized settings
|
| 71 |
-
# to prevent OOM crashes on low-resource servers (like Render's 512MB Free Tier)
|
| 72 |
opts = ort.SessionOptions()
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
providers = ['CPUExecutionProvider']
|
| 81 |
# If GPU is available (optional setup)
|
|
@@ -387,7 +394,7 @@ class FaceEngine:
|
|
| 387 |
vec = np.random.randn(512).astype(np.float32)
|
| 388 |
return vec / np.linalg.norm(vec)
|
| 389 |
|
| 390 |
-
def check_liveness(self, image_np, bbox):
|
| 391 |
"""
|
| 392 |
Silent Face Anti-Spoofing MiniFASNet model.
|
| 393 |
Crops face, resizes, runs liveness model.
|
|
@@ -464,7 +471,8 @@ class FaceEngine:
|
|
| 464 |
else:
|
| 465 |
avg_score = score_27
|
| 466 |
|
| 467 |
-
|
|
|
|
| 468 |
return avg_score, is_live
|
| 469 |
|
| 470 |
except Exception as e:
|
|
|
|
| 67 |
def _init_sessions(self):
|
| 68 |
try:
|
| 69 |
import gc
|
|
|
|
|
|
|
| 70 |
opts = ort.SessionOptions()
|
| 71 |
+
low_mem = getattr(settings, "LOW_MEMORY_MODE", True)
|
| 72 |
+
if low_mem:
|
| 73 |
+
opts.intra_op_num_threads = 1
|
| 74 |
+
opts.inter_op_num_threads = 1
|
| 75 |
+
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
|
| 76 |
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
|
| 77 |
+
opts.enable_cpu_mem_arena = False
|
| 78 |
+
opts.add_session_config_entry("memory.enable_memory_arena_shrinkage", "cpu:0")
|
| 79 |
+
logger.info("ONNX Runtime running in LOW MEMORY MODE (optimized for <= 512MB RAM instances).")
|
| 80 |
+
else:
|
| 81 |
+
# High-performance multi-threaded settings for production (VPS/Dedicated)
|
| 82 |
+
threads = getattr(settings, "ORT_INTRA_OP_NUM_THREADS", 0)
|
| 83 |
+
opts.intra_op_num_threads = threads
|
| 84 |
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 85 |
+
logger.info(f"ONNX Runtime running in HIGH PERFORMANCE MODE (intra_op_threads={threads if threads > 0 else 'auto'}, all optimizations enabled).")
|
| 86 |
|
| 87 |
providers = ['CPUExecutionProvider']
|
| 88 |
# If GPU is available (optional setup)
|
|
|
|
| 394 |
vec = np.random.randn(512).astype(np.float32)
|
| 395 |
return vec / np.linalg.norm(vec)
|
| 396 |
|
| 397 |
+
def check_liveness(self, image_np, bbox, threshold=None):
|
| 398 |
"""
|
| 399 |
Silent Face Anti-Spoofing MiniFASNet model.
|
| 400 |
Crops face, resizes, runs liveness model.
|
|
|
|
| 471 |
else:
|
| 472 |
avg_score = score_27
|
| 473 |
|
| 474 |
+
liveness_threshold = threshold if threshold is not None else settings.KIOSK_LIVENESS_THRESHOLD
|
| 475 |
+
is_live = avg_score >= liveness_threshold
|
| 476 |
return avg_score, is_live
|
| 477 |
|
| 478 |
except Exception as e:
|
backend/app/tests/test_employees.py
CHANGED
|
@@ -8,7 +8,7 @@ def test_read_employees(mock_get, authenticated_client):
|
|
| 8 |
employee_id="EMP101",
|
| 9 |
name="John Doe",
|
| 10 |
email="john@netraid.ai",
|
| 11 |
-
phone="
|
| 12 |
designation="Software Engineer",
|
| 13 |
joining_date="2026-06-10",
|
| 14 |
status="Active"
|
|
@@ -30,11 +30,15 @@ def test_read_employee_not_found(mock_get_id, authenticated_client):
|
|
| 30 |
|
| 31 |
@patch("app.api.v1.employees.crud.get_employee_by_uuid")
|
| 32 |
@patch("app.api.v1.employees.crud.get_employee_by_email")
|
|
|
|
|
|
|
| 33 |
@patch("app.api.v1.employees.crud.create_employee")
|
| 34 |
@patch("app.api.v1.employees.crud.create_audit_log")
|
| 35 |
-
def test_create_employee(mock_audit, mock_create, mock_get_email, mock_get_uuid, authenticated_client):
|
| 36 |
mock_get_uuid.return_value = None
|
| 37 |
mock_get_email.return_value = None
|
|
|
|
|
|
|
| 38 |
|
| 39 |
mock_employee = models.Employee(
|
| 40 |
id=2,
|
|
@@ -50,7 +54,7 @@ def test_create_employee(mock_audit, mock_create, mock_get_email, mock_get_uuid,
|
|
| 50 |
"employee_id": "EMP102",
|
| 51 |
"name": "Jane Smith",
|
| 52 |
"email": "jane@netraid.ai",
|
| 53 |
-
"phone": "
|
| 54 |
"designation": "HR Manager",
|
| 55 |
"joining_date": "2026-06-10",
|
| 56 |
"status": "Active",
|
|
|
|
| 8 |
employee_id="EMP101",
|
| 9 |
name="John Doe",
|
| 10 |
email="john@netraid.ai",
|
| 11 |
+
phone="9876543210",
|
| 12 |
designation="Software Engineer",
|
| 13 |
joining_date="2026-06-10",
|
| 14 |
status="Active"
|
|
|
|
| 30 |
|
| 31 |
@patch("app.api.v1.employees.crud.get_employee_by_uuid")
|
| 32 |
@patch("app.api.v1.employees.crud.get_employee_by_email")
|
| 33 |
+
@patch("app.api.v1.employees.crud.get_employee_by_name")
|
| 34 |
+
@patch("app.api.v1.employees.crud.get_employee_by_phone")
|
| 35 |
@patch("app.api.v1.employees.crud.create_employee")
|
| 36 |
@patch("app.api.v1.employees.crud.create_audit_log")
|
| 37 |
+
def test_create_employee(mock_audit, mock_create, mock_get_phone, mock_get_name, mock_get_email, mock_get_uuid, authenticated_client):
|
| 38 |
mock_get_uuid.return_value = None
|
| 39 |
mock_get_email.return_value = None
|
| 40 |
+
mock_get_name.return_value = None
|
| 41 |
+
mock_get_phone.return_value = None
|
| 42 |
|
| 43 |
mock_employee = models.Employee(
|
| 44 |
id=2,
|
|
|
|
| 54 |
"employee_id": "EMP102",
|
| 55 |
"name": "Jane Smith",
|
| 56 |
"email": "jane@netraid.ai",
|
| 57 |
+
"phone": "9876543211",
|
| 58 |
"designation": "HR Manager",
|
| 59 |
"joining_date": "2026-06-10",
|
| 60 |
"status": "Active",
|
frontend/app/attendance/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import React, { useState } from "react";
|
| 4 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
|
|
|
| 6 |
import { fetchApi, parseDateTime, getLocalDateString, getBackendUrl } from "@/app/utils/api";
|
| 7 |
import { Clock, Search, Edit3, Calendar, Activity, AlertTriangle, X, CheckCircle2, ChevronDown, Check } from "lucide-react";
|
| 8 |
|
|
@@ -34,7 +35,7 @@ function EmployeeAvatar({ emp, avatarColor, size = "md" }: { emp: any; avatarCol
|
|
| 34 |
const [error, setError] = useState(false);
|
| 35 |
const hasFrontImage = emp.images?.some((img: any) => img.pose_type.toLowerCase() === "front");
|
| 36 |
|
| 37 |
-
const
|
| 38 |
|
| 39 |
if (hasFrontImage && !error) {
|
| 40 |
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
|
@@ -42,14 +43,14 @@ function EmployeeAvatar({ emp, avatarColor, size = "md" }: { emp: any; avatarCol
|
|
| 42 |
<img
|
| 43 |
src={`${baseUrl}/uploads/${emp.employee_id}/front.jpg`}
|
| 44 |
alt={emp.name}
|
| 45 |
-
className={`${
|
| 46 |
onError={() => setError(true)}
|
| 47 |
/>
|
| 48 |
);
|
| 49 |
}
|
| 50 |
|
| 51 |
return (
|
| 52 |
-
<div className={`${
|
| 53 |
{emp.name.charAt(0).toUpperCase()}
|
| 54 |
</div>
|
| 55 |
);
|
|
@@ -57,6 +58,7 @@ function EmployeeAvatar({ emp, avatarColor, size = "md" }: { emp: any; avatarCol
|
|
| 57 |
|
| 58 |
export default function AttendancePage() {
|
| 59 |
const queryClient = useQueryClient();
|
|
|
|
| 60 |
const [activeTab, setActiveTab] = useState<Tab>("feed");
|
| 61 |
const [selectedDate, setSelectedDate] = useState(getLocalDateString());
|
| 62 |
const [search, setSearch] = useState("");
|
|
@@ -66,6 +68,7 @@ export default function AttendancePage() {
|
|
| 66 |
const [checkInTime, setCheckInTime] = useState("");
|
| 67 |
const [checkOutTime, setCheckOutTime] = useState("");
|
| 68 |
const [statusVal, setStatusVal] = useState("Present");
|
|
|
|
| 69 |
|
| 70 |
const { data: departments } = useQuery({
|
| 71 |
queryKey: ["departments"],
|
|
@@ -95,7 +98,7 @@ export default function AttendancePage() {
|
|
| 95 |
setShowEditDialog(false);
|
| 96 |
setEditingRecord(null);
|
| 97 |
},
|
| 98 |
-
onError: (err: any) =>
|
| 99 |
});
|
| 100 |
|
| 101 |
const handleEditClick = (record: any) => {
|
|
@@ -109,6 +112,7 @@ export default function AttendancePage() {
|
|
| 109 |
setCheckInTime(fmt(record.check_in));
|
| 110 |
setCheckOutTime(fmt(record.check_out));
|
| 111 |
setStatusVal(record.status);
|
|
|
|
| 112 |
setShowEditDialog(true);
|
| 113 |
};
|
| 114 |
|
|
@@ -119,12 +123,17 @@ export default function AttendancePage() {
|
|
| 119 |
if (!t) return null;
|
| 120 |
const [yr, mo, dy] = selectedDate.split("-").map(Number);
|
| 121 |
const [hr, mn] = t.split(":").map(Number);
|
| 122 |
-
const
|
| 123 |
-
return
|
| 124 |
};
|
| 125 |
updateMutation.mutate({
|
| 126 |
id: editingRecord.id,
|
| 127 |
-
payload: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
});
|
| 129 |
};
|
| 130 |
|
|
@@ -152,7 +161,6 @@ export default function AttendancePage() {
|
|
| 152 |
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
|
| 153 |
<div>
|
| 154 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Attendance Ledger</h1>
|
| 155 |
-
<p className="text-xs text-slate-500 mt-0.5">Audit daily presence ledger records and raw biometric authentication logs.</p>
|
| 156 |
</div>
|
| 157 |
|
| 158 |
{/* Tab switcher */}
|
|
@@ -310,12 +318,12 @@ export default function AttendancePage() {
|
|
| 310 |
<table className="data-table">
|
| 311 |
<thead>
|
| 312 |
<tr>
|
| 313 |
-
<th className="text-left py-3.5 px-5">Timestamp</th>
|
| 314 |
-
<th className="text-left py-3.5 px-5">Identity Profile</th>
|
| 315 |
-
<th className="text-left py-3.5 px-5">Terminal</th>
|
| 316 |
-
<th className="text-left py-3.5 px-5">Confidence</th>
|
| 317 |
-
<th className="text-left py-3.5 px-5">Liveness</th>
|
| 318 |
-
<th className="text-left py-3.5 px-5">Result</th>
|
| 319 |
</tr>
|
| 320 |
</thead>
|
| 321 |
<tbody>
|
|
@@ -344,45 +352,93 @@ export default function AttendancePage() {
|
|
| 344 |
const avatarColor = log.employee ? avatarColors[log.employee.id % avatarColors.length] : "";
|
| 345 |
return (
|
| 346 |
<tr key={log.id}>
|
| 347 |
-
<td className="py-3.5 px-5 font-mono text-[11px] text-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
</td>
|
| 350 |
-
<td className="py-3.5 px-5">
|
| 351 |
{log.employee ? (
|
| 352 |
<div className="flex items-center gap-2.5">
|
| 353 |
<EmployeeAvatar emp={log.employee} avatarColor={avatarColor} size="sm" />
|
| 354 |
<div>
|
| 355 |
-
<p className="text-[12.5px] font-semibold text-
|
| 356 |
-
<p className="text-[10px] text-slate-
|
| 357 |
</div>
|
| 358 |
</div>
|
| 359 |
) : (
|
| 360 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
)}
|
| 362 |
</td>
|
| 363 |
-
<td className="py-3.5 px-5
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
{log.confidence ? (
|
| 366 |
-
<
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
) : (
|
| 370 |
-
<span className="text-slate-
|
| 371 |
)}
|
| 372 |
</td>
|
| 373 |
-
<td className="py-3.5 px-5 font-mono text-[11.5px]">
|
| 374 |
{log.liveness_score ? (
|
| 375 |
-
<
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
) : (
|
| 379 |
-
<span className="text-slate-
|
| 380 |
)}
|
| 381 |
</td>
|
| 382 |
-
<td className="py-3.5 px-5">
|
| 383 |
-
<span className={`badge ${
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
</span>
|
| 387 |
</td>
|
| 388 |
</tr>
|
|
@@ -438,6 +494,21 @@ export default function AttendancePage() {
|
|
| 438 |
<ChevronDown className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 439 |
</div>
|
| 440 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 443 |
<button
|
|
|
|
| 3 |
import React, { useState } from "react";
|
| 4 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
+
import { useToast } from "@/app/utils/toast";
|
| 7 |
import { fetchApi, parseDateTime, getLocalDateString, getBackendUrl } from "@/app/utils/api";
|
| 8 |
import { Clock, Search, Edit3, Calendar, Activity, AlertTriangle, X, CheckCircle2, ChevronDown, Check } from "lucide-react";
|
| 9 |
|
|
|
|
| 35 |
const [error, setError] = useState(false);
|
| 36 |
const hasFrontImage = emp.images?.some((img: any) => img.pose_type.toLowerCase() === "front");
|
| 37 |
|
| 38 |
+
const sizeCls = size === "sm" ? "w-8 h-8 text-[10px]" : "w-10 h-10 text-[11px]";
|
| 39 |
|
| 40 |
if (hasFrontImage && !error) {
|
| 41 |
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
|
|
|
| 43 |
<img
|
| 44 |
src={`${baseUrl}/uploads/${emp.employee_id}/front.jpg`}
|
| 45 |
alt={emp.name}
|
| 46 |
+
className={`${size === "sm" ? "w-8 h-8" : "w-10 h-10"} rounded-lg object-cover border border-zinc-250 shadow-sm shrink-0`}
|
| 47 |
onError={() => setError(true)}
|
| 48 |
/>
|
| 49 |
);
|
| 50 |
}
|
| 51 |
|
| 52 |
return (
|
| 53 |
+
<div className={`${sizeCls} rounded-lg bg-gradient-to-br ${avatarColor} flex items-center justify-center shrink-0 border font-bold shadow-sm`}>
|
| 54 |
{emp.name.charAt(0).toUpperCase()}
|
| 55 |
</div>
|
| 56 |
);
|
|
|
|
| 58 |
|
| 59 |
export default function AttendancePage() {
|
| 60 |
const queryClient = useQueryClient();
|
| 61 |
+
const { toast } = useToast();
|
| 62 |
const [activeTab, setActiveTab] = useState<Tab>("feed");
|
| 63 |
const [selectedDate, setSelectedDate] = useState(getLocalDateString());
|
| 64 |
const [search, setSearch] = useState("");
|
|
|
|
| 68 |
const [checkInTime, setCheckInTime] = useState("");
|
| 69 |
const [checkOutTime, setCheckOutTime] = useState("");
|
| 70 |
const [statusVal, setStatusVal] = useState("Present");
|
| 71 |
+
const [emergencyAllowed, setEmergencyAllowed] = useState(false);
|
| 72 |
|
| 73 |
const { data: departments } = useQuery({
|
| 74 |
queryKey: ["departments"],
|
|
|
|
| 98 |
setShowEditDialog(false);
|
| 99 |
setEditingRecord(null);
|
| 100 |
},
|
| 101 |
+
onError: (err: any) => toast.error(err.message || "Failed to update attendance.")
|
| 102 |
});
|
| 103 |
|
| 104 |
const handleEditClick = (record: any) => {
|
|
|
|
| 112 |
setCheckInTime(fmt(record.check_in));
|
| 113 |
setCheckOutTime(fmt(record.check_out));
|
| 114 |
setStatusVal(record.status);
|
| 115 |
+
setEmergencyAllowed(record.emergency_allowed || false);
|
| 116 |
setShowEditDialog(true);
|
| 117 |
};
|
| 118 |
|
|
|
|
| 123 |
if (!t) return null;
|
| 124 |
const [yr, mo, dy] = selectedDate.split("-").map(Number);
|
| 125 |
const [hr, mn] = t.split(":").map(Number);
|
| 126 |
+
const pad = (n: number) => String(n).padStart(2, "0");
|
| 127 |
+
return `${yr}-${pad(mo)}-${pad(dy)}T${pad(hr)}:${pad(mn)}:00`;
|
| 128 |
};
|
| 129 |
updateMutation.mutate({
|
| 130 |
id: editingRecord.id,
|
| 131 |
+
payload: {
|
| 132 |
+
status: statusVal,
|
| 133 |
+
check_in: combine(checkInTime),
|
| 134 |
+
check_out: combine(checkOutTime),
|
| 135 |
+
emergency_allowed: emergencyAllowed
|
| 136 |
+
}
|
| 137 |
});
|
| 138 |
};
|
| 139 |
|
|
|
|
| 161 |
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
|
| 162 |
<div>
|
| 163 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Attendance Ledger</h1>
|
|
|
|
| 164 |
</div>
|
| 165 |
|
| 166 |
{/* Tab switcher */}
|
|
|
|
| 318 |
<table className="data-table">
|
| 319 |
<thead>
|
| 320 |
<tr>
|
| 321 |
+
<th className="text-left py-3.5 px-5 whitespace-nowrap">Timestamp</th>
|
| 322 |
+
<th className="text-left py-3.5 px-5 whitespace-nowrap">Identity Profile</th>
|
| 323 |
+
<th className="text-left py-3.5 px-5 whitespace-nowrap">Terminal</th>
|
| 324 |
+
<th className="text-left py-3.5 px-5 whitespace-nowrap">Confidence</th>
|
| 325 |
+
<th className="text-left py-3.5 px-5 whitespace-nowrap">Liveness</th>
|
| 326 |
+
<th className="text-left py-3.5 px-5 whitespace-nowrap">Result</th>
|
| 327 |
</tr>
|
| 328 |
</thead>
|
| 329 |
<tbody>
|
|
|
|
| 352 |
const avatarColor = log.employee ? avatarColors[log.employee.id % avatarColors.length] : "";
|
| 353 |
return (
|
| 354 |
<tr key={log.id}>
|
| 355 |
+
<td className="py-3.5 px-5 font-mono text-[11px] text-slate-500 whitespace-nowrap">
|
| 356 |
+
<div className="font-semibold text-slate-700">
|
| 357 |
+
{parseDateTime(log.timestamp)?.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" })}
|
| 358 |
+
</div>
|
| 359 |
+
<div className="text-[10px] text-slate-400 mt-0.5">
|
| 360 |
+
{parseDateTime(log.timestamp)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
| 361 |
+
</div>
|
| 362 |
</td>
|
| 363 |
+
<td className="py-3.5 px-5 whitespace-nowrap">
|
| 364 |
{log.employee ? (
|
| 365 |
<div className="flex items-center gap-2.5">
|
| 366 |
<EmployeeAvatar emp={log.employee} avatarColor={avatarColor} size="sm" />
|
| 367 |
<div>
|
| 368 |
+
<p className="text-[12.5px] font-semibold text-slate-900 leading-none">{log.employee.name}</p>
|
| 369 |
+
<p className="text-[10px] text-slate-400 font-mono mt-1 leading-none">{log.employee.employee_id}</p>
|
| 370 |
</div>
|
| 371 |
</div>
|
| 372 |
) : (
|
| 373 |
+
<div className="flex items-center gap-2.5">
|
| 374 |
+
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center border border-dashed border-slate-200 shrink-0">
|
| 375 |
+
<AlertTriangle className="w-3.5 h-3.5 text-slate-400" />
|
| 376 |
+
</div>
|
| 377 |
+
<div>
|
| 378 |
+
<p className="text-[12.5px] font-medium text-slate-400 italic leading-none">Unknown Identity</p>
|
| 379 |
+
<p className="text-[10px] text-slate-350 font-mono mt-1 leading-none">External Scan</p>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
)}
|
| 383 |
</td>
|
| 384 |
+
<td className="py-3.5 px-5 whitespace-nowrap">
|
| 385 |
+
<span className="inline-flex items-center gap-1.5 px-2 py-1 bg-slate-50 border border-slate-100 rounded-lg text-[10px] font-mono text-slate-600 font-semibold uppercase tracking-wider">
|
| 386 |
+
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 shrink-0" />
|
| 387 |
+
{log.camera}
|
| 388 |
+
</span>
|
| 389 |
+
</td>
|
| 390 |
+
<td className="py-3.5 px-5 font-mono text-[11.5px] whitespace-nowrap">
|
| 391 |
{log.confidence ? (
|
| 392 |
+
<div className="space-y-1">
|
| 393 |
+
<div className="flex items-center gap-1.5">
|
| 394 |
+
<span className={`w-1.5 h-1.5 rounded-full ${log.confidence >= 0.75 ? "bg-emerald-500" : log.confidence >= 0.60 ? "bg-amber-500" : "bg-rose-500"}`} />
|
| 395 |
+
<span className="font-semibold text-slate-700">
|
| 396 |
+
{(log.confidence * 100).toFixed(1)}%
|
| 397 |
+
</span>
|
| 398 |
+
</div>
|
| 399 |
+
<div className="w-16 h-1 bg-slate-100 rounded-full overflow-hidden">
|
| 400 |
+
<div
|
| 401 |
+
className={`h-full rounded-full ${log.confidence >= 0.75 ? "bg-emerald-500" : log.confidence >= 0.60 ? "bg-amber-500" : "bg-rose-500"}`}
|
| 402 |
+
style={{ width: `${log.confidence * 100}%` }}
|
| 403 |
+
/>
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
) : (
|
| 407 |
+
<span className="text-slate-400 italic">—</span>
|
| 408 |
)}
|
| 409 |
</td>
|
| 410 |
+
<td className="py-3.5 px-5 font-mono text-[11.5px] whitespace-nowrap">
|
| 411 |
{log.liveness_score ? (
|
| 412 |
+
<div className="space-y-1">
|
| 413 |
+
<div className="flex items-center gap-1.5">
|
| 414 |
+
<span className={`w-1.5 h-1.5 rounded-full ${log.liveness_score >= 0.85 ? "bg-emerald-500" : log.liveness_score >= 0.70 ? "bg-amber-500" : "bg-rose-500"}`} />
|
| 415 |
+
<span className="font-semibold text-slate-700">
|
| 416 |
+
{(log.liveness_score * 100).toFixed(1)}%
|
| 417 |
+
</span>
|
| 418 |
+
</div>
|
| 419 |
+
<div className="w-16 h-1 bg-slate-100 rounded-full overflow-hidden">
|
| 420 |
+
<div
|
| 421 |
+
className={`h-full rounded-full ${log.liveness_score >= 0.85 ? "bg-emerald-500" : log.liveness_score >= 0.70 ? "bg-amber-500" : "bg-rose-500"}`}
|
| 422 |
+
style={{ width: `${log.liveness_score * 100}%` }}
|
| 423 |
+
/>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
) : (
|
| 427 |
+
<span className="text-slate-400 italic">—</span>
|
| 428 |
)}
|
| 429 |
</td>
|
| 430 |
+
<td className="py-3.5 px-5 whitespace-nowrap">
|
| 431 |
+
<span className={`badge ${
|
| 432 |
+
isSuccess
|
| 433 |
+
? "badge-emerald"
|
| 434 |
+
: isSpoof
|
| 435 |
+
? "badge-rose animate-pulse"
|
| 436 |
+
: log.status.toLowerCase().includes("lock")
|
| 437 |
+
? "badge-rose"
|
| 438 |
+
: "badge-amber"
|
| 439 |
+
} flex items-center gap-1.5 w-fit font-bold`}>
|
| 440 |
+
{isSpoof && <AlertTriangle className="w-3 h-3 text-rose-500" />}
|
| 441 |
+
{isSuccess ? "Matched" : isSpoof ? "Liveness Failed" : log.status}
|
| 442 |
</span>
|
| 443 |
</td>
|
| 444 |
</tr>
|
|
|
|
| 494 |
<ChevronDown className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 495 |
</div>
|
| 496 |
</div>
|
| 497 |
+
|
| 498 |
+
<div className="p-3 rounded-2xl bg-zinc-55 border border-zinc-150 space-y-1 mt-2">
|
| 499 |
+
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
| 500 |
+
<input
|
| 501 |
+
type="checkbox"
|
| 502 |
+
checked={emergencyAllowed}
|
| 503 |
+
onChange={() => setEmergencyAllowed(!emergencyAllowed)}
|
| 504 |
+
className="w-4 h-4 rounded border-slate-350 text-slate-950 bg-white focus:ring-slate-500/50 cursor-pointer"
|
| 505 |
+
/>
|
| 506 |
+
<span className="text-[12px] font-bold text-slate-800">Allow Emergency Re-entry (Bypass Lock)</span>
|
| 507 |
+
</label>
|
| 508 |
+
<p className="text-[10px] text-slate-500 leading-normal pl-6.5">
|
| 509 |
+
Allows employee to scan again at the kiosk today after check-out. The lock will automatically re-apply upon their next scan.
|
| 510 |
+
</p>
|
| 511 |
+
</div>
|
| 512 |
|
| 513 |
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 514 |
<button
|
frontend/app/audit/page.tsx
CHANGED
|
@@ -49,7 +49,6 @@ export default function AuditLogsPage() {
|
|
| 49 |
<History className="w-5 h-5 text-zinc-700" />
|
| 50 |
System Audit Logs
|
| 51 |
</h1>
|
| 52 |
-
<p className="text-xs text-zinc-500 mt-0.5">Track admin dashboard actions, user activities, and kiosk alerts.</p>
|
| 53 |
</div>
|
| 54 |
<button
|
| 55 |
onClick={() => refetch()}
|
|
|
|
| 49 |
<History className="w-5 h-5 text-zinc-700" />
|
| 50 |
System Audit Logs
|
| 51 |
</h1>
|
|
|
|
| 52 |
</div>
|
| 53 |
<button
|
| 54 |
onClick={() => refetch()}
|
frontend/app/dashboard/page.tsx
CHANGED
|
@@ -67,32 +67,21 @@ function StatCard({
|
|
| 67 |
color: "blue" | "emerald" | "amber" | "rose" | "indigo";
|
| 68 |
loading?: boolean; sublabel?: string; sparkData: number[];
|
| 69 |
}) {
|
| 70 |
-
const colors = {
|
| 71 |
-
blue: { icon: "text-zinc-600", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 72 |
-
emerald: { icon: "text-zinc-800", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 73 |
-
amber: { icon: "text-zinc-650", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 74 |
-
rose: { icon: "text-zinc-700", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 75 |
-
indigo: { icon: "text-zinc-900", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 76 |
-
};
|
| 77 |
-
const c = colors[color];
|
| 78 |
-
|
| 79 |
return (
|
| 80 |
-
<div className=
|
| 81 |
<div className="flex items-center justify-between">
|
| 82 |
-
<p className="text-[
|
| 83 |
-
<
|
| 84 |
-
<Icon className="w-4 h-4" />
|
| 85 |
-
</div>
|
| 86 |
</div>
|
| 87 |
|
| 88 |
<div className="flex items-end justify-between mt-auto">
|
| 89 |
-
<div className="space-y-
|
| 90 |
{loading ? (
|
| 91 |
-
<div className="skeleton h-
|
| 92 |
) : (
|
| 93 |
-
<p className="text-
|
| 94 |
)}
|
| 95 |
-
{sublabel && <p className="text-[
|
| 96 |
</div>
|
| 97 |
{!loading && <Sparkline color={color} data={sparkData} />}
|
| 98 |
</div>
|
|
@@ -101,78 +90,79 @@ function StatCard({
|
|
| 101 |
}
|
| 102 |
|
| 103 |
const AVATAR_COLORS = [
|
| 104 |
-
"
|
| 105 |
-
"from-emerald-500/20 to-teal-500/20 text-emerald-400 border-emerald-500/15",
|
| 106 |
-
"from-amber-500/20 to-orange-500/20 text-amber-400 border-amber-500/15",
|
| 107 |
-
"from-indigo-500/20 to-purple-500/20 text-indigo-400 border-indigo-500/15",
|
| 108 |
-
"from-cyan-500/20 to-blue-500/20 text-cyan-400 border-cyan-500/15",
|
| 109 |
];
|
| 110 |
|
| 111 |
function ScanLogItem({ log }: { log: any }) {
|
|
|
|
| 112 |
const isSuccess = log.status === "Match Success";
|
| 113 |
const isSpoof = log.is_spoof;
|
| 114 |
const isUnknown = log.status === "Unknown Person";
|
| 115 |
|
| 116 |
-
let dotColor = "bg-slate-
|
| 117 |
-
let statusColor = "text-
|
| 118 |
-
let statusBg = "bg-slate-500/8 border-slate-500/15";
|
| 119 |
let statusLabel = log.status;
|
| 120 |
|
| 121 |
if (isSuccess) {
|
| 122 |
-
dotColor = "bg-emerald-
|
| 123 |
-
statusColor = "text-emerald-
|
| 124 |
-
statusBg = "bg-emerald-500/8 border-emerald-500/15";
|
| 125 |
statusLabel = "Matched";
|
| 126 |
} else if (isSpoof) {
|
| 127 |
-
dotColor = "bg-rose-500 animate-
|
| 128 |
-
statusColor = "text-rose-
|
| 129 |
-
|
| 130 |
-
statusLabel = "Spoof Blocked";
|
| 131 |
} else if (isUnknown) {
|
| 132 |
-
dotColor = "bg-amber-
|
| 133 |
-
statusColor = "text-amber-
|
| 134 |
-
statusBg = "bg-amber-500/8 border-amber-500/20";
|
| 135 |
statusLabel = "Unknown";
|
| 136 |
}
|
| 137 |
|
| 138 |
-
|
| 139 |
-
const
|
| 140 |
-
const avatarCls = AVATAR_COLORS[colorIndex];
|
| 141 |
|
| 142 |
return (
|
| 143 |
-
<div className="flex items-center gap-3
|
| 144 |
<div className="relative shrink-0">
|
| 145 |
{log.employee ? (
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
) : (
|
| 150 |
-
<div className="w-
|
| 151 |
?
|
| 152 |
</div>
|
| 153 |
)}
|
| 154 |
-
<div className=
|
| 155 |
-
<div className={`w-1.5 h-1.5 rounded-full ${dotColor
|
| 156 |
</div>
|
| 157 |
</div>
|
| 158 |
|
| 159 |
<div className="flex-1 min-w-0">
|
| 160 |
-
<div className="flex items-center gap-
|
| 161 |
-
<p className="text-[
|
| 162 |
-
{log.employee ? log.employee.name : "Unknown
|
| 163 |
</p>
|
| 164 |
-
<span className="text-[9px] text-zinc-
|
| 165 |
</div>
|
| 166 |
-
<p className="text-[
|
| 167 |
{log.employee ? `${log.employee.designation} (${log.employee.employee_id})` : "Unauthorized access attempt"}
|
| 168 |
</p>
|
| 169 |
</div>
|
| 170 |
|
| 171 |
<div className="shrink-0 text-right space-y-1">
|
| 172 |
-
<p className="text-[
|
| 173 |
{parseDateTime(log.timestamp)?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) || ""}
|
| 174 |
</p>
|
| 175 |
-
<span className={`inline-block text-[
|
| 176 |
{statusLabel}
|
| 177 |
</span>
|
| 178 |
</div>
|
|
@@ -181,43 +171,11 @@ function ScanLogItem({ log }: { log: any }) {
|
|
| 181 |
}
|
| 182 |
|
| 183 |
const getDeptTheme = (code: string) => {
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
barFill: "bg-gradient-to-r from-blue-500 to-blue-600"
|
| 190 |
-
},
|
| 191 |
-
HR: {
|
| 192 |
-
bg: "bg-purple-50/50 hover:bg-purple-50/70 border-purple-200 text-purple-900",
|
| 193 |
-
iconBg: "bg-purple-500/10 text-purple-600 border-purple-500/20",
|
| 194 |
-
barBg: "bg-purple-100",
|
| 195 |
-
barFill: "bg-gradient-to-r from-purple-500 to-purple-600"
|
| 196 |
-
},
|
| 197 |
-
MKT: {
|
| 198 |
-
bg: "bg-amber-50/50 hover:bg-amber-50/70 border-amber-200 text-amber-900",
|
| 199 |
-
iconBg: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
| 200 |
-
barBg: "bg-amber-100",
|
| 201 |
-
barFill: "bg-gradient-to-r from-amber-500 to-amber-600"
|
| 202 |
-
},
|
| 203 |
-
FIN: {
|
| 204 |
-
bg: "bg-emerald-50/50 hover:bg-emerald-50/70 border-emerald-200 text-emerald-900",
|
| 205 |
-
iconBg: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
|
| 206 |
-
barBg: "bg-emerald-100",
|
| 207 |
-
barFill: "bg-gradient-to-r from-emerald-500 to-emerald-600"
|
| 208 |
-
},
|
| 209 |
-
OPS: {
|
| 210 |
-
bg: "bg-rose-50/50 hover:bg-rose-50/70 border-rose-200 text-rose-900",
|
| 211 |
-
iconBg: "bg-rose-500/10 text-rose-600 border-rose-500/20",
|
| 212 |
-
barBg: "bg-rose-100",
|
| 213 |
-
barFill: "bg-gradient-to-r from-rose-500 to-rose-600"
|
| 214 |
-
},
|
| 215 |
-
};
|
| 216 |
-
return themes[code as keyof typeof themes] || {
|
| 217 |
-
bg: "bg-slate-50/50 hover:bg-slate-50/70 border-slate-200 text-slate-900",
|
| 218 |
-
iconBg: "bg-slate-500/10 text-slate-600 border-slate-500/20",
|
| 219 |
-
barBg: "bg-slate-100",
|
| 220 |
-
barFill: "bg-gradient-to-r from-slate-500 to-slate-600"
|
| 221 |
};
|
| 222 |
};
|
| 223 |
|
|
@@ -226,8 +184,21 @@ export default function DashboardPage() {
|
|
| 226 |
const [currentDate, setCurrentDate] = React.useState("");
|
| 227 |
const [statusFilter, setStatusFilter] = React.useState<"ALL" | "MATCHED" | "UNKNOWN" | "SPOOF">("ALL");
|
| 228 |
const [selectedDept, setSelectedDept] = React.useState<any | null>(null);
|
|
|
|
| 229 |
const queryClient = useQueryClient();
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
const { data: deptAttendance, isLoading: loadingDeptAttendance } = useQuery({
|
| 232 |
queryKey: ["dept-attendance", selectedDept?.id],
|
| 233 |
queryFn: () => {
|
|
@@ -339,21 +310,30 @@ export default function DashboardPage() {
|
|
| 339 |
|
| 340 |
const trendOption = () => {
|
| 341 |
if (!trends) return {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
return {
|
| 343 |
backgroundColor: "transparent",
|
| 344 |
tooltip: {
|
| 345 |
trigger: "axis",
|
| 346 |
-
backgroundColor:
|
| 347 |
-
borderColor:
|
| 348 |
borderWidth: 1,
|
| 349 |
shadowColor: "rgba(0,0,0,0.02)",
|
| 350 |
shadowBlur: 10,
|
| 351 |
-
textStyle: { color:
|
| 352 |
extraCssText: "border-radius:12px;padding:8px 12px;box-shadow: 0 4px 16px rgba(0,0,0,0.04);"
|
| 353 |
},
|
| 354 |
legend: {
|
| 355 |
data: ["Present", "Late Arrivals"],
|
| 356 |
-
textStyle: { color:
|
| 357 |
bottom: 0,
|
| 358 |
icon: "circle",
|
| 359 |
itemWidth: 8,
|
|
@@ -373,14 +353,14 @@ export default function DashboardPage() {
|
|
| 373 |
}
|
| 374 |
return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
| 375 |
}),
|
| 376 |
-
axisLine: { lineStyle: { color:
|
| 377 |
axisTick: { show: false },
|
| 378 |
-
axisLabel: { color:
|
| 379 |
},
|
| 380 |
yAxis: {
|
| 381 |
type: "value",
|
| 382 |
-
splitLine: { lineStyle: { color:
|
| 383 |
-
axisLabel: { color:
|
| 384 |
axisLine: { show: false }
|
| 385 |
},
|
| 386 |
series: [
|
|
@@ -392,18 +372,18 @@ export default function DashboardPage() {
|
|
| 392 |
symbolSize: 6,
|
| 393 |
data: trends.map((t: any) => t.present),
|
| 394 |
lineStyle: {
|
| 395 |
-
color:
|
| 396 |
width: 2.25,
|
| 397 |
-
shadowColor: "rgba(24,24,27,0.1)",
|
| 398 |
shadowBlur: 8,
|
| 399 |
shadowOffsetY: 4
|
| 400 |
},
|
| 401 |
-
itemStyle: { color:
|
| 402 |
areaStyle: {
|
| 403 |
color: {
|
| 404 |
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
| 405 |
colorStops: [
|
| 406 |
-
{ offset: 0, color:
|
| 407 |
{ offset: 1, color: "rgba(24,24,27,0)" }
|
| 408 |
]
|
| 409 |
}
|
|
@@ -414,7 +394,7 @@ export default function DashboardPage() {
|
|
| 414 |
type: "bar",
|
| 415 |
data: trends.map((t: any) => t.late),
|
| 416 |
itemStyle: {
|
| 417 |
-
color:
|
| 418 |
borderRadius: [3, 3, 0, 0]
|
| 419 |
},
|
| 420 |
barWidth: 6,
|
|
@@ -426,22 +406,32 @@ export default function DashboardPage() {
|
|
| 426 |
|
| 427 |
const deptOption = () => {
|
| 428 |
if (!deptStats) return {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
return {
|
| 430 |
backgroundColor: "transparent",
|
| 431 |
tooltip: {
|
| 432 |
trigger: "axis",
|
| 433 |
axisPointer: { type: "shadow" },
|
| 434 |
-
backgroundColor:
|
| 435 |
-
borderColor:
|
| 436 |
borderWidth: 1,
|
| 437 |
-
textStyle: { color:
|
| 438 |
extraCssText: "border-radius:12px;padding:8px 12px;box-shadow: 0 4px 16px rgba(0,0,0,0.04);"
|
| 439 |
},
|
| 440 |
grid: { top: 10, left: 90, right: 20, bottom: 20 },
|
| 441 |
xAxis: {
|
| 442 |
type: "value",
|
| 443 |
-
splitLine: { lineStyle: { color:
|
| 444 |
-
axisLabel: { color:
|
| 445 |
axisLine: { show: false }
|
| 446 |
},
|
| 447 |
yAxis: {
|
|
@@ -449,14 +439,14 @@ export default function DashboardPage() {
|
|
| 449 |
data: deptStats.map((d: any) => d.name || d.code),
|
| 450 |
axisLine: { show: false },
|
| 451 |
axisTick: { show: false },
|
| 452 |
-
axisLabel: { color:
|
| 453 |
},
|
| 454 |
series: [
|
| 455 |
{
|
| 456 |
name: "Total Employees",
|
| 457 |
type: "bar",
|
| 458 |
data: deptStats.map((d: any) => d.total_employees),
|
| 459 |
-
itemStyle: { color:
|
| 460 |
barGap: "-100%",
|
| 461 |
barWidth: 10
|
| 462 |
},
|
|
@@ -470,8 +460,8 @@ export default function DashboardPage() {
|
|
| 470 |
color: {
|
| 471 |
type: "linear", x: 0, y: 0, x2: 1, y2: 0,
|
| 472 |
colorStops: [
|
| 473 |
-
{ offset: 0, color:
|
| 474 |
-
{ offset: 1, color:
|
| 475 |
]
|
| 476 |
}
|
| 477 |
}
|
|
@@ -479,36 +469,29 @@ export default function DashboardPage() {
|
|
| 479 |
]
|
| 480 |
};
|
| 481 |
};
|
| 482 |
-
|
| 483 |
return (
|
| 484 |
<SidebarLayout>
|
| 485 |
-
<div className="space-y-
|
| 486 |
{/* ─── Header ─── */}
|
| 487 |
-
<div className="flex flex-col
|
| 488 |
-
<div>
|
| 489 |
-
<h1 className="text-
|
| 490 |
-
Dashboard
|
| 491 |
</h1>
|
| 492 |
-
<
|
| 493 |
-
|
| 494 |
-
|
|
|
|
| 495 |
</div>
|
| 496 |
-
|
| 497 |
-
|
|
|
|
| 498 |
{currentTime && (
|
| 499 |
-
<
|
| 500 |
-
<
|
| 501 |
-
<span>{
|
| 502 |
-
|
| 503 |
-
<Clock className="w-3.5 h-3.5 text-zinc-500" />
|
| 504 |
-
<span className="tabular-nums font-semibold">{currentTime}</span>
|
| 505 |
-
</div>
|
| 506 |
)}
|
| 507 |
-
|
| 508 |
-
<div className="flex items-center gap-2 text-[10px] font-semibold bg-emerald-50 border border-emerald-200 px-3 py-1.5 rounded-full text-emerald-800">
|
| 509 |
-
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 510 |
-
<span className="font-mono uppercase tracking-wider">Live Feed Active</span>
|
| 511 |
-
</div>
|
| 512 |
</div>
|
| 513 |
</div>
|
| 514 |
|
|
@@ -524,17 +507,9 @@ export default function DashboardPage() {
|
|
| 524 |
{/* ─── Charts Row ─── */}
|
| 525 |
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
| 526 |
{/* Trend Chart */}
|
| 527 |
-
<div className="lg:col-span-3
|
| 528 |
-
<div className="flex items-center justify-between mb-
|
| 529 |
-
<
|
| 530 |
-
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center border border-white/5">
|
| 531 |
-
<TrendingUp className="w-4 h-4 text-blue-400" />
|
| 532 |
-
</div>
|
| 533 |
-
<div>
|
| 534 |
-
<h2 className="text-xs font-semibold text-[var(--text-primary)] uppercase tracking-wider">Attendance Trends</h2>
|
| 535 |
-
<p className="text-[10px] text-slate-500 mt-0.5">Biometric logs for the past 7 days</p>
|
| 536 |
-
</div>
|
| 537 |
-
</div>
|
| 538 |
</div>
|
| 539 |
<div className="h-56">
|
| 540 |
{loadingTrends ? (
|
|
@@ -548,37 +523,29 @@ export default function DashboardPage() {
|
|
| 548 |
</div>
|
| 549 |
|
| 550 |
{/* Live Feed */}
|
| 551 |
-
<div className="lg:col-span-2
|
| 552 |
-
<div className="p-5 pb-3
|
| 553 |
<div className="flex items-center justify-between">
|
| 554 |
-
<
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
</
|
| 558 |
-
<div>
|
| 559 |
-
<h2 className="text-xs font-semibold text-[var(--text-primary)] uppercase tracking-wider">Live Activity Feed</h2>
|
| 560 |
-
<p className="text-[10px] text-slate-500 mt-0.5">Real-time scan logs ticker</p>
|
| 561 |
-
</div>
|
| 562 |
-
</div>
|
| 563 |
-
<div className="flex items-center gap-1.5 bg-indigo-500/5 px-2 py-1 rounded-md border border-indigo-500/15">
|
| 564 |
-
<span className="w-1 h-1 rounded-full bg-indigo-400 animate-pulse" />
|
| 565 |
-
<span className="text-[9px] text-indigo-400 font-mono tracking-wider">STREAMING</span>
|
| 566 |
</div>
|
| 567 |
</div>
|
| 568 |
|
| 569 |
{/* Status Filter pills */}
|
| 570 |
-
<div className="flex flex-wrap gap-1
|
| 571 |
{(["ALL", "MATCHED", "UNKNOWN", "SPOOF"] as const).map((filter) => (
|
| 572 |
<button
|
| 573 |
key={filter}
|
| 574 |
onClick={() => setStatusFilter(filter)}
|
| 575 |
-
className={`px-2
|
| 576 |
statusFilter === filter
|
| 577 |
-
? "bg-zinc-
|
| 578 |
-
: "bg-transparent border-
|
| 579 |
}`}
|
| 580 |
>
|
| 581 |
-
{filter === "SPOOF" ? "
|
| 582 |
</button>
|
| 583 |
))}
|
| 584 |
</div>
|
|
@@ -620,10 +587,10 @@ export default function DashboardPage() {
|
|
| 620 |
})()}
|
| 621 |
</div>
|
| 622 |
|
| 623 |
-
<div className="p-
|
| 624 |
<Link
|
| 625 |
href="/attendance"
|
| 626 |
-
className="flex items-center justify-between text-[11px] text-
|
| 627 |
>
|
| 628 |
<span>Access Complete Ledger Logs</span>
|
| 629 |
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
|
|
@@ -633,17 +600,9 @@ export default function DashboardPage() {
|
|
| 633 |
</div>
|
| 634 |
|
| 635 |
{/* ─── Bottom Section: Department Breakdown ─── */}
|
| 636 |
-
<div className="
|
| 637 |
-
<div className="flex items-center justify-between mb-
|
| 638 |
-
<
|
| 639 |
-
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center border border-white/5">
|
| 640 |
-
<Users className="w-4 h-4 text-indigo-400" />
|
| 641 |
-
</div>
|
| 642 |
-
<div>
|
| 643 |
-
<h2 className="text-xs font-semibold text-[var(--text-primary)] uppercase tracking-wider">Department Distribution</h2>
|
| 644 |
-
<p className="text-[10px] text-slate-500 mt-0.5">Staff presence stats by company department</p>
|
| 645 |
-
</div>
|
| 646 |
-
</div>
|
| 647 |
</div>
|
| 648 |
|
| 649 |
<div className="min-h-36">
|
|
@@ -660,7 +619,6 @@ export default function DashboardPage() {
|
|
| 660 |
) : (
|
| 661 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
| 662 |
{deptStats.map((d: any) => {
|
| 663 |
-
const theme = getDeptTheme(d.code);
|
| 664 |
const percent = d.total_employees > 0
|
| 665 |
? Math.round((d.present_today / d.total_employees) * 100)
|
| 666 |
: 0;
|
|
@@ -669,36 +627,36 @@ export default function DashboardPage() {
|
|
| 669 |
<div
|
| 670 |
key={d.code}
|
| 671 |
onClick={() => setSelectedDept(d)}
|
| 672 |
-
className=
|
| 673 |
>
|
| 674 |
{/* Top Header Row */}
|
| 675 |
<div className="flex items-center justify-between w-full">
|
| 676 |
-
<span className=
|
| 677 |
{d.code}
|
| 678 |
</span>
|
| 679 |
|
| 680 |
<div className="text-right">
|
| 681 |
-
<span className="text-xs font-
|
| 682 |
-
{d.present_today} <span className="text-slate-450 font-
|
| 683 |
</span>
|
| 684 |
</div>
|
| 685 |
</div>
|
| 686 |
|
| 687 |
{/* Department Name & Subheading */}
|
| 688 |
-
<div className="mt-
|
| 689 |
<h3 className="text-xs font-bold text-slate-800 truncate" title={d.department}>
|
| 690 |
{d.department}
|
| 691 |
</h3>
|
| 692 |
-
<p className="text-[9.5px] text-slate-
|
| 693 |
-
{percent}% present
|
| 694 |
</p>
|
| 695 |
</div>
|
| 696 |
|
| 697 |
{/* Progress Bar */}
|
| 698 |
-
<div className="mt-2
|
| 699 |
-
<div className=
|
| 700 |
<div
|
| 701 |
-
className=
|
| 702 |
style={{ width: `${d.total_employees > 0 ? percent : 0}%` }}
|
| 703 |
/>
|
| 704 |
</div>
|
|
@@ -732,7 +690,7 @@ export default function DashboardPage() {
|
|
| 732 |
onClick={() => setSelectedDept(null)}
|
| 733 |
className="p-2 rounded-xl hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
|
| 734 |
>
|
| 735 |
-
<X className="w-4
|
| 736 |
</button>
|
| 737 |
</div>
|
| 738 |
|
|
@@ -758,7 +716,7 @@ export default function DashboardPage() {
|
|
| 758 |
return (
|
| 759 |
<div key={rec.id} className="flex items-center justify-between py-3">
|
| 760 |
<div className="flex items-center gap-3">
|
| 761 |
-
<div className={`w-8
|
| 762 |
{rec.employee.name.charAt(0).toUpperCase()}
|
| 763 |
</div>
|
| 764 |
<div>
|
|
|
|
| 67 |
color: "blue" | "emerald" | "amber" | "rose" | "indigo";
|
| 68 |
loading?: boolean; sublabel?: string; sparkData: number[];
|
| 69 |
}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
return (
|
| 71 |
+
<div className="bg-white border border-zinc-100 rounded-xl p-4 flex flex-col justify-between h-[125px] transition-all hover:border-zinc-200">
|
| 72 |
<div className="flex items-center justify-between">
|
| 73 |
+
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">{label}</p>
|
| 74 |
+
<Icon className="w-3.5 h-3.5 text-slate-405" />
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
|
| 77 |
<div className="flex items-end justify-between mt-auto">
|
| 78 |
+
<div className="space-y-0.5">
|
| 79 |
{loading ? (
|
| 80 |
+
<div className="skeleton h-7 w-16" />
|
| 81 |
) : (
|
| 82 |
+
<p className="text-xl font-bold tracking-tight text-slate-900">{value}</p>
|
| 83 |
)}
|
| 84 |
+
{sublabel && <p className="text-[9px] text-slate-400 font-mono">{sublabel}</p>}
|
| 85 |
</div>
|
| 86 |
{!loading && <Sparkline color={color} data={sparkData} />}
|
| 87 |
</div>
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
const AVATAR_COLORS = [
|
| 93 |
+
"bg-zinc-100 text-zinc-800 border-zinc-200/50",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
];
|
| 95 |
|
| 96 |
function ScanLogItem({ log }: { log: any }) {
|
| 97 |
+
const [imgError, setImgError] = React.useState(false);
|
| 98 |
const isSuccess = log.status === "Match Success";
|
| 99 |
const isSpoof = log.is_spoof;
|
| 100 |
const isUnknown = log.status === "Unknown Person";
|
| 101 |
|
| 102 |
+
let dotColor = "bg-slate-400";
|
| 103 |
+
let statusColor = "text-zinc-600 bg-zinc-50 border-zinc-100";
|
|
|
|
| 104 |
let statusLabel = log.status;
|
| 105 |
|
| 106 |
if (isSuccess) {
|
| 107 |
+
dotColor = "bg-emerald-500";
|
| 108 |
+
statusColor = "text-emerald-700 bg-emerald-50/50 border-emerald-100/55";
|
|
|
|
| 109 |
statusLabel = "Matched";
|
| 110 |
} else if (isSpoof) {
|
| 111 |
+
dotColor = "bg-rose-500 animate-pulse";
|
| 112 |
+
statusColor = "text-rose-700 bg-rose-50/50 border-rose-100/55";
|
| 113 |
+
statusLabel = "Liveness Failed";
|
|
|
|
| 114 |
} else if (isUnknown) {
|
| 115 |
+
dotColor = "bg-amber-500";
|
| 116 |
+
statusColor = "text-amber-700 bg-amber-50/50 border-amber-100/55";
|
|
|
|
| 117 |
statusLabel = "Unknown";
|
| 118 |
}
|
| 119 |
|
| 120 |
+
const hasFrontImage = log.employee?.images?.some((img: any) => img.pose_type.toLowerCase() === "front");
|
| 121 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
|
|
|
| 122 |
|
| 123 |
return (
|
| 124 |
+
<div className="flex items-center gap-3 py-2 px-2.5 rounded-lg hover:bg-zinc-50 transition-all duration-150 group">
|
| 125 |
<div className="relative shrink-0">
|
| 126 |
{log.employee ? (
|
| 127 |
+
hasFrontImage && !imgError ? (
|
| 128 |
+
<img
|
| 129 |
+
src={`${baseUrl}/uploads/${log.employee.employee_id}/front.jpg`}
|
| 130 |
+
alt={log.employee.name}
|
| 131 |
+
className="w-8 h-8 rounded-full object-cover border border-zinc-200/40"
|
| 132 |
+
onError={() => setImgError(true)}
|
| 133 |
+
/>
|
| 134 |
+
) : (
|
| 135 |
+
<div className="w-8 h-8 rounded-full bg-zinc-100 border border-zinc-200/40 flex items-center justify-center font-bold text-xs text-zinc-800">
|
| 136 |
+
{log.employee.name.charAt(0).toUpperCase()}
|
| 137 |
+
</div>
|
| 138 |
+
)
|
| 139 |
) : (
|
| 140 |
+
<div className="w-8 h-8 rounded-full bg-zinc-50 border border-zinc-250/30 text-zinc-400 flex items-center justify-center font-bold text-xs">
|
| 141 |
?
|
| 142 |
</div>
|
| 143 |
)}
|
| 144 |
+
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-white flex items-center justify-center shadow-xs">
|
| 145 |
+
<div className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
|
| 146 |
</div>
|
| 147 |
</div>
|
| 148 |
|
| 149 |
<div className="flex-1 min-w-0">
|
| 150 |
+
<div className="flex items-center gap-1.5">
|
| 151 |
+
<p className="text-[12.5px] font-semibold text-slate-800 truncate">
|
| 152 |
+
{log.employee ? log.employee.name : "Unknown"}
|
| 153 |
</p>
|
| 154 |
+
<span className="text-[9px] text-zinc-400 font-mono">· {log.camera}</span>
|
| 155 |
</div>
|
| 156 |
+
<p className="text-[9.5px] text-slate-400 truncate mt-0.5">
|
| 157 |
{log.employee ? `${log.employee.designation} (${log.employee.employee_id})` : "Unauthorized access attempt"}
|
| 158 |
</p>
|
| 159 |
</div>
|
| 160 |
|
| 161 |
<div className="shrink-0 text-right space-y-1">
|
| 162 |
+
<p className="text-[9.5px] text-slate-400 font-mono leading-none">
|
| 163 |
{parseDateTime(log.timestamp)?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) || ""}
|
| 164 |
</p>
|
| 165 |
+
<span className={`inline-block text-[8.5px] font-mono font-bold px-1.5 py-0.5 rounded border ${statusColor}`}>
|
| 166 |
{statusLabel}
|
| 167 |
</span>
|
| 168 |
</div>
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
const getDeptTheme = (code: string) => {
|
| 174 |
+
return {
|
| 175 |
+
bg: "bg-white border-zinc-100 hover:border-zinc-200 text-slate-900",
|
| 176 |
+
iconBg: "bg-zinc-50 text-zinc-600 border-zinc-200/50",
|
| 177 |
+
barBg: "bg-zinc-150",
|
| 178 |
+
barFill: "bg-zinc-950"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
};
|
| 180 |
};
|
| 181 |
|
|
|
|
| 184 |
const [currentDate, setCurrentDate] = React.useState("");
|
| 185 |
const [statusFilter, setStatusFilter] = React.useState<"ALL" | "MATCHED" | "UNKNOWN" | "SPOOF">("ALL");
|
| 186 |
const [selectedDept, setSelectedDept] = React.useState<any | null>(null);
|
| 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(() => {
|
| 193 |
+
setIsDark(document.documentElement.classList.contains("dark"));
|
| 194 |
+
});
|
| 195 |
+
observer.observe(document.documentElement, {
|
| 196 |
+
attributes: true,
|
| 197 |
+
attributeFilter: ["class"]
|
| 198 |
+
});
|
| 199 |
+
return () => observer.disconnect();
|
| 200 |
+
}, []);
|
| 201 |
+
|
| 202 |
const { data: deptAttendance, isLoading: loadingDeptAttendance } = useQuery({
|
| 203 |
queryKey: ["dept-attendance", selectedDept?.id],
|
| 204 |
queryFn: () => {
|
|
|
|
| 310 |
|
| 311 |
const trendOption = () => {
|
| 312 |
if (!trends) return {};
|
| 313 |
+
const labelColor = isDark ? "#a1a1aa" : "#71717a";
|
| 314 |
+
const gridColor = isDark ? "rgba(255,255,255,0.06)" : "rgba(24,24,27,0.06)";
|
| 315 |
+
const lineColor = isDark ? "#ffffff" : "#18181b";
|
| 316 |
+
const areaColor = isDark ? "rgba(255,255,255,0.08)" : "rgba(24,24,27,0.06)";
|
| 317 |
+
const barColor = isDark ? "#52525b" : "#71717a";
|
| 318 |
+
const tooltipBg = isDark ? "#18181b" : "#ffffff";
|
| 319 |
+
const tooltipColor = isDark ? "#f4f4f5" : "#18181b";
|
| 320 |
+
const tooltipBorder = isDark ? "#27272a" : "rgba(24,24,27,0.08)";
|
| 321 |
+
|
| 322 |
return {
|
| 323 |
backgroundColor: "transparent",
|
| 324 |
tooltip: {
|
| 325 |
trigger: "axis",
|
| 326 |
+
backgroundColor: tooltipBg,
|
| 327 |
+
borderColor: tooltipBorder,
|
| 328 |
borderWidth: 1,
|
| 329 |
shadowColor: "rgba(0,0,0,0.02)",
|
| 330 |
shadowBlur: 10,
|
| 331 |
+
textStyle: { color: tooltipColor, fontSize: 11, fontFamily: "var(--font-inter)" },
|
| 332 |
extraCssText: "border-radius:12px;padding:8px 12px;box-shadow: 0 4px 16px rgba(0,0,0,0.04);"
|
| 333 |
},
|
| 334 |
legend: {
|
| 335 |
data: ["Present", "Late Arrivals"],
|
| 336 |
+
textStyle: { color: labelColor, fontSize: 10, fontFamily: "var(--font-inter)" },
|
| 337 |
bottom: 0,
|
| 338 |
icon: "circle",
|
| 339 |
itemWidth: 8,
|
|
|
|
| 353 |
}
|
| 354 |
return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
| 355 |
}),
|
| 356 |
+
axisLine: { lineStyle: { color: gridColor } },
|
| 357 |
axisTick: { show: false },
|
| 358 |
+
axisLabel: { color: labelColor, fontSize: 10, fontFamily: "var(--font-inter)" }
|
| 359 |
},
|
| 360 |
yAxis: {
|
| 361 |
type: "value",
|
| 362 |
+
splitLine: { lineStyle: { color: gridColor, type: "dashed" } },
|
| 363 |
+
axisLabel: { color: labelColor, fontSize: 10, fontFamily: "var(--font-inter)" },
|
| 364 |
axisLine: { show: false }
|
| 365 |
},
|
| 366 |
series: [
|
|
|
|
| 372 |
symbolSize: 6,
|
| 373 |
data: trends.map((t: any) => t.present),
|
| 374 |
lineStyle: {
|
| 375 |
+
color: lineColor,
|
| 376 |
width: 2.25,
|
| 377 |
+
shadowColor: isDark ? "rgba(255,255,255,0.05)" : "rgba(24,24,27,0.1)",
|
| 378 |
shadowBlur: 8,
|
| 379 |
shadowOffsetY: 4
|
| 380 |
},
|
| 381 |
+
itemStyle: { color: lineColor },
|
| 382 |
areaStyle: {
|
| 383 |
color: {
|
| 384 |
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
| 385 |
colorStops: [
|
| 386 |
+
{ offset: 0, color: areaColor },
|
| 387 |
{ offset: 1, color: "rgba(24,24,27,0)" }
|
| 388 |
]
|
| 389 |
}
|
|
|
|
| 394 |
type: "bar",
|
| 395 |
data: trends.map((t: any) => t.late),
|
| 396 |
itemStyle: {
|
| 397 |
+
color: barColor,
|
| 398 |
borderRadius: [3, 3, 0, 0]
|
| 399 |
},
|
| 400 |
barWidth: 6,
|
|
|
|
| 406 |
|
| 407 |
const deptOption = () => {
|
| 408 |
if (!deptStats) return {};
|
| 409 |
+
const labelColor = isDark ? "#a1a1aa" : "#71717a";
|
| 410 |
+
const textPrimary = isDark ? "#f4f4f5" : "#18181b";
|
| 411 |
+
const gridColor = isDark ? "rgba(255,255,255,0.06)" : "rgba(24,24,27,0.04)";
|
| 412 |
+
const totalBarColor = isDark ? "rgba(255,255,255,0.06)" : "rgba(24,24,27,0.04)";
|
| 413 |
+
const fillStart = isDark ? "#ffffff" : "#18181b";
|
| 414 |
+
const fillEnd = isDark ? "#d4d4d8" : "#27272a";
|
| 415 |
+
const tooltipBg = isDark ? "#18181b" : "#ffffff";
|
| 416 |
+
const tooltipColor = isDark ? "#f4f4f5" : "#18181b";
|
| 417 |
+
const tooltipBorder = isDark ? "#27272a" : "rgba(24,24,27,0.08)";
|
| 418 |
+
|
| 419 |
return {
|
| 420 |
backgroundColor: "transparent",
|
| 421 |
tooltip: {
|
| 422 |
trigger: "axis",
|
| 423 |
axisPointer: { type: "shadow" },
|
| 424 |
+
backgroundColor: tooltipBg,
|
| 425 |
+
borderColor: tooltipBorder,
|
| 426 |
borderWidth: 1,
|
| 427 |
+
textStyle: { color: tooltipColor, fontSize: 11 },
|
| 428 |
extraCssText: "border-radius:12px;padding:8px 12px;box-shadow: 0 4px 16px rgba(0,0,0,0.04);"
|
| 429 |
},
|
| 430 |
grid: { top: 10, left: 90, right: 20, bottom: 20 },
|
| 431 |
xAxis: {
|
| 432 |
type: "value",
|
| 433 |
+
splitLine: { lineStyle: { color: gridColor, type: "dashed" } },
|
| 434 |
+
axisLabel: { color: labelColor, fontSize: 9 },
|
| 435 |
axisLine: { show: false }
|
| 436 |
},
|
| 437 |
yAxis: {
|
|
|
|
| 439 |
data: deptStats.map((d: any) => d.name || d.code),
|
| 440 |
axisLine: { show: false },
|
| 441 |
axisTick: { show: false },
|
| 442 |
+
axisLabel: { color: textPrimary, fontSize: 10, fontFamily: "var(--font-inter)" }
|
| 443 |
},
|
| 444 |
series: [
|
| 445 |
{
|
| 446 |
name: "Total Employees",
|
| 447 |
type: "bar",
|
| 448 |
data: deptStats.map((d: any) => d.total_employees),
|
| 449 |
+
itemStyle: { color: totalBarColor, borderRadius: [0, 4, 4, 0] },
|
| 450 |
barGap: "-100%",
|
| 451 |
barWidth: 10
|
| 452 |
},
|
|
|
|
| 460 |
color: {
|
| 461 |
type: "linear", x: 0, y: 0, x2: 1, y2: 0,
|
| 462 |
colorStops: [
|
| 463 |
+
{ offset: 0, color: fillStart },
|
| 464 |
+
{ offset: 1, color: fillEnd }
|
| 465 |
]
|
| 466 |
}
|
| 467 |
}
|
|
|
|
| 469 |
]
|
| 470 |
};
|
| 471 |
};
|
|
|
|
| 472 |
return (
|
| 473 |
<SidebarLayout>
|
| 474 |
+
<div className="space-y-6 page-enter">
|
| 475 |
{/* ─── Header ─── */}
|
| 476 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between pb-4 border-b border-zinc-100 gap-4">
|
| 477 |
+
<div className="flex items-center gap-3">
|
| 478 |
+
<h1 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
|
| 479 |
+
Dashboard
|
| 480 |
</h1>
|
| 481 |
+
<div className="flex items-center gap-1.5 text-[9px] font-bold tracking-wider text-emerald-600 bg-emerald-500/5 px-2 py-0.5 rounded border border-emerald-500/10 uppercase font-mono">
|
| 482 |
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 483 |
+
<span>Live Feed Active</span>
|
| 484 |
+
</div>
|
| 485 |
</div>
|
| 486 |
+
|
| 487 |
+
<div className="flex items-center gap-2 font-mono text-[11px] text-slate-400">
|
| 488 |
+
<span>{currentDate}</span>
|
| 489 |
{currentTime && (
|
| 490 |
+
<>
|
| 491 |
+
<span className="text-zinc-200">|</span>
|
| 492 |
+
<span className="tabular-nums text-slate-800 font-medium">{currentTime}</span>
|
| 493 |
+
</>
|
|
|
|
|
|
|
|
|
|
| 494 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
</div>
|
| 496 |
</div>
|
| 497 |
|
|
|
|
| 507 |
{/* ─── Charts Row ─── */}
|
| 508 |
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
| 509 |
{/* Trend Chart */}
|
| 510 |
+
<div className="lg:col-span-3 bg-white border border-zinc-100 rounded-xl p-5 flex flex-col justify-between">
|
| 511 |
+
<div className="flex items-center justify-between mb-4">
|
| 512 |
+
<h2 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">Attendance Trends</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
</div>
|
| 514 |
<div className="h-56">
|
| 515 |
{loadingTrends ? (
|
|
|
|
| 523 |
</div>
|
| 524 |
|
| 525 |
{/* Live Feed */}
|
| 526 |
+
<div className="lg:col-span-2 bg-white border border-zinc-100 rounded-xl flex flex-col">
|
| 527 |
+
<div className="p-5 pb-3 border-b border-zinc-100">
|
| 528 |
<div className="flex items-center justify-between">
|
| 529 |
+
<h2 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">Live Activity</h2>
|
| 530 |
+
<div className="flex items-center gap-1.5 bg-zinc-50 border border-zinc-100 px-2 py-0.5 rounded text-[9px] text-zinc-500 font-mono tracking-wider">
|
| 531 |
+
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
|
| 532 |
+
<span>STREAMING</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
</div>
|
| 534 |
</div>
|
| 535 |
|
| 536 |
{/* Status Filter pills */}
|
| 537 |
+
<div className="flex flex-wrap gap-1 mt-2.5">
|
| 538 |
{(["ALL", "MATCHED", "UNKNOWN", "SPOOF"] as const).map((filter) => (
|
| 539 |
<button
|
| 540 |
key={filter}
|
| 541 |
onClick={() => setStatusFilter(filter)}
|
| 542 |
+
className={`px-2 py-0.5 rounded text-[9px] font-bold tracking-wide transition-all border cursor-pointer uppercase font-mono ${
|
| 543 |
statusFilter === filter
|
| 544 |
+
? "bg-zinc-950 border-zinc-950 text-white"
|
| 545 |
+
: "bg-transparent border-zinc-100 text-slate-400 hover:text-slate-700 hover:border-zinc-200"
|
| 546 |
}`}
|
| 547 |
>
|
| 548 |
+
{filter === "SPOOF" ? "Liveness Failed" : filter.toLowerCase()}
|
| 549 |
</button>
|
| 550 |
))}
|
| 551 |
</div>
|
|
|
|
| 587 |
})()}
|
| 588 |
</div>
|
| 589 |
|
| 590 |
+
<div className="p-3 border-t border-zinc-100 bg-zinc-55/30">
|
| 591 |
<Link
|
| 592 |
href="/attendance"
|
| 593 |
+
className="flex items-center justify-between text-[11px] text-zinc-500 hover:text-zinc-900 font-semibold transition-colors group"
|
| 594 |
>
|
| 595 |
<span>Access Complete Ledger Logs</span>
|
| 596 |
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
|
|
|
|
| 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">
|
| 605 |
+
<h2 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">Departments</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
</div>
|
| 607 |
|
| 608 |
<div className="min-h-36">
|
|
|
|
| 619 |
) : (
|
| 620 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
| 621 |
{deptStats.map((d: any) => {
|
|
|
|
| 622 |
const percent = d.total_employees > 0
|
| 623 |
? Math.round((d.present_today / d.total_employees) * 100)
|
| 624 |
: 0;
|
|
|
|
| 627 |
<div
|
| 628 |
key={d.code}
|
| 629 |
onClick={() => setSelectedDept(d)}
|
| 630 |
+
className="p-4 rounded-xl border border-zinc-100 bg-white hover:border-zinc-300 transition-all duration-200 flex flex-col justify-between h-28 cursor-pointer group"
|
| 631 |
>
|
| 632 |
{/* Top Header Row */}
|
| 633 |
<div className="flex items-center justify-between w-full">
|
| 634 |
+
<span className="text-[9px] font-bold font-mono px-1.5 py-0.5 rounded bg-zinc-50 text-zinc-650 border border-zinc-100 uppercase tracking-wider">
|
| 635 |
{d.code}
|
| 636 |
</span>
|
| 637 |
|
| 638 |
<div className="text-right">
|
| 639 |
+
<span className="text-xs font-semibold text-slate-800">
|
| 640 |
+
{d.present_today} <span className="text-slate-450 font-normal">/ {d.total_employees}</span>
|
| 641 |
</span>
|
| 642 |
</div>
|
| 643 |
</div>
|
| 644 |
|
| 645 |
{/* Department Name & Subheading */}
|
| 646 |
+
<div className="mt-1.5 flex-1 min-w-0">
|
| 647 |
<h3 className="text-xs font-bold text-slate-800 truncate" title={d.department}>
|
| 648 |
{d.department}
|
| 649 |
</h3>
|
| 650 |
+
<p className="text-[9.5px] text-slate-450 font-mono mt-0.5">
|
| 651 |
+
{percent}% present
|
| 652 |
</p>
|
| 653 |
</div>
|
| 654 |
|
| 655 |
{/* Progress Bar */}
|
| 656 |
+
<div className="mt-2 w-full">
|
| 657 |
+
<div className="w-full h-1 bg-zinc-100 rounded-full overflow-hidden">
|
| 658 |
<div
|
| 659 |
+
className="h-full bg-zinc-950 rounded-full transition-all duration-500 group-hover:bg-zinc-800"
|
| 660 |
style={{ width: `${d.total_employees > 0 ? percent : 0}%` }}
|
| 661 |
/>
|
| 662 |
</div>
|
|
|
|
| 690 |
onClick={() => setSelectedDept(null)}
|
| 691 |
className="p-2 rounded-xl hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
|
| 692 |
>
|
| 693 |
+
<X className="w-4 h-4" />
|
| 694 |
</button>
|
| 695 |
</div>
|
| 696 |
|
|
|
|
| 716 |
return (
|
| 717 |
<div key={rec.id} className="flex items-center justify-between py-3">
|
| 718 |
<div className="flex items-center gap-3">
|
| 719 |
+
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${avatarColor} flex items-center justify-center shrink-0 border font-bold text-[10.5px] shadow-sm`}>
|
| 720 |
{rec.employee.name.charAt(0).toUpperCase()}
|
| 721 |
</div>
|
| 722 |
<div>
|
frontend/app/employees/page.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
| 9 |
X, Users, CheckCircle2, XCircle, ChevronDown, UserCheck, ShieldAlert,
|
| 10 |
Download, Mail, Phone, Calendar, Briefcase, Clock, TrendingUp
|
| 11 |
} from "lucide-react";
|
|
|
|
| 12 |
import Link from "next/link";
|
| 13 |
|
| 14 |
// Avatar gradient styles
|
|
@@ -64,6 +65,7 @@ function InputField({ label, required, children }: { label: string; required?: b
|
|
| 64 |
|
| 65 |
export default function EmployeesPage() {
|
| 66 |
const queryClient = useQueryClient();
|
|
|
|
| 67 |
const [search, setSearch] = useState("");
|
| 68 |
const [deptFilter, setDeptFilter] = useState("");
|
| 69 |
const [statusFilter, setStatusFilter] = useState("");
|
|
@@ -74,11 +76,15 @@ export default function EmployeesPage() {
|
|
| 74 |
const [importErrors, setImportErrors] = useState<string[]>([]);
|
| 75 |
const [submitting, setSubmitting] = useState(false);
|
| 76 |
const [selectedEmployee, setSelectedEmployee] = useState<any | null>(null);
|
|
|
|
|
|
|
| 77 |
|
| 78 |
const [empId, setEmpId] = useState("");
|
| 79 |
const [name, setName] = useState("");
|
| 80 |
const [email, setEmail] = useState("");
|
| 81 |
const [phone, setPhone] = useState("");
|
|
|
|
|
|
|
| 82 |
const [designation, setDesignation] = useState("");
|
| 83 |
const [joiningDate, setJoiningDate] = useState(getLocalDateString());
|
| 84 |
const [statusVal, setStatusVal] = useState("Active");
|
|
@@ -120,7 +126,9 @@ export default function EmployeesPage() {
|
|
| 120 |
resetForm();
|
| 121 |
setShowAddDialog(false);
|
| 122 |
},
|
| 123 |
-
onError: (err: any) =>
|
|
|
|
|
|
|
| 124 |
});
|
| 125 |
|
| 126 |
const deleteMutation = useMutation({
|
|
@@ -128,18 +136,62 @@ export default function EmployeesPage() {
|
|
| 128 |
onSuccess: () => {
|
| 129 |
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 130 |
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
|
|
|
| 131 |
},
|
| 132 |
-
onError: (err: any) =>
|
| 133 |
});
|
| 134 |
|
| 135 |
const resetForm = () => {
|
| 136 |
setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
|
| 137 |
setJoiningDate(getLocalDateString()); setStatusVal("Active");
|
| 138 |
setDeptId(""); setCreateUserLogin(false); setPassword("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
};
|
| 140 |
|
|
|
|
|
|
|
| 141 |
const handleAddSubmit = (e: React.FormEvent) => {
|
| 142 |
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
const payload: any = {
|
| 144 |
employee_id: empId, name, email, phone: phone || null,
|
| 145 |
designation: designation || null, joining_date: joiningDate,
|
|
@@ -151,8 +203,16 @@ export default function EmployeesPage() {
|
|
| 151 |
};
|
| 152 |
|
| 153 |
const handleDelete = (id: number, name: string) => {
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
};
|
| 157 |
|
| 158 |
const handleImportSubmit = async (e: React.FormEvent) => {
|
|
@@ -185,8 +245,9 @@ export default function EmployeesPage() {
|
|
| 185 |
a.click();
|
| 186 |
a.remove();
|
| 187 |
window.URL.revokeObjectURL(url);
|
|
|
|
| 188 |
} catch (err: any) {
|
| 189 |
-
|
| 190 |
}
|
| 191 |
};
|
| 192 |
|
|
@@ -203,7 +264,6 @@ export default function EmployeesPage() {
|
|
| 203 |
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
|
| 204 |
<div>
|
| 205 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Staff Management</h1>
|
| 206 |
-
<p className="text-xs text-slate-500 mt-0.5">Register staff, manage details, and initiate camera enrollment.</p>
|
| 207 |
</div>
|
| 208 |
<div className="flex items-center gap-2.5 shrink-0">
|
| 209 |
<button
|
|
@@ -367,10 +427,9 @@ export default function EmployeesPage() {
|
|
| 367 |
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
|
| 368 |
<div>
|
| 369 |
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Register Employee</h3>
|
| 370 |
-
<p className="text-xs text-slate-500 mt-0.5">Fill in the fields to create a new profile.</p>
|
| 371 |
</div>
|
| 372 |
<button
|
| 373 |
-
onClick={() => setShowAddDialog(false)}
|
| 374 |
className="p-2 rounded-xl hover:bg-white/6 text-slate-500 hover:text-slate-300 transition-all cursor-pointer"
|
| 375 |
>
|
| 376 |
<X className="w-4 h-4" />
|
|
@@ -378,6 +437,26 @@ export default function EmployeesPage() {
|
|
| 378 |
</div>
|
| 379 |
|
| 380 |
<form onSubmit={handleAddSubmit} className="space-y-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
<div className="grid grid-cols-2 gap-3.5">
|
| 382 |
<InputField label="Employee ID" required>
|
| 383 |
<input type="text" required placeholder="EMP001" value={empId}
|
|
@@ -395,8 +474,21 @@ export default function EmployeesPage() {
|
|
| 395 |
onChange={(e) => setEmail(e.target.value)} className={inputCls} />
|
| 396 |
</InputField>
|
| 397 |
<InputField label="Phone Number">
|
| 398 |
-
<
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
</InputField>
|
| 401 |
</div>
|
| 402 |
|
|
@@ -454,15 +546,15 @@ export default function EmployeesPage() {
|
|
| 454 |
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 455 |
<button
|
| 456 |
type="button"
|
| 457 |
-
onClick={() => setShowAddDialog(false)}
|
| 458 |
className="btn-ghost h-9.5 px-4 text-[12px] rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 459 |
>
|
| 460 |
Cancel
|
| 461 |
</button>
|
| 462 |
<button
|
| 463 |
type="submit"
|
| 464 |
-
disabled={createMutation.isPending}
|
| 465 |
-
className="btn-primary h-9.5 px-5 text-[12px] flex items-center gap-2 rounded-xl cursor-pointer"
|
| 466 |
>
|
| 467 |
{createMutation.isPending ? (
|
| 468 |
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Saving...</span></>
|
|
@@ -483,7 +575,6 @@ export default function EmployeesPage() {
|
|
| 483 |
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
|
| 484 |
<div>
|
| 485 |
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Bulk Import via CSV</h3>
|
| 486 |
-
<p className="text-xs text-slate-500 mt-0.5">Register multiple staff via CSV upload.</p>
|
| 487 |
</div>
|
| 488 |
<button
|
| 489 |
onClick={() => { setShowImportDialog(false); setSelectedFile(null); setImportMessage(null); setImportErrors([]); }}
|
|
@@ -705,6 +796,49 @@ export default function EmployeesPage() {
|
|
| 705 |
</div>
|
| 706 |
</div>
|
| 707 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
</>
|
| 709 |
);
|
| 710 |
}
|
|
|
|
| 9 |
X, Users, CheckCircle2, XCircle, ChevronDown, UserCheck, ShieldAlert,
|
| 10 |
Download, Mail, Phone, Calendar, Briefcase, Clock, TrendingUp
|
| 11 |
} from "lucide-react";
|
| 12 |
+
import { useToast } from "@/app/utils/toast";
|
| 13 |
import Link from "next/link";
|
| 14 |
|
| 15 |
// Avatar gradient styles
|
|
|
|
| 65 |
|
| 66 |
export default function EmployeesPage() {
|
| 67 |
const queryClient = useQueryClient();
|
| 68 |
+
const { toast } = useToast();
|
| 69 |
const [search, setSearch] = useState("");
|
| 70 |
const [deptFilter, setDeptFilter] = useState("");
|
| 71 |
const [statusFilter, setStatusFilter] = useState("");
|
|
|
|
| 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 |
|
| 82 |
const [empId, setEmpId] = useState("");
|
| 83 |
const [name, setName] = useState("");
|
| 84 |
const [email, setEmail] = useState("");
|
| 85 |
const [phone, setPhone] = useState("");
|
| 86 |
+
const [phoneError, setPhoneError] = useState<string | null>(null);
|
| 87 |
+
const [submissionError, setSubmissionError] = useState<string | null>(null);
|
| 88 |
const [designation, setDesignation] = useState("");
|
| 89 |
const [joiningDate, setJoiningDate] = useState(getLocalDateString());
|
| 90 |
const [statusVal, setStatusVal] = useState("Active");
|
|
|
|
| 126 |
resetForm();
|
| 127 |
setShowAddDialog(false);
|
| 128 |
},
|
| 129 |
+
onError: (err: any) => {
|
| 130 |
+
setSubmissionError(err.message || "Failed to create employee.");
|
| 131 |
+
}
|
| 132 |
});
|
| 133 |
|
| 134 |
const deleteMutation = useMutation({
|
|
|
|
| 136 |
onSuccess: () => {
|
| 137 |
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 138 |
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 139 |
+
toast.success("Employee record deleted successfully.");
|
| 140 |
},
|
| 141 |
+
onError: (err: any) => toast.error(err.message || "Failed to delete employee.")
|
| 142 |
});
|
| 143 |
|
| 144 |
const resetForm = () => {
|
| 145 |
setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
|
| 146 |
setJoiningDate(getLocalDateString()); setStatusVal("Active");
|
| 147 |
setDeptId(""); setCreateUserLogin(false); setPassword("");
|
| 148 |
+
setPhoneError(null);
|
| 149 |
+
setSubmissionError(null);
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
// Live duplicate checking against loaded employees
|
| 153 |
+
const getDuplicateWarning = () => {
|
| 154 |
+
if (!employees) return null;
|
| 155 |
+
|
| 156 |
+
if (empId.trim()) {
|
| 157 |
+
const match = employees.find((e: any) => e.employee_id.toLowerCase() === empId.trim().toLowerCase());
|
| 158 |
+
if (match) return `Employee ID "${empId}" is already assigned to ${match.name}.`;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
if (name.trim()) {
|
| 162 |
+
const match = employees.find((e: any) => e.name.toLowerCase() === name.trim().toLowerCase());
|
| 163 |
+
if (match) return `An employee named "${name}" is already registered.`;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
if (phone.trim()) {
|
| 167 |
+
const cleanedInput = phone.replace(/[\s\-()]/g, "");
|
| 168 |
+
if (cleanedInput) {
|
| 169 |
+
const match = employees.find((e: any) => {
|
| 170 |
+
if (!e.phone) return false;
|
| 171 |
+
const cleanedExisting = e.phone.replace(/[\s\-()]/g, "");
|
| 172 |
+
return cleanedExisting === cleanedInput;
|
| 173 |
+
});
|
| 174 |
+
if (match) return `Phone number is already registered to ${match.name}.`;
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
return null;
|
| 179 |
};
|
| 180 |
|
| 181 |
+
const duplicateWarning = getDuplicateWarning();
|
| 182 |
+
|
| 183 |
const handleAddSubmit = (e: React.FormEvent) => {
|
| 184 |
e.preventDefault();
|
| 185 |
+
|
| 186 |
+
if (phone) {
|
| 187 |
+
const cleaned = phone.replace(/[\s\-()]/g, "");
|
| 188 |
+
if (!/^(?:\+91|91|0)?[6-9]\d{9}$/.test(cleaned)) {
|
| 189 |
+
setPhoneError("Invalid Indian mobile number. Must be a 10-digit number starting with 6-9, optionally prefixed with +91, 91, or 0.");
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
setPhoneError(null);
|
| 194 |
+
|
| 195 |
const payload: any = {
|
| 196 |
employee_id: empId, name, email, phone: phone || null,
|
| 197 |
designation: designation || null, joining_date: joiningDate,
|
|
|
|
| 203 |
};
|
| 204 |
|
| 205 |
const handleDelete = (id: number, name: string) => {
|
| 206 |
+
setDeleteConfirmId(id);
|
| 207 |
+
setDeleteConfirmName(name);
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
const confirmDelete = () => {
|
| 211 |
+
if (deleteConfirmId !== null) {
|
| 212 |
+
deleteMutation.mutate(deleteConfirmId);
|
| 213 |
+
setDeleteConfirmId(null);
|
| 214 |
+
setDeleteConfirmName("");
|
| 215 |
+
}
|
| 216 |
};
|
| 217 |
|
| 218 |
const handleImportSubmit = async (e: React.FormEvent) => {
|
|
|
|
| 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 |
|
|
|
|
| 264 |
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
|
| 265 |
<div>
|
| 266 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Staff Management</h1>
|
|
|
|
| 267 |
</div>
|
| 268 |
<div className="flex items-center gap-2.5 shrink-0">
|
| 269 |
<button
|
|
|
|
| 427 |
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
|
| 428 |
<div>
|
| 429 |
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Register Employee</h3>
|
|
|
|
| 430 |
</div>
|
| 431 |
<button
|
| 432 |
+
onClick={() => { setShowAddDialog(false); resetForm(); }}
|
| 433 |
className="p-2 rounded-xl hover:bg-white/6 text-slate-500 hover:text-slate-300 transition-all cursor-pointer"
|
| 434 |
>
|
| 435 |
<X className="w-4 h-4" />
|
|
|
|
| 437 |
</div>
|
| 438 |
|
| 439 |
<form onSubmit={handleAddSubmit} className="space-y-4">
|
| 440 |
+
{duplicateWarning && (
|
| 441 |
+
<div className="flex items-start gap-3 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 text-xs animate-fadeInUp">
|
| 442 |
+
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5 text-amber-500" />
|
| 443 |
+
<div className="flex-1 space-y-1">
|
| 444 |
+
<p className="font-bold uppercase tracking-wider text-[9px] font-mono leading-none">Duplicate Detected</p>
|
| 445 |
+
<p className="leading-relaxed font-medium mt-1">{duplicateWarning}</p>
|
| 446 |
+
</div>
|
| 447 |
+
</div>
|
| 448 |
+
)}
|
| 449 |
+
|
| 450 |
+
{submissionError && (
|
| 451 |
+
<div className="flex items-start gap-3 p-3 rounded-xl bg-rose-500/10 border border-rose-500/25 text-rose-600 dark:text-rose-400 text-xs animate-fadeInUp">
|
| 452 |
+
<XCircle className="w-4 h-4 shrink-0 mt-0.5 text-rose-500" />
|
| 453 |
+
<div className="flex-1 space-y-1">
|
| 454 |
+
<p className="font-bold uppercase tracking-wider text-[9px] font-mono leading-none">Registration Failed</p>
|
| 455 |
+
<p className="leading-relaxed font-medium mt-1">{submissionError}</p>
|
| 456 |
+
</div>
|
| 457 |
+
</div>
|
| 458 |
+
)}
|
| 459 |
+
|
| 460 |
<div className="grid grid-cols-2 gap-3.5">
|
| 461 |
<InputField label="Employee ID" required>
|
| 462 |
<input type="text" required placeholder="EMP001" value={empId}
|
|
|
|
| 474 |
onChange={(e) => setEmail(e.target.value)} className={inputCls} />
|
| 475 |
</InputField>
|
| 476 |
<InputField label="Phone Number">
|
| 477 |
+
<div className="relative">
|
| 478 |
+
<input
|
| 479 |
+
type="text"
|
| 480 |
+
placeholder="+91 98765 43210"
|
| 481 |
+
value={phone}
|
| 482 |
+
onChange={(e) => {
|
| 483 |
+
setPhone(e.target.value);
|
| 484 |
+
if (phoneError) setPhoneError(null);
|
| 485 |
+
}}
|
| 486 |
+
className={`${inputCls} ${phoneError ? "border-rose-500 focus:border-rose-500" : ""}`}
|
| 487 |
+
/>
|
| 488 |
+
{phoneError && (
|
| 489 |
+
<p className="text-[10px] text-rose-500 mt-1 font-medium leading-tight">{phoneError}</p>
|
| 490 |
+
)}
|
| 491 |
+
</div>
|
| 492 |
</InputField>
|
| 493 |
</div>
|
| 494 |
|
|
|
|
| 546 |
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 547 |
<button
|
| 548 |
type="button"
|
| 549 |
+
onClick={() => { setShowAddDialog(false); resetForm(); }}
|
| 550 |
className="btn-ghost h-9.5 px-4 text-[12px] rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 551 |
>
|
| 552 |
Cancel
|
| 553 |
</button>
|
| 554 |
<button
|
| 555 |
type="submit"
|
| 556 |
+
disabled={createMutation.isPending || !!duplicateWarning}
|
| 557 |
+
className="btn-primary h-9.5 px-5 text-[12px] flex items-center gap-2 rounded-xl cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
| 558 |
>
|
| 559 |
{createMutation.isPending ? (
|
| 560 |
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Saving...</span></>
|
|
|
|
| 575 |
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
|
| 576 |
<div>
|
| 577 |
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Bulk Import via CSV</h3>
|
|
|
|
| 578 |
</div>
|
| 579 |
<button
|
| 580 |
onClick={() => { setShowImportDialog(false); setSelectedFile(null); setImportMessage(null); setImportErrors([]); }}
|
|
|
|
| 796 |
</div>
|
| 797 |
</div>
|
| 798 |
)}
|
| 799 |
+
|
| 800 |
+
{/* Delete Confirmation Modal */}
|
| 801 |
+
{deleteConfirmId !== null && (
|
| 802 |
+
<div className="modal-backdrop z-50">
|
| 803 |
+
<div className="modal-content max-w-sm border border-red-500/10 shadow-[0_12px_40px_rgba(239,68,68,0.12)]">
|
| 804 |
+
<div className="flex flex-col items-center text-center p-2 space-y-4">
|
| 805 |
+
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/15 flex items-center justify-center text-rose-500 shadow-inner">
|
| 806 |
+
<Trash2 className="w-6 h-6" />
|
| 807 |
+
</div>
|
| 808 |
+
<div>
|
| 809 |
+
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Confirm Deletion</h3>
|
| 810 |
+
<p className="text-xs text-slate-500 mt-2 leading-relaxed">
|
| 811 |
+
Are you absolutely sure you want to delete <span className="font-semibold text-[var(--text-primary)]">{deleteConfirmName}</span>?
|
| 812 |
+
</p>
|
| 813 |
+
<p className="text-[10.5px] text-rose-500 font-medium bg-rose-500/5 border border-rose-500/10 rounded-xl p-2.5 mt-3 leading-normal">
|
| 814 |
+
Warning: All facial biometric profile records and logs will be permanently deleted. This action cannot be undone.
|
| 815 |
+
</p>
|
| 816 |
+
</div>
|
| 817 |
+
<div className="flex gap-2.5 w-full pt-2 border-t border-white/5">
|
| 818 |
+
<button
|
| 819 |
+
type="button"
|
| 820 |
+
onClick={() => { setDeleteConfirmId(null); setDeleteConfirmName(""); }}
|
| 821 |
+
className="flex-1 btn-ghost h-9.5 text-[12px] rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 822 |
+
>
|
| 823 |
+
Cancel
|
| 824 |
+
</button>
|
| 825 |
+
<button
|
| 826 |
+
type="button"
|
| 827 |
+
onClick={confirmDelete}
|
| 828 |
+
disabled={deleteMutation.isPending}
|
| 829 |
+
className="flex-1 bg-rose-600 hover:bg-rose-500 active:bg-rose-700 text-white font-bold text-[12px] rounded-xl cursor-pointer h-9.5 flex items-center justify-center gap-2 shadow-md shadow-rose-950/20 border border-rose-500/20"
|
| 830 |
+
>
|
| 831 |
+
{deleteMutation.isPending ? (
|
| 832 |
+
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 833 |
+
) : (
|
| 834 |
+
"Delete Record"
|
| 835 |
+
)}
|
| 836 |
+
</button>
|
| 837 |
+
</div>
|
| 838 |
+
</div>
|
| 839 |
+
</div>
|
| 840 |
+
</div>
|
| 841 |
+
)}
|
| 842 |
</>
|
| 843 |
);
|
| 844 |
}
|
frontend/app/enroll/[id]/page.tsx
CHANGED
|
@@ -7,14 +7,15 @@ import SidebarLayout from "@/components/SidebarLayout";
|
|
| 7 |
import { fetchApi, getBackendUrl } from "@/app/utils/api";
|
| 8 |
import {
|
| 9 |
Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video,
|
| 10 |
-
RefreshCw, AlertCircle, Trash2, Play, Pause, Save, RotateCcw, Shield, Activity, Sparkles
|
|
|
|
| 11 |
} from "lucide-react";
|
| 12 |
|
| 13 |
interface PoseInfo {
|
| 14 |
label: string;
|
| 15 |
hint: string;
|
| 16 |
speech: string;
|
| 17 |
-
icon:
|
| 18 |
}
|
| 19 |
|
| 20 |
const POSES: Record<string, PoseInfo> = {
|
|
@@ -22,61 +23,61 @@ const POSES: Record<string, PoseInfo> = {
|
|
| 22 |
label: "Front Profile",
|
| 23 |
hint: "Look straight into the camera with a neutral expression.",
|
| 24 |
speech: "Please look straight into the camera.",
|
| 25 |
-
icon:
|
| 26 |
},
|
| 27 |
left: {
|
| 28 |
label: "Left Profile",
|
| 29 |
hint: "Turn your head slowly to the left.",
|
| 30 |
speech: "Please turn your head to the left.",
|
| 31 |
-
icon:
|
| 32 |
},
|
| 33 |
right: {
|
| 34 |
label: "Right Profile",
|
| 35 |
hint: "Turn your head slowly to the right.",
|
| 36 |
speech: "Please turn your head to the right.",
|
| 37 |
-
icon:
|
| 38 |
},
|
| 39 |
up: {
|
| 40 |
label: "Looking Up",
|
| 41 |
hint: "Tilt your chin upwards slightly.",
|
| 42 |
speech: "Please tilt your head upwards.",
|
| 43 |
-
icon:
|
| 44 |
},
|
| 45 |
down: {
|
| 46 |
label: "Looking Down",
|
| 47 |
hint: "Tilt your chin downwards slightly.",
|
| 48 |
speech: "Please tilt your head downwards.",
|
| 49 |
-
icon:
|
| 50 |
},
|
| 51 |
smile: {
|
| 52 |
label: "Smiling Face",
|
| 53 |
hint: "Give a natural, relaxed smile.",
|
| 54 |
speech: "Now, smile naturally.",
|
| 55 |
-
icon:
|
| 56 |
},
|
| 57 |
neutral: {
|
| 58 |
label: "Neutral Face",
|
| 59 |
hint: "Keep a relaxed, standard neutral expression.",
|
| 60 |
speech: "Relax your face, show a neutral expression.",
|
| 61 |
-
icon:
|
| 62 |
},
|
| 63 |
indoor: {
|
| 64 |
label: "Indoor Light",
|
| 65 |
hint: "Look straight with standard indoor room lighting.",
|
| 66 |
speech: "Look straight for typical indoor lighting.",
|
| 67 |
-
icon:
|
| 68 |
},
|
| 69 |
outdoor: {
|
| 70 |
label: "Outdoor Light",
|
| 71 |
hint: "Look straight with bright/outdoor lighting.",
|
| 72 |
speech: "Look straight for bright light capture.",
|
| 73 |
-
icon:
|
| 74 |
},
|
| 75 |
glasses: {
|
| 76 |
label: "Glasses Option",
|
| 77 |
hint: "Put on glasses if you wear them, otherwise look straight.",
|
| 78 |
speech: "If you wear glasses, put them on. Otherwise, look straight.",
|
| 79 |
-
icon:
|
| 80 |
}
|
| 81 |
};
|
| 82 |
|
|
@@ -110,6 +111,7 @@ export default function EnrollPage() {
|
|
| 110 |
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
| 111 |
const [webcamActive, setWebcamActive] = useState(false);
|
| 112 |
const [singleRetakePose, setSingleRetakePose] = useState<string | null>(null);
|
|
|
|
| 113 |
|
| 114 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 115 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
@@ -249,15 +251,16 @@ export default function EnrollPage() {
|
|
| 249 |
}
|
| 250 |
|
| 251 |
countdownIntervalRef.current = setInterval(() => {
|
| 252 |
-
|
| 253 |
-
if (
|
| 254 |
-
clearInterval(countdownIntervalRef.current
|
| 255 |
-
|
| 256 |
-
return 3;
|
| 257 |
}
|
|
|
|
|
|
|
| 258 |
playSound("beep");
|
| 259 |
-
|
| 260 |
-
}
|
| 261 |
}, 1000);
|
| 262 |
|
| 263 |
return () => {
|
|
@@ -304,6 +307,7 @@ export default function EnrollPage() {
|
|
| 304 |
setSingleRetakePose(poseKey);
|
| 305 |
setSelectedPose(poseKey);
|
| 306 |
setCountdown(3);
|
|
|
|
| 307 |
setCaptureState("capturing");
|
| 308 |
|
| 309 |
// Set index to match keys
|
|
@@ -428,6 +432,27 @@ export default function EnrollPage() {
|
|
| 428 |
border-width: 2px;
|
| 429 |
pointer-events: none;
|
| 430 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
`}</style>
|
| 432 |
|
| 433 |
<canvas ref={canvasRef} className="hidden" />
|
|
@@ -459,7 +484,7 @@ export default function EnrollPage() {
|
|
| 459 |
|
| 460 |
{enrolledCount > 0 && (
|
| 461 |
<button
|
| 462 |
-
onClick={() =>
|
| 463 |
className="flex items-center gap-2 text-[11.5px] font-bold text-rose-600 hover:text-white bg-white hover:bg-rose-600 border border-rose-200 hover:border-rose-600 px-4 py-2 rounded-xl transition-all cursor-pointer shadow-sm"
|
| 464 |
>
|
| 465 |
<Trash2 className="w-3.5 h-3.5" />
|
|
@@ -551,24 +576,33 @@ export default function EnrollPage() {
|
|
| 551 |
To capture accurate biometric details under varying orientations and lighting, we index 10 distinct facial angles.
|
| 552 |
</p>
|
| 553 |
|
| 554 |
-
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 pt-2">
|
| 555 |
{POSE_KEYS.map((key) => {
|
| 556 |
const done = status?.enrolled_poses?.some((p: string) => p.toLowerCase() === key.toLowerCase());
|
|
|
|
| 557 |
return (
|
| 558 |
<div
|
| 559 |
key={key}
|
| 560 |
-
className={`p-
|
| 561 |
done
|
| 562 |
-
? "bg-emerald-
|
| 563 |
-
: "bg-slate-50/50 border-slate-200
|
| 564 |
}`}
|
| 565 |
>
|
| 566 |
-
<
|
| 567 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
{done ? (
|
| 569 |
-
<CheckCircle2 className="w-
|
| 570 |
) : (
|
| 571 |
-
<div className="w-
|
| 572 |
)}
|
| 573 |
</div>
|
| 574 |
);
|
|
@@ -585,7 +619,14 @@ export default function EnrollPage() {
|
|
| 585 |
<div className="w-full bg-slate-950 text-white rounded-2xl p-5 flex items-center justify-between shadow-lg relative overflow-hidden">
|
| 586 |
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] pointer-events-none" />
|
| 587 |
<div className="flex items-center gap-4 relative z-10">
|
| 588 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
<div>
|
| 590 |
<p className="text-[10px] font-bold text-cyan-400 font-mono tracking-widest uppercase">
|
| 591 |
SCAN PHASE {currentPoseIndex + 1} OF {POSE_KEYS.length}
|
|
@@ -611,17 +652,34 @@ export default function EnrollPage() {
|
|
| 611 |
{/* Native video element */}
|
| 612 |
<video
|
| 613 |
ref={videoRef}
|
| 614 |
-
className=
|
| 615 |
autoPlay playsInline muted
|
| 616 |
/>
|
| 617 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
{/* Cybernetic HUD elements */}
|
| 619 |
-
<div className="scanner-line" />
|
| 620 |
-
|
| 621 |
-
<div className="
|
| 622 |
-
<div className="
|
|
|
|
|
|
|
| 623 |
</div>
|
| 624 |
-
|
| 625 |
|
| 626 |
{/* HUD corners */}
|
| 627 |
<div className="hud-corner corner-bracket-tl top-6 left-6 border-t-2 border-l-2" />
|
|
@@ -631,20 +689,28 @@ export default function EnrollPage() {
|
|
| 631 |
|
| 632 |
{/* Scanner stats HUD */}
|
| 633 |
<div className="absolute top-6 left-12 right-12 flex justify-between text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none uppercase">
|
| 634 |
-
<span>SYS.STATUS: ACQUIRING_DATA</span>
|
| 635 |
-
<span>FPS: 60 · ISO: 200 · SHUTTER: AUTO</span>
|
| 636 |
</div>
|
| 637 |
|
| 638 |
<div className="absolute bottom-6 left-12 right-12 flex justify-between items-center text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none">
|
| 639 |
<span>ANGLE: {POSE_KEYS[currentPoseIndex]?.toUpperCase()}</span>
|
| 640 |
-
<span>LIVENESS CHECK: ACTIVE</span>
|
| 641 |
</div>
|
| 642 |
</div>
|
| 643 |
|
| 644 |
{/* Controls */}
|
| 645 |
<div className="flex gap-4 w-full max-w-lg">
|
| 646 |
<button
|
| 647 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
className="flex-1 h-11 bg-white hover:bg-slate-50 border border-slate-250 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
|
| 649 |
>
|
| 650 |
{isPaused ? <Play className="w-3.5 h-3.5 fill-current" /> : <Pause className="w-3.5 h-3.5 fill-current" />}
|
|
@@ -652,7 +718,7 @@ export default function EnrollPage() {
|
|
| 652 |
</button>
|
| 653 |
|
| 654 |
<button
|
| 655 |
-
onClick={() => {
|
| 656 |
if (singleRetakePose) {
|
| 657 |
stopWebcam();
|
| 658 |
setSingleRetakePose(null);
|
|
@@ -661,6 +727,10 @@ export default function EnrollPage() {
|
|
| 661 |
// Skip pose
|
| 662 |
setCurrentPoseIndex(prev => prev + 1);
|
| 663 |
setCountdown(3);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
}
|
| 665 |
}}
|
| 666 |
className="flex-1 h-11 bg-slate-950 hover:bg-slate-900 border border-slate-950 text-white font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-md cursor-pointer"
|
|
@@ -710,7 +780,7 @@ export default function EnrollPage() {
|
|
| 710 |
</div>
|
| 711 |
|
| 712 |
<div className="mt-2.5 flex items-center justify-between text-[11px] font-bold">
|
| 713 |
-
<span className="text-slate-900 uppercase font-mono">{POSES[key].label.
|
| 714 |
{imgUrl ? (
|
| 715 |
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
|
| 716 |
) : (
|
|
@@ -766,7 +836,7 @@ export default function EnrollPage() {
|
|
| 766 |
<div className="space-y-1.5">
|
| 767 |
<div className="relative h-2 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
| 768 |
<div
|
| 769 |
-
className="absolute inset-y-0 left-0 bg-gradient-to-
|
| 770 |
style={{ width: `${uploadProgress}%` }}
|
| 771 |
/>
|
| 772 |
</div>
|
|
@@ -781,8 +851,10 @@ export default function EnrollPage() {
|
|
| 781 |
{/* ─── State 5: SUCCESS SCREEN ─── */}
|
| 782 |
{captureState === "success" && (
|
| 783 |
<div className="max-w-md mx-auto bg-white border border-slate-200 rounded-3xl p-8 shadow-2xl text-center space-y-6 animate-scaleIn">
|
| 784 |
-
<div className="relative w-20 h-20 mx-auto bg-emerald-50 rounded-full flex items-center justify-center border border-emerald-100">
|
| 785 |
-
<
|
|
|
|
|
|
|
| 786 |
</div>
|
| 787 |
|
| 788 |
<div className="space-y-2">
|
|
@@ -795,14 +867,14 @@ export default function EnrollPage() {
|
|
| 795 |
<div className="flex gap-3 justify-center pt-2">
|
| 796 |
<button
|
| 797 |
onClick={() => router.push("/employees")}
|
| 798 |
-
className="h-10 px-6 bg-slate-
|
| 799 |
>
|
| 800 |
Employees List
|
| 801 |
</button>
|
| 802 |
|
| 803 |
<button
|
| 804 |
onClick={startAutoCapture}
|
| 805 |
-
className="h-10 px-6 bg-slate-
|
| 806 |
>
|
| 807 |
Re-enroll Profile
|
| 808 |
</button>
|
|
@@ -823,6 +895,52 @@ export default function EnrollPage() {
|
|
| 823 |
</div>
|
| 824 |
)}
|
| 825 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
</SidebarLayout>
|
| 827 |
);
|
| 828 |
}
|
|
|
|
| 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 {
|
| 15 |
label: string;
|
| 16 |
hint: string;
|
| 17 |
speech: string;
|
| 18 |
+
icon: any;
|
| 19 |
}
|
| 20 |
|
| 21 |
const POSES: Record<string, PoseInfo> = {
|
|
|
|
| 23 |
label: "Front Profile",
|
| 24 |
hint: "Look straight into the camera with a neutral expression.",
|
| 25 |
speech: "Please look straight into the camera.",
|
| 26 |
+
icon: User
|
| 27 |
},
|
| 28 |
left: {
|
| 29 |
label: "Left Profile",
|
| 30 |
hint: "Turn your head slowly to the left.",
|
| 31 |
speech: "Please turn your head to the left.",
|
| 32 |
+
icon: ArrowLeft
|
| 33 |
},
|
| 34 |
right: {
|
| 35 |
label: "Right Profile",
|
| 36 |
hint: "Turn your head slowly to the right.",
|
| 37 |
speech: "Please turn your head to the right.",
|
| 38 |
+
icon: ArrowRight
|
| 39 |
},
|
| 40 |
up: {
|
| 41 |
label: "Looking Up",
|
| 42 |
hint: "Tilt your chin upwards slightly.",
|
| 43 |
speech: "Please tilt your head upwards.",
|
| 44 |
+
icon: ArrowUp
|
| 45 |
},
|
| 46 |
down: {
|
| 47 |
label: "Looking Down",
|
| 48 |
hint: "Tilt your chin downwards slightly.",
|
| 49 |
speech: "Please tilt your head downwards.",
|
| 50 |
+
icon: ArrowDown
|
| 51 |
},
|
| 52 |
smile: {
|
| 53 |
label: "Smiling Face",
|
| 54 |
hint: "Give a natural, relaxed smile.",
|
| 55 |
speech: "Now, smile naturally.",
|
| 56 |
+
icon: Smile
|
| 57 |
},
|
| 58 |
neutral: {
|
| 59 |
label: "Neutral Face",
|
| 60 |
hint: "Keep a relaxed, standard neutral expression.",
|
| 61 |
speech: "Relax your face, show a neutral expression.",
|
| 62 |
+
icon: Meh
|
| 63 |
},
|
| 64 |
indoor: {
|
| 65 |
label: "Indoor Light",
|
| 66 |
hint: "Look straight with standard indoor room lighting.",
|
| 67 |
speech: "Look straight for typical indoor lighting.",
|
| 68 |
+
icon: Lightbulb
|
| 69 |
},
|
| 70 |
outdoor: {
|
| 71 |
label: "Outdoor Light",
|
| 72 |
hint: "Look straight with bright/outdoor lighting.",
|
| 73 |
speech: "Look straight for bright light capture.",
|
| 74 |
+
icon: Sun
|
| 75 |
},
|
| 76 |
glasses: {
|
| 77 |
label: "Glasses Option",
|
| 78 |
hint: "Put on glasses if you wear them, otherwise look straight.",
|
| 79 |
speech: "If you wear glasses, put them on. Otherwise, look straight.",
|
| 80 |
+
icon: Glasses
|
| 81 |
}
|
| 82 |
};
|
| 83 |
|
|
|
|
| 111 |
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
| 112 |
const [webcamActive, setWebcamActive] = useState(false);
|
| 113 |
const [singleRetakePose, setSingleRetakePose] = useState<string | null>(null);
|
| 114 |
+
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
| 115 |
|
| 116 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 117 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
| 251 |
}
|
| 252 |
|
| 253 |
countdownIntervalRef.current = setInterval(() => {
|
| 254 |
+
if (countdown <= 1) {
|
| 255 |
+
if (countdownIntervalRef.current) {
|
| 256 |
+
clearInterval(countdownIntervalRef.current);
|
| 257 |
+
countdownIntervalRef.current = null;
|
|
|
|
| 258 |
}
|
| 259 |
+
captureFrame(currentKey);
|
| 260 |
+
} else {
|
| 261 |
playSound("beep");
|
| 262 |
+
setCountdown(countdown - 1);
|
| 263 |
+
}
|
| 264 |
}, 1000);
|
| 265 |
|
| 266 |
return () => {
|
|
|
|
| 307 |
setSingleRetakePose(poseKey);
|
| 308 |
setSelectedPose(poseKey);
|
| 309 |
setCountdown(3);
|
| 310 |
+
setIsPaused(false);
|
| 311 |
setCaptureState("capturing");
|
| 312 |
|
| 313 |
// Set index to match keys
|
|
|
|
| 432 |
border-width: 2px;
|
| 433 |
pointer-events: none;
|
| 434 |
}
|
| 435 |
+
@keyframes draw-check {
|
| 436 |
+
0% { stroke-dashoffset: 48; }
|
| 437 |
+
100% { stroke-dashoffset: 0; }
|
| 438 |
+
}
|
| 439 |
+
@keyframes scale-up {
|
| 440 |
+
0% { transform: scale(0); opacity: 0; }
|
| 441 |
+
100% { transform: scale(1); opacity: 1; }
|
| 442 |
+
}
|
| 443 |
+
@keyframes success-glowing {
|
| 444 |
+
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
| 445 |
+
70% { box-shadow: 0 0 0 15px rgba(16, 185, 129, 0); }
|
| 446 |
+
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
| 447 |
+
}
|
| 448 |
+
.success-circle {
|
| 449 |
+
animation: scale-up 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards, success-glowing 2s infinite;
|
| 450 |
+
}
|
| 451 |
+
.success-check {
|
| 452 |
+
stroke-dasharray: 48;
|
| 453 |
+
stroke-dashoffset: 48;
|
| 454 |
+
animation: draw-check 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.3s forwards;
|
| 455 |
+
}
|
| 456 |
`}</style>
|
| 457 |
|
| 458 |
<canvas ref={canvasRef} className="hidden" />
|
|
|
|
| 484 |
|
| 485 |
{enrolledCount > 0 && (
|
| 486 |
<button
|
| 487 |
+
onClick={() => setShowClearConfirm(true)}
|
| 488 |
className="flex items-center gap-2 text-[11.5px] font-bold text-rose-600 hover:text-white bg-white hover:bg-rose-600 border border-rose-200 hover:border-rose-600 px-4 py-2 rounded-xl transition-all cursor-pointer shadow-sm"
|
| 489 |
>
|
| 490 |
<Trash2 className="w-3.5 h-3.5" />
|
|
|
|
| 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) => {
|
| 581 |
const done = status?.enrolled_poses?.some((p: string) => p.toLowerCase() === key.toLowerCase());
|
| 582 |
+
const Icon = POSES[key].icon;
|
| 583 |
return (
|
| 584 |
<div
|
| 585 |
key={key}
|
| 586 |
+
className={`p-4 rounded-2xl border flex flex-col items-center text-center justify-center transition-all hover:scale-[1.03] duration-200 select-none ${
|
| 587 |
done
|
| 588 |
+
? "bg-emerald-500/[0.03] border-emerald-500/20 text-emerald-800 shadow-[0_2px_12px_rgba(16,185,129,0.02)]"
|
| 589 |
+
: "bg-slate-50/50 border-slate-200/80 hover:border-slate-350"
|
| 590 |
}`}
|
| 591 |
>
|
| 592 |
+
<div className={`w-9 h-9 rounded-xl flex items-center justify-center mb-2.5 transition-all ${
|
| 593 |
+
done
|
| 594 |
+
? "bg-emerald-500/10 text-emerald-600"
|
| 595 |
+
: "bg-slate-100 text-slate-400"
|
| 596 |
+
}`}>
|
| 597 |
+
<Icon className="w-4.5 h-4.5" />
|
| 598 |
+
</div>
|
| 599 |
+
<span className={`text-[10px] font-bold uppercase tracking-wider font-mono ${done ? "text-emerald-800" : "text-slate-505"}`}>
|
| 600 |
+
{POSES[key].label.replace(" Profile", "").replace(" Face", "").replace(" Option", "").replace(" Light", "")}
|
| 601 |
+
</span>
|
| 602 |
{done ? (
|
| 603 |
+
<CheckCircle2 className="w-4 h-4 text-emerald-500 mt-3 shrink-0" />
|
| 604 |
) : (
|
| 605 |
+
<div className="w-4 h-4 rounded-full border-2 border-slate-200 mt-3 shrink-0 bg-white" />
|
| 606 |
)}
|
| 607 |
</div>
|
| 608 |
);
|
|
|
|
| 619 |
<div className="w-full bg-slate-950 text-white rounded-2xl p-5 flex items-center justify-between shadow-lg relative overflow-hidden">
|
| 620 |
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] pointer-events-none" />
|
| 621 |
<div className="flex items-center gap-4 relative z-10">
|
| 622 |
+
{(() => {
|
| 623 |
+
const Icon = POSES[POSE_KEYS[currentPoseIndex]]?.icon;
|
| 624 |
+
return (
|
| 625 |
+
<div className="w-12 h-12 rounded-xl bg-white/10 flex items-center justify-center shrink-0 border border-white/10 text-cyan-400">
|
| 626 |
+
{Icon && <Icon className="w-6 h-6" />}
|
| 627 |
+
</div>
|
| 628 |
+
);
|
| 629 |
+
})()}
|
| 630 |
<div>
|
| 631 |
<p className="text-[10px] font-bold text-cyan-400 font-mono tracking-widest uppercase">
|
| 632 |
SCAN PHASE {currentPoseIndex + 1} OF {POSE_KEYS.length}
|
|
|
|
| 652 |
{/* Native video element */}
|
| 653 |
<video
|
| 654 |
ref={videoRef}
|
| 655 |
+
className={`w-full h-full object-cover scale-x-[-1] transition-opacity duration-300 ${isPaused ? "opacity-20" : "opacity-100"}`}
|
| 656 |
autoPlay playsInline muted
|
| 657 |
/>
|
| 658 |
|
| 659 |
+
{/* Paused Overlay */}
|
| 660 |
+
{isPaused && (
|
| 661 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm z-20 transition-all duration-300">
|
| 662 |
+
<div className="w-16 h-16 rounded-2xl bg-white/10 border border-white/20 flex items-center justify-center shadow-lg text-white mb-3 animate-pulse">
|
| 663 |
+
<Pause className="w-8 h-8 fill-current text-cyan-400" />
|
| 664 |
+
</div>
|
| 665 |
+
<p className="text-[11px] font-bold text-cyan-400 font-mono tracking-widest uppercase">
|
| 666 |
+
SYS.STATUS: SCAN_PAUSED
|
| 667 |
+
</p>
|
| 668 |
+
<p className="text-xs text-slate-300 font-medium mt-1">
|
| 669 |
+
Camera turned off to preserve resources & privacy.
|
| 670 |
+
</p>
|
| 671 |
+
</div>
|
| 672 |
+
)}
|
| 673 |
+
|
| 674 |
{/* Cybernetic HUD elements */}
|
| 675 |
+
{!isPaused && <div className="scanner-line" />}
|
| 676 |
+
{!isPaused && (
|
| 677 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
| 678 |
+
<div className="scanner-target">
|
| 679 |
+
<div className="w-4 h-4 border border-cyan-400 rounded-full animate-ping" />
|
| 680 |
+
</div>
|
| 681 |
</div>
|
| 682 |
+
)}
|
| 683 |
|
| 684 |
{/* HUD corners */}
|
| 685 |
<div className="hud-corner corner-bracket-tl top-6 left-6 border-t-2 border-l-2" />
|
|
|
|
| 689 |
|
| 690 |
{/* Scanner stats HUD */}
|
| 691 |
<div className="absolute top-6 left-12 right-12 flex justify-between text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none uppercase">
|
| 692 |
+
<span>SYS.STATUS: {isPaused ? "SCAN_PAUSED" : "ACQUIRING_DATA"}</span>
|
| 693 |
+
<span>FPS: {isPaused ? "0" : "60"} · ISO: 200 · SHUTTER: AUTO</span>
|
| 694 |
</div>
|
| 695 |
|
| 696 |
<div className="absolute bottom-6 left-12 right-12 flex justify-between items-center text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none">
|
| 697 |
<span>ANGLE: {POSE_KEYS[currentPoseIndex]?.toUpperCase()}</span>
|
| 698 |
+
<span>LIVENESS CHECK: {isPaused ? "INACTIVE" : "ACTIVE"}</span>
|
| 699 |
</div>
|
| 700 |
</div>
|
| 701 |
|
| 702 |
{/* Controls */}
|
| 703 |
<div className="flex gap-4 w-full max-w-lg">
|
| 704 |
<button
|
| 705 |
+
onClick={async () => {
|
| 706 |
+
if (isPaused) {
|
| 707 |
+
setIsPaused(false);
|
| 708 |
+
await startWebcam();
|
| 709 |
+
} else {
|
| 710 |
+
setIsPaused(true);
|
| 711 |
+
stopWebcam();
|
| 712 |
+
}
|
| 713 |
+
}}
|
| 714 |
className="flex-1 h-11 bg-white hover:bg-slate-50 border border-slate-250 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
|
| 715 |
>
|
| 716 |
{isPaused ? <Play className="w-3.5 h-3.5 fill-current" /> : <Pause className="w-3.5 h-3.5 fill-current" />}
|
|
|
|
| 718 |
</button>
|
| 719 |
|
| 720 |
<button
|
| 721 |
+
onClick={async () => {
|
| 722 |
if (singleRetakePose) {
|
| 723 |
stopWebcam();
|
| 724 |
setSingleRetakePose(null);
|
|
|
|
| 727 |
// Skip pose
|
| 728 |
setCurrentPoseIndex(prev => prev + 1);
|
| 729 |
setCountdown(3);
|
| 730 |
+
if (isPaused) {
|
| 731 |
+
setIsPaused(false);
|
| 732 |
+
await startWebcam();
|
| 733 |
+
}
|
| 734 |
}
|
| 735 |
}}
|
| 736 |
className="flex-1 h-11 bg-slate-950 hover:bg-slate-900 border border-slate-950 text-white font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-md cursor-pointer"
|
|
|
|
| 780 |
</div>
|
| 781 |
|
| 782 |
<div className="mt-2.5 flex items-center justify-between text-[11px] font-bold">
|
| 783 |
+
<span className="text-slate-900 uppercase font-mono">{POSES[key].label.replace(" Profile", "").replace(" Face", "").replace(" Option", "").replace(" Light", "")}</span>
|
| 784 |
{imgUrl ? (
|
| 785 |
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
|
| 786 |
) : (
|
|
|
|
| 836 |
<div className="space-y-1.5">
|
| 837 |
<div className="relative h-2 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
| 838 |
<div
|
| 839 |
+
className="absolute inset-y-0 left-0 bg-gradient-to-r from-cyan-400 to-cyan-500 transition-all duration-300"
|
| 840 |
style={{ width: `${uploadProgress}%` }}
|
| 841 |
/>
|
| 842 |
</div>
|
|
|
|
| 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">
|
|
|
|
| 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>
|
|
|
|
| 895 |
</div>
|
| 896 |
)}
|
| 897 |
</div>
|
| 898 |
+
|
| 899 |
+
{/* Clear Biometric Confirmation Modal */}
|
| 900 |
+
{showClearConfirm && (
|
| 901 |
+
<div className="modal-backdrop z-50">
|
| 902 |
+
<div className="modal-content max-w-sm border border-red-500/10 shadow-[0_12px_40px_rgba(239,68,68,0.12)]">
|
| 903 |
+
<div className="flex flex-col items-center text-center p-2 space-y-4">
|
| 904 |
+
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/15 flex items-center justify-center text-rose-500 shadow-inner">
|
| 905 |
+
<Trash2 className="w-6 h-6" />
|
| 906 |
+
</div>
|
| 907 |
+
<div>
|
| 908 |
+
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Clear Biometric Data</h3>
|
| 909 |
+
<p className="text-xs text-slate-500 mt-2 leading-relaxed">
|
| 910 |
+
Wipe all registered biometric vectors and images for <span className="font-semibold text-[var(--text-primary)]">{employee?.name}</span>?
|
| 911 |
+
</p>
|
| 912 |
+
<p className="text-[10.5px] text-rose-500 font-medium bg-rose-500/5 border border-rose-500/10 rounded-xl p-2.5 mt-3 leading-normal">
|
| 913 |
+
Warning: This cannot be undone and the user will not be able to log in or register attendance at the kiosk until re-enrolled.
|
| 914 |
+
</p>
|
| 915 |
+
</div>
|
| 916 |
+
<div className="flex gap-2.5 w-full pt-2 border-t border-white/5">
|
| 917 |
+
<button
|
| 918 |
+
type="button"
|
| 919 |
+
onClick={() => setShowClearConfirm(false)}
|
| 920 |
+
className="flex-1 btn-ghost h-9.5 text-[12px] rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 921 |
+
>
|
| 922 |
+
Cancel
|
| 923 |
+
</button>
|
| 924 |
+
<button
|
| 925 |
+
type="button"
|
| 926 |
+
onClick={() => {
|
| 927 |
+
clearMutation.mutate();
|
| 928 |
+
setShowClearConfirm(false);
|
| 929 |
+
}}
|
| 930 |
+
disabled={clearMutation.isPending}
|
| 931 |
+
className="flex-1 bg-rose-600 hover:bg-rose-500 active:bg-rose-700 text-white font-bold text-[12px] rounded-xl cursor-pointer h-9.5 flex items-center justify-center gap-2 shadow-md shadow-rose-950/20 border border-rose-500/20"
|
| 932 |
+
>
|
| 933 |
+
{clearMutation.isPending ? (
|
| 934 |
+
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 935 |
+
) : (
|
| 936 |
+
"Wipe Data"
|
| 937 |
+
)}
|
| 938 |
+
</button>
|
| 939 |
+
</div>
|
| 940 |
+
</div>
|
| 941 |
+
</div>
|
| 942 |
+
</div>
|
| 943 |
+
)}
|
| 944 |
</SidebarLayout>
|
| 945 |
);
|
| 946 |
}
|
frontend/app/globals.css
CHANGED
|
@@ -7,32 +7,32 @@
|
|
| 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.
|
| 11 |
-
|
| 12 |
-
--accent-primary: #
|
| 13 |
-
--accent-cyan: #
|
| 14 |
-
--accent-indigo: #
|
| 15 |
-
--accent-emerald: #
|
| 16 |
-
--accent-amber: #
|
| 17 |
-
--accent-rose: #
|
| 18 |
-
|
| 19 |
-
--border-subtle: #
|
| 20 |
-
--border-medium: #
|
| 21 |
-
--border-strong: #
|
| 22 |
-
|
| 23 |
-
--text-primary: #
|
| 24 |
-
--text-secondary: #
|
| 25 |
-
--text-muted: #71717a; /* Zinc 500
|
| 26 |
-
--text-faint: #a1a1aa; /* Zinc 400
|
| 27 |
-
|
| 28 |
-
--glow-blue:
|
| 29 |
-
--glow-cyan:
|
| 30 |
-
--radius-sm:
|
| 31 |
-
--radius-md:
|
| 32 |
-
--radius-lg:
|
| 33 |
-
--radius-xl:
|
| 34 |
-
--radius-2xl:
|
| 35 |
-
--radius-3xl:
|
| 36 |
}
|
| 37 |
|
| 38 |
/* ─── Base Reset ─── */
|
|
@@ -91,15 +91,14 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 91 |
.glass-card {
|
| 92 |
background: #ffffff;
|
| 93 |
border: 1px solid var(--border-medium);
|
| 94 |
-
box-shadow:
|
| 95 |
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 96 |
}
|
| 97 |
|
| 98 |
.glass-card:hover {
|
| 99 |
border-color: var(--border-strong);
|
| 100 |
background: #ffffff;
|
| 101 |
-
|
| 102 |
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
| 103 |
}
|
| 104 |
|
| 105 |
/* ─── Card Glow Variants (Clean zinc shadows) ─── */
|
|
@@ -125,14 +124,13 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 125 |
.btn-primary {
|
| 126 |
background: var(--text-primary);
|
| 127 |
color: white;
|
| 128 |
-
font-weight:
|
| 129 |
border: 1px solid var(--text-primary);
|
| 130 |
-
border-radius: var(--radius-
|
| 131 |
-
padding:
|
| 132 |
-
font-size:
|
| 133 |
cursor: pointer;
|
| 134 |
-
transition: all 0.
|
| 135 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 136 |
position: relative;
|
| 137 |
overflow: hidden;
|
| 138 |
}
|
|
@@ -140,26 +138,24 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 140 |
.btn-primary:hover {
|
| 141 |
background: #27272a;
|
| 142 |
border-color: #27272a;
|
| 143 |
-
transform: translateY(-0.5px);
|
| 144 |
-
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
|
| 145 |
}
|
| 146 |
-
.btn-primary:active { transform:
|
| 147 |
-
.btn-primary:disabled { background: #
|
| 148 |
|
| 149 |
.btn-ghost {
|
| 150 |
background: #ffffff;
|
| 151 |
color: var(--text-secondary);
|
| 152 |
border: 1px solid var(--border-medium);
|
| 153 |
-
border-radius: var(--radius-
|
| 154 |
-
padding:
|
| 155 |
-
font-size:
|
| 156 |
font-weight: 500;
|
| 157 |
cursor: pointer;
|
| 158 |
-
transition: all 0.
|
| 159 |
}
|
| 160 |
|
| 161 |
.btn-ghost:hover {
|
| 162 |
-
background: #
|
| 163 |
border-color: var(--border-strong);
|
| 164 |
color: var(--text-primary);
|
| 165 |
}
|
|
@@ -170,12 +166,12 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 170 |
background: #ffffff;
|
| 171 |
border: 1px solid var(--border-medium);
|
| 172 |
border-radius: var(--radius-md);
|
| 173 |
-
padding: 0
|
| 174 |
-
height:
|
| 175 |
color: var(--text-primary);
|
| 176 |
-
font-size:
|
| 177 |
font-family: inherit;
|
| 178 |
-
transition: all 0.
|
| 179 |
outline: none;
|
| 180 |
}
|
| 181 |
|
|
@@ -184,7 +180,6 @@ h1, h2, h3, h4, h5, h6 {
|
|
| 184 |
.input-field:focus {
|
| 185 |
border-color: var(--text-primary);
|
| 186 |
background: #ffffff;
|
| 187 |
-
box-shadow: 0 0 0 3px rgba(24, 24, 27, 0.05);
|
| 188 |
}
|
| 189 |
|
| 190 |
select.input-field option {
|
|
@@ -254,21 +249,41 @@ select.input-field option {
|
|
| 254 |
margin: 0;
|
| 255 |
}
|
| 256 |
|
| 257 |
-
/* ─── Ambient Background Effects (
|
| 258 |
-
.ambient-bg
|
|
|
|
| 259 |
position: fixed;
|
| 260 |
inset: 0;
|
| 261 |
pointer-events: none;
|
| 262 |
z-index: 0;
|
| 263 |
-
background:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}
|
| 265 |
|
| 266 |
-
/* ─── Grid Mesh Background (Minimalist) ─── */
|
| 267 |
.mesh-bg {
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
}
|
| 273 |
|
| 274 |
/* ─── Kiosk Scanner Laser ─── */
|
|
@@ -362,30 +377,31 @@ select.input-field option {
|
|
| 362 |
}
|
| 363 |
|
| 364 |
.data-table thead th {
|
| 365 |
-
padding:
|
| 366 |
-
font-size:
|
| 367 |
-
font-weight:
|
| 368 |
text-transform: uppercase;
|
| 369 |
-
letter-spacing: 0.
|
| 370 |
-
color: var(--text-
|
| 371 |
-
font-family:
|
| 372 |
-
background: #
|
|
|
|
| 373 |
}
|
| 374 |
|
| 375 |
.data-table tbody tr {
|
| 376 |
-
border-bottom: 1px solid
|
| 377 |
-
transition: background 0.
|
| 378 |
}
|
| 379 |
|
| 380 |
.data-table tbody tr:last-child { border-bottom: none; }
|
| 381 |
|
| 382 |
.data-table tbody tr:hover {
|
| 383 |
-
background: #
|
| 384 |
}
|
| 385 |
|
| 386 |
.data-table tbody td {
|
| 387 |
-
padding:
|
| 388 |
-
font-size:
|
| 389 |
color: var(--text-secondary);
|
| 390 |
}
|
| 391 |
|
|
@@ -557,3 +573,297 @@ select.input-field option {
|
|
| 557 |
animation: laserFlicker 1.5s ease-in-out infinite;
|
| 558 |
}
|
| 559 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ─── */
|
|
|
|
| 91 |
.glass-card {
|
| 92 |
background: #ffffff;
|
| 93 |
border: 1px solid var(--border-medium);
|
| 94 |
+
box-shadow: none;
|
| 95 |
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 96 |
}
|
| 97 |
|
| 98 |
.glass-card:hover {
|
| 99 |
border-color: var(--border-strong);
|
| 100 |
background: #ffffff;
|
| 101 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.015);
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
/* ─── Card Glow Variants (Clean zinc shadows) ─── */
|
|
|
|
| 124 |
.btn-primary {
|
| 125 |
background: var(--text-primary);
|
| 126 |
color: white;
|
| 127 |
+
font-weight: 500;
|
| 128 |
border: 1px solid var(--text-primary);
|
| 129 |
+
border-radius: var(--radius-md);
|
| 130 |
+
padding: 8px 16px;
|
| 131 |
+
font-size: 12px;
|
| 132 |
cursor: pointer;
|
| 133 |
+
transition: all 0.12s ease;
|
|
|
|
| 134 |
position: relative;
|
| 135 |
overflow: hidden;
|
| 136 |
}
|
|
|
|
| 138 |
.btn-primary:hover {
|
| 139 |
background: #27272a;
|
| 140 |
border-color: #27272a;
|
|
|
|
|
|
|
| 141 |
}
|
| 142 |
+
.btn-primary:active { transform: scale(0.98); }
|
| 143 |
+
.btn-primary:disabled { background: #f4f4f5; border-color: #f4f4f5; color: #a1a1aa; cursor: not-allowed; transform: none; box-shadow: none; }
|
| 144 |
|
| 145 |
.btn-ghost {
|
| 146 |
background: #ffffff;
|
| 147 |
color: var(--text-secondary);
|
| 148 |
border: 1px solid var(--border-medium);
|
| 149 |
+
border-radius: var(--radius-md);
|
| 150 |
+
padding: 8px 16px;
|
| 151 |
+
font-size: 12px;
|
| 152 |
font-weight: 500;
|
| 153 |
cursor: pointer;
|
| 154 |
+
transition: all 0.12s ease;
|
| 155 |
}
|
| 156 |
|
| 157 |
.btn-ghost:hover {
|
| 158 |
+
background: #fafafa;
|
| 159 |
border-color: var(--border-strong);
|
| 160 |
color: var(--text-primary);
|
| 161 |
}
|
|
|
|
| 166 |
background: #ffffff;
|
| 167 |
border: 1px solid var(--border-medium);
|
| 168 |
border-radius: var(--radius-md);
|
| 169 |
+
padding: 0 12px;
|
| 170 |
+
height: 36px;
|
| 171 |
color: var(--text-primary);
|
| 172 |
+
font-size: 12.5px;
|
| 173 |
font-family: inherit;
|
| 174 |
+
transition: all 0.12s ease;
|
| 175 |
outline: none;
|
| 176 |
}
|
| 177 |
|
|
|
|
| 180 |
.input-field:focus {
|
| 181 |
border-color: var(--text-primary);
|
| 182 |
background: #ffffff;
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
select.input-field option {
|
|
|
|
| 249 |
margin: 0;
|
| 250 |
}
|
| 251 |
|
| 252 |
+
/* ─── Ambient Background Effects (Flowing Cyber Wave & Tech Grid) ─── */
|
| 253 |
+
.ambient-bg,
|
| 254 |
+
.mesh-bg {
|
| 255 |
position: fixed;
|
| 256 |
inset: 0;
|
| 257 |
pointer-events: none;
|
| 258 |
z-index: 0;
|
| 259 |
+
background-color: var(--bg-base);
|
| 260 |
+
background-image:
|
| 261 |
+
radial-gradient(circle at 50% 50%, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 40%, transparent 70%),
|
| 262 |
+
radial-gradient(circle at 50% 50%, transparent 30%, var(--bg-base) 85%),
|
| 263 |
+
linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
| 264 |
+
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
| 265 |
+
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 600' width='100%' height='100%'><defs><filter id='glow-light' x='-50%' y='-50%' width='200%' height='200%'><feGaussianBlur in='SourceGraphic' stdDeviation='4' result='blur'/><feMerge><feMergeNode in='blur'/><feMergeNode in='SourceGraphic'/></feMerge></filter><linearGradient id='grad-cyan-light' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%2306b6d4' stop-opacity='0.15'/><stop offset='100%' stop-color='%2306b6d4' stop-opacity='0'/></linearGradient><linearGradient id='grad-indigo-light' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%236366f1' stop-opacity='0.1'/><stop offset='100%' stop-color='%236366f1' stop-opacity='0'/></linearGradient></defs><style>.wave-1{animation:flow-1 38s linear infinite, bob-1 12s ease-in-out infinite alternate}.wave-2{animation:flow-2 28s linear infinite, bob-2 8s ease-in-out infinite alternate}.wave-3{animation:flow-3 22s linear infinite, bob-3 10s ease-in-out infinite alternate}.wave-4{animation:flow-4 16s linear infinite, bob-4 6s ease-in-out infinite alternate}@keyframes flow-1{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-2{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes flow-3{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-4{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes bob-1{0%{transform:translateY(-10px)}100%{transform:translateY(10px)}}@keyframes bob-2{0%{transform:translateY(8px)}100%{transform:translateY(-8px)}}@keyframes bob-3{0%{transform:translateY(-6px)}100%{transform:translateY(6px)}}@keyframes bob-4{0%{transform:translateY(5px)}100%{transform:translateY(-5px)}}</style><path class='wave-1' fill='url(%23grad-cyan-light)' d='M-1440,320 C-1080,240 -720,400 -360,320 C0,240 360,400 720,320 C1080,240 1440,400 1800,320 C2160,240 2520,400 2880,320 L2880,600 L-1440,600 Z'/><path class='wave-2' fill='url(%23grad-indigo-light)' d='M-1440,340 C-1080,440 -720,240 -360,340 C0,440 360,240 720,340 C1080,440 1440,240 1800,340 C2160,440 2520,240 2880,340 L2880,600 L-1440,600 Z'/><path class='wave-3' stroke='%2306b6d4' stroke-width='1.5' fill='none' opacity='0.45' filter='url(%23glow-light)' d='M-1440,310 C-1080,250 -720,370 -360,310 C0,250 360,370 720,310 C1080,250 1440,370 1800,310 C2160,250 2520,370 2880,310'/><path class='wave-4' stroke='%236366f1' stroke-width='1.25' fill='none' opacity='0.35' d='M-1440,330 C-1080,380 -720,280 -360,330 C0,380 360,280 720,330 C1080,380 1440,280 1800,330 C2160,380 2520,280 2880,330'/><path class='wave-3' stroke='%233b82f6' stroke-width='0.75' fill='none' opacity='0.5' d='M-1440,300 C-1080,220 -720,380 -360,300 C0,220 360,380 720,300 C1080,220 1440,380 1800,300 C2160,220 2520,380 2880,300'/></svg>");
|
| 266 |
+
background-size: cover, cover, 80px 80px, 80px 80px, cover;
|
| 267 |
+
background-position: center;
|
| 268 |
+
background-repeat: no-repeat, no-repeat, repeat, repeat, no-repeat;
|
| 269 |
}
|
| 270 |
|
|
|
|
| 271 |
.mesh-bg {
|
| 272 |
+
position: absolute;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/* Translucent Glass Overlay for Cards and Modals */
|
| 276 |
+
.glass-overlay {
|
| 277 |
+
background-color: rgba(255, 255, 255, 0.45) !important;
|
| 278 |
+
backdrop-filter: blur(12px);
|
| 279 |
+
-webkit-backdrop-filter: blur(12px);
|
| 280 |
+
border: 1px solid rgba(228, 228, 231, 0.6) !important;
|
| 281 |
+
}
|
| 282 |
+
.dark .glass-overlay {
|
| 283 |
+
background-color: rgba(9, 9, 11, 0.45) !important;
|
| 284 |
+
backdrop-filter: blur(12px);
|
| 285 |
+
-webkit-backdrop-filter: blur(12px);
|
| 286 |
+
border: 1px solid rgba(39, 39, 42, 0.4) !important;
|
| 287 |
}
|
| 288 |
|
| 289 |
/* ─── Kiosk Scanner Laser ─── */
|
|
|
|
| 377 |
}
|
| 378 |
|
| 379 |
.data-table thead th {
|
| 380 |
+
padding: 10px 16px;
|
| 381 |
+
font-size: 9.5px;
|
| 382 |
+
font-weight: 500;
|
| 383 |
text-transform: uppercase;
|
| 384 |
+
letter-spacing: 0.05em;
|
| 385 |
+
color: var(--text-muted);
|
| 386 |
+
font-family: var(--font-inter), sans-serif;
|
| 387 |
+
background: #ffffff;
|
| 388 |
+
border-bottom: 1px solid var(--border-medium);
|
| 389 |
}
|
| 390 |
|
| 391 |
.data-table tbody tr {
|
| 392 |
+
border-bottom: 1px solid var(--border-medium);
|
| 393 |
+
transition: background 0.12s ease;
|
| 394 |
}
|
| 395 |
|
| 396 |
.data-table tbody tr:last-child { border-bottom: none; }
|
| 397 |
|
| 398 |
.data-table tbody tr:hover {
|
| 399 |
+
background: #fafafa;
|
| 400 |
}
|
| 401 |
|
| 402 |
.data-table tbody td {
|
| 403 |
+
padding: 12px 16px;
|
| 404 |
+
font-size: 12px;
|
| 405 |
color: var(--text-secondary);
|
| 406 |
}
|
| 407 |
|
|
|
|
| 573 |
animation: laserFlicker 1.5s ease-in-out infinite;
|
| 574 |
}
|
| 575 |
|
| 576 |
+
/* ─── Dark Theme Overrides ─── */
|
| 577 |
+
.dark {
|
| 578 |
+
--bg-base: #09090b; /* Zinc 950 */
|
| 579 |
+
--bg-surface: #09090b; /* Zinc 950 */
|
| 580 |
+
--bg-elevated: #18181b; /* Zinc 900 */
|
| 581 |
+
--bg-overlay: rgba(9, 9, 11, 0.98);
|
| 582 |
+
|
| 583 |
+
--accent-primary: #ffffff;
|
| 584 |
+
--accent-cyan: #22d3ee;
|
| 585 |
+
--accent-indigo: #818cf8;
|
| 586 |
+
--accent-emerald: #34d399;
|
| 587 |
+
--accent-amber: #fbbf24;
|
| 588 |
+
--accent-rose: #f87171;
|
| 589 |
+
|
| 590 |
+
--border-subtle: #18181b; /* Zinc 900 */
|
| 591 |
+
--border-medium: #27272a; /* Zinc 800 */
|
| 592 |
+
--border-strong: #3f3f46; /* Zinc 700 */
|
| 593 |
+
|
| 594 |
+
--text-primary: #f4f4f5; /* Zinc 100 */
|
| 595 |
+
--text-secondary: #e4e4e7; /* Zinc 200 */
|
| 596 |
+
--text-muted: #a1a1aa; /* Zinc 400 */
|
| 597 |
+
--text-faint: #71717a; /* Zinc 500 */
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
/* Dynamic dark mode utility overrides for hardcoded Tailwind classes */
|
| 601 |
+
.dark .bg-white {
|
| 602 |
+
background-color: #18181b !important;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.dark .border-zinc-100,
|
| 606 |
+
.dark .border-slate-100,
|
| 607 |
+
.dark .border-slate-200,
|
| 608 |
+
.dark .border-slate-250 {
|
| 609 |
+
border-color: #27272a !important;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.dark .text-slate-900,
|
| 613 |
+
.dark .text-slate-800,
|
| 614 |
+
.dark .text-zinc-950,
|
| 615 |
+
.dark .text-zinc-900,
|
| 616 |
+
.dark .text-zinc-850,
|
| 617 |
+
.dark .text-zinc-805,
|
| 618 |
+
.dark .text-zinc-800,
|
| 619 |
+
.dark .text-zinc-755,
|
| 620 |
+
.dark .text-zinc-750 {
|
| 621 |
+
color: #f4f4f5 !important; /* Zinc 100 */
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.dark .text-slate-555,
|
| 625 |
+
.dark .text-slate-550,
|
| 626 |
+
.dark .text-slate-500,
|
| 627 |
+
.dark .text-slate-450,
|
| 628 |
+
.dark .text-slate-400,
|
| 629 |
+
.dark .text-zinc-555,
|
| 630 |
+
.dark .text-zinc-550,
|
| 631 |
+
.dark .text-zinc-500,
|
| 632 |
+
.dark .text-zinc-450,
|
| 633 |
+
.dark .text-zinc-400 {
|
| 634 |
+
color: #a1a1aa !important; /* Zinc 400 */
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.dark .text-slate-650,
|
| 638 |
+
.dark .text-slate-600,
|
| 639 |
+
.dark .text-slate-700,
|
| 640 |
+
.dark .text-zinc-650,
|
| 641 |
+
.dark .text-zinc-605,
|
| 642 |
+
.dark .text-zinc-600,
|
| 643 |
+
.dark .text-zinc-700 {
|
| 644 |
+
color: #d4d4d8 !important; /* Zinc 300 */
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.dark .bg-zinc-50,
|
| 648 |
+
.dark .bg-zinc-55,
|
| 649 |
+
.dark .bg-slate-50,
|
| 650 |
+
.dark .bg-slate-100 {
|
| 651 |
+
background-color: #27272a !important;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.dark .bg-zinc-100 {
|
| 655 |
+
background-color: #18181b !important;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.dark .border-zinc-200 {
|
| 659 |
+
border-color: #3f3f46 !important;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
.dark .hover\:bg-zinc-50:hover,
|
| 663 |
+
.dark .hover\:bg-zinc-100:hover,
|
| 664 |
+
.dark .hover\:bg-slate-50:hover,
|
| 665 |
+
.dark .hover\:bg-slate-100:hover {
|
| 666 |
+
background-color: #27272a !important;
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
.dark .hover\:bg-white\/\[0\.015\]:hover {
|
| 670 |
+
background-color: rgba(255, 255, 255, 0.03) !important;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.dark .hover\:border-zinc-205:hover,
|
| 674 |
+
.dark .hover\:border-zinc-200:hover,
|
| 675 |
+
.dark .hover\:border-zinc-300:hover,
|
| 676 |
+
.dark .hover\:border-slate-200:hover,
|
| 677 |
+
.dark .hover\:border-slate-300:hover {
|
| 678 |
+
border-color: #3f3f46 !important;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
/* Component overrides */
|
| 682 |
+
.dark .glass,
|
| 683 |
+
.dark .glass-panel,
|
| 684 |
+
.dark .glass-card {
|
| 685 |
+
background: #18181b;
|
| 686 |
+
border-color: #27272a;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.dark .data-table thead th {
|
| 690 |
+
background: #18181b;
|
| 691 |
+
border-color: #27272a;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.dark .data-table tbody tr {
|
| 695 |
+
border-color: #27272a;
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
.dark .data-table tbody tr:hover {
|
| 699 |
+
background: #27272a !important;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.dark .modal-content {
|
| 703 |
+
background: #18181b;
|
| 704 |
+
border-color: #27272a;
|
| 705 |
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.dark .input-field {
|
| 709 |
+
background: #18181b !important;
|
| 710 |
+
color: #f4f4f5 !important;
|
| 711 |
+
border-color: #27272a !important;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.dark .input-field:focus {
|
| 715 |
+
border-color: #ffffff !important;
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
.dark select.input-field option {
|
| 719 |
+
background: #18181b;
|
| 720 |
+
color: #f4f4f5;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.dark .bg-zinc-50\/30 {
|
| 724 |
+
background-color: rgba(39, 39, 42, 0.3) !important;
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
.dark .border-zinc-150 {
|
| 728 |
+
border-color: #27272a !important;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.dark .divide-zinc-100 > :not([hidden]) ~ :not([hidden]) {
|
| 732 |
+
border-color: #27272a !important;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.dark .badge-emerald {
|
| 736 |
+
background-color: rgba(16, 185, 129, 0.1) !important;
|
| 737 |
+
color: #34d399 !important;
|
| 738 |
+
border-color: rgba(16, 185, 129, 0.2) !important;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.dark .badge-amber {
|
| 742 |
+
background-color: rgba(245, 158, 11, 0.1) !important;
|
| 743 |
+
color: #fbbf24 !important;
|
| 744 |
+
border-color: rgba(245, 158, 11, 0.2) !important;
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
.dark .badge-rose {
|
| 748 |
+
background-color: rgba(244, 63, 94, 0.1) !important;
|
| 749 |
+
color: #f87171 !important;
|
| 750 |
+
border-color: rgba(244, 63, 94, 0.2) !important;
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
/* Custom dark mode overrides for buttons, active components, and alerts */
|
| 754 |
+
.dark .btn-primary {
|
| 755 |
+
background: #ffffff !important;
|
| 756 |
+
color: #09090b !important;
|
| 757 |
+
border-color: #ffffff !important;
|
| 758 |
+
}
|
| 759 |
+
.dark .btn-primary:hover {
|
| 760 |
+
background: #e4e4e7 !important;
|
| 761 |
+
border-color: #e4e4e7 !important;
|
| 762 |
+
}
|
| 763 |
+
.dark .btn-primary:disabled {
|
| 764 |
+
background: #27272a !important;
|
| 765 |
+
border-color: #27272a !important;
|
| 766 |
+
color: #71717a !important;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.dark .btn-ghost {
|
| 770 |
+
background: #18181b !important;
|
| 771 |
+
color: var(--text-secondary) !important;
|
| 772 |
+
border-color: var(--border-medium) !important;
|
| 773 |
+
}
|
| 774 |
+
.dark .btn-ghost:hover {
|
| 775 |
+
background: #27272a !important;
|
| 776 |
+
border-color: var(--border-strong) !important;
|
| 777 |
+
color: var(--text-primary) !important;
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
/* Background overrides for active elements */
|
| 781 |
+
.dark .bg-zinc-950 {
|
| 782 |
+
background-color: #ffffff !important;
|
| 783 |
+
color: #09090b !important;
|
| 784 |
+
}
|
| 785 |
+
.dark .border-zinc-950 {
|
| 786 |
+
border-color: #ffffff !important;
|
| 787 |
+
}
|
| 788 |
+
.dark .bg-zinc-900 {
|
| 789 |
+
background-color: #27272a !important;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
/* Specific overrides for non-badge amber alerts in dark mode */
|
| 793 |
+
.dark .bg-amber-50\/50 {
|
| 794 |
+
background-color: rgba(245, 158, 11, 0.1) !important;
|
| 795 |
+
}
|
| 796 |
+
.dark .border-amber-200\/50 {
|
| 797 |
+
border-color: rgba(245, 158, 11, 0.2) !important;
|
| 798 |
+
}
|
| 799 |
+
.dark .text-amber-900 {
|
| 800 |
+
color: #fbbf24 !important;
|
| 801 |
+
}
|
| 802 |
+
.dark .text-amber-700 {
|
| 803 |
+
color: #f59e0b !important;
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
/* Accent icon/text color overrides */
|
| 807 |
+
.dark .text-emerald-600 {
|
| 808 |
+
color: #34d399 !important;
|
| 809 |
+
}
|
| 810 |
+
.dark .text-blue-600 {
|
| 811 |
+
color: #60a5fa !important;
|
| 812 |
+
}
|
| 813 |
+
.dark .text-rose-600 {
|
| 814 |
+
color: #f87171 !important;
|
| 815 |
+
}
|
| 816 |
+
.dark .text-amber-600 {
|
| 817 |
+
color: #fbbf24 !important;
|
| 818 |
+
}
|
| 819 |
+
.dark .border-rose-100 {
|
| 820 |
+
border-color: rgba(244, 63, 94, 0.2) !important;
|
| 821 |
+
}
|
| 822 |
+
.dark .hover\:bg-rose-50:hover {
|
| 823 |
+
background-color: rgba(244, 63, 94, 0.1) !important;
|
| 824 |
+
}
|
| 825 |
+
.dark .border-zinc-300 {
|
| 826 |
+
border-color: #52525b !important;
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
/* Dark mode flowing wave background */
|
| 830 |
+
.dark .ambient-bg,
|
| 831 |
+
.dark .mesh-bg {
|
| 832 |
+
background-color: #09090b !important;
|
| 833 |
+
background-image:
|
| 834 |
+
radial-gradient(circle at 50% 50%, rgba(6, 182, 212, 0.12) 0%, rgba(37, 99, 235, 0.06) 40%, transparent 70%),
|
| 835 |
+
radial-gradient(circle at 50% 50%, transparent 30%, #09090b 85%),
|
| 836 |
+
linear-gradient(rgba(34, 211, 238, 0.02) 1px, transparent 1px),
|
| 837 |
+
linear-gradient(90deg, rgba(34, 211, 238, 0.02) 1px, transparent 1px),
|
| 838 |
+
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 600' width='100%' height='100%'><defs><filter id='glow' x='-50%' y='-50%' width='200%' height='200%'><feGaussianBlur in='SourceGraphic' stdDeviation='3' result='blur1'/><feGaussianBlur in='SourceGraphic' stdDeviation='10' result='blur2'/><feMerge><feMergeNode in='blur2'/><feMergeNode in='blur1'/><feMergeNode in='SourceGraphic'/></feMerge></filter><linearGradient id='grad-cyan' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%2306b6d4' stop-opacity='0.16'/><stop offset='100%' stop-color='%2306b6d4' stop-opacity='0'/></linearGradient><linearGradient id='grad-blue' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%232563eb' stop-opacity='0.12'/><stop offset='100%' stop-color='%232563eb' stop-opacity='0'/></linearGradient></defs><style>.wave-1{animation:flow-1 30s linear infinite, bob-1 10s ease-in-out infinite alternate}.wave-2{animation:flow-2 22s linear infinite, bob-2 7s ease-in-out infinite alternate}.wave-3{animation:flow-3 18s linear infinite, bob-3 8s ease-in-out infinite alternate}.wave-4{animation:flow-4 14s linear infinite, bob-4 5s ease-in-out infinite alternate}@keyframes flow-1{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-2{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes flow-3{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-4{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes bob-1{0%{transform:translateY(-12px)}100%{transform:translateY(12px)}}@keyframes bob-2{0%{transform:translateY(9px)}100%{transform:translateY(-9px)}}@keyframes bob-3{0%{transform:translateY(-7px)}100%{transform:translateY(7px)}}@keyframes bob-4{0%{transform:translateY(6px)}100%{transform:translateY(-6px)}}</style><path class='wave-1' fill='url(%23grad-blue)' d='M-1440,300 C-1080,220 -720,380 -360,300 C0,220 360,380 720,300 C1080,220 1440,380 1800,300 C2160,220 2520,380 2880,300 L2880,600 L-1440,600 Z'/><path class='wave-2' fill='url(%23grad-cyan)' d='M-1440,320 C-1080,440 -720,200 -360,320 C0,440 360,200 720,320 C1080,440 1440,200 1800,320 C2160,450 2520,190 2880,320 L2880,600 L-1440,600 Z'/><path class='wave-3' stroke='%2322d3ee' stroke-width='2' fill='none' filter='url(%23glow)' opacity='0.75' d='M-1440,300 C-1080,240 -720,360 -360,300 C0,240 360,360 720,300 C1080,240 1440,360 1800,300 C2160,240 2520,360 2880,300'/><path class='wave-4' stroke='%233b82f6' stroke-width='1.25' fill='none' opacity='0.65' d='M-1440,315 C-1080,365 -720,265 -360,315 C0,365 360,265 720,315 C1080,365 1440,265 1800,315 C2160,365 2520,265 2880,315'/><path class='wave-3' stroke='%23ffffff' stroke-width='0.75' fill='none' opacity='0.85' filter='url(%23glow)' d='M-1440,295 C-1080,230 -720,370 -360,295 C0,230 360,370 720,295 C1080,230 1440,370 1800,295 C2160,230 2520,370 2880,295'/><path class='wave-1' stroke='%230891b2' stroke-width='1' fill='none' opacity='0.55' filter='url(%23glow)' d='M-1440,290 C-1080,190 -720,390 -360,290 C0,190 360,390 720,290 C1080,190 1440,390 1800,290 C2160,190 2520,390 2880,290'/></svg>") !important;
|
| 839 |
+
background-size: cover, cover, 80px 80px, 80px 80px, cover !important;
|
| 840 |
+
background-position: center !important;
|
| 841 |
+
background-repeat: no-repeat, no-repeat, repeat, repeat, no-repeat !important;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
/* Kiosk header and footer translucent glass overlays */
|
| 845 |
+
.kiosk-header {
|
| 846 |
+
background-color: rgba(255, 255, 255, 0.45) !important;
|
| 847 |
+
backdrop-filter: blur(16px);
|
| 848 |
+
-webkit-backdrop-filter: blur(16px);
|
| 849 |
+
}
|
| 850 |
+
.dark .kiosk-header {
|
| 851 |
+
background-color: rgba(9, 9, 11, 0.45) !important;
|
| 852 |
+
backdrop-filter: blur(16px);
|
| 853 |
+
-webkit-backdrop-filter: blur(16px);
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
.kiosk-footer {
|
| 857 |
+
background-color: rgba(255, 255, 255, 0.45) !important;
|
| 858 |
+
backdrop-filter: blur(16px);
|
| 859 |
+
-webkit-backdrop-filter: blur(16px);
|
| 860 |
+
}
|
| 861 |
+
.dark .kiosk-footer {
|
| 862 |
+
background-color: rgba(9, 9, 11, 0.45) !important;
|
| 863 |
+
backdrop-filter: blur(16px);
|
| 864 |
+
-webkit-backdrop-filter: blur(16px);
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
|
frontend/app/kiosk/page.tsx
CHANGED
|
@@ -6,20 +6,42 @@ import {
|
|
| 6 |
Volume2, VolumeX, Clock as ClockIcon, Play, Wifi, Fingerprint, Shield
|
| 7 |
} from "lucide-react";
|
| 8 |
import { getBackendUrl } from "@/app/utils/api";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
export default function KioskPage() {
|
|
|
|
| 11 |
const [kioskActive, setKioskActive] = useState(false);
|
| 12 |
const [currentTime, setCurrentTime] = useState("");
|
| 13 |
const [currentDate, setCurrentDate] = useState("");
|
| 14 |
const [matchTime, setMatchTime] = useState("");
|
| 15 |
const [scanning, setScanning] = useState(false);
|
| 16 |
-
const [scanStatus, setScanStatus] = useState<"idle" | "success" | "spoof" | "unknown" | "maintenance" | "no_employees">("idle");
|
| 17 |
const [scanResult, setScanResult] = useState<any>(null);
|
| 18 |
const [scanFeedback, setScanFeedback] = useState<string | null>(null);
|
| 19 |
const [cameraLabel] = useState("Main Entrance");
|
| 20 |
const [voiceEnabled, setVoiceEnabled] = useState(true);
|
| 21 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 22 |
const [profileImageError, setProfileImageError] = useState(false);
|
|
|
|
|
|
|
| 23 |
|
| 24 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 25 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
@@ -31,7 +53,7 @@ export default function KioskPage() {
|
|
| 31 |
useEffect(() => {
|
| 32 |
const tick = () => {
|
| 33 |
const now = new Date();
|
| 34 |
-
setCurrentTime(now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12:
|
| 35 |
setCurrentDate(now.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" }));
|
| 36 |
};
|
| 37 |
tick();
|
|
@@ -39,6 +61,31 @@ export default function KioskPage() {
|
|
| 39 |
return () => clearInterval(t);
|
| 40 |
}, []);
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
const toggleFullscreen = () => {
|
| 43 |
if (!document.fullscreenElement) {
|
| 44 |
document.documentElement.requestFullscreen().catch(() => {});
|
|
@@ -68,7 +115,7 @@ export default function KioskPage() {
|
|
| 68 |
intervalRef.current = setInterval(captureAndScan, 1000);
|
| 69 |
} catch (err) {
|
| 70 |
console.error(err);
|
| 71 |
-
|
| 72 |
}
|
| 73 |
};
|
| 74 |
|
|
@@ -108,6 +155,7 @@ export default function KioskPage() {
|
|
| 108 |
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 109 |
|
| 110 |
const base64 = canvas.toDataURL("image/jpeg", 0.82);
|
|
|
|
| 111 |
setScanning(true);
|
| 112 |
try {
|
| 113 |
// Connect to the backend running at the configured URL
|
|
@@ -136,6 +184,27 @@ export default function KioskPage() {
|
|
| 136 |
}
|
| 137 |
};
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
const handleResult = (data: any) => {
|
| 140 |
if (data.status === "success") {
|
| 141 |
setProfileImageError(false);
|
|
@@ -148,6 +217,18 @@ export default function KioskPage() {
|
|
| 148 |
console.error("Autoplay voice greeting failed:", err);
|
| 149 |
});
|
| 150 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
} else if (data.status === "spoof_detected") {
|
| 152 |
setScanStatus("spoof"); setScanResult(data);
|
| 153 |
triggerCooldown(3000);
|
|
@@ -175,44 +256,38 @@ export default function KioskPage() {
|
|
| 175 |
return (
|
| 176 |
<div className="min-h-screen bg-[var(--bg-base)] text-[var(--text-primary)] flex flex-col relative select-none overflow-hidden font-sans">
|
| 177 |
{/* Background Grid Mesh */}
|
| 178 |
-
<div className="absolute inset-0 mesh-bg
|
| 179 |
<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" />
|
| 180 |
|
| 181 |
{/* ─── Top HUD Bar ─── */}
|
| 182 |
-
<header className="relative z-30 h-
|
| 183 |
{/* Brand info */}
|
| 184 |
-
<div className="flex items-center gap-
|
| 185 |
-
<div className="relative inline-flex items-center justify-center w-
|
| 186 |
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)]" />
|
| 187 |
-
<div className="absolute -bottom-0.5 -right-0.5 w-
|
| 188 |
-
<svg viewBox="0 0 100 100" className="w-
|
| 189 |
-
<circle cx="50" cy="50" r="45" stroke="#
|
| 190 |
-
<circle cx="50" cy="50" r="40" stroke="#
|
| 191 |
-
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#
|
| 192 |
<g className="animate-eye-lid">
|
| 193 |
-
<circle cx="50" cy="50" r="22" fill="#
|
| 194 |
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 195 |
</g>
|
| 196 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 197 |
</svg>
|
| 198 |
</div>
|
| 199 |
<div>
|
| 200 |
-
<h1 className="font-bold text-[
|
| 201 |
-
<
|
| 202 |
-
<Wifi className="w-2.5 h-2.5 text-emerald-500" />
|
| 203 |
-
<p className="text-[9.5px] text-slate-500 font-mono uppercase tracking-wider">{cameraLabel}</p>
|
| 204 |
-
</div>
|
| 205 |
</div>
|
| 206 |
</div>
|
| 207 |
|
| 208 |
{/* Clock */}
|
| 209 |
-
<div className="text-center absolute left-1/2 -translate-
|
| 210 |
-
<p className="text-
|
| 211 |
{currentTime || "00:00:00"}
|
| 212 |
</p>
|
| 213 |
-
<p className="text-[9.5px] text-slate-500 mt-1 uppercase tracking-wider font-mono">
|
| 214 |
-
{currentDate}
|
| 215 |
-
</p>
|
| 216 |
</div>
|
| 217 |
|
| 218 |
{/* Controls */}
|
|
@@ -220,10 +295,10 @@ export default function KioskPage() {
|
|
| 220 |
<button
|
| 221 |
onClick={() => setVoiceEnabled(!voiceEnabled)}
|
| 222 |
title={voiceEnabled ? "Mute audio assistance" : "Enable audio assistance"}
|
| 223 |
-
className={`p-
|
| 224 |
voiceEnabled
|
| 225 |
? "bg-zinc-950 border-zinc-950 text-white"
|
| 226 |
-
: "bg-slate-
|
| 227 |
}`}
|
| 228 |
>
|
| 229 |
{voiceEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
|
|
@@ -231,36 +306,41 @@ export default function KioskPage() {
|
|
| 231 |
<button
|
| 232 |
onClick={toggleFullscreen}
|
| 233 |
title="Fullscreen toggle"
|
| 234 |
-
className="p-
|
| 235 |
>
|
| 236 |
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
| 237 |
</button>
|
| 238 |
</div>
|
| 239 |
</header>
|
| 240 |
|
| 241 |
-
{/* ─── Main Area ─── */}
|
| 242 |
<main className="flex-1 flex flex-col items-center justify-center p-6 relative z-10">
|
| 243 |
<canvas ref={canvasRef} className="hidden" />
|
| 244 |
|
| 245 |
-
{/* Video / Camera frame
|
| 246 |
-
<div className="w-full max-w-3xl
|
| 247 |
|
| 248 |
-
{/* Pre-start
|
| 249 |
{!kioskActive && (
|
| 250 |
-
<div className="
|
| 251 |
-
<div className="relative">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
{/* Subtle outer breathing ring */}
|
| 253 |
-
<div className="absolute inset-[-
|
| 254 |
|
| 255 |
{/* Cybernetic blinking/glowing robot eye logo */}
|
| 256 |
-
<div className="relative inline-flex items-center justify-center w-
|
| 257 |
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.
|
| 258 |
-
<svg viewBox="0 0 100 100" className="w-
|
| 259 |
-
<circle cx="50" cy="50" r="45" stroke="#
|
| 260 |
-
<circle cx="50" cy="50" r="40" stroke="#
|
| 261 |
-
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#
|
| 262 |
<g className="animate-eye-lid">
|
| 263 |
-
<circle cx="50" cy="50" r="22" fill="#
|
| 264 |
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 265 |
</g>
|
| 266 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
|
@@ -268,25 +348,25 @@ export default function KioskPage() {
|
|
| 268 |
</div>
|
| 269 |
</div>
|
| 270 |
|
| 271 |
-
<div className="space-y-
|
| 272 |
-
<h2 className="text-
|
| 273 |
-
<p className="text-
|
| 274 |
-
|
| 275 |
</p>
|
| 276 |
</div>
|
| 277 |
|
| 278 |
<button
|
| 279 |
onClick={startKiosk}
|
| 280 |
-
className="btn-primary h-
|
| 281 |
>
|
| 282 |
-
<Play className="w-
|
| 283 |
-
Start Scanner
|
| 284 |
</button>
|
| 285 |
</div>
|
| 286 |
)}
|
| 287 |
|
| 288 |
{/* Camera Frame Frame */}
|
| 289 |
-
<div className=
|
| 290 |
|
| 291 |
{/* Native Video player (Centrally mounted) */}
|
| 292 |
<video
|
|
@@ -300,14 +380,14 @@ export default function KioskPage() {
|
|
| 300 |
<>
|
| 301 |
<div className="scanner-laser" />
|
| 302 |
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
| 303 |
-
<div className="relative w-
|
| 304 |
-
<div className="absolute top-0 left-0 w-
|
| 305 |
-
<div className="absolute top-0 right-0 w-
|
| 306 |
-
<div className="absolute bottom-0 left-0 w-
|
| 307 |
-
<div className="absolute bottom-0 right-0 w-
|
| 308 |
|
| 309 |
-
<div className="absolute inset-0 flex items-center justify-center">
|
| 310 |
-
<span className="text-[
|
| 311 |
{scanFeedback || (scanning ? "Processing..." : "Center Face")}
|
| 312 |
</span>
|
| 313 |
</div>
|
|
@@ -318,61 +398,55 @@ export default function KioskPage() {
|
|
| 318 |
|
| 319 |
{/* SUCCESS screen */}
|
| 320 |
{kioskActive && scanStatus === "success" && (
|
| 321 |
-
<div className="absolute inset-0
|
| 322 |
-
<div className="space-y-
|
| 323 |
-
{/*
|
| 324 |
-
<div className="
|
| 325 |
-
{/* Ring animation */}
|
| 326 |
-
<div className="absolute inset-[-6px] rounded-full border border-emerald-500/30 animate-ping" />
|
| 327 |
{scanResult?.employee?.employee_id && !profileImageError ? (
|
| 328 |
<img
|
| 329 |
src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
|
| 330 |
alt={scanResult.employee.name}
|
| 331 |
-
className="w-
|
| 332 |
onError={() => setProfileImageError(true)}
|
| 333 |
/>
|
| 334 |
) : (
|
| 335 |
-
<
|
| 336 |
{scanResult?.employee?.name
|
| 337 |
? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
|
| 338 |
: "PK"}
|
| 339 |
-
</
|
| 340 |
)}
|
| 341 |
</div>
|
| 342 |
|
| 343 |
-
{/* Name and
|
| 344 |
-
<div className="space-y-
|
| 345 |
-
<h2 className="text-
|
| 346 |
{scanResult?.employee?.name || "Employee"}
|
| 347 |
</h2>
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 351 |
-
<span>{cameraLabel}</span>
|
| 352 |
-
</div>
|
| 353 |
-
|
| 354 |
-
<p className="text-sm text-slate-700 font-semibold">
|
| 355 |
-
{scanResult?.employee?.designation || "SDE-1"} ({scanResult?.employee?.employee_id || "int01"})
|
| 356 |
</p>
|
| 357 |
</div>
|
| 358 |
|
| 359 |
-
{/*
|
| 360 |
-
<div className="
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
<div className="space-y-4">
|
| 364 |
-
<p className="text-2xl font-black font-mono text-slate-900 tracking-tight tabular-nums">
|
| 365 |
-
{matchTime || "00:00:00 AM"}
|
| 366 |
</p>
|
| 367 |
|
| 368 |
-
<div className="flex flex-col items-center gap-1
|
| 369 |
-
<span className="inline-flex items-center gap-1
|
| 370 |
-
<UserCheck className="w-3.5 h-3.5" />
|
| 371 |
Matched
|
| 372 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
{scanResult?.confidence && (
|
| 374 |
-
<p className="text-[
|
| 375 |
-
|
| 376 |
</p>
|
| 377 |
)}
|
| 378 |
</div>
|
|
@@ -381,83 +455,153 @@ export default function KioskPage() {
|
|
| 381 |
</div>
|
| 382 |
)}
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
{/* SPOOF screen */}
|
| 385 |
{kioskActive && scanStatus === "spoof" && (
|
| 386 |
-
<div className="absolute inset-0
|
| 387 |
-
<div className="
|
| 388 |
-
<div className="w-
|
| 389 |
-
<ShieldAlert className="w-
|
| 390 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
</div>
|
| 392 |
-
<h2 className="text-2xl font-bold text-slate-800 tracking-tight">Security Check Failed</h2>
|
| 393 |
-
<p className="text-sm text-rose-600 mt-1 font-semibold">{scanResult?.message}</p>
|
| 394 |
-
<p className="text-[10px] text-slate-400 font-mono mt-4">
|
| 395 |
-
Liveness Prob: {scanResult?.liveness_score?.toFixed(3)} · Lockout applied
|
| 396 |
-
</p>
|
| 397 |
</div>
|
| 398 |
)}
|
| 399 |
|
| 400 |
{/* UNKNOWN screen */}
|
| 401 |
{kioskActive && scanStatus === "unknown" && (
|
| 402 |
-
<div className="absolute inset-0
|
| 403 |
-
<div className="
|
| 404 |
-
<div className="w-
|
| 405 |
-
<HelpCircle className="w-
|
| 406 |
</div>
|
|
|
|
|
|
|
| 407 |
</div>
|
| 408 |
-
<h2 className="text-xl font-bold text-slate-800 tracking-tight">Unknown Identity</h2>
|
| 409 |
-
<p className="text-xs text-amber-700 mt-1.5 font-medium">{scanResult?.message || "Face not registered on company database."}</p>
|
| 410 |
-
<p className="text-[10px] text-slate-400 font-mono mt-3">
|
| 411 |
-
Match Score: {scanResult?.confidence?.toFixed(3)}
|
| 412 |
-
</p>
|
| 413 |
</div>
|
| 414 |
)}
|
| 415 |
|
| 416 |
{/* MAINTENANCE screen */}
|
| 417 |
{kioskActive && scanStatus === "maintenance" && (
|
| 418 |
-
<div className="absolute inset-0
|
| 419 |
-
<div className="
|
| 420 |
-
<div className="w-
|
| 421 |
-
<ShieldAlert className="w-
|
| 422 |
</div>
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
</p>
|
| 428 |
-
<div className="mt-4">
|
| 429 |
-
<span className="inline-flex items-center gap-1.5 px-4.5 py-1.5 rounded-full bg-zinc-100 border border-zinc-200 text-zinc-800 font-mono text-xs font-bold uppercase tracking-wider shadow-sm">
|
| 430 |
-
Offline Standby
|
| 431 |
-
</span>
|
| 432 |
</div>
|
| 433 |
</div>
|
| 434 |
)}
|
| 435 |
|
| 436 |
{/* NO_EMPLOYEES screen */}
|
| 437 |
{kioskActive && scanStatus === "no_employees" && (
|
| 438 |
-
<div className="absolute inset-0
|
| 439 |
-
<div className="
|
| 440 |
-
<div className="w-
|
| 441 |
-
<UserCheck className="w-
|
| 442 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
</div>
|
| 444 |
-
<h2 className="text-2xl font-bold text-slate-800 tracking-tight">No Registered Employees</h2>
|
| 445 |
-
<p className="text-sm text-amber-700 mt-2.5 font-semibold max-w-xs leading-relaxed">
|
| 446 |
-
{scanResult?.message || "Please add the employee"}
|
| 447 |
-
</p>
|
| 448 |
</div>
|
| 449 |
)}
|
| 450 |
|
| 451 |
{/* Frame Info HUD Overlay */}
|
| 452 |
{kioskActive && (
|
| 453 |
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between pointer-events-none z-10">
|
| 454 |
-
<div className="flex items-center gap-1.5 text-[9px] font-mono font-bold text-
|
| 455 |
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 456 |
ONLINE · {cameraLabel.toUpperCase()}
|
| 457 |
</div>
|
| 458 |
<button
|
| 459 |
onClick={(e) => { e.stopPropagation(); stopKiosk(); }}
|
| 460 |
-
className="pointer-events-auto text-[
|
| 461 |
>
|
| 462 |
Disable Camera
|
| 463 |
</button>
|
|
@@ -467,17 +611,17 @@ export default function KioskPage() {
|
|
| 467 |
|
| 468 |
{/* Active bottom status bar indicators */}
|
| 469 |
{kioskActive && (
|
| 470 |
-
<div className="flex items-center justify-center gap-
|
| 471 |
<div className="flex items-center gap-1.5">
|
| 472 |
-
<div className={`w-1.5 h-1.5 rounded-full ${scanning ? "bg-zinc-950 animate-pulse" : "bg-
|
| 473 |
-
<span>{scanning ? "
|
| 474 |
</div>
|
| 475 |
-
<
|
| 476 |
-
|
| 477 |
-
<span>Anti-
|
| 478 |
</div>
|
| 479 |
-
<
|
| 480 |
-
|
| 481 |
<span>Auto-indexing</span>
|
| 482 |
</div>
|
| 483 |
</div>
|
|
@@ -486,9 +630,9 @@ export default function KioskPage() {
|
|
| 486 |
</main>
|
| 487 |
|
| 488 |
{/* ─── Footer ─── */}
|
| 489 |
-
<footer className="relative z-10 h-10 border-t border-[var(--border-medium)] flex items-center justify-center
|
| 490 |
-
<p className="text-[9.5px] font-mono font-bold text-
|
| 491 |
-
NETRAID SECURE TERMINAL GATEWAY v1.0.0 ·
|
| 492 |
</p>
|
| 493 |
</footer>
|
| 494 |
</div>
|
|
|
|
| 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 "";
|
| 13 |
+
if (timeStr.includes("AM") || timeStr.includes("PM")) return timeStr;
|
| 14 |
+
|
| 15 |
+
const parts = timeStr.split(":");
|
| 16 |
+
if (parts.length < 2) return timeStr;
|
| 17 |
+
|
| 18 |
+
const hr = parseInt(parts[0], 10);
|
| 19 |
+
const mn = parseInt(parts[1], 10);
|
| 20 |
+
const sc = parts[2] ? parseInt(parts[2], 10) : 0;
|
| 21 |
+
|
| 22 |
+
const suffix = hr >= 12 ? "PM" : "AM";
|
| 23 |
+
const hour12 = hr % 12 || 12;
|
| 24 |
+
const pad = (num: number) => String(num).padStart(2, "0");
|
| 25 |
+
|
| 26 |
+
return `${pad(hour12)}:${pad(mn)}:${pad(sc)} ${suffix}`;
|
| 27 |
+
}
|
| 28 |
|
| 29 |
export default function KioskPage() {
|
| 30 |
+
const { toast } = useToast();
|
| 31 |
const [kioskActive, setKioskActive] = useState(false);
|
| 32 |
const [currentTime, setCurrentTime] = useState("");
|
| 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");
|
| 40 |
const [voiceEnabled, setVoiceEnabled] = useState(true);
|
| 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);
|
|
|
|
| 53 |
useEffect(() => {
|
| 54 |
const tick = () => {
|
| 55 |
const now = new Date();
|
| 56 |
+
setCurrentTime(now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true }));
|
| 57 |
setCurrentDate(now.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" }));
|
| 58 |
};
|
| 59 |
tick();
|
|
|
|
| 61 |
return () => clearInterval(t);
|
| 62 |
}, []);
|
| 63 |
|
| 64 |
+
// Fetch engine state dynamically on mount
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
const checkEngine = async () => {
|
| 67 |
+
try {
|
| 68 |
+
const url = `${getBackendUrl().replace("/api/v1", "")}/health`;
|
| 69 |
+
const res = await fetch(url);
|
| 70 |
+
if (res.ok) {
|
| 71 |
+
const data = await res.json();
|
| 72 |
+
if (data.mock_mode) {
|
| 73 |
+
setEngineMode("MOCKED OFFLINE ENGINE");
|
| 74 |
+
} else {
|
| 75 |
+
setEngineMode("REAL BIOMETRIC ENGINE");
|
| 76 |
+
}
|
| 77 |
+
} else {
|
| 78 |
+
setEngineMode("REAL BIOMETRIC ENGINE");
|
| 79 |
+
}
|
| 80 |
+
} catch (err) {
|
| 81 |
+
setEngineMode("REAL BIOMETRIC ENGINE");
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
checkEngine();
|
| 85 |
+
const interval = setInterval(checkEngine, 10000);
|
| 86 |
+
return () => clearInterval(interval);
|
| 87 |
+
}, []);
|
| 88 |
+
|
| 89 |
const toggleFullscreen = () => {
|
| 90 |
if (!document.fullscreenElement) {
|
| 91 |
document.documentElement.requestFullscreen().catch(() => {});
|
|
|
|
| 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.");
|
| 119 |
}
|
| 120 |
};
|
| 121 |
|
|
|
|
| 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
|
|
|
|
| 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();
|
| 199 |
+
handleResult(data);
|
| 200 |
+
} catch (err) {
|
| 201 |
+
console.error("Checkout confirmation error:", err);
|
| 202 |
+
setScanFeedback("Confirmation error");
|
| 203 |
+
} finally {
|
| 204 |
+
setScanning(false);
|
| 205 |
+
}
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
const handleResult = (data: any) => {
|
| 209 |
if (data.status === "success") {
|
| 210 |
setProfileImageError(false);
|
|
|
|
| 217 |
console.error("Autoplay voice greeting failed:", err);
|
| 218 |
});
|
| 219 |
}
|
| 220 |
+
} else if (data.status === "ask_checkout") {
|
| 221 |
+
setProfileImageError(false);
|
| 222 |
+
setScanStatus("ask_checkout"); setScanResult(data);
|
| 223 |
+
if (voiceEnabled && data.tts_url) {
|
| 224 |
+
const backendBaseUrl = getBackendUrl().replace("/api/v1", "");
|
| 225 |
+
new Audio(`${backendBaseUrl}${data.tts_url}`).play().catch((err) => {
|
| 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);
|
|
|
|
| 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" />
|
| 261 |
|
| 262 |
{/* ─── Top HUD Bar ─── */}
|
| 263 |
+
<header className="relative z-30 h-14 border-b border-[var(--border-medium)] px-6 flex items-center justify-between kiosk-header">
|
| 264 |
{/* Brand info */}
|
| 265 |
+
<div className="flex items-center gap-2.5 animate-fadeInUp">
|
| 266 |
+
<div className="relative inline-flex items-center justify-center w-9 h-9 rounded-lg bg-slate-900 border border-slate-700/80 shadow-[0_0_12px_rgba(6,182,212,0.2)] overflow-hidden shrink-0">
|
| 267 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
| 268 |
+
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500 border border-white z-20" />
|
| 269 |
+
<svg viewBox="0 0 100 100" className="w-6 h-6 relative z-10 animate-fade-in" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 270 |
+
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 271 |
+
<circle cx="50" cy="50" r="40" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.8 }} />
|
| 272 |
+
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
| 273 |
<g className="animate-eye-lid">
|
| 274 |
+
<circle cx="50" cy="50" r="22" fill="#1e293b" stroke="#06b6d4" strokeWidth="2" />
|
| 275 |
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 276 |
</g>
|
| 277 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 278 |
</svg>
|
| 279 |
</div>
|
| 280 |
<div>
|
| 281 |
+
<h1 className="font-bold text-[15px] tracking-tight text-[var(--text-primary)] leading-none">NetraID Kiosk</h1>
|
| 282 |
+
<p className="text-[9px] text-[var(--text-muted)] font-mono mt-0.5 uppercase tracking-wider">{cameraLabel}</p>
|
|
|
|
|
|
|
|
|
|
| 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 */}
|
|
|
|
| 295 |
<button
|
| 296 |
onClick={() => setVoiceEnabled(!voiceEnabled)}
|
| 297 |
title={voiceEnabled ? "Mute audio assistance" : "Enable audio assistance"}
|
| 298 |
+
className={`p-1.5 rounded-lg border transition-all cursor-pointer ${
|
| 299 |
voiceEnabled
|
| 300 |
? "bg-zinc-950 border-zinc-950 text-white"
|
| 301 |
+
: "bg-slate-55 border-slate-200 text-slate-400"
|
| 302 |
}`}
|
| 303 |
>
|
| 304 |
{voiceEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
|
|
|
|
| 306 |
<button
|
| 307 |
onClick={toggleFullscreen}
|
| 308 |
title="Fullscreen toggle"
|
| 309 |
+
className="p-1.5 rounded-lg bg-slate-55 border border-slate-200 text-slate-500 hover:text-slate-800 transition-all cursor-pointer"
|
| 310 |
>
|
| 311 |
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
| 312 |
</button>
|
| 313 |
</div>
|
| 314 |
</header>
|
| 315 |
|
|
|
|
| 316 |
<main className="flex-1 flex flex-col items-center justify-center p-6 relative z-10">
|
| 317 |
<canvas ref={canvasRef} className="hidden" />
|
| 318 |
|
| 319 |
+
{/* Video / Camera frame container */}
|
| 320 |
+
<div className="w-full max-w-3xl relative">
|
| 321 |
|
| 322 |
+
{/* Pre-start offline glass card */}
|
| 323 |
{!kioskActive && (
|
| 324 |
+
<div className="w-full max-w-2xl mx-auto glass-overlay border border-[var(--border-medium)] rounded-3xl text-center p-12 aspect-video flex flex-col items-center justify-center space-y-6 animate-fadeInUp shadow-[0_8px_32px_rgba(0,0,0,0.03)] relative overflow-hidden">
|
| 325 |
+
<div className="relative flex flex-col items-center">
|
| 326 |
+
{/* Status Badge */}
|
| 327 |
+
<div className="mb-4 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-rose-500/10 border border-rose-500/20 text-rose-600 font-mono text-[9px] font-bold uppercase tracking-wider">
|
| 328 |
+
<span className="w-1.5 h-1.5 rounded-full bg-rose-500 animate-pulse" />
|
| 329 |
+
Terminal Offline
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
{/* Subtle outer breathing ring */}
|
| 333 |
+
<div className="absolute inset-[-6px] rounded-full bg-cyan-500/5 border border-cyan-500/10 animate-pulse" />
|
| 334 |
|
| 335 |
{/* Cybernetic blinking/glowing robot eye logo */}
|
| 336 |
+
<div className="relative inline-flex items-center justify-center w-20 h-20 rounded-full bg-[var(--bg-surface)] border border-cyan-500/30 dark:border-cyan-500/40 shadow-[0_0_20px_rgba(6,182,212,0.15)] overflow-hidden shrink-0">
|
| 337 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.1)_0%,transparent_70%)]" />
|
| 338 |
+
<svg viewBox="0 0 100 100" className="w-11 h-11 relative z-10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 339 |
+
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 340 |
+
<circle cx="50" cy="50" r="40" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.8 }} />
|
| 341 |
+
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
| 342 |
<g className="animate-eye-lid">
|
| 343 |
+
<circle cx="50" cy="50" r="22" fill="#1e293b" stroke="#06b6d4" strokeWidth="2" />
|
| 344 |
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 345 |
</g>
|
| 346 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
|
|
|
| 348 |
</div>
|
| 349 |
</div>
|
| 350 |
|
| 351 |
+
<div className="space-y-1 max-w-sm mx-auto">
|
| 352 |
+
<h2 className="text-xl font-extrabold text-[var(--text-primary)] tracking-tight">Kiosk Offline</h2>
|
| 353 |
+
<p className="text-xs text-[var(--text-muted)] leading-relaxed">
|
| 354 |
+
Start the camera terminal to begin face biometric logging.
|
| 355 |
</p>
|
| 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" />
|
| 363 |
+
Start Scanner
|
| 364 |
</button>
|
| 365 |
</div>
|
| 366 |
)}
|
| 367 |
|
| 368 |
{/* Camera Frame Frame */}
|
| 369 |
+
<div className={`relative aspect-video rounded-2xl overflow-hidden bg-black border border-zinc-100 shadow-sm ${kioskActive ? "block" : "hidden"}`}>
|
| 370 |
|
| 371 |
{/* Native Video player (Centrally mounted) */}
|
| 372 |
<video
|
|
|
|
| 380 |
<>
|
| 381 |
<div className="scanner-laser" />
|
| 382 |
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
| 383 |
+
<div className="relative w-48 h-48">
|
| 384 |
+
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-zinc-950 rounded-tl-xl" />
|
| 385 |
+
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-zinc-950 rounded-tr-xl" />
|
| 386 |
+
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-zinc-950 rounded-bl-xl" />
|
| 387 |
+
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-zinc-950 rounded-br-xl" />
|
| 388 |
|
| 389 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
| 390 |
+
<span className="text-[9px] text-[var(--text-primary)] font-mono font-bold tracking-wider uppercase bg-[var(--bg-surface)] px-3 py-1.5 rounded border border-[var(--border-medium)] shadow-2xs">
|
| 391 |
{scanFeedback || (scanning ? "Processing..." : "Center Face")}
|
| 392 |
</span>
|
| 393 |
</div>
|
|
|
|
| 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>
|
|
|
|
| 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>
|
| 534 |
+
)}
|
| 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>
|
| 578 |
)}
|
| 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">
|
| 598 |
+
<div className="flex items-center gap-1.5 text-[9px] font-mono font-bold text-[var(--text-muted)] bg-[var(--bg-surface)] border border-[var(--border-medium)] shadow-2xs px-3 py-1.5 rounded-lg">
|
| 599 |
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 600 |
ONLINE · {cameraLabel.toUpperCase()}
|
| 601 |
</div>
|
| 602 |
<button
|
| 603 |
onClick={(e) => { e.stopPropagation(); stopKiosk(); }}
|
| 604 |
+
className="pointer-events-auto text-[9.5px] font-semibold text-rose-500 bg-[var(--bg-surface)] border border-rose-500/20 hover:bg-rose-500/10 shadow-2xs px-3 py-1 rounded-lg transition-all cursor-pointer"
|
| 605 |
>
|
| 606 |
Disable Camera
|
| 607 |
</button>
|
|
|
|
| 611 |
|
| 612 |
{/* Active bottom status bar indicators */}
|
| 613 |
{kioskActive && (
|
| 614 |
+
<div className="flex items-center justify-center gap-5 text-[9px] font-mono text-slate-400 font-bold uppercase tracking-wider">
|
| 615 |
<div className="flex items-center gap-1.5">
|
| 616 |
+
<div className={`w-1.5 h-1.5 rounded-full ${scanning ? "bg-zinc-950 animate-pulse" : "bg-zinc-350"}`} />
|
| 617 |
+
<span>{scanning ? "Scanning" : "Standby"}</span>
|
| 618 |
</div>
|
| 619 |
+
<span className="text-zinc-200">|</span>
|
| 620 |
+
<div className="flex items-center gap-1">
|
| 621 |
+
<span>Anti-Spoof Active</span>
|
| 622 |
</div>
|
| 623 |
+
<span className="text-zinc-200">|</span>
|
| 624 |
+
<div className="flex items-center gap-1">
|
| 625 |
<span>Auto-indexing</span>
|
| 626 |
</div>
|
| 627 |
</div>
|
|
|
|
| 630 |
</main>
|
| 631 |
|
| 632 |
{/* ─── Footer ─── */}
|
| 633 |
+
<footer className="relative z-10 h-10 border-t border-[var(--border-medium)] flex items-center justify-center kiosk-footer">
|
| 634 |
+
<p className="text-[9.5px] font-mono font-bold text-[var(--text-muted)] tracking-wider">
|
| 635 |
+
NETRAID SECURE TERMINAL GATEWAY v1.0.0 · {engineMode}
|
| 636 |
</p>
|
| 637 |
</footer>
|
| 638 |
</div>
|
frontend/app/page.tsx
CHANGED
|
@@ -47,22 +47,18 @@ export default function LoginPage() {
|
|
| 47 |
return (
|
| 48 |
<div className="min-h-screen relative bg-[var(--bg-base)] flex items-center justify-center overflow-hidden px-4">
|
| 49 |
{/* Grid Mesh background */}
|
| 50 |
-
<div className="absolute inset-0 mesh-bg
|
| 51 |
-
|
| 52 |
-
{/* Ambient background glows */}
|
| 53 |
-
<div className="absolute top-0 left-1/4 w-96 h-96 bg-slate-100/50 rounded-full blur-[100px] pointer-events-none" />
|
| 54 |
-
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-zinc-100/50 rounded-full blur-[100px] pointer-events-none" />
|
| 55 |
|
| 56 |
<div className="w-full max-w-sm relative z-10 animate-fadeInUp">
|
| 57 |
|
| 58 |
{/* Logo block */}
|
| 59 |
-
<div className="text-center mb-
|
| 60 |
-
<div className="relative inline-flex items-center justify-center w-
|
| 61 |
{/* Ambient glowing background inside the container */}
|
| 62 |
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
| 63 |
|
| 64 |
{/* Robotic eye SVG */}
|
| 65 |
-
<svg viewBox="0 0 100 100" className="w-
|
| 66 |
{/* Outer technical rotating rings */}
|
| 67 |
<circle cx="50" cy="50" r="45" stroke="#1e293b" strokeWidth="1.5" strokeDasharray="6 12" className="animate-rotate-ring" />
|
| 68 |
<circle cx="50" cy="50" r="40" stroke="#0891b2" strokeWidth="1" strokeDasharray="40 10 15 5" className="animate-rotate-ring-reverse" style={{ opacity: 0.6 }} />
|
|
@@ -88,7 +84,6 @@ export default function LoginPage() {
|
|
| 88 |
|
| 89 |
{/* Futuristic crosshairs / HUD markers */}
|
| 90 |
<path d="M50 5 L50 12 M50 88 L50 95 M5 50 L12 50 M88 50 L95 50" stroke="#475569" strokeWidth="1.5" />
|
| 91 |
-
<path d="M30 30 L35 35 M65 65 L70 70 M65 30 L60 35 M35 65 L30 70" stroke="#0891b2" strokeWidth="1" opacity="0.5" />
|
| 92 |
|
| 93 |
{/* Scanning laser beam overlay */}
|
| 94 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="1.5" className="animate-laser" filter="url(#glow-logo)" />
|
|
@@ -102,41 +97,38 @@ export default function LoginPage() {
|
|
| 102 |
</defs>
|
| 103 |
</svg>
|
| 104 |
</div>
|
| 105 |
-
<h1 className="text-
|
| 106 |
NetraID
|
| 107 |
</h1>
|
| 108 |
-
<p className="text-slate-
|
| 109 |
-
AI Biometric Attendance
|
| 110 |
</p>
|
| 111 |
</div>
|
| 112 |
|
| 113 |
{/* Login card */}
|
| 114 |
-
<div className="
|
| 115 |
-
<div className="mb-
|
| 116 |
-
<h2 className="text-
|
| 117 |
-
|
| 118 |
</h2>
|
| 119 |
-
<p className="text-xs text-slate-500 mt-1">
|
| 120 |
-
Use your administrator credentials to continue
|
| 121 |
-
</p>
|
| 122 |
</div>
|
| 123 |
|
| 124 |
{/* Error state */}
|
| 125 |
{error && (
|
| 126 |
-
<div className="flex items-start gap-2
|
| 127 |
-
<ShieldAlert className="w-
|
| 128 |
<span className="leading-relaxed">{error}</span>
|
| 129 |
</div>
|
| 130 |
)}
|
| 131 |
|
| 132 |
-
<form onSubmit={handleSubmit} className="space-y-
|
| 133 |
{/* Email */}
|
| 134 |
<div className="space-y-1.5">
|
| 135 |
-
<label className="block text-[
|
| 136 |
Email Address
|
| 137 |
</label>
|
| 138 |
<div className="relative">
|
| 139 |
-
<Mail className="absolute left-3
|
| 140 |
<input
|
| 141 |
id="login-email"
|
| 142 |
type="email"
|
|
@@ -144,7 +136,7 @@ export default function LoginPage() {
|
|
| 144 |
placeholder="admin@netraid.ai"
|
| 145 |
value={email}
|
| 146 |
onChange={(e) => setEmail(e.target.value)}
|
| 147 |
-
className="input-field pl-icon
|
| 148 |
autoComplete="email"
|
| 149 |
suppressHydrationWarning
|
| 150 |
/>
|
|
@@ -153,11 +145,11 @@ export default function LoginPage() {
|
|
| 153 |
|
| 154 |
{/* Password */}
|
| 155 |
<div className="space-y-1.5">
|
| 156 |
-
<label className="block text-[
|
| 157 |
Password
|
| 158 |
</label>
|
| 159 |
<div className="relative">
|
| 160 |
-
<Lock className="absolute left-3
|
| 161 |
<input
|
| 162 |
id="login-password"
|
| 163 |
type={showPass ? "text" : "password"}
|
|
@@ -165,17 +157,17 @@ export default function LoginPage() {
|
|
| 165 |
placeholder="••••••••"
|
| 166 |
value={password}
|
| 167 |
onChange={(e) => setPassword(e.target.value)}
|
| 168 |
-
className="input-field pl-icon pr-icon
|
| 169 |
autoComplete="current-password"
|
| 170 |
suppressHydrationWarning
|
| 171 |
/>
|
| 172 |
<button
|
| 173 |
type="button"
|
| 174 |
onClick={() => setShowPass(!showPass)}
|
| 175 |
-
className="absolute right-3
|
| 176 |
suppressHydrationWarning
|
| 177 |
>
|
| 178 |
-
{showPass ? <EyeOff className="w-
|
| 179 |
</button>
|
| 180 |
</div>
|
| 181 |
</div>
|
|
@@ -185,18 +177,18 @@ export default function LoginPage() {
|
|
| 185 |
id="login-submit"
|
| 186 |
type="submit"
|
| 187 |
disabled={loading}
|
| 188 |
-
className="btn-primary w-full
|
| 189 |
suppressHydrationWarning
|
| 190 |
>
|
| 191 |
{loading ? (
|
| 192 |
<>
|
| 193 |
-
<div className="w-
|
| 194 |
<span>Authenticating...</span>
|
| 195 |
</>
|
| 196 |
) : (
|
| 197 |
<>
|
| 198 |
<span>Sign In</span>
|
| 199 |
-
<ArrowRight className="w-
|
| 200 |
</>
|
| 201 |
)}
|
| 202 |
</button>
|
|
@@ -204,21 +196,21 @@ export default function LoginPage() {
|
|
| 204 |
</div>
|
| 205 |
|
| 206 |
{/* Kiosk link */}
|
| 207 |
-
<div className="mt-
|
| 208 |
<a
|
| 209 |
href="/kiosk"
|
| 210 |
target="_blank"
|
| 211 |
rel="noopener noreferrer"
|
| 212 |
-
className="inline-flex items-center gap-
|
| 213 |
>
|
| 214 |
-
<ExternalLink className="w-3
|
| 215 |
<span>Open Kiosk Terminal</span>
|
| 216 |
</a>
|
| 217 |
</div>
|
| 218 |
|
| 219 |
{/* Footer */}
|
| 220 |
-
<p className="text-center text-[
|
| 221 |
-
NetraID v1.0.0
|
| 222 |
</p>
|
| 223 |
</div>
|
| 224 |
</div>
|
|
|
|
| 47 |
return (
|
| 48 |
<div className="min-h-screen relative bg-[var(--bg-base)] flex items-center justify-center overflow-hidden px-4">
|
| 49 |
{/* Grid Mesh background */}
|
| 50 |
+
<div className="absolute inset-0 mesh-bg pointer-events-none" />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
<div className="w-full max-w-sm relative z-10 animate-fadeInUp">
|
| 53 |
|
| 54 |
{/* Logo block */}
|
| 55 |
+
<div className="text-center mb-6">
|
| 56 |
+
<div className="relative inline-flex items-center justify-center w-18 h-18 rounded-2xl bg-slate-950 border border-slate-800/80 shadow-[0_0_20px_rgba(6,182,212,0.25)] mb-4 overflow-hidden group">
|
| 57 |
{/* Ambient glowing background inside the container */}
|
| 58 |
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
| 59 |
|
| 60 |
{/* Robotic eye SVG */}
|
| 61 |
+
<svg viewBox="0 0 100 100" className="w-11 h-11 relative z-10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 62 |
{/* Outer technical rotating rings */}
|
| 63 |
<circle cx="50" cy="50" r="45" stroke="#1e293b" strokeWidth="1.5" strokeDasharray="6 12" className="animate-rotate-ring" />
|
| 64 |
<circle cx="50" cy="50" r="40" stroke="#0891b2" strokeWidth="1" strokeDasharray="40 10 15 5" className="animate-rotate-ring-reverse" style={{ opacity: 0.6 }} />
|
|
|
|
| 84 |
|
| 85 |
{/* Futuristic crosshairs / HUD markers */}
|
| 86 |
<path d="M50 5 L50 12 M50 88 L50 95 M5 50 L12 50 M88 50 L95 50" stroke="#475569" strokeWidth="1.5" />
|
|
|
|
| 87 |
|
| 88 |
{/* Scanning laser beam overlay */}
|
| 89 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="1.5" className="animate-laser" filter="url(#glow-logo)" />
|
|
|
|
| 97 |
</defs>
|
| 98 |
</svg>
|
| 99 |
</div>
|
| 100 |
+
<h1 className="text-xl font-extrabold text-slate-900 tracking-tight">
|
| 101 |
NetraID
|
| 102 |
</h1>
|
| 103 |
+
<p className="text-slate-400 text-[11px] mt-0.5">
|
| 104 |
+
AI Biometric Attendance
|
| 105 |
</p>
|
| 106 |
</div>
|
| 107 |
|
| 108 |
{/* Login card */}
|
| 109 |
+
<div className="glass-overlay rounded-2xl p-6 shadow-[0_4px_30px_rgba(0,0,0,0.03)]">
|
| 110 |
+
<div className="mb-5 text-center">
|
| 111 |
+
<h2 className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
| 112 |
+
Admin Credentials
|
| 113 |
</h2>
|
|
|
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
|
| 116 |
{/* Error state */}
|
| 117 |
{error && (
|
| 118 |
+
<div className="flex items-start gap-2 bg-rose-500/5 border border-rose-500/15 text-rose-600 p-2.5 rounded-lg mb-4 text-[11.5px] animate-fadeInUp">
|
| 119 |
+
<ShieldAlert className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
| 120 |
<span className="leading-relaxed">{error}</span>
|
| 121 |
</div>
|
| 122 |
)}
|
| 123 |
|
| 124 |
+
<form onSubmit={handleSubmit} className="space-y-3.5">
|
| 125 |
{/* Email */}
|
| 126 |
<div className="space-y-1.5">
|
| 127 |
+
<label className="block text-[9.5px] font-bold text-slate-400 uppercase tracking-wider">
|
| 128 |
Email Address
|
| 129 |
</label>
|
| 130 |
<div className="relative">
|
| 131 |
+
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
|
| 132 |
<input
|
| 133 |
id="login-email"
|
| 134 |
type="email"
|
|
|
|
| 136 |
placeholder="admin@netraid.ai"
|
| 137 |
value={email}
|
| 138 |
onChange={(e) => setEmail(e.target.value)}
|
| 139 |
+
className="input-field pl-icon"
|
| 140 |
autoComplete="email"
|
| 141 |
suppressHydrationWarning
|
| 142 |
/>
|
|
|
|
| 145 |
|
| 146 |
{/* Password */}
|
| 147 |
<div className="space-y-1.5">
|
| 148 |
+
<label className="block text-[9.5px] font-bold text-slate-400 uppercase tracking-wider">
|
| 149 |
Password
|
| 150 |
</label>
|
| 151 |
<div className="relative">
|
| 152 |
+
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
|
| 153 |
<input
|
| 154 |
id="login-password"
|
| 155 |
type={showPass ? "text" : "password"}
|
|
|
|
| 157 |
placeholder="••••••••"
|
| 158 |
value={password}
|
| 159 |
onChange={(e) => setPassword(e.target.value)}
|
| 160 |
+
className="input-field pl-icon pr-icon"
|
| 161 |
autoComplete="current-password"
|
| 162 |
suppressHydrationWarning
|
| 163 |
/>
|
| 164 |
<button
|
| 165 |
type="button"
|
| 166 |
onClick={() => setShowPass(!showPass)}
|
| 167 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
| 168 |
suppressHydrationWarning
|
| 169 |
>
|
| 170 |
+
{showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
| 171 |
</button>
|
| 172 |
</div>
|
| 173 |
</div>
|
|
|
|
| 177 |
id="login-submit"
|
| 178 |
type="submit"
|
| 179 |
disabled={loading}
|
| 180 |
+
className="btn-primary w-full flex items-center justify-center gap-1.5 mt-2 h-9"
|
| 181 |
suppressHydrationWarning
|
| 182 |
>
|
| 183 |
{loading ? (
|
| 184 |
<>
|
| 185 |
+
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 186 |
<span>Authenticating...</span>
|
| 187 |
</>
|
| 188 |
) : (
|
| 189 |
<>
|
| 190 |
<span>Sign In</span>
|
| 191 |
+
<ArrowRight className="w-3.5 h-3.5" />
|
| 192 |
</>
|
| 193 |
)}
|
| 194 |
</button>
|
|
|
|
| 196 |
</div>
|
| 197 |
|
| 198 |
{/* Kiosk link */}
|
| 199 |
+
<div className="mt-4 text-center">
|
| 200 |
<a
|
| 201 |
href="/kiosk"
|
| 202 |
target="_blank"
|
| 203 |
rel="noopener noreferrer"
|
| 204 |
+
className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-slate-800 transition-colors"
|
| 205 |
>
|
| 206 |
+
<ExternalLink className="w-3 h-3" />
|
| 207 |
<span>Open Kiosk Terminal</span>
|
| 208 |
</a>
|
| 209 |
</div>
|
| 210 |
|
| 211 |
{/* Footer */}
|
| 212 |
+
<p className="text-center text-[9px] text-slate-400 mt-6 font-mono">
|
| 213 |
+
NetraID v1.0.0
|
| 214 |
</p>
|
| 215 |
</div>
|
| 216 |
</div>
|
frontend/app/providers.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import React, { useState } from "react";
|
| 4 |
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
| 5 |
|
| 6 |
export default function Providers({ children }: { children: React.ReactNode }) {
|
| 7 |
const [queryClient] = useState(
|
|
@@ -17,9 +18,23 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
|
| 17 |
})
|
| 18 |
);
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return (
|
| 21 |
<QueryClientProvider client={queryClient}>
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
</QueryClientProvider>
|
| 24 |
);
|
| 25 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 5 |
+
import { ToastProvider } from "./utils/toast";
|
| 6 |
|
| 7 |
export default function Providers({ children }: { children: React.ReactNode }) {
|
| 8 |
const [queryClient] = useState(
|
|
|
|
| 18 |
})
|
| 19 |
);
|
| 20 |
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
const savedTheme = localStorage.getItem("theme");
|
| 23 |
+
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
| 24 |
+
const initialTheme = savedTheme || (systemPrefersDark ? "dark" : "light");
|
| 25 |
+
|
| 26 |
+
if (initialTheme === "dark") {
|
| 27 |
+
document.documentElement.classList.add("dark");
|
| 28 |
+
} else {
|
| 29 |
+
document.documentElement.classList.remove("dark");
|
| 30 |
+
}
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
return (
|
| 34 |
<QueryClientProvider client={queryClient}>
|
| 35 |
+
<ToastProvider>
|
| 36 |
+
{children}
|
| 37 |
+
</ToastProvider>
|
| 38 |
</QueryClientProvider>
|
| 39 |
);
|
| 40 |
}
|
frontend/app/reports/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import React, { useState } from "react";
|
|
| 4 |
import { useQuery } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
import { fetchApi, getLocalDateString } from "@/app/utils/api";
|
|
|
|
| 7 |
import {
|
| 8 |
FileText, Calendar, Download, Loader2, CheckCircle2,
|
| 9 |
FileSpreadsheet, FileDown, CalendarDays, User, Layers, ChevronDown, Check
|
|
@@ -23,6 +24,7 @@ const FORMATS = [
|
|
| 23 |
];
|
| 24 |
|
| 25 |
export default function ReportsPage() {
|
|
|
|
| 26 |
const [reportType, setReportType] = useState("daily");
|
| 27 |
const [format, setFormat] = useState("xlsx");
|
| 28 |
const [startDate, setStartDate] = useState(() => {
|
|
@@ -55,7 +57,7 @@ export default function ReportsPage() {
|
|
| 55 |
setSuccess(true);
|
| 56 |
setTimeout(() => setSuccess(false), 5000);
|
| 57 |
} catch (err: any) {
|
| 58 |
-
|
| 59 |
} finally {
|
| 60 |
setExporting(false);
|
| 61 |
}
|
|
@@ -70,9 +72,6 @@ export default function ReportsPage() {
|
|
| 70 |
{/* Header */}
|
| 71 |
<div className="pb-5 border-b border-white/5">
|
| 72 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Reports & Export</h1>
|
| 73 |
-
<p className="text-xs text-slate-500 mt-0.5">
|
| 74 |
-
Compile and generate compliance-ready corporate records in PDF, Excel, or CSV format.
|
| 75 |
-
</p>
|
| 76 |
</div>
|
| 77 |
|
| 78 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
|
@@ -84,7 +83,6 @@ export default function ReportsPage() {
|
|
| 84 |
</div>
|
| 85 |
<div>
|
| 86 |
<h2 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Export Parameters</h2>
|
| 87 |
-
<p className="text-[10px] text-slate-500 mt-0.5">Specify report criteria and format</p>
|
| 88 |
</div>
|
| 89 |
</div>
|
| 90 |
|
|
|
|
| 4 |
import { useQuery } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
import { fetchApi, getLocalDateString } from "@/app/utils/api";
|
| 7 |
+
import { useToast } from "@/app/utils/toast";
|
| 8 |
import {
|
| 9 |
FileText, Calendar, Download, Loader2, CheckCircle2,
|
| 10 |
FileSpreadsheet, FileDown, CalendarDays, User, Layers, ChevronDown, Check
|
|
|
|
| 24 |
];
|
| 25 |
|
| 26 |
export default function ReportsPage() {
|
| 27 |
+
const { toast } = useToast();
|
| 28 |
const [reportType, setReportType] = useState("daily");
|
| 29 |
const [format, setFormat] = useState("xlsx");
|
| 30 |
const [startDate, setStartDate] = useState(() => {
|
|
|
|
| 57 |
setSuccess(true);
|
| 58 |
setTimeout(() => setSuccess(false), 5000);
|
| 59 |
} catch (err: any) {
|
| 60 |
+
toast.error(err.message || "Failed to generate report.");
|
| 61 |
} finally {
|
| 62 |
setExporting(false);
|
| 63 |
}
|
|
|
|
| 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">
|
|
|
|
| 83 |
</div>
|
| 84 |
<div>
|
| 85 |
<h2 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Export Parameters</h2>
|
|
|
|
| 86 |
</div>
|
| 87 |
</div>
|
| 88 |
|
frontend/app/settings/page.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import React, { useState } from "react";
|
|
| 4 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
import { fetchApi } from "@/app/utils/api";
|
|
|
|
| 7 |
import {
|
| 8 |
Check, Loader2, CheckCircle2, AlertCircle, Sliders, Info,
|
| 9 |
Fingerprint, Clock, Volume2
|
|
@@ -13,6 +14,7 @@ type SettingsTab = "biometrics" | "shift" | "voice" | "advanced";
|
|
| 13 |
|
| 14 |
export default function SettingsPage() {
|
| 15 |
const queryClient = useQueryClient();
|
|
|
|
| 16 |
const [activeTab, setActiveTab] = useState<SettingsTab>("biometrics");
|
| 17 |
const [updatingKey, setUpdatingKey] = useState<string | null>(null);
|
| 18 |
const [successKey, setSuccessKey] = useState<string | null>(null);
|
|
@@ -40,7 +42,7 @@ export default function SettingsPage() {
|
|
| 40 |
},
|
| 41 |
onError: (err: any, vars) => {
|
| 42 |
setUpdatingKey(null);
|
| 43 |
-
|
| 44 |
}
|
| 45 |
});
|
| 46 |
|
|
@@ -209,9 +211,6 @@ export default function SettingsPage() {
|
|
| 209 |
{/* Header */}
|
| 210 |
<div className="pb-5 border-b border-slate-200">
|
| 211 |
<h1 className="text-xl font-bold text-slate-900 tracking-tight">System Configuration</h1>
|
| 212 |
-
<p className="text-xs text-slate-500 mt-0.5">
|
| 213 |
-
Optimize biometric accuracy rates, manage shift schedule rules, and toggle voice assistant audio.
|
| 214 |
-
</p>
|
| 215 |
</div>
|
| 216 |
|
| 217 |
{/* Outer container */}
|
|
@@ -252,9 +251,6 @@ export default function SettingsPage() {
|
|
| 252 |
<h2 className="text-sm font-bold text-slate-900">
|
| 253 |
{getTabLabel(activeTab)}
|
| 254 |
</h2>
|
| 255 |
-
<p className="text-[11px] text-slate-400 mt-0.5">
|
| 256 |
-
{getTabDesc(activeTab)}
|
| 257 |
-
</p>
|
| 258 |
</div>
|
| 259 |
|
| 260 |
{isLoading ? (
|
|
|
|
| 4 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
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
|
|
|
|
| 14 |
|
| 15 |
export default function SettingsPage() {
|
| 16 |
const queryClient = useQueryClient();
|
| 17 |
+
const { toast } = useToast();
|
| 18 |
const [activeTab, setActiveTab] = useState<SettingsTab>("biometrics");
|
| 19 |
const [updatingKey, setUpdatingKey] = useState<string | null>(null);
|
| 20 |
const [successKey, setSuccessKey] = useState<string | null>(null);
|
|
|
|
| 42 |
},
|
| 43 |
onError: (err: any, vars) => {
|
| 44 |
setUpdatingKey(null);
|
| 45 |
+
toast.error(err.message || `Failed to save ${vars.key}`);
|
| 46 |
}
|
| 47 |
});
|
| 48 |
|
|
|
|
| 211 |
{/* Header */}
|
| 212 |
<div className="pb-5 border-b border-slate-200">
|
| 213 |
<h1 className="text-xl font-bold text-slate-900 tracking-tight">System Configuration</h1>
|
|
|
|
|
|
|
|
|
|
| 214 |
</div>
|
| 215 |
|
| 216 |
{/* Outer container */}
|
|
|
|
| 251 |
<h2 className="text-sm font-bold text-slate-900">
|
| 252 |
{getTabLabel(activeTab)}
|
| 253 |
</h2>
|
|
|
|
|
|
|
|
|
|
| 254 |
</div>
|
| 255 |
|
| 256 |
{isLoading ? (
|
frontend/app/utils/api.ts
CHANGED
|
@@ -124,9 +124,10 @@ export async function fetchApi(endpoint: string, options: RequestInit = {}): Pro
|
|
| 124 |
|
| 125 |
export function parseDateTime(dateStr: string | null | undefined): Date | null {
|
| 126 |
if (!dateStr) return null;
|
| 127 |
-
// If
|
|
|
|
| 128 |
const hasTimezone = dateStr.endsWith("Z") || dateStr.includes("+") || /-\d{2}:\d{2}$/.test(dateStr);
|
| 129 |
-
const formattedStr = hasTimezone ? dateStr :
|
| 130 |
return new Date(formattedStr);
|
| 131 |
}
|
| 132 |
|
|
|
|
| 124 |
|
| 125 |
export function parseDateTime(dateStr: string | null | undefined): Date | null {
|
| 126 |
if (!dateStr) return null;
|
| 127 |
+
// If the date string has a timezone, parse it as-is.
|
| 128 |
+
// Otherwise, treat it as a local ISO string (do NOT append 'Z', as backend stores local time).
|
| 129 |
const hasTimezone = dateStr.endsWith("Z") || dateStr.includes("+") || /-\d{2}:\d{2}$/.test(dateStr);
|
| 130 |
+
const formattedStr = hasTimezone ? dateStr : dateStr.replace(" ", "T");
|
| 131 |
return new Date(formattedStr);
|
| 132 |
}
|
| 133 |
|
frontend/app/utils/toast.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { createContext, useContext, useState, useCallback } from "react";
|
| 4 |
+
import { AlertCircle, CheckCircle2, Info, X } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
type ToastType = "success" | "error" | "info" | "warning";
|
| 7 |
+
|
| 8 |
+
interface Toast {
|
| 9 |
+
id: string;
|
| 10 |
+
message: string;
|
| 11 |
+
type: ToastType;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface ToastContextType {
|
| 15 |
+
toast: {
|
| 16 |
+
success: (message: string) => void;
|
| 17 |
+
error: (message: string) => void;
|
| 18 |
+
info: (message: string) => void;
|
| 19 |
+
warning: (message: string) => void;
|
| 20 |
+
};
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
| 24 |
+
|
| 25 |
+
export function useToast() {
|
| 26 |
+
const context = useContext(ToastContext);
|
| 27 |
+
if (!context) {
|
| 28 |
+
throw new Error("useToast must be used within a ToastProvider");
|
| 29 |
+
}
|
| 30 |
+
return context;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
| 34 |
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
| 35 |
+
|
| 36 |
+
const removeToast = useCallback((id: string) => {
|
| 37 |
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
| 38 |
+
}, []);
|
| 39 |
+
|
| 40 |
+
const addToast = useCallback((message: string, type: ToastType) => {
|
| 41 |
+
const id = Math.random().toString(36).substring(2, 9);
|
| 42 |
+
setToasts((prev) => [...prev, { id, message, type }]);
|
| 43 |
+
|
| 44 |
+
// Auto remove after 4.5 seconds
|
| 45 |
+
setTimeout(() => {
|
| 46 |
+
removeToast(id);
|
| 47 |
+
}, 4500);
|
| 48 |
+
}, [removeToast]);
|
| 49 |
+
|
| 50 |
+
const toast = {
|
| 51 |
+
success: (msg: string) => addToast(msg, "success"),
|
| 52 |
+
error: (msg: string) => addToast(msg, "error"),
|
| 53 |
+
info: (msg: string) => addToast(msg, "info"),
|
| 54 |
+
warning: (msg: string) => addToast(msg, "warning"),
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<ToastContext.Provider value={{ toast }}>
|
| 59 |
+
{children}
|
| 60 |
+
<style>{`
|
| 61 |
+
@keyframes slideInRight {
|
| 62 |
+
from {
|
| 63 |
+
transform: translateX(105%);
|
| 64 |
+
opacity: 0;
|
| 65 |
+
}
|
| 66 |
+
to {
|
| 67 |
+
transform: translateX(0);
|
| 68 |
+
opacity: 1;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
.animate-slide-in-right {
|
| 72 |
+
animation: slideInRight 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 73 |
+
}
|
| 74 |
+
`}</style>
|
| 75 |
+
|
| 76 |
+
{/* Toast Portal Container */}
|
| 77 |
+
<div className="fixed top-5 right-5 z-[9999] flex flex-col gap-2.5 max-w-sm w-[calc(100vw-40px)] pointer-events-none">
|
| 78 |
+
{toasts.map((t) => (
|
| 79 |
+
<div
|
| 80 |
+
key={t.id}
|
| 81 |
+
className={`pointer-events-auto flex items-start gap-3 p-3.5 rounded-2xl border shadow-lg transition-all duration-300 animate-slide-in-right ${
|
| 82 |
+
t.type === "success"
|
| 83 |
+
? "bg-emerald-50/90 border-emerald-200 text-emerald-900 dark:bg-emerald-950/90 dark:border-emerald-900/40 dark:text-emerald-200"
|
| 84 |
+
: t.type === "error"
|
| 85 |
+
? "bg-rose-50/90 border-rose-200 text-rose-900 dark:bg-rose-950/90 dark:border-rose-900/40 dark:text-rose-200"
|
| 86 |
+
: t.type === "warning"
|
| 87 |
+
? "bg-amber-50/90 border-amber-200 text-amber-900 dark:bg-amber-950/90 dark:border-amber-900/40 dark:text-amber-200"
|
| 88 |
+
: "bg-blue-50/90 border-blue-200 text-blue-900 dark:bg-blue-950/90 dark:border-blue-900/40 dark:text-blue-200"
|
| 89 |
+
}`}
|
| 90 |
+
style={{ backdropFilter: "blur(12px)" }}
|
| 91 |
+
>
|
| 92 |
+
{t.type === "success" && <CheckCircle2 className="w-4.5 h-4.5 text-emerald-500 shrink-0 mt-0.5" />}
|
| 93 |
+
{t.type === "error" && <AlertCircle className="w-4.5 h-4.5 text-rose-500 shrink-0 mt-0.5" />}
|
| 94 |
+
{t.type === "warning" && <AlertCircle className="w-4.5 h-4.5 text-amber-500 shrink-0 mt-0.5" />}
|
| 95 |
+
{t.type === "info" && <Info className="w-4.5 h-4.5 text-blue-500 shrink-0 mt-0.5" />}
|
| 96 |
+
|
| 97 |
+
<p className="text-[12px] font-semibold flex-1 leading-relaxed">{t.message}</p>
|
| 98 |
+
|
| 99 |
+
<button
|
| 100 |
+
onClick={() => removeToast(t.id)}
|
| 101 |
+
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-lg text-slate-400 hover:text-slate-600 dark:text-slate-400 dark:hover:text-slate-200 transition-colors cursor-pointer"
|
| 102 |
+
>
|
| 103 |
+
<X className="w-3.5 h-3.5" />
|
| 104 |
+
</button>
|
| 105 |
+
</div>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
</ToastContext.Provider>
|
| 109 |
+
);
|
| 110 |
+
}
|
frontend/components/SidebarLayout.tsx
CHANGED
|
@@ -15,14 +15,16 @@ import {
|
|
| 15 |
X,
|
| 16 |
ChevronRight,
|
| 17 |
Scan,
|
| 18 |
-
History
|
|
|
|
|
|
|
| 19 |
} from "lucide-react";
|
| 20 |
import { getAccessToken, getUserProfile, clearTokens } from "@/app/utils/api";
|
| 21 |
|
| 22 |
const navItems = [
|
| 23 |
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
| 24 |
-
{ name: "Employees", href: "/employees", icon: Users },
|
| 25 |
{ name: "Attendance", href: "/attendance", icon: Clock },
|
|
|
|
| 26 |
{ name: "Reports", href: "/reports", icon: FileSpreadsheet },
|
| 27 |
{ name: "Audit Logs", href: "/audit", icon: History },
|
| 28 |
{ name: "Settings", href: "/settings", icon: Settings },
|
|
@@ -85,14 +87,38 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 85 |
const [user, setUser] = useState<any>(null);
|
| 86 |
const [authorized, setAuthorized] = useState(false);
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
useEffect(() => {
|
| 90 |
const saved = localStorage.getItem("sidebar_collapsed");
|
| 91 |
if (saved === "true") {
|
| 92 |
setIsCollapsed(true);
|
| 93 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}, []);
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
const toggleCollapse = () => {
|
| 97 |
const nextVal = !isCollapsed;
|
| 98 |
setIsCollapsed(nextVal);
|
|
@@ -148,10 +174,10 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 148 |
<div className={`flex items-center ${isCollapsed ? "justify-center" : "justify-between"} px-1 py-3 mb-6`}>
|
| 149 |
{!isCollapsed && (
|
| 150 |
<div className="flex items-center gap-3">
|
| 151 |
-
<div className="relative inline-flex items-center justify-center w-
|
| 152 |
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.
|
| 153 |
-
<div className="absolute -bottom-0.5 -right-0.5 w-
|
| 154 |
-
<svg viewBox="0 0 100 100" className="w-
|
| 155 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 156 |
<circle cx="50" cy="50" r="40" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.8 }} />
|
| 157 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
|
@@ -163,21 +189,18 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 163 |
</svg>
|
| 164 |
</div>
|
| 165 |
<div>
|
| 166 |
-
<h1 className="font-
|
| 167 |
NetraID
|
| 168 |
</h1>
|
| 169 |
-
<p className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest uppercase mt-0.5">
|
| 170 |
-
Face Auth
|
| 171 |
-
</p>
|
| 172 |
</div>
|
| 173 |
</div>
|
| 174 |
)}
|
| 175 |
|
| 176 |
{isCollapsed && (
|
| 177 |
-
<div className="relative inline-flex items-center justify-center w-
|
| 178 |
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.
|
| 179 |
-
<div className="absolute -bottom-0.5 -right-0.5 w-
|
| 180 |
-
<svg viewBox="0 0 100 100" className="w-
|
| 181 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 182 |
<circle cx="50" cy="50" r="40" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.8 }} />
|
| 183 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
|
@@ -196,7 +219,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 196 |
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)] transition-all cursor-pointer"
|
| 197 |
title="Collapse sidebar"
|
| 198 |
>
|
| 199 |
-
<Menu className="w-4
|
| 200 |
</button>
|
| 201 |
)}
|
| 202 |
</div>
|
|
@@ -208,7 +231,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 208 |
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)] transition-all cursor-pointer"
|
| 209 |
title="Expand sidebar"
|
| 210 |
>
|
| 211 |
-
<Menu className="w-4
|
| 212 |
</button>
|
| 213 |
</div>
|
| 214 |
)}
|
|
@@ -249,6 +272,22 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 249 |
</>
|
| 250 |
)}
|
| 251 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
</nav>
|
| 253 |
|
| 254 |
{/* User Profile Footer */}
|
|
@@ -298,11 +337,11 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 298 |
|
| 299 |
{/* ─── Mobile Top Bar ─── */}
|
| 300 |
<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">
|
| 301 |
-
<div className="flex items-center gap-
|
| 302 |
-
<div className="relative inline-flex items-center justify-center w-
|
| 303 |
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.
|
| 304 |
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500 border border-white z-20" />
|
| 305 |
-
<svg viewBox="0 0 100 100" className="w-
|
| 306 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 307 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
| 308 |
<g className="animate-eye-lid">
|
|
@@ -312,14 +351,24 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 312 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 313 |
</svg>
|
| 314 |
</div>
|
| 315 |
-
<span className="font-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
</div>
|
| 317 |
-
<button
|
| 318 |
-
onClick={() => setSidebarOpen(!sidebarOpen)}
|
| 319 |
-
className="p-2 rounded-lg hover:bg-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all cursor-pointer"
|
| 320 |
-
>
|
| 321 |
-
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
| 322 |
-
</button>
|
| 323 |
</div>
|
| 324 |
|
| 325 |
{/* ─── Mobile Drawer ─── */}
|
|
@@ -333,10 +382,10 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 333 |
onClick={(e) => e.stopPropagation()}
|
| 334 |
>
|
| 335 |
<div className="flex items-center gap-3 px-1 py-3 mb-6 mt-14">
|
| 336 |
-
<div className="relative inline-flex items-center justify-center w-
|
| 337 |
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.
|
| 338 |
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500 border border-white z-20" />
|
| 339 |
-
<svg viewBox="0 0 100 100" className="w-
|
| 340 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 341 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
| 342 |
<g className="animate-eye-lid">
|
|
@@ -346,7 +395,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 346 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 347 |
</svg>
|
| 348 |
</div>
|
| 349 |
-
<h1 className="font-
|
| 350 |
</div>
|
| 351 |
|
| 352 |
<nav className="flex-1 space-y-0.5">
|
|
@@ -389,6 +438,19 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 389 |
|
| 390 |
{/* ─── Main Content ─── */}
|
| 391 |
<main className="flex-1 min-h-screen overflow-y-auto relative z-10 pt-14 md:pt-0">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
<div className="max-w-7xl mx-auto px-5 py-6 md:px-8 md:py-8">
|
| 393 |
{children}
|
| 394 |
</div>
|
|
|
|
| 15 |
X,
|
| 16 |
ChevronRight,
|
| 17 |
Scan,
|
| 18 |
+
History,
|
| 19 |
+
Sun,
|
| 20 |
+
Moon
|
| 21 |
} from "lucide-react";
|
| 22 |
import { getAccessToken, getUserProfile, clearTokens } from "@/app/utils/api";
|
| 23 |
|
| 24 |
const navItems = [
|
| 25 |
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
|
|
|
| 26 |
{ name: "Attendance", href: "/attendance", icon: Clock },
|
| 27 |
+
{ name: "Employees", href: "/employees", icon: Users },
|
| 28 |
{ name: "Reports", href: "/reports", icon: FileSpreadsheet },
|
| 29 |
{ name: "Audit Logs", href: "/audit", icon: History },
|
| 30 |
{ name: "Settings", href: "/settings", icon: Settings },
|
|
|
|
| 87 |
const [user, setUser] = useState<any>(null);
|
| 88 |
const [authorized, setAuthorized] = useState(false);
|
| 89 |
|
| 90 |
+
const [theme, setTheme] = useState<"light" | "dark">("light");
|
| 91 |
+
|
| 92 |
+
// Load theme and sidebar state from localStorage on client side
|
| 93 |
useEffect(() => {
|
| 94 |
const saved = localStorage.getItem("sidebar_collapsed");
|
| 95 |
if (saved === "true") {
|
| 96 |
setIsCollapsed(true);
|
| 97 |
}
|
| 98 |
+
|
| 99 |
+
const savedTheme = localStorage.getItem("theme");
|
| 100 |
+
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
| 101 |
+
const initialTheme = (savedTheme as "light" | "dark") || (systemPrefersDark ? "dark" : "light");
|
| 102 |
+
|
| 103 |
+
setTheme(initialTheme);
|
| 104 |
+
if (initialTheme === "dark") {
|
| 105 |
+
document.documentElement.classList.add("dark");
|
| 106 |
+
} else {
|
| 107 |
+
document.documentElement.classList.remove("dark");
|
| 108 |
+
}
|
| 109 |
}, []);
|
| 110 |
|
| 111 |
+
const toggleTheme = () => {
|
| 112 |
+
const nextTheme = theme === "dark" ? "light" : "dark";
|
| 113 |
+
setTheme(nextTheme);
|
| 114 |
+
localStorage.setItem("theme", nextTheme);
|
| 115 |
+
if (nextTheme === "dark") {
|
| 116 |
+
document.documentElement.classList.add("dark");
|
| 117 |
+
} else {
|
| 118 |
+
document.documentElement.classList.remove("dark");
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
const toggleCollapse = () => {
|
| 123 |
const nextVal = !isCollapsed;
|
| 124 |
setIsCollapsed(nextVal);
|
|
|
|
| 174 |
<div className={`flex items-center ${isCollapsed ? "justify-center" : "justify-between"} px-1 py-3 mb-6`}>
|
| 175 |
{!isCollapsed && (
|
| 176 |
<div className="flex items-center gap-3">
|
| 177 |
+
<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">
|
| 178 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
| 179 |
+
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500 border border-white z-20" />
|
| 180 |
+
<svg viewBox="0 0 100 100" className="w-6 h-6 relative z-10 animate-fade-in" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 181 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 182 |
<circle cx="50" cy="50" r="40" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.8 }} />
|
| 183 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
|
|
|
| 189 |
</svg>
|
| 190 |
</div>
|
| 191 |
<div>
|
| 192 |
+
<h1 className="font-extrabold text-[17px] tracking-tight text-[var(--text-primary)] leading-none">
|
| 193 |
NetraID
|
| 194 |
</h1>
|
|
|
|
|
|
|
|
|
|
| 195 |
</div>
|
| 196 |
</div>
|
| 197 |
)}
|
| 198 |
|
| 199 |
{isCollapsed && (
|
| 200 |
+
<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">
|
| 201 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
| 202 |
+
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500 border border-white z-20" />
|
| 203 |
+
<svg viewBox="0 0 100 100" className="w-6 h-6 relative z-10 animate-fade-in" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 204 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 205 |
<circle cx="50" cy="50" r="40" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.8 }} />
|
| 206 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
|
|
|
| 219 |
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)] transition-all cursor-pointer"
|
| 220 |
title="Collapse sidebar"
|
| 221 |
>
|
| 222 |
+
<Menu className="w-4 h-4" />
|
| 223 |
</button>
|
| 224 |
)}
|
| 225 |
</div>
|
|
|
|
| 231 |
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)] transition-all cursor-pointer"
|
| 232 |
title="Expand sidebar"
|
| 233 |
>
|
| 234 |
+
<Menu className="w-4 h-4" />
|
| 235 |
</button>
|
| 236 |
</div>
|
| 237 |
)}
|
|
|
|
| 272 |
</>
|
| 273 |
)}
|
| 274 |
</a>
|
| 275 |
+
|
| 276 |
+
{/* Admin Log Out */}
|
| 277 |
+
<button
|
| 278 |
+
onClick={handleLogout}
|
| 279 |
+
data-tooltip={isCollapsed ? "Admin Log Out" : undefined}
|
| 280 |
+
className={`group flex items-center ${isCollapsed ? "justify-center px-2" : "gap-3 px-3"} py-2.5 rounded-xl text-[var(--text-secondary)] hover:text-rose-600 hover:bg-rose-500/10 dark:hover:bg-rose-500/15 transition-all duration-200 w-full text-left cursor-pointer`}
|
| 281 |
+
>
|
| 282 |
+
<div className="w-8 h-8 rounded-lg bg-[var(--border-subtle)] flex items-center justify-center group-hover:bg-rose-600 transition-all">
|
| 283 |
+
<LogOut className="w-4 h-4 text-[var(--text-secondary)] group-hover:text-white" />
|
| 284 |
+
</div>
|
| 285 |
+
{!isCollapsed && (
|
| 286 |
+
<div className="flex-1">
|
| 287 |
+
<p className="text-sm font-medium">Admin Log Out</p>
|
| 288 |
+
</div>
|
| 289 |
+
)}
|
| 290 |
+
</button>
|
| 291 |
</nav>
|
| 292 |
|
| 293 |
{/* User Profile Footer */}
|
|
|
|
| 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" />
|
| 343 |
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500 border border-white z-20" />
|
| 344 |
+
<svg viewBox="0 0 100 100" className="w-6 h-6 relative z-10 animate-fade-in" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 345 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 346 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
| 347 |
<g className="animate-eye-lid">
|
|
|
|
| 351 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 352 |
</svg>
|
| 353 |
</div>
|
| 354 |
+
<span className="font-extrabold text-[17px] text-[var(--text-primary)] tracking-tight">NetraID</span>
|
| 355 |
+
</div>
|
| 356 |
+
<div className="flex items-center gap-2">
|
| 357 |
+
{/* Theme Toggle Mobile */}
|
| 358 |
+
<button
|
| 359 |
+
onClick={toggleTheme}
|
| 360 |
+
className="p-2 rounded-lg hover:bg-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all cursor-pointer"
|
| 361 |
+
title="Toggle theme"
|
| 362 |
+
>
|
| 363 |
+
{theme === "dark" ? <Sun className="w-5 h-5 text-amber-500" /> : <Moon className="w-5 h-5" />}
|
| 364 |
+
</button>
|
| 365 |
+
<button
|
| 366 |
+
onClick={() => setSidebarOpen(!sidebarOpen)}
|
| 367 |
+
className="p-2 rounded-lg hover:bg-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all cursor-pointer"
|
| 368 |
+
>
|
| 369 |
+
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
| 370 |
+
</button>
|
| 371 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
</div>
|
| 373 |
|
| 374 |
{/* ─── Mobile Drawer ─── */}
|
|
|
|
| 382 |
onClick={(e) => e.stopPropagation()}
|
| 383 |
>
|
| 384 |
<div className="flex items-center gap-3 px-1 py-3 mb-6 mt-14">
|
| 385 |
+
<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">
|
| 386 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
| 387 |
<div className="absolute -bottom-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-emerald-500 border border-white z-20" />
|
| 388 |
+
<svg viewBox="0 0 100 100" className="w-6 h-6 relative z-10 animate-fade-in" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 389 |
<circle cx="50" cy="50" r="45" stroke="#334155" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 390 |
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#64748b" strokeWidth="2.5" />
|
| 391 |
<g className="animate-eye-lid">
|
|
|
|
| 395 |
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 396 |
</svg>
|
| 397 |
</div>
|
| 398 |
+
<h1 className="font-extrabold text-[17px] text-[var(--text-primary)] tracking-tight">NetraID</h1>
|
| 399 |
</div>
|
| 400 |
|
| 401 |
<nav className="flex-1 space-y-0.5">
|
|
|
|
| 438 |
|
| 439 |
{/* ─── Main Content ─── */}
|
| 440 |
<main className="flex-1 min-h-screen overflow-y-auto relative z-10 pt-14 md:pt-0">
|
| 441 |
+
{/* Desktop Top Navbar */}
|
| 442 |
+
<header className="hidden md:flex h-14 border-b border-[var(--border-subtle)] px-8 items-center justify-end bg-[var(--bg-surface)]/95 backdrop-blur-xl sticky top-0 z-30">
|
| 443 |
+
<div className="flex items-center gap-2">
|
| 444 |
+
<button
|
| 445 |
+
onClick={toggleTheme}
|
| 446 |
+
className="p-2 rounded-lg hover:bg-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all cursor-pointer"
|
| 447 |
+
title="Toggle theme"
|
| 448 |
+
>
|
| 449 |
+
{theme === "dark" ? <Sun className="w-5 h-5 text-amber-500" /> : <Moon className="w-5 h-5" />}
|
| 450 |
+
</button>
|
| 451 |
+
</div>
|
| 452 |
+
</header>
|
| 453 |
+
|
| 454 |
<div className="max-w-7xl mx-auto px-5 py-6 md:px-8 md:py-8">
|
| 455 |
{children}
|
| 456 |
</div>
|
frontend/tsconfig.tsbuildinfo
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","../../../../node_modules/buffer/index.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/lib/fallback.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/lib/revalidate.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/worker.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/build/rendering-mode.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/lib/experimental/ppr.d.ts","./node_modules/next/dist/build/webpack/plugins/app-build-manifest-plugin.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","./node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/route-kind.d.ts","./node_modules/next/dist/server/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/server/lib/cache-handlers/types.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","./node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/route-modules/route-module.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/client/flight-data-helpers.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/instrumentation/types.d.ts","./node_modules/next/dist/server/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/lib/i18n-provider.d.ts","./node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/normalizers/normalizer.d.ts","./node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/after/builtin-request-context.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/server/web/adapter.d.ts","./node_modules/next/dist/server/use-cache/cache-life.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","./node_modules/next/dist/server/request/fallback-params.d.ts","./node_modules/next/dist/server/app-render/clean-async-snapshot-instance.d.ts","./node_modules/next/dist/server/app-render/clean-async-snapshot.external.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/server/app-render/cache-signal.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","./node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/client-segment.d.ts","./node_modules/next/dist/server/request/search-params.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/client/components/not-found-boundary.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/dist/lib/metadata/types/resolvers.d.ts","./node_modules/next/dist/lib/metadata/metadata.d.ts","./node_modules/next/dist/lib/metadata/metadata-boundary.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/collect-segment-data.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/server/node-environment-baseline.d.ts","./node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","./node_modules/next/dist/server/node-environment-extensions/random.d.ts","./node_modules/next/dist/server/node-environment-extensions/date.d.ts","./node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","./node_modules/next/dist/server/async-storage/work-store.d.ts","./node_modules/next/dist/server/web/http.d.ts","./node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/build/templates/app-route.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","./node_modules/next/dist/build/segment-config/app/app-segments.d.ts","./node_modules/next/dist/build/utils.d.ts","./node_modules/next/dist/build/turborepo-access-trace/types.d.ts","./node_modules/next/dist/build/turborepo-access-trace/result.d.ts","./node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","./node_modules/next/dist/build/turborepo-access-trace/index.d.ts","./node_modules/next/dist/export/types.d.ts","./node_modules/next/dist/export/worker.d.ts","./node_modules/next/dist/build/worker.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/server/after/after.d.ts","./node_modules/next/dist/server/after/after-context.d.ts","./node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","./node_modules/next/dist/server/request/params.d.ts","./node_modules/next/dist/server/route-matches/route-match.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/cli/next-test.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/swc/generated-native.d.ts","./node_modules/next/dist/build/swc/types.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/client/components/react-dev-overlay/types.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/lru-cache.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/types.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/dist/server/use-cache/cache-tag.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/server/request/cookies.d.ts","./node_modules/next/dist/server/request/headers.d.ts","./node_modules/next/dist/server/request/draft-mode.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/dist/server/after/index.d.ts","./node_modules/next/dist/server/request/connection.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/types.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./app/utils/api.ts","./node_modules/next/dist/compiled/@next/font/dist/types.d.ts","./node_modules/next/dist/compiled/@next/font/dist/google/index.d.ts","./node_modules/next/font/google/index.d.ts","./node_modules/@tanstack/query-core/build/legacy/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/query-core/build/legacy/index.d.ts","./node_modules/@tanstack/react-query/build/legacy/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/react-query/build/legacy/index.d.ts","./node_modules/lucide-react/dist/lucide-react.d.ts","./app/utils/toast.tsx","./app/providers.tsx","./app/layout.tsx","./app/page.tsx","./components/sidebarlayout.tsx","./app/attendance/page.tsx","./app/audit/page.tsx","./node_modules/echarts/types/dist/echarts.d.ts","./node_modules/echarts/index.d.ts","./node_modules/echarts-for-react/lib/types.d.ts","./node_modules/echarts-for-react/lib/core.d.ts","./node_modules/echarts-for-react/lib/index.d.ts","./app/dashboard/page.tsx","./app/employees/page.tsx","./app/enroll/[id]/page.tsx","./app/kiosk/page.tsx","./app/reports/page.tsx","./app/settings/page.tsx","./.next/types/cache-life.d.ts","./.next/types/app/layout.ts","./.next/types/app/page.ts","./.next/types/app/attendance/page.ts","./.next/types/app/audit/page.ts","./.next/types/app/dashboard/page.ts","./.next/types/app/employees/page.ts","./.next/types/app/enroll/[id]/page.ts","./.next/types/app/kiosk/page.ts","./.next/types/app/reports/page.ts","./.next/types/app/settings/page.ts","../../../../node_modules/@types/connect/index.d.ts","../../../../node_modules/@types/body-parser/index.d.ts","../../../../node_modules/@types/bonjour/index.d.ts","../../../../node_modules/@types/send/index.d.ts","../../../../node_modules/@types/qs/index.d.ts","../../../../node_modules/@types/range-parser/index.d.ts","../../../../node_modules/@types/express-serve-static-core/index.d.ts","../../../../node_modules/@types/connect-history-api-fallback/index.d.ts","../../../../node_modules/@types/estree/index.d.ts","../../../../node_modules/@types/json-schema/index.d.ts","../../../../node_modules/@types/eslint/use-at-your-own-risk.d.ts","../../../../node_modules/@types/eslint/index.d.ts","../../../../node_modules/@types/eslint-scope/index.d.ts","../../../../node_modules/@types/http-errors/index.d.ts","../../../../node_modules/@types/mime/index.d.ts","../../../../node_modules/@types/serve-static/node_modules/@types/send/index.d.ts","../../../../node_modules/@types/serve-static/index.d.ts","../../../../node_modules/@types/express/index.d.ts","../../../../node_modules/@types/http-proxy/index.d.ts","../../../../node_modules/@types/node-forge/index.d.ts","../../../../node_modules/@types/retry/index.d.ts","../../../../node_modules/@types/serve-index/index.d.ts","../../../../node_modules/@types/sockjs/index.d.ts","../../../../node_modules/@types/ws/index.d.ts"],"fileIdsList":[[99,146,160,194,501],[99,146,152,194],[99,146,187,194,507],[99,146,160,194],[99,146,509,512],[99,146,509,510,511],[99,146,512],[99,146],[99,146,157,160,194,504,505,506],[99,146,502,505,507,517],[99,146,157,160,162,165,176,187,194],[99,146,194],[99,146,158,176,194],[99,146,158,518],[99,146,160,194,514,516],[99,146,158,176,194,515],[99,146,157,160,162,165,176,184,187,193,194],[99,146,328,477],[99,146,328,478],[99,146,328,484],[99,146,328,485],[99,146,328,486],[99,146,328,487],[99,146,328,474],[99,146,328,475],[99,146,328,488],[99,146,328,489],[99,146,415,416,417,418],[85,99,146,463,470,471,472,476],[85,99,146,463,470,471,476],[85,99,146,439,463,470,471,476,483],[85,99,146,439,463,470,471,472,476],[85,99,146,444,463,470,471,476],[85,99,146,463,471,472],[99,146,460,466,473],[85,99,146,444,463,471],[85,99,146,470,472],[85,99,146,471],[85,99,146,439,444,463,471],[99,146,460,461],[99,146,467],[85,99,146,304,468],[99,146,469],[99,143,146],[99,145,146],[146],[99,146,151,179],[99,146,147,152,157,165,176,187],[99,146,147,148,157,165],[94,95,96,99,146],[99,146,149,188],[99,146,150,151,158,166],[99,146,151,176,184],[99,146,152,154,157,165],[99,145,146,153],[99,146,154,155],[99,146,156,157],[99,145,146,157],[99,146,157,158,159,176,187],[99,146,157,158,159,172,176,179],[99,146,154,157,160,165,176,187],[99,146,157,158,160,161,165,176,184,187],[99,146,160,162,176,184,187],[97,98,99,100,101,102,103,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[99,146,157,163],[99,146,164,187,192],[99,146,154,157,165,176],[99,146,166],[99,146,167],[99,145,146,168],[99,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193],[99,146,170],[99,146,171],[99,146,157,172,173],[99,146,172,174,188,190],[99,146,157,176,177,179],[99,146,178,179],[99,146,176,177],[99,146,179],[99,146,180],[99,143,146,176,181],[99,146,157,182,183],[99,146,182,183],[99,146,151,165,176,184],[99,146,185],[99,146,165,186],[99,146,160,171,187],[99,146,151,188],[99,146,176,189],[99,146,164,190],[99,146,191],[99,141,146],[99,141,146,157,159,168,176,179,187,190,192],[99,146,176,193],[85,89,99,146,195,196,197,199,409,453],[85,89,99,146,195,196,197,198,409,453],[85,89,99,146,195,196,198,199,409,453],[85,89,99,146,196,197,198,199,409,453],[85,89,99,146,195,197,198,199,409,453],[83,84,99,146],[85,99,146],[85,99,146,480,481],[99,146,481,482],[85,99,146,480],[99,146,479],[91,99,146],[99,146,413],[99,146,420],[99,146,203,216,217,218,220,375],[99,146,203,207,209,210,211,212,364,375,377],[99,146,375],[99,146,217,233,303,355,371],[99,146,203],[99,146,393],[99,146,375,377,392],[99,146,296,303,336,458],[99,146,314,320,355,370],[99,146,258],[99,146,359],[99,146,358,359,360],[99,146,358],[93,99,146,160,200,203,210,213,214,215,217,221,289,294,338,346,356,366,375,409],[99,146,203,219,247,292,375,389,390,458],[99,146,219,458],[99,146,292,293,294,375,458],[99,146,458],[99,146,203,219,220,458],[99,146,213,357,363],[99,146,171,304,371],[99,146,304,371],[85,99,146,304],[85,99,146,290,304,305],[99,146,238,256,371,442],[99,146,352,440,441],[99,146,351],[99,146,235,236,290],[99,146,237,238,290],[99,146,290],[85,99,146,204,434],[85,99,146,187],[85,99,146,219,245],[85,99,146,219],[99,146,243,248],[85,99,146,244,412],[99,146,464],[85,89,99,146,160,194,195,196,197,198,199,409,451,452],[99,146,158,160,207,233,261,279,290,361,375,376,458],[99,146,346,362],[99,146,409],[99,146,202],[99,146,171,296,301,329,370,371],[99,146,322,323,324,325,326,327],[99,146,324],[99,146,328],[85,99,146,244,304,412],[85,99,146,304,410,412],[85,99,146,304,412],[99,146,279,367],[99,146,367],[99,146,160,376,412],[99,146,316],[99,145,146,315],[99,146,229,230,232,262,290,297,298,300,338,370,373,376],[99,146,299],[99,146,230],[99,146,314,370],[99,146,305,306,307,314,316,317,318,319,320,321,330,331,332,333,334,335,370,371,458],[99,146,312],[99,146,160,171,207,228,230,232,233,234,238,266,279,288,289,338,366,375,376,377,409,458],[99,146,370],[99,145,146,217,232,289,298,320,366,368,369,376],[99,146,314],[99,145,146,228,262,282,308,309,310,311,312,313],[99,146,160,282,283,308,376,377],[99,146,217,279,289,290,298,366,370,376],[99,146,160,375,377],[99,146,160,176,373,376,377],[99,146,160,171,187,200,207,219,229,230,232,233,234,239,261,262,263,265,266,269,270,272,275,276,277,278,290,365,366,371,373,375,376,377],[99,146,160,176],[99,146,203,204,205,207,214,373,374,409,412,458],[99,146,160,176,187,223,391,393,394,395,458],[99,146,171,187,200,223,233,262,263,270,279,287,290,366,371,373,378,379,383,389,405,406],[99,146,213,214,289,346,357,366,375],[99,146,160,187,204,262,373,375],[99,146,295],[99,146,160,398,403,404],[99,146,373,375],[99,146,207,232,262,365,412],[99,146,385,389,405,408],[99,146,160,213,346,389,398,399,408],[99,146,203,239,365,375,401],[99,146,160,219,239,375,384,385,396,397,400,402],[93,99,146,230,231,232,409,412],[99,146,160,171,187,207,213,221,229,233,234,262,263,265,266,279,287,290,346,365,366,371,372,373,378,379,381,382,412],[99,146,160,213,373,383,403,407],[99,146,341,342,343,344,345],[99,146,269,271],[99,146,273],[99,146,271],[99,146,273,274],[99,146,160,207,228,376],[85,99,146,160,171,202,204,207,229,230,232,233,234,260,373,377,409,412],[99,146,160,171,187,206,211,262,372,376],[99,146,308],[99,146,309],[99,146,310],[99,146,222,226],[99,146,160,207,222,229],[99,146,225,226],[99,146,227],[99,146,222,223],[99,146,222,240],[99,146,222],[99,146,268,269,372],[99,146,267],[99,146,223,371,372],[99,146,264,372],[99,146,223,371],[99,146,338],[99,146,224,229,231,262,290,296,298,301,302,337,373,376],[99,146,238,249,252,253,254,255,256],[99,146,354],[99,146,217,231,232,283,290,314,316,320,347,348,349,350,352,353,356,365,370,375],[99,146,238],[99,146,260],[99,146,160,229,231,241,257,259,261,373,409,412],[99,146,238,249,250,251,252,253,254,255,256,410],[99,146,223],[99,146,283,284,287,366],[99,146,160,269,375],[99,146,160],[99,146,282,314],[99,146,281],[99,146,278,283],[99,146,280,282,375],[99,146,160,206,283,284,285,286,375,376],[85,99,146,235,237,290],[99,146,291],[85,99,146,204],[85,99,146,371],[85,93,99,146,232,234,409,412],[99,146,204,434,435],[85,99,146,248],[85,99,146,171,187,202,242,244,246,247,412],[99,146,219,371,376],[99,146,371,380],[85,99,146,158,160,171,202,248,292,409,410,411],[85,99,146,195,196,197,198,199,409,453],[85,86,87,88,89,99,146],[99,146,151],[99,146,386,387,388],[99,146,386],[85,89,99,146,160,162,171,194,195,196,197,198,199,200,202,266,328,377,408,412,453],[99,146,422],[99,146,424],[99,146,426],[99,146,465],[99,146,428],[99,146,430,431,432],[99,146,436],[90,92,99,146,414,419,421,423,425,427,429,433,437,439,444,445,447,456,457,458,459],[99,146,438],[99,146,443],[99,146,244],[99,146,446],[99,145,146,283,284,285,287,319,371,448,449,450,453,454,455],[99,113,117,146,187],[99,113,146,176,187],[99,108,146],[99,110,113,146,184,187],[99,146,165,184],[99,108,146,194],[99,110,113,146,165,187],[99,105,106,109,112,146,157,176,187],[99,113,120,146],[99,105,111,146],[99,113,134,135,146],[99,109,113,146,179,187,194],[99,134,146,194],[99,107,108,146,194],[99,113,146],[99,107,108,109,110,111,112,113,114,115,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,135,136,137,138,139,140,146],[99,113,128,146],[99,113,120,121,146],[99,111,113,121,122,146],[99,112,146],[99,105,108,113,146],[99,113,117,121,122,146],[99,117,146],[99,111,113,116,146,187],[99,105,110,113,120,146],[99,146,176],[99,108,113,134,146,192,194]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"dc782ff85b2cb10075ecffc158af7bfb27ff97bf8491c917efea0c3d622d5ac4","impliedFormat":1},{"version":"acd8fd5090ac73902278889c38336ff3f48af6ba03aa665eb34a75e7ba1dccc4","impliedFormat":1},{"version":"d6258883868fb2680d2ca96bc8b1352cab69874581493e6d52680c5ffecdb6cc","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"f258e3960f324a956fc76a3d3d9e964fff2244ff5859dcc6ce5951e5413ca826","impliedFormat":1},{"version":"643f7232d07bf75e15bd8f658f664d6183a0efaca5eb84b48201c7671a266979","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"631eff75b0e35d1b1b31081d55209abc43e16b49426546ab5a9b40bdd40b1f60","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"70e15d5c5c14e26442b2f15484b126a982290c5f4713834a6e572fd4274f9119","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"2beff543f6e9a9701df88daeee3cdd70a34b4a1c11cb4c734472195a5cb2af54","impliedFormat":1},{"version":"2e07abf27aa06353d46f4448c0bbac73431f6065eef7113128a5cd804d0c384d","impliedFormat":1},{"version":"be1cc4d94ea60cbe567bc29ed479d42587bf1e6cba490f123d329976b0fe4ee5","impliedFormat":1},{"version":"42bc0e1a903408137c3df2b06dfd7e402cdab5bbfa5fcfb871b22ebfdb30bd0b","impliedFormat":1},{"version":"9894dafe342b976d251aac58e616ac6df8db91fb9d98934ff9dd103e9e82578f","impliedFormat":1},{"version":"3da0083607976261730c44908eab1b6262f727747ef3230a65ecd0153d9e8639","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"dd721e5707f241e4ef4ab36570d9e2a79f66aad63a339e3cbdbac7d9164d2431","impliedFormat":1},{"version":"24f8562308dd8ba6013120557fa7b44950b619610b2c6cb8784c79f11e3c4f90","impliedFormat":1},{"version":"3849a7f92d0e11b785f6ae7bedb25d9aad8d1234b3f1cf530a4e7404be26dd0a","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"57d6ac03382e30e9213641ff4f18cf9402bb246b77c13c8e848c0b1ca2b7ef92","impliedFormat":1},{"version":"f040772329d757ecd38479991101ef7bc9bf8d8f4dd8ee5d96fe00aa264f2a2b","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"57e47d02e88abef89d214cdf52b478104dc17997015746e288cbb580beaef266","impliedFormat":1},{"version":"e621da3d09c38e896cc2d432cbfec5af3f08739a3e64fb52f8723df6764edab9","impliedFormat":1},{"version":"376c21ad92ca004531807ea4498f90a740fd04598b45a19335a865408180eddd","impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"48d37b90a04e753a925228f50304d02c4f95d57bf682f8bb688621c3cd9d32ec","impliedFormat":1},{"version":"361e2b13c6765d7f85bb7600b48fde782b90c7c41105b7dab1f6e7871071ba20","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"b6db56e4903e9c32e533b78ac85522de734b3d3a8541bf24d256058d464bf04b","impliedFormat":1},{"version":"24daa0366f837d22c94a5c0bad5bf1fd0f6b29e1fae92dc47c3072c3fdb2fbd5","impliedFormat":1},{"version":"b68c4ed987ef5693d3dccd85222d60769463aca404f2ffca1c4c42781dce388e","impliedFormat":1},{"version":"cfb5b5d514eb4ad0ee25f313b197f3baa493eee31f27613facd71efb68206720","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"dffe876972134f7ab6b7b9d0906317adb189716b922f55877190836d75d637ff","impliedFormat":1},{"version":"7840deea5b9a76aa60bc7283136a3ace1ed6ae08dfe4bac108e4322d6929f333","impliedFormat":1},{"version":"a5104342bf6b503b51eac266cecac1a441bdf10e4ad743b86cafa9fa5e4b9522","impliedFormat":1},{"version":"07aad78d0fb49288f29d4a852352a47ac8d45d16a333f0040dec849ba10b34f0","impliedFormat":1},{"version":"449eb009b315e1393839d57bfc9036b5ae54b23b95e2ae8f9309993b095dc99a","impliedFormat":1},{"version":"4c7845c76670c65f41a0116ea5f360b2e107f7b69b7a93972b60739282de3e7f","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"973d2650149b7ec576d1a8195c8e9272f19c4a8efb31efe6ddc4ff98f0b9332d","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"fb6029bd56096befddfe0b98eaf23c2f794872610f8fa40dd63618a8d261ec6c","impliedFormat":1},{"version":"fe4860fa03b676d124ac61c8e7c405a83c67e1b10fc30f48c08b64aa1680098f","impliedFormat":1},{"version":"61d8276131ed263cb5323fbfdd1f1a6dd1920f30aedce0274aadcd2bfdc9a5ad","impliedFormat":1},{"version":"80cda0a68679f52326d99646814a8e98fec3051fd7fbed784fc9cd44fbc6fefa","impliedFormat":1},{"version":"a23185bc5ef590c287c28a91baf280367b50ae4ea40327366ad01f6f4a8edbc5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"a020158a317c07774393974d26723af551e569f1ba4d6524e8e245f10e11b976","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"15b36126e0089bfef173ab61329e8286ce74af5e809d8a72edcafd0cc049057f","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"d07cbc787a997d83f7bde3877fec5fb5b12ce8c1b7047eb792996ed9726b4dde","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"8bba776476c48b0e319d243f353190f24096057acede3c2f620fee17ff885dba","impliedFormat":1},{"version":"a3abe92070fbd33714bd837806030b39cfb1f8283a98c7c1f55fffeea388809e","impliedFormat":1},{"version":"ceb6696b98a72f2dae802260c5b0940ea338de65edd372ff9e13ab0a410c3a88","impliedFormat":1},{"version":"2cd914e04d403bdc7263074c63168335d44ce9367e8a74f6896c77d4d26a1038","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"208c9af9429dd3c76f5927b971263174aaa4bc7621ddec63f163640cbd3c473c","impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"3bc8605900fd1668f6d93ce8e14386478b6caa6fda41be633ee0fe4d0c716e62","impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"45490817629431853543adcb91c0673c25af52a456479588b6486daba34f68bb","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"40170617a96a979bb8d137240f39ecf62333e7d52b9ccf18d7a3c105051b087c","impliedFormat":1},{"version":"4aef12ed1bd4615a9cf6d251755753906e9db06804e88a1859bfd22453490453","impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"f11d0dcaa4a1cba6d6513b04ceb31a262f223f56e18b289c0ba3133b4d3cd9a6","impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","impliedFormat":1},{"version":"ca7ab3c16a196b851407e5dd43617c7c4d229115f4c38d5504b9210ed5c60837","impliedFormat":1},{"version":"56013416784a6b754f3855f8f2bf6ce132320679b8a435389aca0361bce4df6b","impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","impliedFormat":1},{"version":"c6c801af2193e5935f66c003ee029fc939d9888b64f8ea7842229d512bf25b4e","impliedFormat":1},{"version":"002eae065e6960458bda3cf695e578b0d1e2785523476f8a9170b103c709cd4f","impliedFormat":1},{"version":"b8590c5d0a36dd9dad69399d765b511b41a6583e9521b95894010c45c7a5e962","impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"8f30fb3ea68f4cc1c766f9aebb0682c3e955411477ea90bb4b81f35b2f48c5fd","impliedFormat":1},{"version":"ef33b8f373b674d6bf04d579a6f332e6fb2b66756ff653df41a78f966fd8d696","impliedFormat":1},{"version":"643672ce383e1c58ea665a92c5481f8441edbd3e91db36e535abccbc9035adeb","impliedFormat":1},{"version":"6dd9bcf10678b889842d467706836a0ab42e6c58711e33918ed127073807ee65","impliedFormat":1},{"version":"8fa022ea514ce0ea78ac9b7092a9f97f08ead20c839c779891019e110fce8307","impliedFormat":1},{"version":"c93235337600b786fd7d0ff9c71a00f37ca65c4d63e5d695fc75153be2690f09","impliedFormat":1},{"version":"713289d81d39985140b4641c47a9daf4617a13519e04a3b711405296d5c72a92","impliedFormat":1},{"version":"ce791f6ea807560f08065d1af6014581eeb54a05abd73294777a281b6dfd73c2","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"c0a666b005521f52e2db0b685d659d7ee9b0b60bc0d347dfc5e826c7957bdb83","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","impliedFormat":1},{"version":"4360ad4de54de2d5c642c4375d5eab0e7fe94ebe8adca907e6c186bbef75a54d","impliedFormat":1},{"version":"c338dff3233675f87a3869417aaea8b8bf590505106d38907dc1d0144f6402ef","impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","impliedFormat":1},{"version":"9c9cae45dc94c2192c7d25f80649414fa13c425d0399a2c7cb2b979e4e50af42","impliedFormat":1},{"version":"068f063c2420b20f8845afadb38a14c640aed6bb01063df224edb24af92b4550","impliedFormat":1},{"version":"4bf183d06c039f0880141389ea403b17f4464455015fd5e91987a8c63301ba95","impliedFormat":1},{"version":"e56d6f3b3e00fdbe11c3a9da83b4693ca0814752da5610e90f4bc9c9a17cacd7","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"6f6d8b734699387b60fcc8300efd98d967f4c255ace55f088a1b93d2c1f31ac6","impliedFormat":1},{"version":"e17cd049a1448de4944800399daa4a64c5db8657cc9be7ef46be66e2a2cd0e7c","impliedFormat":1},{"version":"d05fb434f4ba073aed74b6c62eff1723c835de2a963dbb091e000a2decb5a691","impliedFormat":1},{"version":"10e6166be454ddb8c81000019ce1069b476b478c316e7c25965a91904ec5c1e3","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"ad534b18336a35244d8838029974f6367d54fd96733a570062bcec065db52d2d","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"9b07d156d1db6d2e27cb0180470e16a7956258ebc86d2f757b554f81c1fed075","impliedFormat":1},{"version":"48d7da8c8d53a8601c9747297aab87408d35b5ddee2d2c8168a7dc3c83347c5e","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"18e99839b1ec5ce200181657caa2b3ed830a693f3dea6a5a33c577e838576834","impliedFormat":1},{"version":"d973b85fc71be3e8733c670324631df1a5aa5b0d300b63b509724485e13edb01","impliedFormat":1},{"version":"5b2b575ac31335a49e3610820dda421eba4b50e385954647ebc0a8d462e8d0f7","impliedFormat":1},{"version":"9e21f8e2c0cfea713a4a372f284b60089c0841eb90bf3610539d89dbcd12d65a","impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"d2bae957a5b34f39743d7814c0a35ba0c6a89fe5207001f4498477da17570f4b","impliedFormat":1},{"version":"5b2b799936191252416aa19455f19eafb7eb3fc8a996764bcf7d35937b6bd712","impliedFormat":1},{"version":"8eea4cc42d04d26bcbcaf209366956e9f7abaf56b0601c101016bb773730c5fe","impliedFormat":1},{"version":"f5319e38724c54dff74ee734950926a745c203dcce00bb0343cb08fbb2f6b546","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"12b8dfed70961bea1861e5d39e433580e71323abb5d33da6605182ec569db584","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"7e560f533aaf88cf9d3b427dcf6c112dd3f2ee26d610e2587583b6c354c753db","impliedFormat":1},{"version":"71e0082342008e4dfb43202df85ea0986ef8e003c921a1e49999d0234a3019da","impliedFormat":1},{"version":"27ab780875bcbb65e09da7496f2ca36288b0c541abaa75c311450a077d54ec15","impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","impliedFormat":1},{"version":"745197115b3a563e70f8b6565425370bbfdf8c194c1a61dbc8048daa2b22df15","impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"c7d5d3b5aac1e1b4f7cb6f64351aff02b3a2e98feda9bc8e5e40f35639cad9f2","impliedFormat":1},{"version":"794998dc1c5a19ce77a75086fe829fb9c92f2fd07b5631c7d5e0d04fd9bc540c","impliedFormat":1},{"version":"bd28a12377e68e1fedd4c3d36cbcc2d229f681ba459eff00bba58970b20fc167","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"0427df5c06fafc5fe126d14b9becd24160a288deff40e838bfbd92a35f8d0d00","impliedFormat":1},{"version":"f8ab24dcd2177ca88334f12d7ab07e14587cef5132437ddea18b5074f9860b22","impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","impliedFormat":1},{"version":"6cd4b0986c638d92f7204d1407b1cb3e0a79d7a2d23b0f141c1a0829540ce7ef","impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","impliedFormat":1},{"version":"6511e4503cf74c469c60aafd6589e4d14d5eb0a25f9bf043dcbecdf65f261972","impliedFormat":1},{"version":"d58265e159fc3cb30aa8878ba5e986a314b1759c824ff66d777b9fe42117231a","impliedFormat":1},{"version":"ff8fccaae640b0bb364340216dcc7423e55b6bb182ca2334837fee38636ad32e","impliedFormat":1},{"version":"3d4d58fe8bc7d5f6977cb33ddccf0e210ff75fb5e9d8b69ec4dafa1e64fc25fb","impliedFormat":1},{"version":"14b65941c926f5dd00e9fcc235cc471830042d43c41722fcb34589c54b610ed1","impliedFormat":1},{"version":"22bda3002a475e16a060062ca36bc666443f58af4aacf152ae0aaa00dd9ee2cc","impliedFormat":1},{"version":"36eab071c38859aa13b794e28014f34fb4e17659c82aeda8d841f77e727bff27","impliedFormat":1},{"version":"c590195790d7fa35b4abed577a605d283b8336b9e01fa9bf4ae4be49855940f9","impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","impliedFormat":1},{"version":"026a43d8239b8f12d2fc4fa5a7acbc2ad06dd989d8c71286d791d9f57ca22b78","impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","impliedFormat":1},{"version":"6020d077de5ca3d10712991b7739cea2d41897912f25e079c11af386327eba03","impliedFormat":1},{"version":"12f0fb50e28b9d48fe5b7580580efe7cc0bd38e4b8c02d21c175aa9a4fd839b0","impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","impliedFormat":1},{"version":"7cd657e359eac7829db5f02c856993e8945ffccc71999cdfb4ab3bf801a1bbc6","impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","impliedFormat":1},{"version":"29c2aa0712786a4a504fce3acd50928f086027276f7490965cb467d2ce638bae","impliedFormat":1},{"version":"f14e63395b54caecc486f00a39953ab00b7e4d428a4e2c38325154b08eb5dcc2","impliedFormat":1},{"version":"a0c132dc6ac49d78637c91c43d0808b32ea29d4f6f9b7eb01a142f06a6ab9a73","impliedFormat":1},{"version":"c3d2b0fcdcd32187917a6ea467510fb913c8e9ae199ee41a4af4a94c577c3bb4","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"72179f9dd22a86deaad4cc3490eb0fe69ee084d503b686985965654013f1391b","impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"29164fb428c851bc35b632761daad3ae075993a0bf9c43e9e3bc6468b32d9aa5","impliedFormat":1},{"version":"3c01539405051bffccacffd617254c8d0f665cdce00ec568c6f66ccb712b734f","impliedFormat":1},{"version":"ebd69e950c88b32530930299e4f5d06a3995b9424cb2c89b92f563e6300d79b3","impliedFormat":1},{"version":"70bea51bd3d87afe270228d4388c94d7ae1f0c6b43189c37406ba8b6acfba8df","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","impliedFormat":1},{"version":"96e1caae9b78cde35c62fee46c1ec9fa5f12c16bc1e2ab08d48e5921e29a6958","impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","impliedFormat":1},{"version":"cf97b7e36e26871296e849751af2ee1c9727c9cc817b473cd9697d5bfc4fa4f3","impliedFormat":1},{"version":"e678acbb7d55cacfe74edcf9223cc32e8c600e14643941d03a0bf07905197b51","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"397f568f996f8ffcf12d9156342552b0da42f6571eadba6bce61c99e1651977d","impliedFormat":1},{"version":"78d8c61c0641960db72a65bc60e58c30e5b5bd46e621aad924bb7a421826673f","impliedFormat":1},{"version":"a52674bc98da7979607e0f44d4c015c59c1b1d264c83fc50ec79ff2cfea06723","impliedFormat":1},{"version":"57512aaaefbf4db2e8c7d47ee3762fa12c9521b324c93ea384d37b1b56aa7277","impliedFormat":1},{"version":"6aaa60c75563da35e4632a696b392851f64acacdb8b2b10656ebcf303f7e3569","impliedFormat":1},{"version":"6c7cd3294c6645be448eba2851c92c2931b7ddf84306805c5c502ea0ce345690","impliedFormat":1},{"version":"f26b8c8e4adf86c299a29d93c133a31aa60624b63664f28f1c917509e8cccbfb","impliedFormat":1},{"version":"82fd4516331cc701cedd54f3be2d31197dcf45f6cee6942b704b1f8ef061a2c6","affectsGlobalScope":true,"impliedFormat":1},{"version":"cbedf8280e47eeb541717b79e24d9e5a10abc568480375466a3674d016b46704","impliedFormat":1},{"version":"81f95ded33d1980a5220502cc363311f3ef5558e8ab5557c6949b6265802259d","impliedFormat":1},{"version":"714d8ebb298c7acc9bd1f34bd479c57d12b73371078a0c5a1883a68b8f1b9389","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","impliedFormat":1},{"version":"197047506e1db2b9a2986b07bd16873805f4244d8c8a3f03f9444cee4b2a5b9d","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb0cd7862b72f5eba39909c9889d566e198fcaddf7207c16737d0c2246112678","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"6812502cc640de74782ce9121592ae3765deb1c5c8e795b179736b308dd65e90","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"bad68fd0401eb90fe7da408565c8aee9c7a7021c2577aec92fa1382e8876071a","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"fec01479923e169fb52bd4f668dbeef1d7a7ea6e6d491e15617b46f2cacfa37d","impliedFormat":1},{"version":"8a8fb3097ba52f0ae6530ec6ab34e43e316506eb1d9aa29420a4b1e92a81442d","impliedFormat":1},{"version":"44e09c831fefb6fe59b8e65ad8f68a7ecc0e708d152cfcbe7ba6d6080c31c61e","impliedFormat":1},{"version":"1c0a98de1323051010ce5b958ad47bc1c007f7921973123c999300e2b7b0ecc0","impliedFormat":1},{"version":"b10bc147143031b250dc36815fd835543f67278245bf2d0a46dca765f215124e","impliedFormat":1},{"version":"87affad8e2243635d3a191fa72ef896842748d812e973b7510a55c6200b3c2a4","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"33777bfdf7c070a52fb5ad47e89c937ea833bef733478c5fd7f1164f9186e0e3","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"b685df59addf95ac6b09c594cc1e83b3d4a5b6f9f5eb06609720fee484a7b7ee","impliedFormat":1},{"version":"3c7b3aecd652169787b3c512d8f274a3511c475f84dcd6cead164e40cad64480","impliedFormat":1},{"version":"99b23c2c1986f5b5100b938f65336e49eca8679c532f641890a715d97aeff808","impliedFormat":1},{"version":"315d14addabfc08bcda173a9c2c79c70e831b6c2b38d7f1bb0ea3b58b59c15f1","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"5bf5c7a44e779790d1eb54c234b668b15e34affa95e78eada73e5757f61ed76a","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","impliedFormat":1},{"version":"ec866055bdff922e6b319e537386dccbd768e1750ad91b095593444942dedf11","affectsGlobalScope":true,"impliedFormat":1},{"version":"272a7e7dbe05e8aaba1662ef1a16bbd57975cc352648b24e7a61b7798f3a0ad7","affectsGlobalScope":true,"impliedFormat":1},{"version":"a1219ee18b9282b4c6a31f1f0bcc9255b425e99363268ba6752a932cf76662f0","impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","impliedFormat":1},{"version":"1d63055b690a582006435ddd3aa9c03aac16a696fac77ce2ed808f3e5a06efab","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"e462a655754db9df18b4a657454a7b6a88717ffded4e89403b2b3a47c6603fc3","b11e98774818115d4884dadc533983ae2c7cf191cd942cdf8a53a89c71669246",{"version":"fe93c474ab38ac02e30e3af073412b4f92b740152cf3a751fdaee8cbea982341","impliedFormat":1},{"version":"ad878aa7fdc49e82e47749a6cc284a4903fc2cf62fe8ef189dac13734a4e44fa","impliedFormat":1},{"version":"1e00b8bf9e3766c958218cd6144ffe08418286f89ff44ba5a2cc830c03dd22c7","impliedFormat":1},{"version":"73c078fcbc0fa04ba70b1c3e5a3dea6a980d8765079cdbfb40f903eb8daa4319","impliedFormat":99},{"version":"5297e84d3de08bbe3c00f964d1c74f89cf101d59a4826b335654f44ff41529a8","impliedFormat":99},{"version":"355b33af59287683501f76cbf7d6a141544c5ff1ae5f5c0701a3f89cc38e5238","impliedFormat":99},{"version":"280a996092ab956e80dc7bb7497d472ca5c1be23a9c52ac771f5c750ede462b9","impliedFormat":99},{"version":"76f7360f36a5265a80db1106c3f96f7155a90834a842572de47f87cb02b770bd","impliedFormat":1},{"version":"967a7b3760644baa436d7e894662bffa3f74b11e54de26e98bcf75f78a9d6df8","signature":"9f6c26e8741c14626547785e954f8a521677c6daa181a50730255b5eba213461"},{"version":"2f8cba8848fdfc4fdfe240d8ee7986c9a192e09726e409f7ff0b5821952e4462","signature":"cd81285ebfb0c63cd175d4e46fb6327c68fb1b623e0cd9e531cbafd2af31b104"},"4eb61bb13ef554bda7d5aac3d664957ea516883b8e3b5f265d779baef1f0e562",{"version":"bf30ee28866f6fa28ae4ff2e8179503fd9dc3d438c4e72ffea39f4516a154580","signature":"d0ea9fb700a7b8df441d1b70b53cef5a6246ad338f581f57f5a7295ed3faa684"},{"version":"b9118308d52b5efd93a889fe8957bc094ffc99276e42ebe4071a36e417d81711","signature":"d92ca184dc154e0c54633d748044a4c40428e4ac9c84a6404c1850d8427978a0"},{"version":"0a7c5b0653484c9b5397c91fe6a95889ce3690cf6727aaa53fe07816e9524537","signature":"dd24c8e77286762b0fe0cb1ac1fbcf8b56c20f270fcdc5065fbc5d951de5a229"},"ef4b4e3d5e8eac7b8d583018a95cd2e357a8bac24ec02034c7b641e661928fa6",{"version":"6392353adcff7db02a3f5dcacb5637b791dbbcb76125aac3075da2519af9785a","impliedFormat":99},{"version":"1f3952b74b8c766a2e602a0ba2db19d3d872d00bab4e01746c6b7229c585086c","impliedFormat":99},{"version":"d02858f618f372be74ac6fc803b19fe0e1a1468da517813f450861f7f0ea8097","impliedFormat":1},{"version":"cd4031a2eae33de12cf8af8f97fd20c2d10047e7de9038f4d71e76b730bee127","impliedFormat":1},{"version":"6f41ed944dca7ffcb3d85a71434cd91167cc91ca24967909a79fc6e10f3411bb","impliedFormat":1},{"version":"f8c7fb94f0845aadb40111660df8c1e19a5f0c0245a9283fb225669df3962f57","signature":"9e9184640db072253117c68486ff81928b373fa206e9c9fd015f75929d72caab"},{"version":"a47007011570eda48c8b5b0331b43d9a070cca13a3730cb1a5cdbc5b126b1de5","signature":"95b9f088185647848112b28f9c0d42363de4dcdbd7c7415710c7cc243a1cc05c"},{"version":"13bc7f98a2d71375a2612802003df1e23e034073e36ce55915cb03aa227dce8f","signature":"1f01e90ffc9f0ef9673d8144fba39e9bfc7be8c363e7ded50196bf762b7a512f"},{"version":"8a1201ee1c58ba098495eed6f65d5ee7580ecf26cfc39ca7cb9fddf3b230aac2","signature":"a79011835bbabea49e2c94a318faef60aac67715519b72c6c44d39163230fa1f"},{"version":"3bb78bdddad4ae2e5022aafafa3a5b618fd367def266ee975809a6f8f715af2a","signature":"caa891d8c0a72d95687a7e0722eb8703956e21ca2d5d995c0dfe39b0934c5786"},{"version":"d27c7cd73550e9484e488eba3d951583dc91bf8ece1402a45f0e1d3c0c63c530","signature":"4222f8043b033b82ff30a49281b22f7e15b7701a91dfa38fdb962822bb9cf613"},"4e5346437598bc926a3ce75ed44ebbadd98b2722ad6ce99568cb1b151db5b2ab","8e1e1eae82d463d852b40e927d773e63965f59dc72526ccf4b26187ea51bc040","cfe152d37347c865e4aa52e4dec9bf8f4dae080bd0a2e34bc33a8287dd56df99","ea4ebe18f586edc5096c55a34c6715e8f2f8a49e85fea61e2fcfb654014646fe","7cab244b2b1b8b894708ffaf811a1f6bb1893b95e49dc94b9cf320639b37eb01","a29ed94e5cc9e93ed7ab7056e6d2ffa520be1453f0826f62564c88fa39fb8ef7","38f13037cd2d678081dd0e6bf936a3c0e2c0681bf8a1717bea95946b8c101a5d",{"version":"a0b3bcdf19b537bd04cdc0708fcfbf32375d7e6be51d81a11c3bf1319f36a8e4","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},"ed61940c34b3083345b4d1d1cbf54dfbe8c7d2aaccee0b17221b87bc07b5b662","a24e6a4c2fd1150ff5083ca8a8c3666184461c6553782598eb4b44832e87a92d","37c33aee2e46a532aa6e4c537a3afd9e8de53de26d2078bc7b8d59b7756244af",{"version":"104c67f0da1bdf0d94865419247e20eded83ce7f9911a1aa75fc675c077ca66e","impliedFormat":1},{"version":"cc0d0b339f31ce0ab3b7a5b714d8e578ce698f1e13d7f8c60bfb766baeb1d35c","impliedFormat":1},{"version":"f9e22729fa06ed20f8b1fe60670b7c74933fdfd44d869ddfb1919c15a5cf12fb","impliedFormat":1},{"version":"d34aa8df2d0b18fb56b1d772ff9b3c7aea7256cf0d692f969be6e1d27b74d660","impliedFormat":1},{"version":"f4db16820c99b6db923ab18af5fecb02331d785c4c2a8a88373a0cfc08256589","impliedFormat":1},{"version":"2f5747b1508ccf83fad0c251ba1e5da2f5a30b78b09ffa1cfaf633045160afed","impliedFormat":1},{"version":"90407bbaa24977b8a6a90861148ac98d8652afe69992a90d823f29e9807fe2d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"689be50b735f145624c6f391042155ae2ff6b90a93bac11ca5712bc866f6010c","impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"f3d8c757e148ad968f0d98697987db363070abada5f503da3c06aefd9d4248c1","impliedFormat":1},{"version":"a4a39b5714adfcadd3bbea6698ca2e942606d833bde62ad5fb6ec55f5e438ff8","impliedFormat":1},{"version":"bbc1d029093135d7d9bfa4b38cbf8761db505026cc458b5e9c8b74f4000e5e75","impliedFormat":1},{"version":"1f68ab0e055994eb337b67aa87d2a15e0200951e9664959b3866ee6f6b11a0fe","impliedFormat":1},{"version":"b71c603a539078a5e3a039b20f2b0a0d1708967530cf97dec8850a9ca45baa2b","impliedFormat":1},{"version":"d3f2d715f57df3f04bf7b16dde01dec10366f64fce44503c92b8f78f614c1769","impliedFormat":1},{"version":"cb90077223cc1365fa21ef0911a1f9b8f2f878943523d97350dc557973ca3823","impliedFormat":1},{"version":"18f1541b81b80d806120a3489af683edfb811deb91aeca19735d9bb2613e6311","impliedFormat":1},{"version":"232f118ae64ab84dcd26ddb60eaed5a6e44302d36249abf05e9e3fc2cbb701a2","impliedFormat":1},{"version":"26b7d0cd4b41ab557ef9e3bfeec42dcf24252843633e3d29f38d2c0b13aaa528","impliedFormat":1},{"version":"7fadb2778688ebf3fd5b8d04f63d5bf27a43a3e420bc80732d3c6239067d1a4b","impliedFormat":1},{"version":"510616459e6edd01acbce333fb256e06bdffdad43ca233a9090164bf8bb83912","impliedFormat":1},{"version":"ddef25f825320de051dcb0e62ffce621b41c67712b5b4105740c32fd83f4c449","impliedFormat":1},{"version":"1b3dffaa4ca8e38ac434856843505af767a614d187fb3a5ef4fcebb023c355aa","impliedFormat":1},{"version":"1ba59c8bbeed2cb75b239bb12041582fa3e8ef32f8d0bd0ec802e38442d3f317","impliedFormat":1}],"root":[462,463,[472,478],[484,500]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":1},"referencedMap":[[502,1],[503,2],[508,3],[501,4],[513,5],[512,6],[511,7],[509,8],[507,9],[518,10],[514,8],[519,11],[510,8],[515,8],[520,12],[505,8],[506,8],[521,8],[504,13],[522,14],[517,15],[516,16],[523,4],[524,17],[104,8],[493,18],[494,19],[495,20],[496,21],[497,22],[498,23],[491,24],[492,25],[499,26],[500,27],[490,28],[477,29],[478,30],[484,31],[485,32],[486,33],[487,34],[474,35],[475,36],[473,37],[488,29],[489,29],[463,8],[472,38],[476,39],[462,40],[411,8],[467,8],[468,41],[469,42],[470,43],[143,44],[144,44],[145,45],[99,46],[146,47],[147,48],[148,49],[94,8],[97,50],[95,8],[96,8],[149,51],[150,52],[151,53],[152,54],[153,55],[154,56],[155,56],[156,57],[157,58],[158,59],[159,60],[100,8],[98,8],[160,61],[161,62],[162,63],[194,64],[163,65],[164,66],[165,67],[166,68],[167,69],[168,70],[169,71],[170,72],[171,73],[172,74],[173,74],[174,75],[175,8],[176,76],[178,77],[177,78],[179,79],[180,80],[181,81],[182,82],[183,83],[184,84],[185,85],[186,86],[187,87],[188,88],[189,89],[190,90],[191,91],[101,8],[102,8],[103,8],[142,92],[192,93],[193,94],[198,95],[199,96],[197,97],[195,98],[196,99],[83,8],[85,100],[304,101],[84,8],[482,102],[483,103],[481,104],[480,105],[479,8],[471,101],[92,106],[414,107],[419,28],[421,108],[219,109],[365,110],[390,111],[294,8],[212,8],[217,8],[356,112],[286,113],[218,8],[392,114],[393,115],[337,116],[353,117],[259,118],[360,119],[361,120],[359,121],[358,8],[357,122],[391,123],[220,124],[293,8],[295,125],[215,8],[230,126],[221,127],[234,126],[263,126],[205,126],[364,128],[374,8],[211,8],[317,129],[318,130],[305,131],[320,8],[306,132],[443,133],[442,134],[321,131],[440,8],[395,8],[351,8],[352,135],[307,101],[237,136],[235,137],[441,8],[236,138],[435,139],[438,140],[246,141],[245,142],[244,143],[446,101],[243,144],[281,8],[449,8],[465,145],[464,8],[452,8],[451,101],[453,146],[201,8],[362,147],[363,148],[384,8],[210,149],[200,8],[203,150],[331,101],[330,151],[322,8],[323,8],[325,8],[328,152],[324,8],[326,153],[329,154],[327,153],[216,8],[208,8],[209,126],[413,155],[422,156],[426,157],[368,158],[367,8],[278,8],[454,159],[377,160],[315,161],[316,162],[301,163],[311,8],[299,8],[300,164],[335,165],[312,166],[336,167],[333,168],[332,8],[334,8],[290,169],[369,170],[370,171],[313,172],[314,173],[309,174],[348,175],[376,176],[379,177],[279,178],[206,179],[375,180],[202,111],[396,181],[407,182],[394,8],[406,183],[93,8],[382,184],[266,8],[296,185],[225,8],[405,186],[214,8],[269,187],[366,188],[404,8],[399,189],[207,8],[400,190],[402,191],[403,192],[385,8],[398,179],[233,193],[383,194],[408,195],[340,8],[343,8],[341,8],[345,8],[342,8],[344,8],[346,196],[339,8],[272,197],[271,8],[277,198],[273,199],[276,200],[275,200],[274,199],[229,201],[261,202],[373,203],[455,8],[430,204],[432,205],[298,8],[431,206],[371,170],[319,170],[213,8],[262,207],[226,208],[227,209],[228,210],[224,211],[347,211],[240,211],[264,212],[241,212],[223,213],[222,8],[270,214],[268,215],[267,216],[265,217],[372,218],[303,219],[338,220],[302,221],[355,222],[354,223],[350,224],[258,225],[260,226],[257,227],[231,228],[289,8],[418,8],[288,229],[349,8],[280,230],[310,231],[308,232],[282,233],[284,234],[450,8],[283,235],[285,235],[416,8],[415,8],[417,8],[448,8],[287,236],[255,101],[91,8],[238,237],[247,8],[292,238],[232,8],[424,101],[434,239],[254,101],[428,131],[253,240],[410,241],[252,239],[204,8],[436,242],[250,101],[251,101],[242,8],[291,8],[249,243],[248,244],[239,245],[297,73],[378,73],[401,8],[381,246],[380,8],[420,8],[256,101],[412,247],[86,101],[89,248],[90,249],[87,101],[88,8],[397,250],[389,251],[388,8],[387,252],[386,8],[409,253],[423,254],[425,255],[427,256],[466,257],[429,258],[433,259],[461,260],[437,260],[460,261],[439,262],[444,263],[445,264],[447,265],[456,266],[459,149],[458,8],[457,12],[81,8],[82,8],[13,8],[14,8],[16,8],[15,8],[2,8],[17,8],[18,8],[19,8],[20,8],[21,8],[22,8],[23,8],[24,8],[3,8],[25,8],[26,8],[4,8],[27,8],[31,8],[28,8],[29,8],[30,8],[32,8],[33,8],[34,8],[5,8],[35,8],[36,8],[37,8],[38,8],[6,8],[42,8],[39,8],[40,8],[41,8],[43,8],[7,8],[44,8],[49,8],[50,8],[45,8],[46,8],[47,8],[48,8],[8,8],[54,8],[51,8],[52,8],[53,8],[55,8],[9,8],[56,8],[57,8],[58,8],[60,8],[59,8],[61,8],[62,8],[10,8],[63,8],[64,8],[65,8],[11,8],[66,8],[67,8],[68,8],[69,8],[70,8],[1,8],[71,8],[72,8],[12,8],[76,8],[74,8],[79,8],[78,8],[73,8],[77,8],[75,8],[80,8],[120,267],[130,268],[119,267],[140,269],[111,270],[110,271],[139,12],[133,272],[138,273],[113,274],[127,275],[112,276],[136,277],[108,278],[107,12],[137,279],[109,280],[114,281],[115,8],[118,281],[105,8],[141,282],[131,283],[122,284],[123,285],[125,286],[121,287],[124,288],[134,12],[116,289],[117,290],[126,291],[106,292],[129,283],[128,281],[132,8],[135,293]],"affectedFilesPendingEmit":[493,494,495,496,497,498,491,492,499,500,477,478,484,485,486,487,474,475,473,488,489,463,472,476],"version":"5.9.3"}
|