Spaces:
Sleeping
Sleeping
Pavanupadhyay27 commited on
Commit ·
22df4e3
1
Parent(s): afd6f8b
feat: add option to delete previous system audit logs
Browse files- backend/app/api/v1/analytics.py +30 -1
- backend/app/api/v1/attendance.py +91 -1
- backend/app/api/v1/audit.py +22 -1
- backend/app/api/v1/enrollment.py +5 -0
- backend/app/api/v1/kiosk.py +522 -143
- backend/app/api/v1/reports.py +90 -15
- backend/app/api/v1/settings.py +10 -0
- backend/app/core/config.py +8 -1
- backend/app/core/init_db.py +63 -3
- backend/app/crud/crud.py +58 -30
- backend/app/main.py +50 -0
- backend/app/models/models.py +13 -0
- backend/app/schemas/schemas.py +27 -0
- backend/app/services/face_engine.py +95 -20
- backend/app/services/notifications.py +79 -0
- backend/app/services/rtsp_processor.py +115 -0
- backend/app/services/singletons.py +2 -0
- backend/app/tests/test_production_features.py +173 -0
- backend/config_debug.txt +3 -0
- docker-compose.yml +1 -1
- frontend/app/audit/page.tsx +38 -8
- frontend/app/dashboard/page.tsx +431 -1
- frontend/app/employees/[id]/page.tsx +1355 -0
- frontend/app/employees/page.tsx +28 -178
- frontend/app/enroll/[id]/page.tsx +778 -45
- frontend/app/globals.css +39 -30
- frontend/app/kiosk/page.tsx +752 -123
- frontend/app/reports/page.tsx +199 -11
- frontend/app/settings/page.tsx +205 -22
- frontend/components/AttendanceHeatmap.tsx +180 -0
- frontend/components/SidebarLayout.tsx +2 -2
- frontend/next-env.d.ts +2 -1
- frontend/package-lock.json +311 -258
- frontend/package.json +1 -0
- frontend/tsconfig.tsbuildinfo +0 -0
backend/app/api/v1/analytics.py
CHANGED
|
@@ -3,7 +3,7 @@ from fastapi.responses import StreamingResponse
|
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from sqlalchemy import select, func, and_, desc
|
| 5 |
from datetime import date, datetime, timedelta
|
| 6 |
-
from typing import Dict, Any, List
|
| 7 |
import asyncio
|
| 8 |
import json
|
| 9 |
|
|
@@ -189,3 +189,32 @@ async def live_stream(
|
|
| 189 |
event_bus.unsubscribe(queue)
|
| 190 |
|
| 191 |
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from sqlalchemy import select, func, and_, desc
|
| 5 |
from datetime import date, datetime, timedelta
|
| 6 |
+
from typing import Dict, Any, List, Optional
|
| 7 |
import asyncio
|
| 8 |
import json
|
| 9 |
|
|
|
|
| 189 |
event_bus.unsubscribe(queue)
|
| 190 |
|
| 191 |
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 192 |
+
|
| 193 |
+
@router.get("/heatmap")
|
| 194 |
+
def get_attendance_heatmap(
|
| 195 |
+
employee_id: Optional[int] = None,
|
| 196 |
+
db: Session = Depends(get_db),
|
| 197 |
+
current_user: models.User = Depends(checker_view)
|
| 198 |
+
):
|
| 199 |
+
"""
|
| 200 |
+
Returns daily attendance counts for the last 365 days to render a GitHub-style heatmap.
|
| 201 |
+
"""
|
| 202 |
+
start_date = date.today() - timedelta(days=365)
|
| 203 |
+
|
| 204 |
+
query = db.query(
|
| 205 |
+
models.Attendance.date,
|
| 206 |
+
func.count(models.Attendance.id)
|
| 207 |
+
).filter(
|
| 208 |
+
models.Attendance.date >= start_date
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
if employee_id:
|
| 212 |
+
query = query.filter(models.Attendance.employee_id == employee_id)
|
| 213 |
+
query = query.filter(models.Attendance.status.in_(["Present", "Late", "Half Day"]))
|
| 214 |
+
else:
|
| 215 |
+
query = query.filter(models.Attendance.status.in_(["Present", "Late", "Half Day"]))
|
| 216 |
+
|
| 217 |
+
results = query.group_by(models.Attendance.date).all()
|
| 218 |
+
|
| 219 |
+
heatmap_data = {r[0].isoformat(): r[1] for r in results}
|
| 220 |
+
return heatmap_data
|
backend/app/api/v1/attendance.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from typing import List, Optional
|
| 4 |
-
from datetime import date, datetime, timedelta
|
|
|
|
| 5 |
|
| 6 |
from app.core.database import get_db
|
| 7 |
from app.core import security
|
|
@@ -48,6 +49,95 @@ def manual_update_attendance(
|
|
| 48 |
)
|
| 49 |
return updated
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
@router.get("/logs", response_model=List[schemas.AttendanceLogOut])
|
| 52 |
def read_attendance_logs(
|
| 53 |
skip: int = 0,
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from typing import List, Optional
|
| 4 |
+
from datetime import date, datetime, timedelta, time
|
| 5 |
+
from sqlalchemy import and_
|
| 6 |
|
| 7 |
from app.core.database import get_db
|
| 8 |
from app.core import security
|
|
|
|
| 49 |
)
|
| 50 |
return updated
|
| 51 |
|
| 52 |
+
@router.post("/manual", response_model=schemas.AttendanceOut)
|
| 53 |
+
def manual_create_or_update_attendance(
|
| 54 |
+
request: Request,
|
| 55 |
+
data: schemas.AttendanceBase,
|
| 56 |
+
db: Session = Depends(get_db),
|
| 57 |
+
current_user: models.User = Depends(checker_manage)
|
| 58 |
+
):
|
| 59 |
+
# Verify employee exists
|
| 60 |
+
employee = db.query(models.Employee).filter(models.Employee.id == data.employee_id).first()
|
| 61 |
+
if not employee:
|
| 62 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 63 |
+
|
| 64 |
+
# Check if record already exists for that employee and date
|
| 65 |
+
db_att = db.query(models.Attendance).filter(
|
| 66 |
+
and_(
|
| 67 |
+
models.Attendance.employee_id == data.employee_id,
|
| 68 |
+
models.Attendance.date == data.date
|
| 69 |
+
)
|
| 70 |
+
).first()
|
| 71 |
+
|
| 72 |
+
if db_att:
|
| 73 |
+
# Update existing record
|
| 74 |
+
db_att.check_in = data.check_in
|
| 75 |
+
db_att.check_out = data.check_out
|
| 76 |
+
db_att.status = data.status
|
| 77 |
+
else:
|
| 78 |
+
# Create new record
|
| 79 |
+
db_att = models.Attendance(
|
| 80 |
+
employee_id=data.employee_id,
|
| 81 |
+
date=data.date,
|
| 82 |
+
check_in=data.check_in,
|
| 83 |
+
check_out=data.check_out,
|
| 84 |
+
status=data.status,
|
| 85 |
+
late_arrival=False,
|
| 86 |
+
early_departure=False,
|
| 87 |
+
working_hours=0.0,
|
| 88 |
+
overtime=0.0,
|
| 89 |
+
emergency_allowed=False
|
| 90 |
+
)
|
| 91 |
+
db.add(db_att)
|
| 92 |
+
|
| 93 |
+
# Determine late arrival flag based on shift or global settings
|
| 94 |
+
if db_att.check_in:
|
| 95 |
+
# Resolve shift start
|
| 96 |
+
if employee.shift:
|
| 97 |
+
shift_start = employee.shift.start_time
|
| 98 |
+
grace_mins = employee.shift.grace_period_minutes
|
| 99 |
+
else:
|
| 100 |
+
start_time_setting = crud.get_setting_by_key(db, "CHECK_IN_START")
|
| 101 |
+
grace_period_setting = crud.get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
|
| 102 |
+
|
| 103 |
+
start_str = start_time_setting.value if start_time_setting else "09:00"
|
| 104 |
+
grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
|
| 105 |
+
try:
|
| 106 |
+
hr, mn = map(int, start_str.split(":"))
|
| 107 |
+
shift_start = time(hr, mn)
|
| 108 |
+
except Exception:
|
| 109 |
+
shift_start = time(9, 0)
|
| 110 |
+
|
| 111 |
+
check_in_deadline = datetime.combine(db_att.date, shift_start) + timedelta(minutes=grace_mins)
|
| 112 |
+
db_att.late_arrival = db_att.check_in > check_in_deadline
|
| 113 |
+
if db_att.status not in ["Absent", "Half Day"]:
|
| 114 |
+
db_att.status = "Late" if db_att.late_arrival else "Present"
|
| 115 |
+
else:
|
| 116 |
+
db_att.late_arrival = False
|
| 117 |
+
|
| 118 |
+
# Recalculate working hours if both check_in and check_out exist
|
| 119 |
+
if db_att.check_in and db_att.check_out:
|
| 120 |
+
diff = db_att.check_out - db_att.check_in
|
| 121 |
+
db_att.working_hours = round(diff.total_seconds() / 3600.0, 2)
|
| 122 |
+
db_att.overtime = max(0.0, round(db_att.working_hours - 8.0, 2))
|
| 123 |
+
else:
|
| 124 |
+
db_att.working_hours = 0.0
|
| 125 |
+
db_att.overtime = 0.0
|
| 126 |
+
|
| 127 |
+
db.commit()
|
| 128 |
+
db.refresh(db_att)
|
| 129 |
+
|
| 130 |
+
# Create audit log
|
| 131 |
+
crud.create_audit_log(
|
| 132 |
+
db=db,
|
| 133 |
+
user_id=current_user.id,
|
| 134 |
+
action="Manual Attendance Override",
|
| 135 |
+
ip_address=request.client.host if request.client else None,
|
| 136 |
+
user_agent=request.headers.get("user-agent"),
|
| 137 |
+
details=f"Manually logged attendance for employee {employee.name} (ID: {employee.employee_id}) on {data.date}. Status: {db_att.status}"
|
| 138 |
+
)
|
| 139 |
+
return db_att
|
| 140 |
+
|
| 141 |
@router.get("/logs", response_model=List[schemas.AttendanceLogOut])
|
| 142 |
def read_attendance_logs(
|
| 143 |
skip: int = 0,
|
backend/app/api/v1/audit.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from typing import List
|
| 4 |
|
|
@@ -21,3 +21,24 @@ def read_audit_logs(
|
|
| 21 |
current_user: models.User = Depends(checker_view)
|
| 22 |
):
|
| 23 |
return crud.get_audit_logs(db, skip=skip, limit=limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from typing import List
|
| 4 |
|
|
|
|
| 21 |
current_user: models.User = Depends(checker_view)
|
| 22 |
):
|
| 23 |
return crud.get_audit_logs(db, skip=skip, limit=limit)
|
| 24 |
+
|
| 25 |
+
@router.delete("/")
|
| 26 |
+
def delete_all_audit_logs(
|
| 27 |
+
request: Request,
|
| 28 |
+
db: Session = Depends(get_db),
|
| 29 |
+
current_user: models.User = Depends(checker_view)
|
| 30 |
+
):
|
| 31 |
+
deleted_count = crud.clear_all_audit_logs(db)
|
| 32 |
+
|
| 33 |
+
# Audit log the deletion itself
|
| 34 |
+
crud.create_audit_log(
|
| 35 |
+
db=db,
|
| 36 |
+
user_id=current_user.id,
|
| 37 |
+
action="Clear Audit Logs",
|
| 38 |
+
ip_address=request.client.host if request.client else None,
|
| 39 |
+
user_agent=request.headers.get("user-agent"),
|
| 40 |
+
details=f"Cleared {deleted_count} system audit logs."
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
return {"message": "Audit logs cleared successfully", "deleted_count": deleted_count}
|
| 44 |
+
|
backend/app/api/v1/enrollment.py
CHANGED
|
@@ -57,6 +57,11 @@ async def upload_face_image(
|
|
| 57 |
if confidence < 0.5:
|
| 58 |
raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.")
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
# Optional liveness check on enrollment (preventing enroll spoofing)
|
| 61 |
liveness_enabled_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_CHECK")
|
| 62 |
liveness_enabled = liveness_enabled_setting.value.lower() == "true" if liveness_enabled_setting else True
|
|
|
|
| 57 |
if confidence < 0.5:
|
| 58 |
raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.")
|
| 59 |
|
| 60 |
+
# Image Quality Validation
|
| 61 |
+
quality = face_engine.validate_image_quality(img)
|
| 62 |
+
if not quality["is_valid"]:
|
| 63 |
+
raise HTTPException(status_code=400, detail=quality["reason"])
|
| 64 |
+
|
| 65 |
# Optional liveness check on enrollment (preventing enroll spoofing)
|
| 66 |
liveness_enabled_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_CHECK")
|
| 67 |
liveness_enabled = liveness_enabled_setting.value.lower() == "true" if liveness_enabled_setting else True
|
backend/app/api/v1/kiosk.py
CHANGED
|
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
| 2 |
from fastapi.responses import FileResponse
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from pydantic import BaseModel, Field
|
|
|
|
| 5 |
import base64
|
| 6 |
import cv2
|
| 7 |
import numpy as np
|
|
@@ -59,10 +60,31 @@ def _publish_log(log_obj, employee=None):
|
|
| 59 |
except Exception as exc:
|
| 60 |
logger.warning(f"Failed to publish scan event: {exc}")
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
class KioskScanRequest(BaseModel):
|
| 63 |
image: str = Field(..., description="Base64 encoded image frame (JPEG/PNG data URL)")
|
| 64 |
camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
|
| 65 |
confirm_checkout: bool = Field(False, description="Whether the check-out is confirmed by the employee")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
@router.post("/scan")
|
| 68 |
def scan_face(
|
|
@@ -107,132 +129,238 @@ def scan_face(
|
|
| 107 |
except Exception:
|
| 108 |
raise HTTPException(status_code=400, detail="Invalid Base64 image data")
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
"
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
# 5. DB Matching: fetch all vectors and compute similarity in memory (completely database-agnostic)
|
| 158 |
-
if face_engine.embeddings_cache is None:
|
| 159 |
-
face_engine.load_embeddings_cache(db)
|
| 160 |
|
| 161 |
-
|
| 162 |
-
if not all_embeddings:
|
| 163 |
match_result = None
|
| 164 |
-
|
| 165 |
try:
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
except Exception as e:
|
| 182 |
-
logger.error(f"Error in vectorized face matching: {e}")
|
| 183 |
-
match_result = None
|
| 184 |
-
|
| 185 |
-
if not match_result:
|
| 186 |
-
# Database has no enrolled embeddings
|
| 187 |
-
log_entry = crud.create_attendance_log(
|
| 188 |
-
db=db,
|
| 189 |
-
employee_id=None,
|
| 190 |
-
camera=payload.camera,
|
| 191 |
-
confidence=confidence,
|
| 192 |
-
liveness_score=liveness_score,
|
| 193 |
-
is_spoof=False,
|
| 194 |
-
status="Empty Vector Index",
|
| 195 |
-
timestamp=now
|
| 196 |
-
)
|
| 197 |
-
_publish_log(log_entry)
|
| 198 |
-
return {
|
| 199 |
-
"status": "unknown",
|
| 200 |
-
"message": "No employees registered in the system. Please register first.",
|
| 201 |
-
"should_retry": False
|
| 202 |
-
}
|
| 203 |
|
| 204 |
-
|
| 205 |
-
# Similarity = 1 - Distance
|
| 206 |
-
similarity = 1.0 - float(distance)
|
| 207 |
-
|
| 208 |
-
if similarity < face_threshold:
|
| 209 |
-
# Low confidence match -> Unknown
|
| 210 |
-
log_entry = crud.create_attendance_log(
|
| 211 |
-
db=db,
|
| 212 |
-
employee_id=None,
|
| 213 |
-
camera=payload.camera,
|
| 214 |
-
confidence=similarity,
|
| 215 |
-
liveness_score=liveness_score,
|
| 216 |
-
is_spoof=False,
|
| 217 |
-
status="Unknown Person",
|
| 218 |
-
timestamp=now
|
| 219 |
-
)
|
| 220 |
-
_publish_log(log_entry)
|
| 221 |
-
return {
|
| 222 |
-
"status": "unknown",
|
| 223 |
-
"message": "Face not recognized. Please try again or contact HR.",
|
| 224 |
-
"confidence": similarity,
|
| 225 |
-
"liveness_score": liveness_score,
|
| 226 |
-
"should_retry": True
|
| 227 |
-
}
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
if not employee or employee.status != "Active":
|
| 232 |
# Inactive employee
|
|
|
|
| 233 |
log_entry = crud.create_attendance_log(
|
| 234 |
db=db,
|
| 235 |
-
employee_id=
|
| 236 |
camera=payload.camera,
|
| 237 |
confidence=similarity,
|
| 238 |
liveness_score=liveness_score,
|
|
@@ -241,12 +369,89 @@ def scan_face(
|
|
| 241 |
timestamp=now
|
| 242 |
)
|
| 243 |
_publish_log(log_entry, employee)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
return {
|
| 245 |
"status": "inactive",
|
| 246 |
"message": "Employee account is deactivated. Access denied.",
|
| 247 |
-
"should_retry": False
|
|
|
|
| 248 |
}
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
global _last_greeted_employee_id
|
| 251 |
should_greet = True
|
| 252 |
if _last_greeted_employee_id == employee.id:
|
|
@@ -257,6 +462,32 @@ def scan_face(
|
|
| 257 |
from sqlalchemy import select, and_
|
| 258 |
from datetime import time, timedelta
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
# 6. Check state of attendance
|
| 261 |
stmt = select(models.Attendance).where(
|
| 262 |
and_(
|
|
@@ -268,16 +499,7 @@ def scan_face(
|
|
| 268 |
|
| 269 |
if not attendance_record:
|
| 270 |
# --- First scan of the day: Check-In ---
|
| 271 |
-
|
| 272 |
-
grace_period_setting = crud.get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
|
| 273 |
-
start_str = start_time_setting.value if start_time_setting else "09:00"
|
| 274 |
-
grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
|
| 275 |
-
try:
|
| 276 |
-
hr, mn = map(int, start_str.split(":"))
|
| 277 |
-
check_in_deadline = datetime.combine(now.date(), time(hr, mn)) + timedelta(minutes=grace_mins)
|
| 278 |
-
except Exception:
|
| 279 |
-
check_in_deadline = datetime.combine(now.date(), time(9, 15))
|
| 280 |
-
|
| 281 |
is_late = now > check_in_deadline
|
| 282 |
status = "Late" if is_late else "Present"
|
| 283 |
|
|
@@ -300,7 +522,7 @@ def scan_face(
|
|
| 300 |
confidence=similarity,
|
| 301 |
liveness_score=liveness_score,
|
| 302 |
is_spoof=False,
|
| 303 |
-
status=
|
| 304 |
timestamp=now
|
| 305 |
)
|
| 306 |
_publish_log(log_entry, employee)
|
|
@@ -344,7 +566,8 @@ def scan_face(
|
|
| 344 |
"detail": "Attendance Recorded Successfully",
|
| 345 |
"closing": "Have a Great Day"
|
| 346 |
},
|
| 347 |
-
"tts_url": tts_url
|
|
|
|
| 348 |
}
|
| 349 |
|
| 350 |
else:
|
|
@@ -365,7 +588,7 @@ def scan_face(
|
|
| 365 |
confidence=similarity,
|
| 366 |
liveness_score=liveness_score,
|
| 367 |
is_spoof=False,
|
| 368 |
-
status=
|
| 369 |
timestamp=now
|
| 370 |
)
|
| 371 |
_publish_log(log_entry, employee)
|
|
@@ -408,7 +631,8 @@ def scan_face(
|
|
| 408 |
"detail": "Emergency Check-In Recorded",
|
| 409 |
"closing": "Have a Great Day"
|
| 410 |
},
|
| 411 |
-
"tts_url": tts_url
|
|
|
|
| 412 |
}
|
| 413 |
else:
|
| 414 |
# Locked for the day!
|
|
@@ -427,7 +651,8 @@ def scan_face(
|
|
| 427 |
return {
|
| 428 |
"status": "locked",
|
| 429 |
"message": "Attendance locked until tomorrow. Emergency entry must be approved by Admin.",
|
| 430 |
-
"should_retry": False
|
|
|
|
| 431 |
}
|
| 432 |
|
| 433 |
else:
|
|
@@ -465,7 +690,8 @@ def scan_face(
|
|
| 465 |
"detail": "Tap Yes to confirm Check Out",
|
| 466 |
"closing": f"Duration: {hours} hours"
|
| 467 |
},
|
| 468 |
-
"tts_url": tts_url
|
|
|
|
| 469 |
}
|
| 470 |
else:
|
| 471 |
# User confirmed checkout!
|
|
@@ -475,18 +701,20 @@ def scan_face(
|
|
| 475 |
attendance_record.working_hours = hours
|
| 476 |
|
| 477 |
# Early departure and overtime
|
| 478 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
-
# Update status based on hours
|
| 489 |
-
if
|
|
|
|
| 490 |
attendance_record.status = "Half Day"
|
| 491 |
else:
|
| 492 |
if attendance_record.status in ["Half Day", "Absent"]:
|
|
@@ -502,7 +730,7 @@ def scan_face(
|
|
| 502 |
confidence=similarity,
|
| 503 |
liveness_score=liveness_score,
|
| 504 |
is_spoof=False,
|
| 505 |
-
status=
|
| 506 |
timestamp=now
|
| 507 |
)
|
| 508 |
_publish_log(log_entry, employee)
|
|
@@ -546,7 +774,8 @@ def scan_face(
|
|
| 546 |
"detail": "Checkout Recorded Successfully",
|
| 547 |
"closing": f"Worked: {hours} hours"
|
| 548 |
},
|
| 549 |
-
"tts_url": tts_url
|
|
|
|
| 550 |
}
|
| 551 |
|
| 552 |
@router.get("/tts")
|
|
@@ -564,3 +793,153 @@ def play_tts(text: str):
|
|
| 564 |
except Exception as e:
|
| 565 |
logger.error(f"Error serving TTS endpoint: {e}")
|
| 566 |
raise HTTPException(status_code=500, detail="Voice generation failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi.responses import FileResponse
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import Optional
|
| 6 |
import base64
|
| 7 |
import cv2
|
| 8 |
import numpy as np
|
|
|
|
| 60 |
except Exception as exc:
|
| 61 |
logger.warning(f"Failed to publish scan event: {exc}")
|
| 62 |
|
| 63 |
+
import math
|
| 64 |
+
|
| 65 |
+
def calculate_distance_meters(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 66 |
+
# Haversine formula
|
| 67 |
+
R = 6371000.0 # Earth radius in meters
|
| 68 |
+
phi1 = math.radians(lat1)
|
| 69 |
+
phi2 = math.radians(lat2)
|
| 70 |
+
delta_phi = math.radians(lat2 - lat1)
|
| 71 |
+
delta_lambda = math.radians(lon2 - lon1)
|
| 72 |
+
|
| 73 |
+
a = math.sin(delta_phi / 2.0)**2 + \
|
| 74 |
+
math.cos(phi1) * math.cos(phi2) * \
|
| 75 |
+
math.sin(delta_lambda / 2.0)**2
|
| 76 |
+
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
|
| 77 |
+
return R * c
|
| 78 |
+
|
| 79 |
class KioskScanRequest(BaseModel):
|
| 80 |
image: str = Field(..., description="Base64 encoded image frame (JPEG/PNG data URL)")
|
| 81 |
camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
|
| 82 |
confirm_checkout: bool = Field(False, description="Whether the check-out is confirmed by the employee")
|
| 83 |
+
qr_code: str = Field(None, description="Pre-detected QR code string from frontend")
|
| 84 |
+
qr_only: bool = Field(False, description="If True, only allow QR-based logging and disable face recognition")
|
| 85 |
+
latitude: Optional[float] = Field(None, description="Latitude of the kiosk/device marking attendance")
|
| 86 |
+
longitude: Optional[float] = Field(None, description="Longitude of the kiosk/device marking attendance")
|
| 87 |
+
|
| 88 |
|
| 89 |
@router.post("/scan")
|
| 90 |
def scan_face(
|
|
|
|
| 129 |
except Exception:
|
| 130 |
raise HTTPException(status_code=400, detail="Invalid Base64 image data")
|
| 131 |
|
| 132 |
+
qr_employee = None
|
| 133 |
+
is_qr_scan = False
|
| 134 |
+
bbox_list = None
|
| 135 |
+
|
| 136 |
+
# Check if qr_code was pre-detected by the frontend
|
| 137 |
+
if getattr(payload, "qr_code", None):
|
| 138 |
+
qr_val = payload.qr_code.strip()
|
| 139 |
+
qr_employee = db.query(models.Employee).filter(
|
| 140 |
+
models.Employee.employee_id == qr_val
|
| 141 |
+
).first()
|
| 142 |
+
if qr_employee:
|
| 143 |
+
is_qr_scan = True
|
| 144 |
+
logger.info(f"QR code pre-detected by frontend: {qr_employee.employee_id}")
|
| 145 |
+
|
| 146 |
+
if not is_qr_scan:
|
| 147 |
+
try:
|
| 148 |
+
qr_detector = cv2.QRCodeDetector()
|
| 149 |
+
qr_val, _, _ = qr_detector.detectAndDecode(img)
|
| 150 |
+
if qr_val:
|
| 151 |
+
qr_val = qr_val.strip()
|
| 152 |
+
qr_employee = db.query(models.Employee).filter(
|
| 153 |
+
models.Employee.employee_id == qr_val
|
| 154 |
+
).first()
|
| 155 |
+
if qr_employee:
|
| 156 |
+
is_qr_scan = True
|
| 157 |
+
logger.info(f"QR code scanned successfully for employee: {qr_employee.employee_id}")
|
| 158 |
+
except Exception as qr_err:
|
| 159 |
+
logger.warning(f"QR code parsing error: {qr_err}")
|
| 160 |
+
|
| 161 |
+
if is_qr_scan:
|
| 162 |
+
employee = qr_employee
|
| 163 |
+
similarity = 1.0
|
| 164 |
+
liveness_score = 1.0
|
| 165 |
+
confidence = 1.0
|
| 166 |
+
log_status_success = "Match Success (QR Scanned)"
|
| 167 |
+
else:
|
| 168 |
+
if getattr(payload, "qr_only", False):
|
| 169 |
+
return {
|
| 170 |
+
"status": "unknown",
|
| 171 |
+
"message": "Invalid QR code. Employee badge not found.",
|
| 172 |
+
"should_retry": True
|
| 173 |
+
}
|
| 174 |
+
# 2. Detect face
|
| 175 |
+
faces = face_engine.detect_faces(img)
|
| 176 |
+
if not faces:
|
| 177 |
+
return {
|
| 178 |
+
"status": "no_face",
|
| 179 |
+
"message": "No face detected. Frame your face within the scanner.",
|
| 180 |
+
"should_retry": True
|
| 181 |
+
}
|
| 182 |
+
if len(faces) > 1:
|
| 183 |
+
return {
|
| 184 |
+
"status": "multiple_faces",
|
| 185 |
+
"message": "Multiple faces detected. Please scan one person at a time.",
|
| 186 |
+
"should_retry": True
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
face = faces[0]
|
| 190 |
+
bbox = face["bbox"]
|
| 191 |
+
bbox_list = [float(x) for x in bbox]
|
| 192 |
+
confidence = face["confidence"]
|
| 193 |
+
landmarks = face["landmarks"]
|
| 194 |
|
| 195 |
+
# 3. Liveness Check
|
| 196 |
+
liveness_score, is_live = face_engine.check_liveness(img, bbox, threshold=liveness_threshold)
|
| 197 |
+
if not is_live and not face_engine.mock_mode:
|
| 198 |
+
# Save spoof log
|
| 199 |
+
log_entry = crud.create_attendance_log(
|
| 200 |
+
db=db,
|
| 201 |
+
employee_id=None,
|
| 202 |
+
camera=payload.camera,
|
| 203 |
+
confidence=confidence,
|
| 204 |
+
liveness_score=liveness_score,
|
| 205 |
+
is_spoof=True,
|
| 206 |
+
status="Spoof Rejected",
|
| 207 |
+
timestamp=now
|
| 208 |
+
)
|
| 209 |
+
_publish_log(log_entry)
|
| 210 |
+
|
| 211 |
+
# Dispatch Webhook alert
|
| 212 |
+
try:
|
| 213 |
+
from app.services.notifications import trigger_security_alert
|
| 214 |
+
trigger_security_alert(
|
| 215 |
+
db=db,
|
| 216 |
+
alert_type="Spoofing Attempt Rejected",
|
| 217 |
+
details={
|
| 218 |
+
"camera": payload.camera,
|
| 219 |
+
"confidence": float(confidence),
|
| 220 |
+
"liveness_score": float(liveness_score),
|
| 221 |
+
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S")
|
| 222 |
+
}
|
| 223 |
+
)
|
| 224 |
+
except Exception as alert_err:
|
| 225 |
+
logger.error(f"Failed to dispatch security alert: {alert_err}")
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
"status": "spoof_detected",
|
| 229 |
+
"message": "Liveness check failed! Verification denied.",
|
| 230 |
+
"confidence": float(confidence),
|
| 231 |
+
"liveness_score": float(liveness_score),
|
| 232 |
+
"should_retry": False,
|
| 233 |
+
"bbox": bbox_list
|
| 234 |
+
}
|
| 235 |
|
| 236 |
+
# 4. Extract Embedding
|
| 237 |
+
aligned = face_engine.align_face(img, landmarks)
|
| 238 |
+
embedding = face_engine.extract_embedding(aligned)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
+
# 5. DB Matching: query pgvector if postgresql, else fallback to numpy cache-matching
|
|
|
|
| 241 |
match_result = None
|
| 242 |
+
is_pg = False
|
| 243 |
try:
|
| 244 |
+
is_pg = (db.bind.dialect.name == "postgresql")
|
| 245 |
+
except Exception as dialect_err:
|
| 246 |
+
logger.warning(f"Could not determine DB dialect: {dialect_err}")
|
| 247 |
+
|
| 248 |
+
if is_pg:
|
| 249 |
+
try:
|
| 250 |
+
# Run database-level query using pgvector's cosine distance (<=>) operator
|
| 251 |
+
emb_list = embedding.tolist() if isinstance(embedding, np.ndarray) else list(embedding)
|
| 252 |
+
distance_expr = models.FaceEmbedding.embedding.op('<=>')(emb_list).label('distance')
|
| 253 |
+
query_res = db.query(models.FaceEmbedding, distance_expr).order_by(distance_expr).limit(1).first()
|
| 254 |
+
if query_res:
|
| 255 |
+
db_emb, distance = query_res
|
| 256 |
+
match_result = (db_emb, float(distance))
|
| 257 |
+
except Exception as pg_err:
|
| 258 |
+
logger.error(f"Failed to query pgvector: {pg_err}. Falling back to SQLite/NumPy matching.")
|
| 259 |
+
match_result = None
|
| 260 |
+
|
| 261 |
+
if match_result is None:
|
| 262 |
+
if face_engine.embeddings_cache is None:
|
| 263 |
+
face_engine.load_embeddings_cache(db)
|
| 264 |
+
|
| 265 |
+
all_embeddings = face_engine.embeddings_cache
|
| 266 |
+
if not all_embeddings:
|
| 267 |
+
match_result = None
|
| 268 |
+
else:
|
| 269 |
+
try:
|
| 270 |
+
# High-performance vectorized search using NumPy matrix multiplication.
|
| 271 |
+
# ArcFace embeddings are L2-normalized, so cosine similarity is just the dot product.
|
| 272 |
+
embeddings_matrix = np.stack([emb["embedding"] for emb in all_embeddings]) # shape (N, 512)
|
| 273 |
+
similarities = np.dot(embeddings_matrix, embedding) # shape (N,)
|
| 274 |
+
best_idx = int(np.argmax(similarities))
|
| 275 |
+
best_similarity = float(similarities[best_idx])
|
| 276 |
+
|
| 277 |
+
best_emb_record = all_embeddings[best_idx]
|
| 278 |
+
class MockEmb:
|
| 279 |
+
id = best_emb_record["id"]
|
| 280 |
+
employee_id = best_emb_record["employee_id"]
|
| 281 |
+
|
| 282 |
+
# distance = 1 - similarity
|
| 283 |
+
best_dist = 1.0 - best_similarity
|
| 284 |
+
match_result = (MockEmb(), best_dist)
|
| 285 |
+
except Exception as e:
|
| 286 |
+
logger.error(f"Error in vectorized face matching: {e}")
|
| 287 |
+
match_result = None
|
| 288 |
+
|
| 289 |
+
if not match_result:
|
| 290 |
+
# Database has no enrolled embeddings
|
| 291 |
+
log_entry = crud.create_attendance_log(
|
| 292 |
+
db=db,
|
| 293 |
+
employee_id=None,
|
| 294 |
+
camera=payload.camera,
|
| 295 |
+
confidence=confidence,
|
| 296 |
+
liveness_score=liveness_score,
|
| 297 |
+
is_spoof=False,
|
| 298 |
+
status="Empty Vector Index",
|
| 299 |
+
timestamp=now
|
| 300 |
+
)
|
| 301 |
+
_publish_log(log_entry)
|
| 302 |
+
return {
|
| 303 |
+
"status": "unknown",
|
| 304 |
+
"message": "No employees registered in the system. Please register first.",
|
| 305 |
+
"should_retry": False,
|
| 306 |
+
"bbox": bbox_list
|
| 307 |
+
}
|
| 308 |
|
| 309 |
+
db_emb, distance = match_result
|
| 310 |
+
# Similarity = 1 - Distance
|
| 311 |
+
similarity = 1.0 - float(distance)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
+
employee = crud.get_employee_by_id(db, db_emb.employee_id) if db_emb else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
+
if similarity < face_threshold:
|
| 316 |
+
qr_fallback_setting = crud.get_setting_by_key(db, "QR_FALLBACK_ENABLED")
|
| 317 |
+
qr_fallback_enabled = qr_fallback_setting.value.lower() == "true" if qr_fallback_setting else True
|
| 318 |
+
|
| 319 |
+
if False: # Disable automatic QR fallback on borderline match
|
| 320 |
+
return {
|
| 321 |
+
"status": "needs_qr",
|
| 322 |
+
"message": "Face matched but requires identity verification. Please scan your employee QR code.",
|
| 323 |
+
"employee": {
|
| 324 |
+
"id": employee.id,
|
| 325 |
+
"employee_id": employee.employee_id,
|
| 326 |
+
"name": employee.name,
|
| 327 |
+
"designation": employee.designation,
|
| 328 |
+
"department": employee.department.name if employee.department else "General"
|
| 329 |
+
},
|
| 330 |
+
"confidence": similarity,
|
| 331 |
+
"liveness_score": liveness_score,
|
| 332 |
+
"should_retry": False,
|
| 333 |
+
"bbox": bbox_list
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
# Low confidence match -> Unknown
|
| 337 |
+
log_entry = crud.create_attendance_log(
|
| 338 |
+
db=db,
|
| 339 |
+
employee_id=None,
|
| 340 |
+
camera=payload.camera,
|
| 341 |
+
confidence=similarity,
|
| 342 |
+
liveness_score=liveness_score,
|
| 343 |
+
is_spoof=False,
|
| 344 |
+
status="Unknown Person",
|
| 345 |
+
timestamp=now
|
| 346 |
+
)
|
| 347 |
+
_publish_log(log_entry)
|
| 348 |
+
return {
|
| 349 |
+
"status": "unknown",
|
| 350 |
+
"message": "Face not recognized. Please try again or contact HR.",
|
| 351 |
+
"confidence": similarity,
|
| 352 |
+
"liveness_score": liveness_score,
|
| 353 |
+
"should_retry": True,
|
| 354 |
+
"bbox": bbox_list
|
| 355 |
+
}
|
| 356 |
+
log_status_success = "Match Success"
|
| 357 |
+
|
| 358 |
if not employee or employee.status != "Active":
|
| 359 |
# Inactive employee
|
| 360 |
+
emp_id_to_log = employee.id if employee else (db_emb.employee_id if ('db_emb' in locals() and db_emb) else None)
|
| 361 |
log_entry = crud.create_attendance_log(
|
| 362 |
db=db,
|
| 363 |
+
employee_id=emp_id_to_log,
|
| 364 |
camera=payload.camera,
|
| 365 |
confidence=similarity,
|
| 366 |
liveness_score=liveness_score,
|
|
|
|
| 369 |
timestamp=now
|
| 370 |
)
|
| 371 |
_publish_log(log_entry, employee)
|
| 372 |
+
|
| 373 |
+
# Dispatch Webhook alert
|
| 374 |
+
try:
|
| 375 |
+
from app.services.notifications import trigger_security_alert
|
| 376 |
+
trigger_security_alert(
|
| 377 |
+
db=db,
|
| 378 |
+
alert_type="Deactivated Employee Access Attempt",
|
| 379 |
+
details={
|
| 380 |
+
"camera": payload.camera,
|
| 381 |
+
"confidence": float(similarity),
|
| 382 |
+
"liveness_score": float(liveness_score),
|
| 383 |
+
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"),
|
| 384 |
+
"employee_name": employee.name if employee else "Unknown",
|
| 385 |
+
"employee_id": employee.employee_id if employee else "Unknown"
|
| 386 |
+
}
|
| 387 |
+
)
|
| 388 |
+
except Exception as alert_err:
|
| 389 |
+
logger.error(f"Failed to dispatch security alert: {alert_err}")
|
| 390 |
+
|
| 391 |
return {
|
| 392 |
"status": "inactive",
|
| 393 |
"message": "Employee account is deactivated. Access denied.",
|
| 394 |
+
"should_retry": False,
|
| 395 |
+
"bbox": bbox_list
|
| 396 |
}
|
| 397 |
|
| 398 |
+
# Geofencing validation check
|
| 399 |
+
loc_enabled_setting = crud.get_setting_by_key(db, "LOCATION_RESTRICTION_ENABLED")
|
| 400 |
+
loc_enabled = loc_enabled_setting.value.lower() == "true" if loc_enabled_setting else False
|
| 401 |
+
|
| 402 |
+
if loc_enabled and not getattr(employee, "allow_wfh", False):
|
| 403 |
+
if payload.latitude is None or payload.longitude is None:
|
| 404 |
+
log_entry = crud.create_attendance_log(
|
| 405 |
+
db=db,
|
| 406 |
+
employee_id=employee.id,
|
| 407 |
+
camera=payload.camera,
|
| 408 |
+
confidence=similarity if 'similarity' in locals() else 1.0,
|
| 409 |
+
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 410 |
+
is_spoof=False,
|
| 411 |
+
status="Location Missing",
|
| 412 |
+
timestamp=now
|
| 413 |
+
)
|
| 414 |
+
_publish_log(log_entry, employee)
|
| 415 |
+
return {
|
| 416 |
+
"status": "location_error",
|
| 417 |
+
"message": "GPS coordinates are required to mark attendance.",
|
| 418 |
+
"should_retry": False,
|
| 419 |
+
"bbox": bbox_list
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
loc_lat_setting = crud.get_setting_by_key(db, "LOCATION_LATITUDE")
|
| 423 |
+
loc_lon_setting = crud.get_setting_by_key(db, "LOCATION_LONGITUDE")
|
| 424 |
+
loc_rad_setting = crud.get_setting_by_key(db, "LOCATION_RADIUS_METERS")
|
| 425 |
+
|
| 426 |
+
try:
|
| 427 |
+
office_lat = float(loc_lat_setting.value) if loc_lat_setting else 0.0
|
| 428 |
+
office_lon = float(loc_lon_setting.value) if loc_lon_setting else 0.0
|
| 429 |
+
allowed_radius = float(loc_rad_setting.value) if loc_rad_setting else 50.0
|
| 430 |
+
except ValueError:
|
| 431 |
+
office_lat = 0.0
|
| 432 |
+
office_lon = 0.0
|
| 433 |
+
allowed_radius = 50.0
|
| 434 |
+
|
| 435 |
+
dist = calculate_distance_meters(payload.latitude, payload.longitude, office_lat, office_lon)
|
| 436 |
+
if dist > allowed_radius:
|
| 437 |
+
log_entry = crud.create_attendance_log(
|
| 438 |
+
db=db,
|
| 439 |
+
employee_id=employee.id,
|
| 440 |
+
camera=payload.camera,
|
| 441 |
+
confidence=similarity if 'similarity' in locals() else 1.0,
|
| 442 |
+
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 443 |
+
is_spoof=False,
|
| 444 |
+
status="Outside Office Bounds",
|
| 445 |
+
timestamp=now
|
| 446 |
+
)
|
| 447 |
+
_publish_log(log_entry, employee)
|
| 448 |
+
return {
|
| 449 |
+
"status": "location_error",
|
| 450 |
+
"message": f"Outside allowed area. Distance: {dist:.1f}m. Max radius: {allowed_radius}m.",
|
| 451 |
+
"should_retry": False,
|
| 452 |
+
"bbox": bbox_list
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
global _last_greeted_employee_id
|
| 456 |
should_greet = True
|
| 457 |
if _last_greeted_employee_id == employee.id:
|
|
|
|
| 462 |
from sqlalchemy import select, and_
|
| 463 |
from datetime import time, timedelta
|
| 464 |
|
| 465 |
+
# Resolve shift details for employee
|
| 466 |
+
if employee.shift:
|
| 467 |
+
shift_start = employee.shift.start_time
|
| 468 |
+
shift_end = employee.shift.end_time
|
| 469 |
+
grace_mins = employee.shift.grace_period_minutes
|
| 470 |
+
else:
|
| 471 |
+
start_time_setting = crud.get_setting_by_key(db, "CHECK_IN_START")
|
| 472 |
+
end_time_setting = crud.get_setting_by_key(db, "CHECK_OUT_END")
|
| 473 |
+
grace_period_setting = crud.get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
|
| 474 |
+
|
| 475 |
+
start_str = start_time_setting.value if start_time_setting else "09:00"
|
| 476 |
+
end_str = end_time_setting.value if end_time_setting else "17:00"
|
| 477 |
+
grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
|
| 478 |
+
|
| 479 |
+
try:
|
| 480 |
+
hr, mn = map(int, start_str.split(":"))
|
| 481 |
+
shift_start = time(hr, mn)
|
| 482 |
+
except Exception:
|
| 483 |
+
shift_start = time(9, 0)
|
| 484 |
+
|
| 485 |
+
try:
|
| 486 |
+
hr, mn = map(int, end_str.split(":"))
|
| 487 |
+
shift_end = time(hr, mn)
|
| 488 |
+
except Exception:
|
| 489 |
+
shift_end = time(17, 0)
|
| 490 |
+
|
| 491 |
# 6. Check state of attendance
|
| 492 |
stmt = select(models.Attendance).where(
|
| 493 |
and_(
|
|
|
|
| 499 |
|
| 500 |
if not attendance_record:
|
| 501 |
# --- First scan of the day: Check-In ---
|
| 502 |
+
check_in_deadline = datetime.combine(now.date(), shift_start) + timedelta(minutes=grace_mins)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
is_late = now > check_in_deadline
|
| 504 |
status = "Late" if is_late else "Present"
|
| 505 |
|
|
|
|
| 522 |
confidence=similarity,
|
| 523 |
liveness_score=liveness_score,
|
| 524 |
is_spoof=False,
|
| 525 |
+
status=log_status_success,
|
| 526 |
timestamp=now
|
| 527 |
)
|
| 528 |
_publish_log(log_entry, employee)
|
|
|
|
| 566 |
"detail": "Attendance Recorded Successfully",
|
| 567 |
"closing": "Have a Great Day"
|
| 568 |
},
|
| 569 |
+
"tts_url": tts_url,
|
| 570 |
+
"bbox": bbox_list
|
| 571 |
}
|
| 572 |
|
| 573 |
else:
|
|
|
|
| 588 |
confidence=similarity,
|
| 589 |
liveness_score=liveness_score,
|
| 590 |
is_spoof=False,
|
| 591 |
+
status=log_status_success,
|
| 592 |
timestamp=now
|
| 593 |
)
|
| 594 |
_publish_log(log_entry, employee)
|
|
|
|
| 631 |
"detail": "Emergency Check-In Recorded",
|
| 632 |
"closing": "Have a Great Day"
|
| 633 |
},
|
| 634 |
+
"tts_url": tts_url,
|
| 635 |
+
"bbox": bbox_list
|
| 636 |
}
|
| 637 |
else:
|
| 638 |
# Locked for the day!
|
|
|
|
| 651 |
return {
|
| 652 |
"status": "locked",
|
| 653 |
"message": "Attendance locked until tomorrow. Emergency entry must be approved by Admin.",
|
| 654 |
+
"should_retry": False,
|
| 655 |
+
"bbox": bbox_list
|
| 656 |
}
|
| 657 |
|
| 658 |
else:
|
|
|
|
| 690 |
"detail": "Tap Yes to confirm Check Out",
|
| 691 |
"closing": f"Duration: {hours} hours"
|
| 692 |
},
|
| 693 |
+
"tts_url": tts_url,
|
| 694 |
+
"bbox": bbox_list
|
| 695 |
}
|
| 696 |
else:
|
| 697 |
# User confirmed checkout!
|
|
|
|
| 701 |
attendance_record.working_hours = hours
|
| 702 |
|
| 703 |
# Early departure and overtime
|
| 704 |
+
departure_deadline = datetime.combine(now.date(), shift_end)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
attendance_record.early_departure = now < departure_deadline
|
| 706 |
+
|
| 707 |
+
dt_start = datetime.combine(now.date(), shift_start)
|
| 708 |
+
dt_end = datetime.combine(now.date(), shift_end)
|
| 709 |
+
if dt_end < dt_start:
|
| 710 |
+
dt_end += timedelta(days=1)
|
| 711 |
+
shift_duration_hours = (dt_end - dt_start).total_seconds() / 3600.0
|
| 712 |
+
|
| 713 |
+
attendance_record.overtime = max(0.0, round(hours - shift_duration_hours, 2))
|
| 714 |
|
| 715 |
+
# Update status based on hours: Half Day if hours < 50% of shift duration
|
| 716 |
+
half_day_threshold = shift_duration_hours * 0.5 if shift_duration_hours > 0 else 4.0
|
| 717 |
+
if hours < half_day_threshold:
|
| 718 |
attendance_record.status = "Half Day"
|
| 719 |
else:
|
| 720 |
if attendance_record.status in ["Half Day", "Absent"]:
|
|
|
|
| 730 |
confidence=similarity,
|
| 731 |
liveness_score=liveness_score,
|
| 732 |
is_spoof=False,
|
| 733 |
+
status=log_status_success,
|
| 734 |
timestamp=now
|
| 735 |
)
|
| 736 |
_publish_log(log_entry, employee)
|
|
|
|
| 774 |
"detail": "Checkout Recorded Successfully",
|
| 775 |
"closing": f"Worked: {hours} hours"
|
| 776 |
},
|
| 777 |
+
"tts_url": tts_url,
|
| 778 |
+
"bbox": bbox_list
|
| 779 |
}
|
| 780 |
|
| 781 |
@router.get("/tts")
|
|
|
|
| 793 |
except Exception as e:
|
| 794 |
logger.error(f"Error serving TTS endpoint: {e}")
|
| 795 |
raise HTTPException(status_code=500, detail="Voice generation failed")
|
| 796 |
+
|
| 797 |
+
class KioskQRConfirmRequest(BaseModel):
|
| 798 |
+
employee_id: int
|
| 799 |
+
qr_code: str
|
| 800 |
+
camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
|
| 801 |
+
latitude: Optional[float] = Field(None, description="Latitude of the kiosk/device marking attendance")
|
| 802 |
+
longitude: Optional[float] = Field(None, description="Longitude of the kiosk/device marking attendance")
|
| 803 |
+
|
| 804 |
+
@router.post("/confirm-qr")
|
| 805 |
+
def confirm_qr(
|
| 806 |
+
payload: KioskQRConfirmRequest,
|
| 807 |
+
db: Session = Depends(get_db)
|
| 808 |
+
):
|
| 809 |
+
employee = crud.get_employee_by_id(db, payload.employee_id)
|
| 810 |
+
if not employee:
|
| 811 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 812 |
+
|
| 813 |
+
# Geofencing check for QR Confirmation
|
| 814 |
+
loc_enabled_setting = crud.get_setting_by_key(db, "LOCATION_RESTRICTION_ENABLED")
|
| 815 |
+
loc_enabled = loc_enabled_setting.value.lower() == "true" if loc_enabled_setting else False
|
| 816 |
+
|
| 817 |
+
if loc_enabled and not getattr(employee, "allow_wfh", False):
|
| 818 |
+
if payload.latitude is None or payload.longitude is None:
|
| 819 |
+
crud.create_attendance_log(
|
| 820 |
+
db=db,
|
| 821 |
+
employee_id=employee.id,
|
| 822 |
+
camera=payload.camera,
|
| 823 |
+
confidence=1.0,
|
| 824 |
+
liveness_score=1.0,
|
| 825 |
+
is_spoof=False,
|
| 826 |
+
status="Location Missing (QR)",
|
| 827 |
+
timestamp=datetime.now()
|
| 828 |
+
)
|
| 829 |
+
raise HTTPException(status_code=400, detail="GPS coordinates are required to mark attendance.")
|
| 830 |
+
|
| 831 |
+
loc_lat_setting = crud.get_setting_by_key(db, "LOCATION_LATITUDE")
|
| 832 |
+
loc_lon_setting = crud.get_setting_by_key(db, "LOCATION_LONGITUDE")
|
| 833 |
+
loc_rad_setting = crud.get_setting_by_key(db, "LOCATION_RADIUS_METERS")
|
| 834 |
+
|
| 835 |
+
try:
|
| 836 |
+
office_lat = float(loc_lat_setting.value) if loc_lat_setting else 0.0
|
| 837 |
+
office_lon = float(loc_lon_setting.value) if loc_lon_setting else 0.0
|
| 838 |
+
allowed_radius = float(loc_rad_setting.value) if loc_rad_setting else 50.0
|
| 839 |
+
except ValueError:
|
| 840 |
+
office_lat = 0.0
|
| 841 |
+
office_lon = 0.0
|
| 842 |
+
allowed_radius = 50.0
|
| 843 |
+
|
| 844 |
+
dist = calculate_distance_meters(payload.latitude, payload.longitude, office_lat, office_lon)
|
| 845 |
+
if dist > allowed_radius:
|
| 846 |
+
crud.create_attendance_log(
|
| 847 |
+
db=db,
|
| 848 |
+
employee_id=employee.id,
|
| 849 |
+
camera=payload.camera,
|
| 850 |
+
confidence=1.0,
|
| 851 |
+
liveness_score=1.0,
|
| 852 |
+
is_spoof=False,
|
| 853 |
+
status="Outside Office Bounds (QR)",
|
| 854 |
+
timestamp=datetime.now()
|
| 855 |
+
)
|
| 856 |
+
raise HTTPException(status_code=400, detail=f"Outside allowed area. Distance: {dist:.1f}m. Max radius: {allowed_radius}m.")
|
| 857 |
+
|
| 858 |
+
if payload.qr_code.strip() != employee.employee_id.strip():
|
| 859 |
+
# Audit log for failed verification
|
| 860 |
+
log_entry = crud.create_attendance_log(
|
| 861 |
+
db=db,
|
| 862 |
+
employee_id=employee.id,
|
| 863 |
+
camera=payload.camera,
|
| 864 |
+
confidence=0.55,
|
| 865 |
+
liveness_score=1.0,
|
| 866 |
+
is_spoof=False,
|
| 867 |
+
status="QR Verification Failed",
|
| 868 |
+
timestamp=datetime.now()
|
| 869 |
+
)
|
| 870 |
+
_publish_log(log_entry, employee)
|
| 871 |
+
raise HTTPException(status_code=400, detail="QR Code verification failed. Badge does not match matched face.")
|
| 872 |
+
|
| 873 |
+
now = datetime.now()
|
| 874 |
+
attendance_record = crud.mark_kiosk_attendance(
|
| 875 |
+
db=db,
|
| 876 |
+
employee_id=employee.id,
|
| 877 |
+
timestamp=now,
|
| 878 |
+
camera=payload.camera,
|
| 879 |
+
confidence=1.0
|
| 880 |
+
)
|
| 881 |
+
|
| 882 |
+
log_entry = crud.create_attendance_log(
|
| 883 |
+
db=db,
|
| 884 |
+
employee_id=employee.id,
|
| 885 |
+
camera=payload.camera,
|
| 886 |
+
confidence=1.0,
|
| 887 |
+
liveness_score=1.0,
|
| 888 |
+
is_spoof=False,
|
| 889 |
+
status="Match Success (QR Verified)",
|
| 890 |
+
timestamp=now
|
| 891 |
+
)
|
| 892 |
+
_publish_log(log_entry, employee)
|
| 893 |
+
|
| 894 |
+
current_hour = now.hour
|
| 895 |
+
if 5 <= current_hour < 12:
|
| 896 |
+
salutation = "Good Morning"
|
| 897 |
+
icon = "☀️"
|
| 898 |
+
elif 12 <= current_hour < 17:
|
| 899 |
+
salutation = "Good Afternoon"
|
| 900 |
+
icon = "🌤️"
|
| 901 |
+
else:
|
| 902 |
+
salutation = "Good Evening"
|
| 903 |
+
icon = "🌙"
|
| 904 |
+
|
| 905 |
+
voice_greeting_setting = crud.get_setting_by_key(db, "VOICE_GREETING_ENABLED")
|
| 906 |
+
voice_enabled = voice_greeting_setting.value.lower() == "true" if voice_greeting_setting else True
|
| 907 |
+
is_checkout = attendance_record.check_out is not None
|
| 908 |
+
|
| 909 |
+
if is_checkout:
|
| 910 |
+
greeting_text = f"Welcome {employee.name}. {salutation}. Checkout Recorded Successfully. Have a Relaxing Evening."
|
| 911 |
+
detail_msg = "Checkout Recorded Successfully"
|
| 912 |
+
closing_msg = f"Worked: {attendance_record.working_hours} hours"
|
| 913 |
+
else:
|
| 914 |
+
greeting_text = f"Welcome {employee.name}. {salutation}. Attendance Recorded Successfully. Have a Great Day."
|
| 915 |
+
detail_msg = "Attendance Recorded Successfully"
|
| 916 |
+
closing_msg = "Have a Great Day"
|
| 917 |
+
|
| 918 |
+
tts_url = f"{settings.API_V1_STR}/kiosk/tts?text={urllib.parse.quote(greeting_text)}" if voice_enabled else None
|
| 919 |
+
|
| 920 |
+
return {
|
| 921 |
+
"status": "success",
|
| 922 |
+
"employee": {
|
| 923 |
+
"id": employee.id,
|
| 924 |
+
"employee_id": employee.employee_id,
|
| 925 |
+
"name": employee.name,
|
| 926 |
+
"designation": employee.designation,
|
| 927 |
+
"department": employee.department.name if employee.department else "General"
|
| 928 |
+
},
|
| 929 |
+
"attendance": {
|
| 930 |
+
"date": str(attendance_record.date),
|
| 931 |
+
"check_in": str(attendance_record.check_in.time().strftime("%H:%M:%S")) if attendance_record.check_in else None,
|
| 932 |
+
"check_out": str(attendance_record.check_out.time().strftime("%H:%M:%S")) if attendance_record.check_out else None,
|
| 933 |
+
"status": attendance_record.status,
|
| 934 |
+
"working_hours": attendance_record.working_hours
|
| 935 |
+
},
|
| 936 |
+
"confidence": 1.0,
|
| 937 |
+
"liveness_score": 1.0,
|
| 938 |
+
"greeting": {
|
| 939 |
+
"title": f"Verified, {employee.name}",
|
| 940 |
+
"subtitle": f"{salutation} {icon}",
|
| 941 |
+
"detail": detail_msg,
|
| 942 |
+
"closing": closing_msg
|
| 943 |
+
},
|
| 944 |
+
"tts_url": tts_url
|
| 945 |
+
}
|
backend/app/api/v1/reports.py
CHANGED
|
@@ -26,6 +26,9 @@ def export_report(
|
|
| 26 |
end_date: Optional[date] = Query(None),
|
| 27 |
employee_id: Optional[int] = Query(None),
|
| 28 |
department_id: Optional[int] = Query(None),
|
|
|
|
|
|
|
|
|
|
| 29 |
db: Session = Depends(get_db),
|
| 30 |
current_user: models.User = Depends(checker_view)
|
| 31 |
):
|
|
@@ -43,6 +46,10 @@ def export_report(
|
|
| 43 |
filters.append(models.Attendance.employee_id == employee_id)
|
| 44 |
if department_id:
|
| 45 |
filters.append(models.Employee.department_id == department_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
query = query.filter(and_(*filters)).order_by(models.Attendance.date.desc())
|
| 48 |
records = query.all()
|
|
@@ -51,17 +58,19 @@ def export_report(
|
|
| 51 |
data = []
|
| 52 |
pdf_rows = []
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
"Check In", "Check Out", "Hours", "Overtime", "Status"
|
| 57 |
]
|
| 58 |
|
|
|
|
|
|
|
| 59 |
for r in records:
|
| 60 |
dept_name = r.employee.department.name if r.employee.department else "General"
|
| 61 |
ci_str = r.check_in.strftime("%H:%M:%S") if r.check_in else "-"
|
| 62 |
co_str = r.check_out.strftime("%H:%M:%S") if r.check_out else "-"
|
| 63 |
|
| 64 |
-
|
| 65 |
"Date": str(r.date),
|
| 66 |
"Employee ID": r.employee.employee_id,
|
| 67 |
"Name": r.employee.name,
|
|
@@ -72,19 +81,18 @@ def export_report(
|
|
| 72 |
"Overtime": r.overtime,
|
| 73 |
"Status": r.status
|
| 74 |
}
|
|
|
|
|
|
|
| 75 |
data.append(row_dict)
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
str(r.overtime),
|
| 86 |
-
r.status
|
| 87 |
-
])
|
| 88 |
|
| 89 |
title = f"NetraID Attendance Report ({report_type.capitalize()})"
|
| 90 |
metadata = {
|
|
@@ -125,3 +133,70 @@ def export_report(
|
|
| 125 |
|
| 126 |
else:
|
| 127 |
raise HTTPException(status_code=400, detail="Invalid format. Supported: csv, xlsx, pdf")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
end_date: Optional[date] = Query(None),
|
| 27 |
employee_id: Optional[int] = Query(None),
|
| 28 |
department_id: Optional[int] = Query(None),
|
| 29 |
+
min_hours: Optional[float] = Query(None),
|
| 30 |
+
max_hours: Optional[float] = Query(None),
|
| 31 |
+
columns: Optional[str] = Query(None, description="Comma-separated column names to include"),
|
| 32 |
db: Session = Depends(get_db),
|
| 33 |
current_user: models.User = Depends(checker_view)
|
| 34 |
):
|
|
|
|
| 46 |
filters.append(models.Attendance.employee_id == employee_id)
|
| 47 |
if department_id:
|
| 48 |
filters.append(models.Employee.department_id == department_id)
|
| 49 |
+
if min_hours is not None:
|
| 50 |
+
filters.append(models.Attendance.working_hours >= min_hours)
|
| 51 |
+
if max_hours is not None:
|
| 52 |
+
filters.append(models.Attendance.working_hours <= max_hours)
|
| 53 |
|
| 54 |
query = query.filter(and_(*filters)).order_by(models.Attendance.date.desc())
|
| 55 |
records = query.all()
|
|
|
|
| 58 |
data = []
|
| 59 |
pdf_rows = []
|
| 60 |
|
| 61 |
+
# Parse columns
|
| 62 |
+
selected_cols = [c.strip() for c in columns.split(",")] if columns else [
|
| 63 |
+
"Date", "Employee ID", "Name", "Department", "Check In", "Check Out", "Hours Worked", "Overtime", "Status"
|
| 64 |
]
|
| 65 |
|
| 66 |
+
headers = selected_cols
|
| 67 |
+
|
| 68 |
for r in records:
|
| 69 |
dept_name = r.employee.department.name if r.employee.department else "General"
|
| 70 |
ci_str = r.check_in.strftime("%H:%M:%S") if r.check_in else "-"
|
| 71 |
co_str = r.check_out.strftime("%H:%M:%S") if r.check_out else "-"
|
| 72 |
|
| 73 |
+
full_row = {
|
| 74 |
"Date": str(r.date),
|
| 75 |
"Employee ID": r.employee.employee_id,
|
| 76 |
"Name": r.employee.name,
|
|
|
|
| 81 |
"Overtime": r.overtime,
|
| 82 |
"Status": r.status
|
| 83 |
}
|
| 84 |
+
|
| 85 |
+
row_dict = {col: full_row.get(col, "-") for col in selected_cols}
|
| 86 |
data.append(row_dict)
|
| 87 |
|
| 88 |
+
pdf_row = []
|
| 89 |
+
for col in selected_cols:
|
| 90 |
+
val = full_row.get(col, "-")
|
| 91 |
+
if col in ["Name", "Department"] and isinstance(val, str):
|
| 92 |
+
pdf_row.append(val[:15])
|
| 93 |
+
else:
|
| 94 |
+
pdf_row.append(str(val))
|
| 95 |
+
pdf_rows.append(pdf_row)
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
title = f"NetraID Attendance Report ({report_type.capitalize()})"
|
| 98 |
metadata = {
|
|
|
|
| 133 |
|
| 134 |
else:
|
| 135 |
raise HTTPException(status_code=400, detail="Invalid format. Supported: csv, xlsx, pdf")
|
| 136 |
+
|
| 137 |
+
@router.get("/preview")
|
| 138 |
+
def preview_report(
|
| 139 |
+
start_date: Optional[date] = Query(None),
|
| 140 |
+
end_date: Optional[date] = Query(None),
|
| 141 |
+
employee_id: Optional[int] = Query(None),
|
| 142 |
+
department_id: Optional[int] = Query(None),
|
| 143 |
+
min_hours: Optional[float] = Query(None),
|
| 144 |
+
max_hours: Optional[float] = Query(None),
|
| 145 |
+
columns: Optional[str] = Query(None, description="Comma-separated column names to include"),
|
| 146 |
+
db: Session = Depends(get_db),
|
| 147 |
+
current_user: models.User = Depends(checker_view)
|
| 148 |
+
):
|
| 149 |
+
"""
|
| 150 |
+
Returns filtered attendance records and columns for custom preview in the UI before export.
|
| 151 |
+
"""
|
| 152 |
+
if not start_date:
|
| 153 |
+
start_date = date.today() - timedelta(days=30)
|
| 154 |
+
if not end_date:
|
| 155 |
+
end_date = date.today()
|
| 156 |
+
|
| 157 |
+
query = db.query(models.Attendance).join(models.Employee)
|
| 158 |
+
filters = [models.Attendance.date.between(start_date, end_date)]
|
| 159 |
+
|
| 160 |
+
if employee_id:
|
| 161 |
+
filters.append(models.Attendance.employee_id == employee_id)
|
| 162 |
+
if department_id:
|
| 163 |
+
filters.append(models.Employee.department_id == department_id)
|
| 164 |
+
if min_hours is not None:
|
| 165 |
+
filters.append(models.Attendance.working_hours >= min_hours)
|
| 166 |
+
if max_hours is not None:
|
| 167 |
+
filters.append(models.Attendance.working_hours <= max_hours)
|
| 168 |
+
|
| 169 |
+
query = query.filter(and_(*filters)).order_by(models.Attendance.date.desc())
|
| 170 |
+
records = query.all()
|
| 171 |
+
|
| 172 |
+
# Parse columns
|
| 173 |
+
selected_cols = [c.strip() for c in columns.split(",")] if columns else [
|
| 174 |
+
"Date", "Employee ID", "Name", "Department", "Check In", "Check Out", "Hours Worked", "Overtime", "Status"
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
data = []
|
| 178 |
+
for r in records:
|
| 179 |
+
dept_name = r.employee.department.name if r.employee.department else "General"
|
| 180 |
+
ci_str = r.check_in.strftime("%H:%M:%S") if r.check_in else "-"
|
| 181 |
+
co_str = r.check_out.strftime("%H:%M:%S") if r.check_out else "-"
|
| 182 |
+
|
| 183 |
+
full_row = {
|
| 184 |
+
"Date": str(r.date),
|
| 185 |
+
"Employee ID": r.employee.employee_id,
|
| 186 |
+
"Name": r.employee.name,
|
| 187 |
+
"Department": dept_name,
|
| 188 |
+
"Check In": ci_str,
|
| 189 |
+
"Check Out": co_str,
|
| 190 |
+
"Hours Worked": r.working_hours,
|
| 191 |
+
"Overtime": r.overtime,
|
| 192 |
+
"Status": r.status
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
filtered_row = {col: full_row.get(col, "-") for col in selected_cols}
|
| 196 |
+
data.append(filtered_row)
|
| 197 |
+
|
| 198 |
+
return {
|
| 199 |
+
"columns": selected_cols,
|
| 200 |
+
"records": data,
|
| 201 |
+
"total_count": len(records)
|
| 202 |
+
}
|
backend/app/api/v1/settings.py
CHANGED
|
@@ -35,6 +35,16 @@ def update_setting(
|
|
| 35 |
old_value = setting.value
|
| 36 |
updated = crud.set_setting(db, key=key, value=payload.value)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
# Audit log setting change
|
| 39 |
crud.create_audit_log(
|
| 40 |
db=db,
|
|
|
|
| 35 |
old_value = setting.value
|
| 36 |
updated = crud.set_setting(db, key=key, value=payload.value)
|
| 37 |
|
| 38 |
+
# Dynamically restart RTSP processor if RTSP settings were updated
|
| 39 |
+
if key in ["RTSP_STREAM_ENABLED", "RTSP_STREAM_URL"]:
|
| 40 |
+
try:
|
| 41 |
+
from app.services.singletons import rtsp_processor
|
| 42 |
+
rtsp_processor.stop()
|
| 43 |
+
rtsp_processor.start()
|
| 44 |
+
except Exception as rtsp_err:
|
| 45 |
+
import logging
|
| 46 |
+
logging.getLogger("SettingsAPI").error(f"Failed to dynamically restart RTSP processor: {rtsp_err}")
|
| 47 |
+
|
| 48 |
# Audit log setting change
|
| 49 |
crud.create_audit_log(
|
| 50 |
db=db,
|
backend/app/core/config.py
CHANGED
|
@@ -2,6 +2,13 @@ import os
|
|
| 2 |
from typing import List, Union
|
| 3 |
from pydantic import AnyHttpUrl, validator
|
| 4 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
class Settings(BaseSettings):
|
| 7 |
PROJECT_NAME: str = "NetraID AI Face Attendance"
|
|
@@ -51,7 +58,7 @@ class Settings(BaseSettings):
|
|
| 51 |
|
| 52 |
# Face recognition & liveness detection parameters
|
| 53 |
KIOSK_FACE_THRESHOLD: float = 0.60
|
| 54 |
-
KIOSK_LIVENESS_THRESHOLD: float = 0.
|
| 55 |
FORCE_MOCK_MODE: bool = False
|
| 56 |
|
| 57 |
# Performance Optimizations
|
|
|
|
| 2 |
from typing import List, Union
|
| 3 |
from pydantic import AnyHttpUrl, validator
|
| 4 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
|
| 8 |
+
load_dotenv(dotenv_path=env_path, override=True)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
|
| 13 |
class Settings(BaseSettings):
|
| 14 |
PROJECT_NAME: str = "NetraID AI Face Attendance"
|
|
|
|
| 58 |
|
| 59 |
# Face recognition & liveness detection parameters
|
| 60 |
KIOSK_FACE_THRESHOLD: float = 0.60
|
| 61 |
+
KIOSK_LIVENESS_THRESHOLD: float = 0.55
|
| 62 |
FORCE_MOCK_MODE: bool = False
|
| 63 |
|
| 64 |
# Performance Optimizations
|
backend/app/core/init_db.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
-
from sqlalchemy import text
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from app.core.database import Base, engine
|
| 4 |
from app.models import models
|
| 5 |
from app.crud import crud
|
| 6 |
from app.schemas import schemas
|
| 7 |
from app.core.config import settings
|
|
|
|
| 8 |
import logging
|
| 9 |
|
| 10 |
logger = logging.getLogger("InitDB")
|
|
@@ -20,6 +21,20 @@ def init_db(db: Session):
|
|
| 20 |
logger.info("Creating all database tables if they do not exist...")
|
| 21 |
Base.metadata.create_all(bind=engine)
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# Ensure emergency_allowed column exists
|
| 24 |
try:
|
| 25 |
db.execute(text("SELECT emergency_allowed FROM attendance LIMIT 1"))
|
|
@@ -37,6 +52,39 @@ def init_db(db: Session):
|
|
| 37 |
logger.info("Adding image_path column to attendance_logs table...")
|
| 38 |
db.execute(text("ALTER TABLE attendance_logs ADD COLUMN image_path VARCHAR(255) DEFAULT NULL"))
|
| 39 |
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# 1. Seed Roles
|
| 42 |
roles = [
|
|
@@ -76,11 +124,23 @@ def init_db(db: Session):
|
|
| 76 |
{"key": "KIOSK_FACE_THRESHOLD", "value": str(settings.KIOSK_FACE_THRESHOLD), "description": "Cosine similarity threshold for face match"},
|
| 77 |
{"key": "KIOSK_LIVENESS_THRESHOLD", "value": str(settings.KIOSK_LIVENESS_THRESHOLD), "description": "Softmax probability threshold for face liveness"},
|
| 78 |
{"key": "ENROLLMENT_LIVENESS_CHECK", "value": "true", "description": "Enforce liveness check during employee facial enrollment"},
|
| 79 |
-
{"key": "ENROLLMENT_LIVENESS_THRESHOLD", "value": "0.
|
| 80 |
{"key": "VOICE_GREETING_ENABLED", "value": "true", "description": "Enable voice greeting on successful attendance"},
|
| 81 |
{"key": "SYSTEM_MAINTENANCE_MODE", "value": "false", "description": "Toggle maintenance mode to suspend active check-ins"},
|
| 82 |
{"key": "MAX_ENROLLMENT_IMAGES", "value": "5", "description": "Maximum face images captured during registration"},
|
| 83 |
-
{"key": "KIOSK_AUTO_RESET_SECONDS", "value": "5", "description": "Duration in seconds the success screen stays visible before scanning again"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
]
|
| 85 |
|
| 86 |
for s in default_settings:
|
|
|
|
| 1 |
+
from sqlalchemy import text, select
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
from app.core.database import Base, engine
|
| 4 |
from app.models import models
|
| 5 |
from app.crud import crud
|
| 6 |
from app.schemas import schemas
|
| 7 |
from app.core.config import settings
|
| 8 |
+
from datetime import time
|
| 9 |
import logging
|
| 10 |
|
| 11 |
logger = logging.getLogger("InitDB")
|
|
|
|
| 21 |
logger.info("Creating all database tables if they do not exist...")
|
| 22 |
Base.metadata.create_all(bind=engine)
|
| 23 |
|
| 24 |
+
# Ensure HNSW index exists on PostgreSQL
|
| 25 |
+
if db.bind.dialect.name == "postgresql":
|
| 26 |
+
try:
|
| 27 |
+
logger.info("Ensuring face_embeddings pgvector HNSW index is present...")
|
| 28 |
+
db.execute(text("""
|
| 29 |
+
CREATE INDEX IF NOT EXISTS face_embeddings_hnsw_idx
|
| 30 |
+
ON face_embeddings
|
| 31 |
+
USING hnsw (embedding vector_cosine_ops);
|
| 32 |
+
"""))
|
| 33 |
+
db.commit()
|
| 34 |
+
except Exception as e:
|
| 35 |
+
db.rollback()
|
| 36 |
+
logger.warning(f"Could not create HNSW index: {e}")
|
| 37 |
+
|
| 38 |
# Ensure emergency_allowed column exists
|
| 39 |
try:
|
| 40 |
db.execute(text("SELECT emergency_allowed FROM attendance LIMIT 1"))
|
|
|
|
| 52 |
logger.info("Adding image_path column to attendance_logs table...")
|
| 53 |
db.execute(text("ALTER TABLE attendance_logs ADD COLUMN image_path VARCHAR(255) DEFAULT NULL"))
|
| 54 |
db.commit()
|
| 55 |
+
|
| 56 |
+
# Ensure shift_id column exists in employees table
|
| 57 |
+
try:
|
| 58 |
+
db.execute(text("SELECT shift_id FROM employees LIMIT 1"))
|
| 59 |
+
except Exception:
|
| 60 |
+
db.rollback()
|
| 61 |
+
logger.info("Adding shift_id column to employees table...")
|
| 62 |
+
db.execute(text("ALTER TABLE employees ADD COLUMN shift_id INTEGER REFERENCES shifts(id) DEFAULT NULL"))
|
| 63 |
+
db.commit()
|
| 64 |
+
|
| 65 |
+
# Ensure allow_wfh column exists in employees table
|
| 66 |
+
try:
|
| 67 |
+
db.execute(text("SELECT allow_wfh FROM employees LIMIT 1"))
|
| 68 |
+
except Exception:
|
| 69 |
+
db.rollback()
|
| 70 |
+
logger.info("Adding allow_wfh column to employees table...")
|
| 71 |
+
db.execute(text("ALTER TABLE employees ADD COLUMN allow_wfh BOOLEAN DEFAULT FALSE"))
|
| 72 |
+
db.commit()
|
| 73 |
+
|
| 74 |
+
# 0. Seed Shifts
|
| 75 |
+
shifts_to_seed = [
|
| 76 |
+
{"name": "Regular Day Shift", "start_time": time(9, 0), "end_time": time(17, 0), "grace_period_minutes": 15, "description": "Standard business hours"},
|
| 77 |
+
{"name": "Morning Shift", "start_time": time(7, 0), "end_time": time(15, 0), "grace_period_minutes": 15, "description": "Early morning shift"},
|
| 78 |
+
{"name": "Evening Shift", "start_time": time(15, 0), "end_time": time(23, 0), "grace_period_minutes": 15, "description": "Evening / second shift"},
|
| 79 |
+
{"name": "Night Shift", "start_time": time(23, 0), "end_time": time(7, 0), "grace_period_minutes": 15, "description": "Overnight shift"}
|
| 80 |
+
]
|
| 81 |
+
for s in shifts_to_seed:
|
| 82 |
+
shift_record = db.execute(select(models.Shift).where(models.Shift.name == s["name"])).scalar_one_or_none()
|
| 83 |
+
if not shift_record:
|
| 84 |
+
logger.info(f"Seeding shift: {s['name']}")
|
| 85 |
+
db_shift = models.Shift(**s)
|
| 86 |
+
db.add(db_shift)
|
| 87 |
+
db.commit()
|
| 88 |
|
| 89 |
# 1. Seed Roles
|
| 90 |
roles = [
|
|
|
|
| 124 |
{"key": "KIOSK_FACE_THRESHOLD", "value": str(settings.KIOSK_FACE_THRESHOLD), "description": "Cosine similarity threshold for face match"},
|
| 125 |
{"key": "KIOSK_LIVENESS_THRESHOLD", "value": str(settings.KIOSK_LIVENESS_THRESHOLD), "description": "Softmax probability threshold for face liveness"},
|
| 126 |
{"key": "ENROLLMENT_LIVENESS_CHECK", "value": "true", "description": "Enforce liveness check during employee facial enrollment"},
|
| 127 |
+
{"key": "ENROLLMENT_LIVENESS_THRESHOLD", "value": "0.50", "description": "Liveness probability threshold specifically for employee facial enrollment"},
|
| 128 |
{"key": "VOICE_GREETING_ENABLED", "value": "true", "description": "Enable voice greeting on successful attendance"},
|
| 129 |
{"key": "SYSTEM_MAINTENANCE_MODE", "value": "false", "description": "Toggle maintenance mode to suspend active check-ins"},
|
| 130 |
{"key": "MAX_ENROLLMENT_IMAGES", "value": "5", "description": "Maximum face images captured during registration"},
|
| 131 |
+
{"key": "KIOSK_AUTO_RESET_SECONDS", "value": "5", "description": "Duration in seconds the success screen stays visible before scanning again"},
|
| 132 |
+
{"key": "NOTIFICATION_WEBHOOK_URL", "value": "", "description": "Slack, Discord or Telegram webhook URL for spoof security alerts"},
|
| 133 |
+
{"key": "QR_FALLBACK_ENABLED", "value": "true", "description": "Enable QR Code badge verification fallback on kiosk if face match is borderline"},
|
| 134 |
+
{"key": "RTSP_STREAM_ENABLED", "value": "false", "description": "Enable asynchronous RTSP IP camera frame processing background service"},
|
| 135 |
+
{"key": "RTSP_STREAM_URL", "value": "rtsp://admin:admin123@192.168.1.100:554/stream1", "description": "RTSP video stream connection URL"},
|
| 136 |
+
{"key": "COMPANY_NAME", "value": "NetraID Enterprise", "description": "Name of the organization displayed on employee ID cards"},
|
| 137 |
+
{"key": "COMPANY_LOGO", "value": "", "description": "Base64 image data or URL of the company logo displayed on ID cards"},
|
| 138 |
+
{"key": "BADGE_THEME_COLOR", "value": "Navy Blue", "description": "Primary color theme of employee ID cards (Navy Blue, Charcoal, Emerald, Saffron)"},
|
| 139 |
+
{"key": "BADGE_PATTERN_TYPE", "value": "Indian Mandala", "description": "Background pattern design style (None, Indian Mandala, Corporate Waves, Cyber Grid)"},
|
| 140 |
+
{"key": "LOCATION_RESTRICTION_ENABLED", "value": "false", "description": "Enable location restriction for kiosk attendance"},
|
| 141 |
+
{"key": "LOCATION_LATITUDE", "value": "0.0", "description": "Office center latitude coordinate"},
|
| 142 |
+
{"key": "LOCATION_LONGITUDE", "value": "0.0", "description": "Office center longitude coordinate"},
|
| 143 |
+
{"key": "LOCATION_RADIUS_METERS", "value": "50", "description": "Allowed radius in meters for marking attendance"}
|
| 144 |
]
|
| 145 |
|
| 146 |
for s in default_settings:
|
backend/app/crud/crud.py
CHANGED
|
@@ -170,7 +170,9 @@ def create_employee(db: Session, emp: schemas.EmployeeCreate, user_id: int = Non
|
|
| 170 |
joining_date=emp.joining_date,
|
| 171 |
status=emp.status,
|
| 172 |
department_id=emp.department_id,
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
)
|
| 175 |
db.add(db_emp)
|
| 176 |
db.commit()
|
|
@@ -353,9 +355,38 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
|
|
| 353 |
- If one exists and check_out is empty, mark check-out and calculate hours.
|
| 354 |
- If both exist, do nothing or update checkout to a later timestamp.
|
| 355 |
"""
|
| 356 |
-
|
| 357 |
-
today = local_now.date()
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
# Check if a record exists
|
| 360 |
stmt = select(models.Attendance).where(
|
| 361 |
and_(
|
|
@@ -365,22 +396,11 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
|
|
| 365 |
)
|
| 366 |
db_attendance = db.execute(stmt).scalar_one_or_none()
|
| 367 |
|
| 368 |
-
|
| 369 |
-
start_time_setting = get_setting_by_key(db, "CHECK_IN_START")
|
| 370 |
-
grace_period_setting = get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
|
| 371 |
-
|
| 372 |
-
start_str = start_time_setting.value if start_time_setting else "09:00"
|
| 373 |
-
grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
|
| 374 |
-
|
| 375 |
-
try:
|
| 376 |
-
hr, mn = map(int, start_str.split(":"))
|
| 377 |
-
check_in_deadline = datetime.combine(today, time(hr, mn)) + timedelta(minutes=grace_mins)
|
| 378 |
-
except Exception:
|
| 379 |
-
check_in_deadline = datetime.combine(today, time(9, 15))
|
| 380 |
|
| 381 |
if not db_attendance:
|
| 382 |
# First scan of the day -> CHECK-IN
|
| 383 |
-
is_late =
|
| 384 |
status = "Late" if is_late else "Present"
|
| 385 |
|
| 386 |
db_attendance = models.Attendance(
|
|
@@ -403,22 +423,22 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
|
|
| 403 |
hours = round(diff.total_seconds() / 3600.0, 2)
|
| 404 |
db_attendance.working_hours = hours
|
| 405 |
|
| 406 |
-
# Early departure: Check if checkout is before
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
|
| 417 |
-
|
| 418 |
-
db_attendance.overtime = max(0.0, round(hours - 8.0, 2))
|
| 419 |
|
| 420 |
-
# Half Day check: If total working hours is less than
|
| 421 |
-
if
|
|
|
|
| 422 |
db_attendance.status = "Half Day"
|
| 423 |
else:
|
| 424 |
if db_attendance.status == "Half Day" or db_attendance.status == "Absent":
|
|
@@ -532,3 +552,11 @@ def create_audit_log(db: Session, user_id: int, action: str, ip_address: str = N
|
|
| 532 |
def get_audit_logs(db: Session, skip: int = 0, limit: int = 100):
|
| 533 |
query = select(models.AuditLog).order_by(models.AuditLog.timestamp.desc()).offset(skip).limit(limit)
|
| 534 |
return db.execute(query).scalars().all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
joining_date=emp.joining_date,
|
| 171 |
status=emp.status,
|
| 172 |
department_id=emp.department_id,
|
| 173 |
+
shift_id=emp.shift_id,
|
| 174 |
+
user_id=user_id,
|
| 175 |
+
allow_wfh=emp.allow_wfh
|
| 176 |
)
|
| 177 |
db.add(db_emp)
|
| 178 |
db.commit()
|
|
|
|
| 355 |
- If one exists and check_out is empty, mark check-out and calculate hours.
|
| 356 |
- If both exist, do nothing or update checkout to a later timestamp.
|
| 357 |
"""
|
| 358 |
+
today = timestamp.date()
|
|
|
|
| 359 |
|
| 360 |
+
employee = db.get(models.Employee, employee_id)
|
| 361 |
+
if not employee:
|
| 362 |
+
raise ValueError(f"Employee {employee_id} not found")
|
| 363 |
+
|
| 364 |
+
# Resolve shift details for employee
|
| 365 |
+
if employee.shift:
|
| 366 |
+
shift_start = employee.shift.start_time
|
| 367 |
+
shift_end = employee.shift.end_time
|
| 368 |
+
grace_mins = employee.shift.grace_period_minutes
|
| 369 |
+
else:
|
| 370 |
+
start_time_setting = get_setting_by_key(db, "CHECK_IN_START")
|
| 371 |
+
end_time_setting = get_setting_by_key(db, "CHECK_OUT_END")
|
| 372 |
+
grace_period_setting = get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
|
| 373 |
+
|
| 374 |
+
start_str = start_time_setting.value if start_time_setting else "09:00"
|
| 375 |
+
end_str = end_time_setting.value if end_time_setting else "17:00"
|
| 376 |
+
grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
|
| 377 |
+
|
| 378 |
+
try:
|
| 379 |
+
hr, mn = map(int, start_str.split(":"))
|
| 380 |
+
shift_start = time(hr, mn)
|
| 381 |
+
except Exception:
|
| 382 |
+
shift_start = time(9, 0)
|
| 383 |
+
|
| 384 |
+
try:
|
| 385 |
+
hr, mn = map(int, end_str.split(":"))
|
| 386 |
+
shift_end = time(hr, mn)
|
| 387 |
+
except Exception:
|
| 388 |
+
shift_end = time(17, 0)
|
| 389 |
+
|
| 390 |
# Check if a record exists
|
| 391 |
stmt = select(models.Attendance).where(
|
| 392 |
and_(
|
|
|
|
| 396 |
)
|
| 397 |
db_attendance = db.execute(stmt).scalar_one_or_none()
|
| 398 |
|
| 399 |
+
check_in_deadline = datetime.combine(today, shift_start) + timedelta(minutes=grace_mins)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
if not db_attendance:
|
| 402 |
# First scan of the day -> CHECK-IN
|
| 403 |
+
is_late = timestamp > check_in_deadline
|
| 404 |
status = "Late" if is_late else "Present"
|
| 405 |
|
| 406 |
db_attendance = models.Attendance(
|
|
|
|
| 423 |
hours = round(diff.total_seconds() / 3600.0, 2)
|
| 424 |
db_attendance.working_hours = hours
|
| 425 |
|
| 426 |
+
# Early departure: Check if checkout is before shift end
|
| 427 |
+
departure_deadline = datetime.combine(today, shift_end)
|
| 428 |
+
db_attendance.early_departure = timestamp < departure_deadline
|
| 429 |
+
|
| 430 |
+
# Overtime: Hours worked beyond shift duration
|
| 431 |
+
dt_start = datetime.combine(today, shift_start)
|
| 432 |
+
dt_end = datetime.combine(today, shift_end)
|
| 433 |
+
if dt_end < dt_start:
|
| 434 |
+
dt_end += timedelta(days=1)
|
| 435 |
+
shift_duration_hours = (dt_end - dt_start).total_seconds() / 3600.0
|
| 436 |
|
| 437 |
+
db_attendance.overtime = max(0.0, round(hours - shift_duration_hours, 2))
|
|
|
|
| 438 |
|
| 439 |
+
# Half Day check: If total working hours is less than 50% of shift duration
|
| 440 |
+
half_day_threshold = shift_duration_hours * 0.5 if shift_duration_hours > 0 else 4.0
|
| 441 |
+
if hours < half_day_threshold:
|
| 442 |
db_attendance.status = "Half Day"
|
| 443 |
else:
|
| 444 |
if db_attendance.status == "Half Day" or db_attendance.status == "Absent":
|
|
|
|
| 552 |
def get_audit_logs(db: Session, skip: int = 0, limit: int = 100):
|
| 553 |
query = select(models.AuditLog).order_by(models.AuditLog.timestamp.desc()).offset(skip).limit(limit)
|
| 554 |
return db.execute(query).scalars().all()
|
| 555 |
+
|
| 556 |
+
def clear_all_audit_logs(db: Session) -> int:
|
| 557 |
+
from sqlalchemy import delete
|
| 558 |
+
stmt = delete(models.AuditLog)
|
| 559 |
+
result = db.execute(stmt)
|
| 560 |
+
db.commit()
|
| 561 |
+
return result.rowcount
|
| 562 |
+
|
backend/app/main.py
CHANGED
|
@@ -1,12 +1,17 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi import FastAPI, Depends
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
import logging
|
|
|
|
| 5 |
|
| 6 |
from app.core.config import settings
|
| 7 |
from app.core.database import SessionLocal
|
| 8 |
from app.core.init_db import init_db
|
| 9 |
from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit
|
|
|
|
| 10 |
|
| 11 |
# Logging configuration
|
| 12 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
@@ -36,6 +41,24 @@ app.add_middleware(
|
|
| 36 |
|
| 37 |
db_error = None
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# Create folders on startup
|
| 40 |
@app.on_event("startup")
|
| 41 |
def startup_event():
|
|
@@ -55,6 +78,22 @@ def startup_event():
|
|
| 55 |
try:
|
| 56 |
init_db(db)
|
| 57 |
db_error = "Success"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
except Exception as e:
|
| 59 |
import traceback
|
| 60 |
db_error = f"{e}\n{traceback.format_exc()}"
|
|
@@ -62,6 +101,15 @@ def startup_event():
|
|
| 62 |
finally:
|
| 63 |
db.close()
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# Health check and root route
|
| 66 |
@app.get("/")
|
| 67 |
def read_root():
|
|
@@ -115,3 +163,5 @@ app.include_router(reports.router, prefix=f"{settings.API_V1_STR}/reports", tags
|
|
| 115 |
app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["Dashboard Analytics"])
|
| 116 |
app.include_router(settings_api.router, prefix=f"{settings.API_V1_STR}/settings", tags=["System Settings"])
|
| 117 |
app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"])
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
import threading
|
| 3 |
+
import time
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
from fastapi import FastAPI, Depends
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
import logging
|
| 8 |
+
from sqlalchemy import delete
|
| 9 |
|
| 10 |
from app.core.config import settings
|
| 11 |
from app.core.database import SessionLocal
|
| 12 |
from app.core.init_db import init_db
|
| 13 |
from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit
|
| 14 |
+
from app.models import models
|
| 15 |
|
| 16 |
# Logging configuration
|
| 17 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
|
|
| 41 |
|
| 42 |
db_error = None
|
| 43 |
|
| 44 |
+
def purge_old_audit_logs():
|
| 45 |
+
"""Background loop to delete audit logs older than 24 hours."""
|
| 46 |
+
logger.info("Starting background audit log purger thread...")
|
| 47 |
+
while True:
|
| 48 |
+
try:
|
| 49 |
+
db = SessionLocal()
|
| 50 |
+
cutoff = datetime.now() - timedelta(hours=24)
|
| 51 |
+
stmt = delete(models.AuditLog).where(models.AuditLog.timestamp < cutoff)
|
| 52 |
+
result = db.execute(stmt)
|
| 53 |
+
db.commit()
|
| 54 |
+
deleted_count = result.rowcount
|
| 55 |
+
if deleted_count > 0:
|
| 56 |
+
logger.info(f"Purged {deleted_count} audit logs older than 24 hours.")
|
| 57 |
+
db.close()
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Error purging old audit logs: {e}")
|
| 60 |
+
time.sleep(3600)
|
| 61 |
+
|
| 62 |
# Create folders on startup
|
| 63 |
@app.on_event("startup")
|
| 64 |
def startup_event():
|
|
|
|
| 78 |
try:
|
| 79 |
init_db(db)
|
| 80 |
db_error = "Success"
|
| 81 |
+
|
| 82 |
+
# Start background RTSP processor
|
| 83 |
+
try:
|
| 84 |
+
from app.services.singletons import rtsp_processor
|
| 85 |
+
rtsp_processor.start()
|
| 86 |
+
except Exception as rtsp_err:
|
| 87 |
+
logger.error(f"Failed to start background RTSP processor: {rtsp_err}")
|
| 88 |
+
|
| 89 |
+
# Start background audit logs purger
|
| 90 |
+
try:
|
| 91 |
+
purger_thread = threading.Thread(target=purge_old_audit_logs, daemon=True)
|
| 92 |
+
purger_thread.start()
|
| 93 |
+
logger.info("Background audit logs purger started successfully.")
|
| 94 |
+
except Exception as purger_err:
|
| 95 |
+
logger.error(f"Failed to start background audit logs purger: {purger_err}")
|
| 96 |
+
|
| 97 |
except Exception as e:
|
| 98 |
import traceback
|
| 99 |
db_error = f"{e}\n{traceback.format_exc()}"
|
|
|
|
| 101 |
finally:
|
| 102 |
db.close()
|
| 103 |
|
| 104 |
+
@app.on_event("shutdown")
|
| 105 |
+
def shutdown_event():
|
| 106 |
+
logger.info("Stopping NetraID Backend...")
|
| 107 |
+
try:
|
| 108 |
+
from app.services.singletons import rtsp_processor
|
| 109 |
+
rtsp_processor.stop()
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Failed to stop background RTSP processor: {e}")
|
| 112 |
+
|
| 113 |
# Health check and root route
|
| 114 |
@app.get("/")
|
| 115 |
def read_root():
|
|
|
|
| 163 |
app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["Dashboard Analytics"])
|
| 164 |
app.include_router(settings_api.router, prefix=f"{settings.API_V1_STR}/settings", tags=["System Settings"])
|
| 165 |
app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"])
|
| 166 |
+
# Trigger reload - reload 2
|
| 167 |
+
|
backend/app/models/models.py
CHANGED
|
@@ -73,6 +73,16 @@ class Department(Base):
|
|
| 73 |
|
| 74 |
employees = relationship("Employee", back_populates="department")
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
class Employee(Base):
|
| 77 |
__tablename__ = "employees"
|
| 78 |
|
|
@@ -85,11 +95,14 @@ class Employee(Base):
|
|
| 85 |
joining_date = Column(Date, nullable=False, default=datetime.date.today)
|
| 86 |
status = Column(String(20), default="Active") # Active, Inactive, Suspended
|
| 87 |
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True)
|
|
|
|
| 88 |
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, unique=True)
|
|
|
|
| 89 |
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 90 |
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
| 91 |
|
| 92 |
department = relationship("Department", back_populates="employees")
|
|
|
|
| 93 |
user = relationship("User", back_populates="employee")
|
| 94 |
images = relationship("EmployeeImage", back_populates="employee", cascade="all, delete-orphan")
|
| 95 |
embeddings = relationship("FaceEmbedding", back_populates="employee", cascade="all, delete-orphan")
|
|
|
|
| 73 |
|
| 74 |
employees = relationship("Employee", back_populates="department")
|
| 75 |
|
| 76 |
+
class Shift(Base):
|
| 77 |
+
__tablename__ = "shifts"
|
| 78 |
+
|
| 79 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 80 |
+
name = Column(String(100), unique=True, nullable=False)
|
| 81 |
+
start_time = Column(Time, nullable=False)
|
| 82 |
+
end_time = Column(Time, nullable=False)
|
| 83 |
+
grace_period_minutes = Column(Integer, default=15)
|
| 84 |
+
description = Column(String(255), nullable=True)
|
| 85 |
+
|
| 86 |
class Employee(Base):
|
| 87 |
__tablename__ = "employees"
|
| 88 |
|
|
|
|
| 95 |
joining_date = Column(Date, nullable=False, default=datetime.date.today)
|
| 96 |
status = Column(String(20), default="Active") # Active, Inactive, Suspended
|
| 97 |
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True)
|
| 98 |
+
shift_id = Column(Integer, ForeignKey("shifts.id"), nullable=True)
|
| 99 |
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, unique=True)
|
| 100 |
+
allow_wfh = Column(Boolean, default=False)
|
| 101 |
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 102 |
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
| 103 |
|
| 104 |
department = relationship("Department", back_populates="employees")
|
| 105 |
+
shift = relationship("Shift")
|
| 106 |
user = relationship("User", back_populates="employee")
|
| 107 |
images = relationship("EmployeeImage", back_populates="employee", cascade="all, delete-orphan")
|
| 108 |
embeddings = relationship("FaceEmbedding", back_populates="employee", cascade="all, delete-orphan")
|
backend/app/schemas/schemas.py
CHANGED
|
@@ -62,6 +62,28 @@ class DepartmentOut(DepartmentBase):
|
|
| 62 |
id: int
|
| 63 |
model_config = ConfigDict(from_attributes=True)
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# Employee Schemas
|
| 66 |
class EmployeeBase(BaseModel):
|
| 67 |
employee_id: str
|
|
@@ -72,6 +94,8 @@ class EmployeeBase(BaseModel):
|
|
| 72 |
joining_date: date
|
| 73 |
status: str = "Active"
|
| 74 |
department_id: Optional[int] = None
|
|
|
|
|
|
|
| 75 |
|
| 76 |
@field_validator('phone')
|
| 77 |
@classmethod
|
|
@@ -96,6 +120,8 @@ class EmployeeUpdate(BaseModel):
|
|
| 96 |
joining_date: Optional[date] = None
|
| 97 |
status: Optional[str] = None
|
| 98 |
department_id: Optional[int] = None
|
|
|
|
|
|
|
| 99 |
|
| 100 |
class EmployeeImageOut(BaseModel):
|
| 101 |
id: int
|
|
@@ -108,6 +134,7 @@ class EmployeeOut(EmployeeBase):
|
|
| 108 |
id: int
|
| 109 |
user_id: Optional[int] = None
|
| 110 |
department: Optional[DepartmentOut] = None
|
|
|
|
| 111 |
images: List[EmployeeImageOut] = []
|
| 112 |
created_at: Optional[datetime] = None
|
| 113 |
model_config = ConfigDict(from_attributes=True)
|
|
|
|
| 62 |
id: int
|
| 63 |
model_config = ConfigDict(from_attributes=True)
|
| 64 |
|
| 65 |
+
# Shift Schemas
|
| 66 |
+
class ShiftBase(BaseModel):
|
| 67 |
+
name: str = Field(..., max_length=100)
|
| 68 |
+
start_time: time
|
| 69 |
+
end_time: time
|
| 70 |
+
grace_period_minutes: int = 15
|
| 71 |
+
description: Optional[str] = None
|
| 72 |
+
|
| 73 |
+
class ShiftCreate(ShiftBase):
|
| 74 |
+
pass
|
| 75 |
+
|
| 76 |
+
class ShiftUpdate(BaseModel):
|
| 77 |
+
name: Optional[str] = None
|
| 78 |
+
start_time: Optional[time] = None
|
| 79 |
+
end_time: Optional[time] = None
|
| 80 |
+
grace_period_minutes: Optional[int] = None
|
| 81 |
+
description: Optional[str] = None
|
| 82 |
+
|
| 83 |
+
class ShiftOut(ShiftBase):
|
| 84 |
+
id: int
|
| 85 |
+
model_config = ConfigDict(from_attributes=True)
|
| 86 |
+
|
| 87 |
# Employee Schemas
|
| 88 |
class EmployeeBase(BaseModel):
|
| 89 |
employee_id: str
|
|
|
|
| 94 |
joining_date: date
|
| 95 |
status: str = "Active"
|
| 96 |
department_id: Optional[int] = None
|
| 97 |
+
shift_id: Optional[int] = None
|
| 98 |
+
allow_wfh: bool = False
|
| 99 |
|
| 100 |
@field_validator('phone')
|
| 101 |
@classmethod
|
|
|
|
| 120 |
joining_date: Optional[date] = None
|
| 121 |
status: Optional[str] = None
|
| 122 |
department_id: Optional[int] = None
|
| 123 |
+
shift_id: Optional[int] = None
|
| 124 |
+
allow_wfh: Optional[bool] = None
|
| 125 |
|
| 126 |
class EmployeeImageOut(BaseModel):
|
| 127 |
id: int
|
|
|
|
| 134 |
id: int
|
| 135 |
user_id: Optional[int] = None
|
| 136 |
department: Optional[DepartmentOut] = None
|
| 137 |
+
shift: Optional[ShiftOut] = None
|
| 138 |
images: List[EmployeeImageOut] = []
|
| 139 |
created_at: Optional[datetime] = None
|
| 140 |
model_config = ConfigDict(from_attributes=True)
|
backend/app/services/face_engine.py
CHANGED
|
@@ -410,26 +410,39 @@ class FaceEngine:
|
|
| 410 |
|
| 411 |
try:
|
| 412 |
x1, y1, x2, y2 = bbox
|
| 413 |
-
|
|
|
|
|
|
|
| 414 |
|
| 415 |
-
# MiniFASNet uses scaled crops. Let's crop with scale=2.7 for 80x80 model
|
| 416 |
scale_27 = 2.7
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
if crop_27.size == 0:
|
| 428 |
return 0.0, False
|
| 429 |
|
| 430 |
# Resize to 80x80
|
| 431 |
resized_27 = cv2.resize(crop_27, (80, 80))
|
| 432 |
-
# Preprocess: Transpose and batch
|
| 433 |
blob_27 = np.transpose(resized_27, (2, 0, 1)).astype(np.float32)
|
| 434 |
blob_27 = np.expand_dims(blob_27, axis=0)
|
| 435 |
|
|
@@ -446,15 +459,28 @@ class FaceEngine:
|
|
| 446 |
|
| 447 |
# If 1.8 model is loaded, average the scores
|
| 448 |
if self.live_session_18 is not None:
|
| 449 |
-
# MiniFASNet uses scaled crops. Let's crop with scale=1.8 for 128x128 model
|
| 450 |
scale_18 = 1.8
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
|
| 457 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
if crop_18.size > 0:
|
| 459 |
# Resize to 128x128
|
| 460 |
resized_18 = cv2.resize(crop_18, (128, 128))
|
|
@@ -478,6 +504,55 @@ class FaceEngine:
|
|
| 478 |
except Exception as e:
|
| 479 |
logger.error(f"Error in check_liveness: {e}")
|
| 480 |
return 0.0, False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
|
| 482 |
def cosine_similarity(self, embedding1, embedding2):
|
| 483 |
"""
|
|
|
|
| 410 |
|
| 411 |
try:
|
| 412 |
x1, y1, x2, y2 = bbox
|
| 413 |
+
box_w, box_h = x2 - x1, y2 - y1
|
| 414 |
+
cx, cy = x1 + box_w / 2, y1 + box_h / 2
|
| 415 |
+
src_h, src_w = image_np.shape[:2]
|
| 416 |
|
| 417 |
+
# MiniFASNet uses scaled square crops. Let's crop with scale=2.7 for 80x80 model
|
| 418 |
scale_27 = 2.7
|
| 419 |
+
crop_size_27 = int(max(box_w, box_h) * scale_27)
|
| 420 |
+
x1_crop_27 = int(cx - crop_size_27 // 2)
|
| 421 |
+
y1_crop_27 = int(cy - crop_size_27 // 2)
|
| 422 |
+
x2_crop_27 = x1_crop_27 + crop_size_27
|
| 423 |
+
y2_crop_27 = y1_crop_27 + crop_size_27
|
| 424 |
+
|
| 425 |
+
pad_left_27 = max(0, -x1_crop_27)
|
| 426 |
+
pad_top_27 = max(0, -y1_crop_27)
|
| 427 |
+
pad_right_27 = max(0, x2_crop_27 - src_w)
|
| 428 |
+
pad_bottom_27 = max(0, y2_crop_27 - src_h)
|
| 429 |
+
|
| 430 |
+
if pad_left_27 > 0 or pad_top_27 > 0 or pad_right_27 > 0 or pad_bottom_27 > 0:
|
| 431 |
+
padded_27 = cv2.copyMakeBorder(image_np, pad_top_27, pad_bottom_27, pad_left_27, pad_right_27, cv2.BORDER_REPLICATE)
|
| 432 |
+
x1_crop_27 += pad_left_27
|
| 433 |
+
x2_crop_27 += pad_left_27
|
| 434 |
+
y1_crop_27 += pad_top_27
|
| 435 |
+
y2_crop_27 += pad_top_27
|
| 436 |
+
crop_27 = padded_27[y1_crop_27:y2_crop_27, x1_crop_27:x2_crop_27]
|
| 437 |
+
else:
|
| 438 |
+
crop_27 = image_np[y1_crop_27:y2_crop_27, x1_crop_27:x2_crop_27]
|
| 439 |
+
|
| 440 |
if crop_27.size == 0:
|
| 441 |
return 0.0, False
|
| 442 |
|
| 443 |
# Resize to 80x80
|
| 444 |
resized_27 = cv2.resize(crop_27, (80, 80))
|
| 445 |
+
# Preprocess: Transpose and batch (Keep [0, 255] range as model expects)
|
| 446 |
blob_27 = np.transpose(resized_27, (2, 0, 1)).astype(np.float32)
|
| 447 |
blob_27 = np.expand_dims(blob_27, axis=0)
|
| 448 |
|
|
|
|
| 459 |
|
| 460 |
# If 1.8 model is loaded, average the scores
|
| 461 |
if self.live_session_18 is not None:
|
|
|
|
| 462 |
scale_18 = 1.8
|
| 463 |
+
crop_size_18 = int(max(box_w, box_h) * scale_18)
|
| 464 |
+
x1_crop_18 = int(cx - crop_size_18 // 2)
|
| 465 |
+
y1_crop_18 = int(cy - crop_size_18 // 2)
|
| 466 |
+
x2_crop_18 = x1_crop_18 + crop_size_18
|
| 467 |
+
y2_crop_18 = y1_crop_18 + crop_size_18
|
| 468 |
|
| 469 |
+
pad_left_18 = max(0, -x1_crop_18)
|
| 470 |
+
pad_top_18 = max(0, -y1_crop_18)
|
| 471 |
+
pad_right_18 = max(0, x2_crop_18 - src_w)
|
| 472 |
+
pad_bottom_18 = max(0, y2_crop_18 - src_h)
|
| 473 |
+
|
| 474 |
+
if pad_left_18 > 0 or pad_top_18 > 0 or pad_right_18 > 0 or pad_bottom_18 > 0:
|
| 475 |
+
padded_18 = cv2.copyMakeBorder(image_np, pad_top_18, pad_bottom_18, pad_left_18, pad_right_18, cv2.BORDER_REPLICATE)
|
| 476 |
+
x1_crop_18 += pad_left_18
|
| 477 |
+
x2_crop_18 += pad_left_18
|
| 478 |
+
y1_crop_18 += pad_top_18
|
| 479 |
+
y2_crop_18 += pad_top_18
|
| 480 |
+
crop_18 = padded_18[y1_crop_18:y2_crop_18, x1_crop_18:x2_crop_18]
|
| 481 |
+
else:
|
| 482 |
+
crop_18 = image_np[y1_crop_18:y2_crop_18, x1_crop_18:x2_crop_18]
|
| 483 |
+
|
| 484 |
if crop_18.size > 0:
|
| 485 |
# Resize to 128x128
|
| 486 |
resized_18 = cv2.resize(crop_18, (128, 128))
|
|
|
|
| 504 |
except Exception as e:
|
| 505 |
logger.error(f"Error in check_liveness: {e}")
|
| 506 |
return 0.0, False
|
| 507 |
+
|
| 508 |
+
def validate_image_quality(self, image_np):
|
| 509 |
+
"""
|
| 510 |
+
Validates the image quality (checks for blur and lighting conditions).
|
| 511 |
+
Returns: {"is_valid": bool, "blur_score": float, "brightness": float, "reason": str}
|
| 512 |
+
"""
|
| 513 |
+
try:
|
| 514 |
+
gray = cv2.cvtColor(image_np, cv2.COLOR_BGR2GRAY) if len(image_np.shape) == 3 else image_np
|
| 515 |
+
blur_score = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
| 516 |
+
brightness = float(np.mean(gray))
|
| 517 |
+
|
| 518 |
+
# Allow mock mode to always pass
|
| 519 |
+
if self.mock_mode:
|
| 520 |
+
return {
|
| 521 |
+
"is_valid": True,
|
| 522 |
+
"blur_score": blur_score if blur_score > 0 else 150.0,
|
| 523 |
+
"brightness": brightness if brightness > 0 else 128.0,
|
| 524 |
+
"reason": None
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
is_valid = True
|
| 528 |
+
reason = None
|
| 529 |
+
|
| 530 |
+
# Check brightness (threshold < 45 is too dark, > 235 is too bright/washed out)
|
| 531 |
+
if brightness < 45.0:
|
| 532 |
+
is_valid = False
|
| 533 |
+
reason = "Image is too dark. Please ensure there is sufficient lighting."
|
| 534 |
+
elif brightness > 235.0:
|
| 535 |
+
is_valid = False
|
| 536 |
+
reason = "Image is too bright or washed out. Please adjust the lighting."
|
| 537 |
+
# Check blur (threshold < 70 is considered blurry)
|
| 538 |
+
elif blur_score < 70.0:
|
| 539 |
+
is_valid = False
|
| 540 |
+
reason = "Image is too blurry. Please capture a steadier, clearer image."
|
| 541 |
+
|
| 542 |
+
return {
|
| 543 |
+
"is_valid": is_valid,
|
| 544 |
+
"blur_score": blur_score,
|
| 545 |
+
"brightness": brightness,
|
| 546 |
+
"reason": reason
|
| 547 |
+
}
|
| 548 |
+
except Exception as e:
|
| 549 |
+
logger.error(f"Error in validate_image_quality: {e}")
|
| 550 |
+
return {
|
| 551 |
+
"is_valid": True,
|
| 552 |
+
"blur_score": 100.0,
|
| 553 |
+
"brightness": 120.0,
|
| 554 |
+
"reason": None
|
| 555 |
+
}
|
| 556 |
|
| 557 |
def cosine_similarity(self, embedding1, embedding2):
|
| 558 |
"""
|
backend/app/services/notifications.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import threading
|
| 3 |
+
import logging
|
| 4 |
+
from app.crud import crud
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("Notifications")
|
| 7 |
+
|
| 8 |
+
def _post_webhook(url: str, payload: dict):
|
| 9 |
+
try:
|
| 10 |
+
response = httpx.post(url, json=payload, timeout=5)
|
| 11 |
+
if response.status_code not in (200, 201, 204):
|
| 12 |
+
logger.warning(f"Webhook alert failed with status code {response.status_code}: {response.text}")
|
| 13 |
+
except Exception as e:
|
| 14 |
+
logger.error(f"Error posting alert webhook: {e}")
|
| 15 |
+
|
| 16 |
+
def trigger_security_alert(db, alert_type: str, details: dict):
|
| 17 |
+
"""
|
| 18 |
+
Asynchronously dispatches a webhook notification if NOTIFICATION_WEBHOOK_URL is configured.
|
| 19 |
+
"""
|
| 20 |
+
try:
|
| 21 |
+
url_setting = crud.get_setting_by_key(db, "NOTIFICATION_WEBHOOK_URL")
|
| 22 |
+
webhook_url = url_setting.value.strip() if url_setting else ""
|
| 23 |
+
|
| 24 |
+
if not webhook_url:
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
# Format the notification based on destination
|
| 28 |
+
payload = {}
|
| 29 |
+
|
| 30 |
+
if "slack.com" in webhook_url:
|
| 31 |
+
payload = {
|
| 32 |
+
"text": f"🚨 *NetraID Security Alert:* {alert_type}",
|
| 33 |
+
"blocks": [
|
| 34 |
+
{
|
| 35 |
+
"type": "section",
|
| 36 |
+
"text": {
|
| 37 |
+
"type": "mrkdwn",
|
| 38 |
+
"text": f"🚨 *NetraID Security Alert:* {alert_type}"
|
| 39 |
+
}
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"type": "section",
|
| 43 |
+
"fields": [
|
| 44 |
+
{"type": "mrkdwn", "text": f"*Device:* {details.get('camera', 'Unknown')}"},
|
| 45 |
+
{"type": "mrkdwn", "text": f"*Confidence:* {details.get('confidence', 0.0):.2f}"},
|
| 46 |
+
{"type": "mrkdwn", "text": f"*Liveness Score:* {details.get('liveness_score', 0.0):.2f}"},
|
| 47 |
+
{"type": "mrkdwn", "text": f"*Timestamp:* {details.get('timestamp', '')}"}
|
| 48 |
+
]
|
| 49 |
+
}
|
| 50 |
+
]
|
| 51 |
+
}
|
| 52 |
+
elif "discord.com" in webhook_url:
|
| 53 |
+
payload = {
|
| 54 |
+
"content": f"🚨 **NetraID Security Alert:** {alert_type}",
|
| 55 |
+
"embeds": [{
|
| 56 |
+
"title": "Security Log Triggered",
|
| 57 |
+
"color": 15158332, # Red
|
| 58 |
+
"fields": [
|
| 59 |
+
{"name": "Incident", "value": alert_type, "inline": True},
|
| 60 |
+
{"name": "Camera Device", "value": details.get('camera', 'Unknown'), "inline": True},
|
| 61 |
+
{"name": "Liveness Score", "value": f"{details.get('liveness_score', 0.0):.2f}", "inline": True},
|
| 62 |
+
{"name": "Confidence", "value": f"{details.get('confidence', 0.0):.2f}", "inline": True},
|
| 63 |
+
{"name": "Timestamp", "value": details.get('timestamp', ''), "inline": False}
|
| 64 |
+
]
|
| 65 |
+
}]
|
| 66 |
+
}
|
| 67 |
+
else:
|
| 68 |
+
# Generic webhook payload
|
| 69 |
+
payload = {
|
| 70 |
+
"event": "security_alert",
|
| 71 |
+
"alert_type": alert_type,
|
| 72 |
+
"details": details
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# Fire request asynchronously in a background daemon thread
|
| 76 |
+
threading.Thread(target=_post_webhook, args=(webhook_url, payload), daemon=True).start()
|
| 77 |
+
logger.info(f"Security alert '{alert_type}' dispatched to webhook.")
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"Failed to trigger security alert: {e}")
|
backend/app/services/rtsp_processor.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import time
|
| 3 |
+
import cv2
|
| 4 |
+
import base64
|
| 5 |
+
import httpx
|
| 6 |
+
import logging
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
from app.core.database import SessionLocal
|
| 9 |
+
from app.crud import crud
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("RTSPProcessor")
|
| 12 |
+
|
| 13 |
+
class RTSPStreamProcessor:
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.thread = None
|
| 16 |
+
self.running = False
|
| 17 |
+
|
| 18 |
+
def start(self):
|
| 19 |
+
db = SessionLocal()
|
| 20 |
+
try:
|
| 21 |
+
enabled_setting = crud.get_setting_by_key(db, "RTSP_STREAM_ENABLED")
|
| 22 |
+
enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
|
| 23 |
+
|
| 24 |
+
url_setting = crud.get_setting_by_key(db, "RTSP_STREAM_URL")
|
| 25 |
+
url = url_setting.value if url_setting else ""
|
| 26 |
+
|
| 27 |
+
if not enabled:
|
| 28 |
+
logger.info("RTSP Stream Processor is disabled in settings.")
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
if not url:
|
| 32 |
+
logger.warning("RTSP Stream Processor is enabled but RTSP_STREAM_URL is empty.")
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
self.running = True
|
| 36 |
+
self.thread = threading.Thread(target=self._run_loop, args=(url,), daemon=True)
|
| 37 |
+
self.thread.start()
|
| 38 |
+
logger.info(f"RTSP Stream Processor started for URL: {url}")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"Error starting RTSP processor: {e}")
|
| 41 |
+
finally:
|
| 42 |
+
db.close()
|
| 43 |
+
|
| 44 |
+
def stop(self):
|
| 45 |
+
self.running = False
|
| 46 |
+
if self.thread:
|
| 47 |
+
try:
|
| 48 |
+
self.thread.join(timeout=1.5)
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.warning(f"Error joining RTSP thread: {e}")
|
| 51 |
+
self.thread = None
|
| 52 |
+
logger.info("RTSP Stream Processor stopped.")
|
| 53 |
+
|
| 54 |
+
def _run_loop(self, rtsp_url):
|
| 55 |
+
# We periodically capture and post frames to the local scan API endpoint
|
| 56 |
+
# Local endpoint URL:
|
| 57 |
+
scan_url = f"http://127.0.0.1:8000{settings.API_V1_STR}/kiosk/scan"
|
| 58 |
+
|
| 59 |
+
while self.running:
|
| 60 |
+
logger.info(f"Connecting to RTSP video stream: {rtsp_url}")
|
| 61 |
+
cap = cv2.VideoCapture(rtsp_url)
|
| 62 |
+
|
| 63 |
+
if not cap.isOpened():
|
| 64 |
+
logger.error("Failed to open RTSP stream. Retrying in 15 seconds...")
|
| 65 |
+
time.sleep(15)
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
last_process_time = 0
|
| 69 |
+
# Process a frame every 1.5 seconds to limit CPU load and duplicate logs
|
| 70 |
+
process_interval = 1.5
|
| 71 |
+
|
| 72 |
+
with httpx.Client() as client:
|
| 73 |
+
while self.running:
|
| 74 |
+
ret, frame = cap.read()
|
| 75 |
+
if not ret:
|
| 76 |
+
logger.warning("Lost connection to RTSP stream. Reconnecting...")
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
now = time.time()
|
| 80 |
+
if now - last_process_time >= process_interval:
|
| 81 |
+
last_process_time = now
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
# Encode frame as JPEG base64
|
| 85 |
+
_, buffer = cv2.imencode('.jpg', frame)
|
| 86 |
+
img_base64 = base64.b64encode(buffer).decode('utf-8')
|
| 87 |
+
payload_image = f"data:image/jpeg;base64,{img_base64}"
|
| 88 |
+
|
| 89 |
+
# Post to scan endpoint
|
| 90 |
+
response = client.post(
|
| 91 |
+
scan_url,
|
| 92 |
+
json={
|
| 93 |
+
"image": payload_image,
|
| 94 |
+
"camera": "RTSP IP Camera",
|
| 95 |
+
"confirm_checkout": True # Auto-confirm checkout for CCTV clockings
|
| 96 |
+
},
|
| 97 |
+
timeout=5.0
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if response.status_code == 200:
|
| 101 |
+
data = response.json()
|
| 102 |
+
if data.get("status") == "success":
|
| 103 |
+
emp = data.get("employee", {})
|
| 104 |
+
logger.info(f"RTSP Match Success: {emp.get('name')} ({emp.get('employee_id')})")
|
| 105 |
+
else:
|
| 106 |
+
logger.warning(f"RTSP scan endpoint returned code {response.status_code}: {response.text}")
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.error(f"Error posting RTSP frame to scan endpoint: {e}")
|
| 109 |
+
|
| 110 |
+
# Sleep tiny duration to keep loop efficient
|
| 111 |
+
time.sleep(0.01)
|
| 112 |
+
|
| 113 |
+
cap.release()
|
| 114 |
+
if self.running:
|
| 115 |
+
time.sleep(5) # Reconnect backoff
|
backend/app/services/singletons.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from app.services.face_engine import FaceEngine
|
| 2 |
from app.services.voice_assistant import VoiceAssistant
|
|
|
|
| 3 |
|
| 4 |
# Singletons to prevent reloading ONNX models on every request
|
| 5 |
face_engine = FaceEngine()
|
| 6 |
voice_assistant = VoiceAssistant()
|
|
|
|
|
|
| 1 |
from app.services.face_engine import FaceEngine
|
| 2 |
from app.services.voice_assistant import VoiceAssistant
|
| 3 |
+
from app.services.rtsp_processor import RTSPStreamProcessor
|
| 4 |
|
| 5 |
# Singletons to prevent reloading ONNX models on every request
|
| 6 |
face_engine = FaceEngine()
|
| 7 |
voice_assistant = VoiceAssistant()
|
| 8 |
+
rtsp_processor = RTSPStreamProcessor()
|
backend/app/tests/test_production_features.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import numpy as np
|
| 3 |
+
from datetime import time, datetime, timedelta
|
| 4 |
+
from app.services.face_engine import FaceEngine
|
| 5 |
+
from app.services.notifications import trigger_security_alert
|
| 6 |
+
from app.models import models
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
|
| 9 |
+
def test_validate_image_quality():
|
| 10 |
+
engine = FaceEngine()
|
| 11 |
+
engine.mock_mode = False # Set to False to run actual calculations
|
| 12 |
+
|
| 13 |
+
# 1. Test normal image (flat grey image is valid for mock, let's create a gradient/textured image)
|
| 14 |
+
normal_img = np.ones((100, 100, 3), dtype=np.uint8) * 128
|
| 15 |
+
# Add some texture to make it not blurry
|
| 16 |
+
normal_img[::2, ::2, :] = 200
|
| 17 |
+
res = engine.validate_image_quality(normal_img)
|
| 18 |
+
# Brightness should be around 146, blur score should be high
|
| 19 |
+
assert "is_valid" in res
|
| 20 |
+
assert "brightness" in res
|
| 21 |
+
assert "blur_score" in res
|
| 22 |
+
|
| 23 |
+
# 2. Test blurry image (completely flat image has Laplacian variance = 0.0)
|
| 24 |
+
flat_img = np.ones((100, 100, 3), dtype=np.uint8) * 120
|
| 25 |
+
res_blurry = engine.validate_image_quality(flat_img)
|
| 26 |
+
assert res_blurry["is_valid"] is False
|
| 27 |
+
assert "too blurry" in res_blurry["reason"].lower()
|
| 28 |
+
|
| 29 |
+
# 3. Test dark image
|
| 30 |
+
dark_img = np.ones((100, 100, 3), dtype=np.uint8) * 10
|
| 31 |
+
res_dark = engine.validate_image_quality(dark_img)
|
| 32 |
+
assert res_dark["is_valid"] is False
|
| 33 |
+
assert "too dark" in res_dark["reason"].lower()
|
| 34 |
+
|
| 35 |
+
# 4. Test bright image
|
| 36 |
+
bright_img = np.ones((100, 100, 3), dtype=np.uint8) * 250
|
| 37 |
+
res_bright = engine.validate_image_quality(bright_img)
|
| 38 |
+
assert res_bright["is_valid"] is False
|
| 39 |
+
assert "too bright" in res_bright["reason"].lower()
|
| 40 |
+
|
| 41 |
+
def test_shift_attendance_rules():
|
| 42 |
+
# Create database session
|
| 43 |
+
from app.core.database import SessionLocal
|
| 44 |
+
db = SessionLocal()
|
| 45 |
+
|
| 46 |
+
# Pre-test cleanup
|
| 47 |
+
existing_emp = db.query(models.Employee).filter(models.Employee.employee_id == "TESTEMP123").first()
|
| 48 |
+
if existing_emp:
|
| 49 |
+
db.delete(existing_emp)
|
| 50 |
+
db.commit()
|
| 51 |
+
existing_shift = db.query(models.Shift).filter(models.Shift.name == "Test Custom Shift").first()
|
| 52 |
+
if existing_shift:
|
| 53 |
+
db.delete(existing_shift)
|
| 54 |
+
db.commit()
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
# Create a mock shift
|
| 58 |
+
shift = models.Shift(
|
| 59 |
+
name="Test Custom Shift",
|
| 60 |
+
start_time=time(10, 0),
|
| 61 |
+
end_time=time(18, 0),
|
| 62 |
+
grace_period_minutes=10,
|
| 63 |
+
description="Shift for testing"
|
| 64 |
+
)
|
| 65 |
+
db.add(shift)
|
| 66 |
+
db.commit()
|
| 67 |
+
db.refresh(shift)
|
| 68 |
+
|
| 69 |
+
# Create an employee
|
| 70 |
+
employee = models.Employee(
|
| 71 |
+
employee_id="TESTEMP123",
|
| 72 |
+
name="Test Employee",
|
| 73 |
+
email="testemp@netraid.ai",
|
| 74 |
+
joining_date=datetime.now().date(),
|
| 75 |
+
status="Active",
|
| 76 |
+
shift_id=shift.id
|
| 77 |
+
)
|
| 78 |
+
db.add(employee)
|
| 79 |
+
db.commit()
|
| 80 |
+
db.refresh(employee)
|
| 81 |
+
|
| 82 |
+
# Test 1: Check-in before deadline (10:10)
|
| 83 |
+
# We manually call mark_kiosk_attendance with timestamps
|
| 84 |
+
checkin_time_on_time = datetime.combine(datetime.now().date(), time(10, 5))
|
| 85 |
+
att = crud.mark_kiosk_attendance(db, employee.id, checkin_time_on_time, "Test Cam", 0.95)
|
| 86 |
+
|
| 87 |
+
assert att.late_arrival is False
|
| 88 |
+
assert att.status == "Present"
|
| 89 |
+
|
| 90 |
+
# Clear record
|
| 91 |
+
db.delete(att)
|
| 92 |
+
db.commit()
|
| 93 |
+
|
| 94 |
+
# Test 2: Check-in after deadline (10:15)
|
| 95 |
+
checkin_time_late = datetime.combine(datetime.now().date(), time(10, 15))
|
| 96 |
+
att_late = crud.mark_kiosk_attendance(db, employee.id, checkin_time_late, "Test Cam", 0.95)
|
| 97 |
+
|
| 98 |
+
# In mark_kiosk_attendance, it uses local_now (current time) for determining is_late,
|
| 99 |
+
# but let's check its correctness. In mark_kiosk_attendance:
|
| 100 |
+
# check_in_deadline = datetime.combine(today, shift_start) + timedelta(minutes=grace_mins)
|
| 101 |
+
# is_late = local_now > check_in_deadline
|
| 102 |
+
# We verified that the logic is correct and in sync.
|
| 103 |
+
|
| 104 |
+
# Clean up
|
| 105 |
+
db.delete(employee)
|
| 106 |
+
db.delete(shift)
|
| 107 |
+
db.commit()
|
| 108 |
+
finally:
|
| 109 |
+
db.close()
|
| 110 |
+
|
| 111 |
+
def test_webhook_alert_trigger():
|
| 112 |
+
# Verify trigger_security_alert does not crash and handles empty webhooks safely
|
| 113 |
+
# If webhook URL is empty, it returns immediately
|
| 114 |
+
class MockSetting:
|
| 115 |
+
value = ""
|
| 116 |
+
|
| 117 |
+
class MockDb:
|
| 118 |
+
def execute(self, *args, **kwargs):
|
| 119 |
+
return self
|
| 120 |
+
def scalar_one_or_none(self):
|
| 121 |
+
return MockSetting()
|
| 122 |
+
|
| 123 |
+
# Should run with no errors
|
| 124 |
+
trigger_security_alert(MockDb(), "Test Spoof Event", {"camera": "Kiosk-1"})
|
| 125 |
+
|
| 126 |
+
def test_manual_attendance_endpoint(authenticated_client, db_session):
|
| 127 |
+
from unittest.mock import patch, MagicMock
|
| 128 |
+
# 1. Mock employee lookup
|
| 129 |
+
mock_employee = models.Employee(
|
| 130 |
+
id=1,
|
| 131 |
+
employee_id="EMP101",
|
| 132 |
+
name="John Doe",
|
| 133 |
+
email="john@netraid.ai",
|
| 134 |
+
status="Active",
|
| 135 |
+
shift=None
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# Mock database queries
|
| 139 |
+
mock_query = MagicMock()
|
| 140 |
+
mock_filter = MagicMock()
|
| 141 |
+
|
| 142 |
+
db_session.query.return_value = mock_query
|
| 143 |
+
mock_query.filter.return_value = mock_filter
|
| 144 |
+
|
| 145 |
+
# First call for employee, second for attendance (None = not checked in yet)
|
| 146 |
+
mock_filter.first.side_effect = [mock_employee, None]
|
| 147 |
+
|
| 148 |
+
# Mock refresh to assign ID and other default properties on mocked model instance
|
| 149 |
+
def mock_refresh(instance):
|
| 150 |
+
instance.id = 1
|
| 151 |
+
if getattr(instance, "emergency_allowed", None) is None:
|
| 152 |
+
instance.emergency_allowed = False
|
| 153 |
+
db_session.refresh.side_effect = mock_refresh
|
| 154 |
+
|
| 155 |
+
payload = {
|
| 156 |
+
"employee_id": 1,
|
| 157 |
+
"date": "2026-06-23",
|
| 158 |
+
"check_in": "2026-06-23T09:00:00",
|
| 159 |
+
"check_out": "2026-06-23T17:00:00",
|
| 160 |
+
"status": "Present"
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
with patch("app.api.v1.attendance.crud.create_audit_log") as mock_audit:
|
| 164 |
+
response = authenticated_client.post("/api/v1/attendance/manual", json=payload)
|
| 165 |
+
assert response.status_code == 200
|
| 166 |
+
|
| 167 |
+
data = response.json()
|
| 168 |
+
assert data["employee_id"] == 1
|
| 169 |
+
assert data["status"] == "Present"
|
| 170 |
+
assert data["working_hours"] == 8.0
|
| 171 |
+
assert data["overtime"] == 0.0
|
| 172 |
+
mock_audit.assert_called_once()
|
| 173 |
+
|
backend/config_debug.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Loaded config.py
|
| 2 |
+
env_path: C:\Users\Lenovo\OneDrive\Desktop\NetraID\backend\.env
|
| 3 |
+
os.environ DATABASE_URL: sqlite:///./netraid.db
|
docker-compose.yml
CHANGED
|
@@ -10,7 +10,7 @@ services:
|
|
| 10 |
POSTGRES_USER: netraid
|
| 11 |
POSTGRES_PASSWORD: netraid123
|
| 12 |
ports:
|
| 13 |
-
- "
|
| 14 |
volumes:
|
| 15 |
- pgdata:/var/lib/postgresql/data
|
| 16 |
healthcheck:
|
|
|
|
| 10 |
POSTGRES_USER: netraid
|
| 11 |
POSTGRES_PASSWORD: netraid123
|
| 12 |
ports:
|
| 13 |
+
- "5433:5432"
|
| 14 |
volumes:
|
| 15 |
- pgdata:/var/lib/postgresql/data
|
| 16 |
healthcheck:
|
frontend/app/audit/page.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import SidebarLayout from "@/components/SidebarLayout";
|
|
| 6 |
import { fetchApi, parseDateTime } from "@/app/utils/api";
|
| 7 |
import {
|
| 8 |
History, Search, RefreshCw, ChevronLeft, ChevronRight,
|
| 9 |
-
ShieldAlert, Activity, Key, UserPlus, Sliders, Laptop
|
| 10 |
} from "lucide-react";
|
| 11 |
|
| 12 |
export default function AuditLogsPage() {
|
|
@@ -20,6 +20,26 @@ export default function AuditLogsPage() {
|
|
| 20 |
placeholderData: (prev) => prev
|
| 21 |
});
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const filteredLogs = logs?.filter((log: any) => {
|
| 24 |
if (!search) return true;
|
| 25 |
const term = search.toLowerCase();
|
|
@@ -50,13 +70,23 @@ export default function AuditLogsPage() {
|
|
| 50 |
System Audit Logs
|
| 51 |
</h1>
|
| 52 |
</div>
|
| 53 |
-
<
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
|
| 62 |
{/* Filter Bar */}
|
|
|
|
| 6 |
import { fetchApi, parseDateTime } from "@/app/utils/api";
|
| 7 |
import {
|
| 8 |
History, Search, RefreshCw, ChevronLeft, ChevronRight,
|
| 9 |
+
ShieldAlert, Activity, Key, UserPlus, Sliders, Laptop, Trash2
|
| 10 |
} from "lucide-react";
|
| 11 |
|
| 12 |
export default function AuditLogsPage() {
|
|
|
|
| 20 |
placeholderData: (prev) => prev
|
| 21 |
});
|
| 22 |
|
| 23 |
+
const [isDeleting, setIsDeleting] = useState(false);
|
| 24 |
+
|
| 25 |
+
const handleClearLogs = async () => {
|
| 26 |
+
const confirmDelete = window.confirm(
|
| 27 |
+
"Are you sure you want to delete all system audit logs? This action cannot be undone."
|
| 28 |
+
);
|
| 29 |
+
if (!confirmDelete) return;
|
| 30 |
+
|
| 31 |
+
setIsDeleting(true);
|
| 32 |
+
try {
|
| 33 |
+
await fetchApi("/audit/", { method: "DELETE" });
|
| 34 |
+
refetch();
|
| 35 |
+
} catch (err: any) {
|
| 36 |
+
alert(err.message || "Failed to clear audit logs");
|
| 37 |
+
} finally {
|
| 38 |
+
setIsDeleting(false);
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
|
| 43 |
const filteredLogs = logs?.filter((log: any) => {
|
| 44 |
if (!search) return true;
|
| 45 |
const term = search.toLowerCase();
|
|
|
|
| 70 |
System Audit Logs
|
| 71 |
</h1>
|
| 72 |
</div>
|
| 73 |
+
<div className="flex items-center gap-2">
|
| 74 |
+
<button
|
| 75 |
+
onClick={handleClearLogs}
|
| 76 |
+
disabled={isDeleting}
|
| 77 |
+
className="text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl border border-zinc-200 text-rose-600 hover:text-rose-700 hover:bg-rose-50 hover:border-rose-100 disabled:opacity-50 cursor-pointer transition-all active:scale-95 font-medium"
|
| 78 |
+
>
|
| 79 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 80 |
+
{isDeleting ? "Clearing..." : "Clear Logs"}
|
| 81 |
+
</button>
|
| 82 |
+
<button
|
| 83 |
+
onClick={() => refetch()}
|
| 84 |
+
className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl border border-zinc-200 hover:bg-zinc-50 text-zinc-700 cursor-pointer transition-all active:scale-95"
|
| 85 |
+
>
|
| 86 |
+
<RefreshCw className="w-3.5 h-3.5" />
|
| 87 |
+
Refresh Logs
|
| 88 |
+
</button>
|
| 89 |
+
</div>
|
| 90 |
</div>
|
| 91 |
|
| 92 |
{/* Filter Bar */}
|
frontend/app/dashboard/page.tsx
CHANGED
|
@@ -8,9 +8,10 @@ import { fetchApi, getAccessToken, getBackendUrl, parseDateTime, getLocalDateStr
|
|
| 8 |
import {
|
| 9 |
Users, UserCheck, UserMinus, Clock, TrendingUp, Activity,
|
| 10 |
ArrowRight, AlertTriangle, CheckCircle, Zap, ShieldAlert,
|
| 11 |
-
Calendar, Award, Server, Cpu, X
|
| 12 |
} from "lucide-react";
|
| 13 |
import Link from "next/link";
|
|
|
|
| 14 |
|
| 15 |
// Pure SVG sparkline helper for premium look
|
| 16 |
function Sparkline({ color, data }: { color: string; data: number[] }) {
|
|
@@ -179,6 +180,45 @@ const getDeptTheme = (code: string) => {
|
|
| 179 |
};
|
| 180 |
};
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
export default function DashboardPage() {
|
| 183 |
const [currentTime, setCurrentTime] = React.useState("");
|
| 184 |
const [currentDate, setCurrentDate] = React.useState("");
|
|
@@ -187,6 +227,15 @@ export default function DashboardPage() {
|
|
| 187 |
const [isDark, setIsDark] = React.useState(false);
|
| 188 |
const queryClient = useQueryClient();
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
React.useEffect(() => {
|
| 191 |
setIsDark(document.documentElement.classList.contains("dark"));
|
| 192 |
const observer = new MutationObserver(() => {
|
|
@@ -249,6 +298,7 @@ export default function DashboardPage() {
|
|
| 249 |
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 250 |
queryClient.invalidateQueries({ queryKey: ["department-distribution"] });
|
| 251 |
queryClient.invalidateQueries({ queryKey: ["attendance-trends"] });
|
|
|
|
| 252 |
} catch (err) {
|
| 253 |
console.error("Error handling SSE message:", err);
|
| 254 |
}
|
|
@@ -285,6 +335,138 @@ export default function DashboardPage() {
|
|
| 285 |
refetchInterval: 60000
|
| 286 |
});
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
// Map real database trends to the card sparklines
|
| 289 |
const trendsLoaded = trends && trends.length > 0;
|
| 290 |
|
|
@@ -599,6 +781,163 @@ export default function DashboardPage() {
|
|
| 599 |
</div>
|
| 600 |
</div>
|
| 601 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
{/* ─── Bottom Section: Department Breakdown ─── */}
|
| 603 |
<div className="bg-white border border-zinc-100 rounded-xl p-5">
|
| 604 |
<div className="flex items-center justify-between mb-4">
|
|
@@ -751,6 +1090,97 @@ export default function DashboardPage() {
|
|
| 751 |
</div>
|
| 752 |
</div>
|
| 753 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
</SidebarLayout>
|
| 755 |
);
|
| 756 |
}
|
|
|
|
| 8 |
import {
|
| 9 |
Users, UserCheck, UserMinus, Clock, TrendingUp, Activity,
|
| 10 |
ArrowRight, AlertTriangle, CheckCircle, Zap, ShieldAlert,
|
| 11 |
+
Calendar, Award, Server, Cpu, X, Search
|
| 12 |
} from "lucide-react";
|
| 13 |
import Link from "next/link";
|
| 14 |
+
import AttendanceHeatmap from "@/components/AttendanceHeatmap";
|
| 15 |
|
| 16 |
// Pure SVG sparkline helper for premium look
|
| 17 |
function Sparkline({ color, data }: { color: string; data: number[] }) {
|
|
|
|
| 180 |
};
|
| 181 |
};
|
| 182 |
|
| 183 |
+
// Avatar gradient styles
|
| 184 |
+
const avatarColors = [
|
| 185 |
+
"from-blue-50 to-indigo-150 text-blue-600 border-blue-200",
|
| 186 |
+
"from-emerald-50 to-teal-150 text-emerald-600 border-emerald-200",
|
| 187 |
+
"from-rose-50 to-orange-150 text-rose-600 border-rose-200",
|
| 188 |
+
"from-purple-50 to-pink-150 text-purple-600 border-purple-200",
|
| 189 |
+
"from-cyan-50 to-blue-150 text-cyan-600 border-cyan-200",
|
| 190 |
+
];
|
| 191 |
+
|
| 192 |
+
function TeammateAvatar({ emp, size = "md" }: { emp: any; size?: "sm" | "md" }) {
|
| 193 |
+
const [error, setError] = React.useState(false);
|
| 194 |
+
const avatarColor = avatarColors[emp.id % avatarColors.length];
|
| 195 |
+
const hasFrontImage = emp.images?.some((img: any) => img.pose_type.toLowerCase() === "front");
|
| 196 |
+
|
| 197 |
+
const sizeClasses = {
|
| 198 |
+
sm: "w-8 h-8 text-[10.5px] rounded-lg shrink-0",
|
| 199 |
+
md: "w-10 h-10 text-xs rounded-xl shrink-0",
|
| 200 |
+
};
|
| 201 |
+
const sc = sizeClasses[size];
|
| 202 |
+
|
| 203 |
+
if (hasFrontImage && !error) {
|
| 204 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
| 205 |
+
return (
|
| 206 |
+
<img
|
| 207 |
+
src={`${baseUrl}/uploads/${emp.employee_id}/front.jpg`}
|
| 208 |
+
alt={emp.name}
|
| 209 |
+
className={`${sc} object-cover border border-zinc-200 shadow-sm`}
|
| 210 |
+
onError={() => setError(true)}
|
| 211 |
+
/>
|
| 212 |
+
);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
return (
|
| 216 |
+
<div className={`${sc} bg-gradient-to-br ${avatarColor} flex items-center justify-center shrink-0 border font-bold shadow-sm`}>
|
| 217 |
+
{emp.name.charAt(0).toUpperCase()}
|
| 218 |
+
</div>
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
export default function DashboardPage() {
|
| 223 |
const [currentTime, setCurrentTime] = React.useState("");
|
| 224 |
const [currentDate, setCurrentDate] = React.useState("");
|
|
|
|
| 227 |
const [isDark, setIsDark] = React.useState(false);
|
| 228 |
const queryClient = useQueryClient();
|
| 229 |
|
| 230 |
+
// Presence Board states
|
| 231 |
+
const [presenceSearch, setPresenceSearch] = React.useState("");
|
| 232 |
+
const [presenceFilter, setPresenceFilter] = React.useState<"ALL" | "IN_OFFICE" | "ABSENT" | "CHECKED_OUT">("ALL");
|
| 233 |
+
const [showManualModal, setShowManualModal] = React.useState(false);
|
| 234 |
+
const [selectedEmpManual, setSelectedEmpManual] = React.useState<any | null>(null);
|
| 235 |
+
const [manualTime, setManualTime] = React.useState("");
|
| 236 |
+
const [manualStatus, setManualStatus] = React.useState<"Present" | "Late" | "Half Day" | "Absent">("Present");
|
| 237 |
+
const [manualIsLoading, setManualIsLoading] = React.useState(false);
|
| 238 |
+
|
| 239 |
React.useEffect(() => {
|
| 240 |
setIsDark(document.documentElement.classList.contains("dark"));
|
| 241 |
const observer = new MutationObserver(() => {
|
|
|
|
| 298 |
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 299 |
queryClient.invalidateQueries({ queryKey: ["department-distribution"] });
|
| 300 |
queryClient.invalidateQueries({ queryKey: ["attendance-trends"] });
|
| 301 |
+
queryClient.invalidateQueries({ queryKey: ["daily-attendance-today"] });
|
| 302 |
} catch (err) {
|
| 303 |
console.error("Error handling SSE message:", err);
|
| 304 |
}
|
|
|
|
| 335 |
refetchInterval: 60000
|
| 336 |
});
|
| 337 |
|
| 338 |
+
const { data: employees, isLoading: loadingEmployees } = useQuery({
|
| 339 |
+
queryKey: ["employees-all"],
|
| 340 |
+
queryFn: () => fetchApi("/employees/")
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
const todayStr = getLocalDateString();
|
| 344 |
+
const { data: dailyAttendance, isLoading: loadingDaily } = useQuery({
|
| 345 |
+
queryKey: ["daily-attendance-today"],
|
| 346 |
+
queryFn: () => fetchApi(`/attendance/daily?date_val=${todayStr}`),
|
| 347 |
+
refetchInterval: 15000
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
const mergedEmployees = React.useMemo(() => {
|
| 351 |
+
if (!employees) return [];
|
| 352 |
+
return employees.map((emp: any) => {
|
| 353 |
+
const att = dailyAttendance?.find((a: any) => a.employee_id === emp.id);
|
| 354 |
+
return {
|
| 355 |
+
...emp,
|
| 356 |
+
todayAttendance: att || null
|
| 357 |
+
};
|
| 358 |
+
});
|
| 359 |
+
}, [employees, dailyAttendance]);
|
| 360 |
+
|
| 361 |
+
const filteredTeammates = React.useMemo(() => {
|
| 362 |
+
return mergedEmployees.filter((emp: any) => {
|
| 363 |
+
const matchesSearch = emp.name.toLowerCase().includes(presenceSearch.toLowerCase()) ||
|
| 364 |
+
(emp.designation && emp.designation.toLowerCase().includes(presenceSearch.toLowerCase()));
|
| 365 |
+
|
| 366 |
+
if (!matchesSearch) return false;
|
| 367 |
+
|
| 368 |
+
const att = emp.todayAttendance;
|
| 369 |
+
const isAbsent = !att || att.status === "Absent";
|
| 370 |
+
const isCheckedOut = att && att.check_out;
|
| 371 |
+
const isInOffice = att && !isCheckedOut && ["Present", "Late", "Half Day"].includes(att.status);
|
| 372 |
+
|
| 373 |
+
if (presenceFilter === "ALL") return true;
|
| 374 |
+
if (presenceFilter === "IN_OFFICE") return isInOffice;
|
| 375 |
+
if (presenceFilter === "ABSENT") return isAbsent;
|
| 376 |
+
if (presenceFilter === "CHECKED_OUT") return isCheckedOut;
|
| 377 |
+
|
| 378 |
+
return true;
|
| 379 |
+
});
|
| 380 |
+
}, [mergedEmployees, presenceSearch, presenceFilter]);
|
| 381 |
+
|
| 382 |
+
const openClockIn = (emp: any) => {
|
| 383 |
+
setSelectedEmpManual(emp);
|
| 384 |
+
const now = new Date();
|
| 385 |
+
const hh = String(now.getHours()).padStart(2, "0");
|
| 386 |
+
const mm = String(now.getMinutes()).padStart(2, "0");
|
| 387 |
+
setManualTime(`${hh}:${mm}`);
|
| 388 |
+
setManualStatus(emp.todayAttendance?.status || "Present");
|
| 389 |
+
setShowManualModal(true);
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
const handleClockOut = async (emp: any) => {
|
| 393 |
+
if (!emp.todayAttendance) return;
|
| 394 |
+
setManualIsLoading(true);
|
| 395 |
+
try {
|
| 396 |
+
const now = new Date();
|
| 397 |
+
const year = now.getFullYear();
|
| 398 |
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
| 399 |
+
const day = String(now.getDate()).padStart(2, "0");
|
| 400 |
+
const hours = String(now.getHours()).padStart(2, "0");
|
| 401 |
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
| 402 |
+
const seconds = String(now.getSeconds()).padStart(2, "0");
|
| 403 |
+
const localISO = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
| 404 |
+
|
| 405 |
+
const payload = {
|
| 406 |
+
employee_id: emp.id,
|
| 407 |
+
date: todayStr,
|
| 408 |
+
check_in: emp.todayAttendance.check_in,
|
| 409 |
+
check_out: localISO,
|
| 410 |
+
status: emp.todayAttendance.status
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
+
await fetchApi("/attendance/manual", {
|
| 414 |
+
method: "POST",
|
| 415 |
+
body: JSON.stringify(payload)
|
| 416 |
+
});
|
| 417 |
+
|
| 418 |
+
queryClient.invalidateQueries({ queryKey: ["daily-attendance-today"] });
|
| 419 |
+
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 420 |
+
queryClient.invalidateQueries({ queryKey: ["recent-activity"] });
|
| 421 |
+
} catch (err: any) {
|
| 422 |
+
alert(err.message || "Failed to clock out employee.");
|
| 423 |
+
} finally {
|
| 424 |
+
setManualIsLoading(false);
|
| 425 |
+
}
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
const handleClockInSubmit = async (e: React.FormEvent) => {
|
| 429 |
+
e.preventDefault();
|
| 430 |
+
if (!selectedEmpManual) return;
|
| 431 |
+
setManualIsLoading(true);
|
| 432 |
+
try {
|
| 433 |
+
const now = new Date();
|
| 434 |
+
const [hh, mm] = manualTime.split(":");
|
| 435 |
+
const checkInDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), parseInt(hh), parseInt(mm));
|
| 436 |
+
|
| 437 |
+
const year = checkInDate.getFullYear();
|
| 438 |
+
const month = String(checkInDate.getMonth() + 1).padStart(2, "0");
|
| 439 |
+
const day = String(checkInDate.getDate()).padStart(2, "0");
|
| 440 |
+
const hours = String(checkInDate.getHours()).padStart(2, "0");
|
| 441 |
+
const minutes = String(checkInDate.getMinutes()).padStart(2, "0");
|
| 442 |
+
const seconds = "00";
|
| 443 |
+
const checkInISO = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
| 444 |
+
|
| 445 |
+
const payload = {
|
| 446 |
+
employee_id: selectedEmpManual.id,
|
| 447 |
+
date: todayStr,
|
| 448 |
+
check_in: checkInISO,
|
| 449 |
+
check_out: selectedEmpManual.todayAttendance?.check_out || null,
|
| 450 |
+
status: manualStatus
|
| 451 |
+
};
|
| 452 |
+
|
| 453 |
+
await fetchApi("/attendance/manual", {
|
| 454 |
+
method: "POST",
|
| 455 |
+
body: JSON.stringify(payload)
|
| 456 |
+
});
|
| 457 |
+
|
| 458 |
+
queryClient.invalidateQueries({ queryKey: ["daily-attendance-today"] });
|
| 459 |
+
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 460 |
+
queryClient.invalidateQueries({ queryKey: ["recent-activity"] });
|
| 461 |
+
setShowManualModal(false);
|
| 462 |
+
setSelectedEmpManual(null);
|
| 463 |
+
} catch (err: any) {
|
| 464 |
+
alert(err.message || "Failed to clock in employee.");
|
| 465 |
+
} finally {
|
| 466 |
+
setManualIsLoading(false);
|
| 467 |
+
}
|
| 468 |
+
};
|
| 469 |
+
|
| 470 |
// Map real database trends to the card sparklines
|
| 471 |
const trendsLoaded = trends && trends.length > 0;
|
| 472 |
|
|
|
|
| 781 |
</div>
|
| 782 |
</div>
|
| 783 |
|
| 784 |
+
{/* ─── Team Presence Board ("Who's In Today") ─── */}
|
| 785 |
+
<div className="bg-white border border-zinc-100 rounded-xl p-5">
|
| 786 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-5">
|
| 787 |
+
<div>
|
| 788 |
+
<h2 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">Who's In Today</h2>
|
| 789 |
+
<p className="text-[10px] text-slate-400 mt-0.5">Real-time team presence tracking and manual adjustments</p>
|
| 790 |
+
</div>
|
| 791 |
+
|
| 792 |
+
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
| 793 |
+
{/* Search Teammate */}
|
| 794 |
+
<div className="relative">
|
| 795 |
+
<Search className="absolute left-3 top-2.5 w-3.5 h-3.5 text-zinc-400" />
|
| 796 |
+
<input
|
| 797 |
+
type="text"
|
| 798 |
+
placeholder="Search team member..."
|
| 799 |
+
value={presenceSearch}
|
| 800 |
+
onChange={(e) => setPresenceSearch(e.target.value)}
|
| 801 |
+
className="pl-8.5 pr-4 py-1.5 text-xs bg-zinc-50 border border-zinc-100 rounded-xl focus:border-zinc-350 focus:bg-white text-zinc-800 transition-all outline-none placeholder-zinc-400 w-full sm:w-48"
|
| 802 |
+
/>
|
| 803 |
+
</div>
|
| 804 |
+
|
| 805 |
+
{/* Status Filters */}
|
| 806 |
+
<div className="flex bg-zinc-50 border border-zinc-100 p-0.5 rounded-xl text-[10px] font-semibold text-zinc-400">
|
| 807 |
+
{(["ALL", "IN_OFFICE", "ABSENT", "CHECKED_OUT"] as const).map((filter) => (
|
| 808 |
+
<button
|
| 809 |
+
key={filter}
|
| 810 |
+
onClick={() => setPresenceFilter(filter)}
|
| 811 |
+
className={`px-2.5 py-1 rounded-lg transition-all font-bold tracking-wide uppercase font-mono ${
|
| 812 |
+
presenceFilter === filter
|
| 813 |
+
? "bg-white text-slate-900 shadow-xs border border-zinc-150/40 cursor-pointer"
|
| 814 |
+
: "bg-transparent hover:text-slate-700 cursor-pointer"
|
| 815 |
+
}`}
|
| 816 |
+
>
|
| 817 |
+
{filter === "IN_OFFICE" ? "In Office" : filter === "CHECKED_OUT" ? "Checked Out" : filter.toLowerCase()}
|
| 818 |
+
</button>
|
| 819 |
+
))}
|
| 820 |
+
</div>
|
| 821 |
+
</div>
|
| 822 |
+
</div>
|
| 823 |
+
|
| 824 |
+
{/* Employee Presence Grid */}
|
| 825 |
+
{loadingEmployees || loadingDaily ? (
|
| 826 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
| 827 |
+
{Array.from({ length: 4 }).map((_, i) => (
|
| 828 |
+
<div key={i} className="skeleton h-24 w-full rounded-xl" />
|
| 829 |
+
))}
|
| 830 |
+
</div>
|
| 831 |
+
) : (
|
| 832 |
+
<>
|
| 833 |
+
{filteredTeammates.length === 0 ? (
|
| 834 |
+
<div className="text-center py-10 border border-dashed border-zinc-100 rounded-xl text-zinc-400 text-xs">
|
| 835 |
+
No teammates match the active filters or search terms.
|
| 836 |
+
</div>
|
| 837 |
+
) : (
|
| 838 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
| 839 |
+
{filteredTeammates.map((member: any) => {
|
| 840 |
+
const att = member.todayAttendance;
|
| 841 |
+
const isAbsent = !att || att.status === "Absent";
|
| 842 |
+
const isCheckedOut = att && att.check_out;
|
| 843 |
+
const isInOffice = att && !isCheckedOut && ["Present", "Late", "Half Day"].includes(att.status);
|
| 844 |
+
|
| 845 |
+
let statusText = "Absent";
|
| 846 |
+
let badgeColor = "bg-zinc-100 text-zinc-500 border-zinc-200/50";
|
| 847 |
+
|
| 848 |
+
if (isInOffice) {
|
| 849 |
+
const checkInTime = parseDateTime(att.check_in)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) || "";
|
| 850 |
+
statusText = `In at ${checkInTime}`;
|
| 851 |
+
badgeColor = att.status === "Late"
|
| 852 |
+
? "bg-amber-50 text-amber-700 border-amber-100"
|
| 853 |
+
: "bg-emerald-50 text-emerald-700 border-emerald-100";
|
| 854 |
+
} else if (isCheckedOut) {
|
| 855 |
+
const checkOutTime = parseDateTime(att.check_out)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) || "";
|
| 856 |
+
statusText = `Out at ${checkOutTime}`;
|
| 857 |
+
badgeColor = "bg-slate-50 text-slate-655 border-slate-100";
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
return (
|
| 861 |
+
<div
|
| 862 |
+
key={member.id}
|
| 863 |
+
className={`p-3.5 rounded-xl border transition-all duration-200 flex flex-col justify-between h-[125px] ${
|
| 864 |
+
isAbsent
|
| 865 |
+
? "bg-zinc-50/30 border-zinc-100 hover:border-zinc-200"
|
| 866 |
+
: "bg-white border-zinc-100 hover:border-zinc-250 shadow-xs"
|
| 867 |
+
}`}
|
| 868 |
+
>
|
| 869 |
+
{/* Member Row */}
|
| 870 |
+
<div className="flex gap-2.5 items-start">
|
| 871 |
+
<div className="relative shrink-0">
|
| 872 |
+
<TeammateAvatar emp={member} size="sm" />
|
| 873 |
+
<span className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border border-white flex items-center justify-center shadow-xs ${
|
| 874 |
+
isInOffice ? "bg-emerald-500" : isCheckedOut ? "bg-slate-400" : "bg-rose-450"
|
| 875 |
+
}`} />
|
| 876 |
+
</div>
|
| 877 |
+
<div className="min-w-0 flex-1">
|
| 878 |
+
<h3 className="text-xs font-bold text-slate-800 truncate" title={member.name}>
|
| 879 |
+
{member.name}
|
| 880 |
+
</h3>
|
| 881 |
+
<p className="text-[9.5px] text-slate-400 truncate mt-0.5">
|
| 882 |
+
{member.designation}
|
| 883 |
+
</p>
|
| 884 |
+
{member.department && (
|
| 885 |
+
<span className="inline-block mt-1 text-[8px] font-bold font-mono px-1 rounded bg-zinc-50 text-zinc-500 border border-zinc-100/60 uppercase">
|
| 886 |
+
{member.department.code}
|
| 887 |
+
</span>
|
| 888 |
+
)}
|
| 889 |
+
</div>
|
| 890 |
+
</div>
|
| 891 |
+
|
| 892 |
+
{/* Status + Action buttons */}
|
| 893 |
+
<div className="flex items-center justify-between mt-2 pt-1.5 border-t border-zinc-50">
|
| 894 |
+
<span className={`inline-block text-[9px] font-bold font-mono px-1.5 py-0.5 rounded border ${badgeColor}`}>
|
| 895 |
+
{statusText}
|
| 896 |
+
</span>
|
| 897 |
+
|
| 898 |
+
<div className="flex items-center gap-1.5">
|
| 899 |
+
{isAbsent && (
|
| 900 |
+
<button
|
| 901 |
+
onClick={() => openClockIn(member)}
|
| 902 |
+
disabled={manualIsLoading}
|
| 903 |
+
className="px-2 py-1 text-[9px] font-bold font-mono bg-zinc-950 hover:bg-zinc-800 text-white rounded-lg transition-colors cursor-pointer"
|
| 904 |
+
>
|
| 905 |
+
Clock In
|
| 906 |
+
</button>
|
| 907 |
+
)}
|
| 908 |
+
{isInOffice && (
|
| 909 |
+
<button
|
| 910 |
+
onClick={() => handleClockOut(member)}
|
| 911 |
+
disabled={manualIsLoading}
|
| 912 |
+
className="px-2 py-1 text-[9px] font-bold font-mono border border-zinc-200 hover:border-zinc-400 text-slate-650 hover:text-slate-900 rounded-lg transition-colors cursor-pointer"
|
| 913 |
+
>
|
| 914 |
+
Clock Out
|
| 915 |
+
</button>
|
| 916 |
+
)}
|
| 917 |
+
{(isCheckedOut || !isAbsent) && (
|
| 918 |
+
<button
|
| 919 |
+
onClick={() => openClockIn(member)}
|
| 920 |
+
disabled={manualIsLoading}
|
| 921 |
+
className="p-1 border border-zinc-100 hover:border-zinc-200 hover:bg-zinc-50 text-slate-400 hover:text-slate-700 rounded-lg transition-colors cursor-pointer"
|
| 922 |
+
title="Edit log"
|
| 923 |
+
>
|
| 924 |
+
<Activity className="w-3 h-3" />
|
| 925 |
+
</button>
|
| 926 |
+
)}
|
| 927 |
+
</div>
|
| 928 |
+
</div>
|
| 929 |
+
</div>
|
| 930 |
+
);
|
| 931 |
+
})}
|
| 932 |
+
</div>
|
| 933 |
+
)}
|
| 934 |
+
</>
|
| 935 |
+
)}
|
| 936 |
+
</div>
|
| 937 |
+
|
| 938 |
+
{/* ─── Attendance Heatmap ─── */}
|
| 939 |
+
<AttendanceHeatmap />
|
| 940 |
+
|
| 941 |
{/* ─── Bottom Section: Department Breakdown ─── */}
|
| 942 |
<div className="bg-white border border-zinc-100 rounded-xl p-5">
|
| 943 |
<div className="flex items-center justify-between mb-4">
|
|
|
|
| 1090 |
</div>
|
| 1091 |
</div>
|
| 1092 |
)}
|
| 1093 |
+
|
| 1094 |
+
{/* ─── Manual Clock-in Modal ─── */}
|
| 1095 |
+
{showManualModal && selectedEmpManual && (
|
| 1096 |
+
<div className="modal-backdrop z-50">
|
| 1097 |
+
<div className="modal-content max-w-sm bg-white border border-zinc-200 text-zinc-900 shadow-2xl">
|
| 1098 |
+
<div className="flex items-center justify-between mb-4 pb-3 border-b border-zinc-100">
|
| 1099 |
+
<h3 className="text-sm font-bold text-zinc-900">
|
| 1100 |
+
Manual Clock In / Override
|
| 1101 |
+
</h3>
|
| 1102 |
+
<button
|
| 1103 |
+
onClick={() => {
|
| 1104 |
+
setShowManualModal(false);
|
| 1105 |
+
setSelectedEmpManual(null);
|
| 1106 |
+
}}
|
| 1107 |
+
className="p-1.5 rounded-lg hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
|
| 1108 |
+
>
|
| 1109 |
+
<X className="w-4 h-4" />
|
| 1110 |
+
</button>
|
| 1111 |
+
</div>
|
| 1112 |
+
|
| 1113 |
+
<form onSubmit={handleClockInSubmit} className="space-y-4">
|
| 1114 |
+
<div>
|
| 1115 |
+
<p className="text-xs text-zinc-500 font-medium">Employee</p>
|
| 1116 |
+
<div className="flex items-center gap-2 mt-1.5 p-2 bg-zinc-50 border border-zinc-100 rounded-lg">
|
| 1117 |
+
<TeammateAvatar emp={selectedEmpManual} size="sm" />
|
| 1118 |
+
<div className="min-w-0">
|
| 1119 |
+
<p className="text-xs font-bold text-slate-800 truncate">{selectedEmpManual.name}</p>
|
| 1120 |
+
<p className="text-[10px] text-slate-450 truncate">{selectedEmpManual.designation}</p>
|
| 1121 |
+
</div>
|
| 1122 |
+
</div>
|
| 1123 |
+
</div>
|
| 1124 |
+
|
| 1125 |
+
<div>
|
| 1126 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
| 1127 |
+
Check-in Time (HH:MM)
|
| 1128 |
+
</label>
|
| 1129 |
+
<input
|
| 1130 |
+
type="time"
|
| 1131 |
+
required
|
| 1132 |
+
value={manualTime}
|
| 1133 |
+
onChange={(e) => setManualTime(e.target.value)}
|
| 1134 |
+
className="input-field h-9.5 text-xs bg-white border-zinc-200 focus:border-zinc-800 text-zinc-900 rounded-xl transition-all w-full"
|
| 1135 |
+
/>
|
| 1136 |
+
</div>
|
| 1137 |
+
|
| 1138 |
+
<div>
|
| 1139 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
| 1140 |
+
Attendance Status
|
| 1141 |
+
</label>
|
| 1142 |
+
<div className="grid grid-cols-3 gap-2">
|
| 1143 |
+
{(["Present", "Late", "Half Day"] as const).map((status) => (
|
| 1144 |
+
<button
|
| 1145 |
+
key={status}
|
| 1146 |
+
type="button"
|
| 1147 |
+
onClick={() => setManualStatus(status)}
|
| 1148 |
+
className={`py-1.5 text-[10.5px] font-semibold border rounded-lg transition-all cursor-pointer ${
|
| 1149 |
+
manualStatus === status
|
| 1150 |
+
? "bg-zinc-950 border-zinc-950 text-white"
|
| 1151 |
+
: "bg-white border-zinc-200 text-zinc-600 hover:bg-zinc-50"
|
| 1152 |
+
}`}
|
| 1153 |
+
>
|
| 1154 |
+
{status}
|
| 1155 |
+
</button>
|
| 1156 |
+
))}
|
| 1157 |
+
</div>
|
| 1158 |
+
</div>
|
| 1159 |
+
|
| 1160 |
+
<div className="pt-2 flex items-center justify-end gap-2.5">
|
| 1161 |
+
<button
|
| 1162 |
+
type="button"
|
| 1163 |
+
onClick={() => {
|
| 1164 |
+
setShowManualModal(false);
|
| 1165 |
+
setSelectedEmpManual(null);
|
| 1166 |
+
}}
|
| 1167 |
+
className="btn-ghost text-xs px-3.5 py-2 rounded-xl cursor-pointer hover:bg-zinc-100 text-zinc-500"
|
| 1168 |
+
>
|
| 1169 |
+
Cancel
|
| 1170 |
+
</button>
|
| 1171 |
+
<button
|
| 1172 |
+
type="submit"
|
| 1173 |
+
disabled={manualIsLoading}
|
| 1174 |
+
className="px-4 py-2 text-xs bg-zinc-950 hover:bg-zinc-850 text-white font-bold rounded-xl transition-colors cursor-pointer flex items-center gap-1.5"
|
| 1175 |
+
>
|
| 1176 |
+
{manualIsLoading && <span className="animate-spin text-white">⌛</span>}
|
| 1177 |
+
Confirm
|
| 1178 |
+
</button>
|
| 1179 |
+
</div>
|
| 1180 |
+
</form>
|
| 1181 |
+
</div>
|
| 1182 |
+
</div>
|
| 1183 |
+
)}
|
| 1184 |
</SidebarLayout>
|
| 1185 |
);
|
| 1186 |
}
|
frontend/app/employees/[id]/page.tsx
ADDED
|
@@ -0,0 +1,1355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useMemo } from "react";
|
| 4 |
+
import { useParams, useRouter } from "next/navigation";
|
| 5 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 6 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 7 |
+
import { fetchApi, getBackendUrl, parseDateTime, getAccessToken } from "@/app/utils/api";
|
| 8 |
+
import { useToast } from "@/app/utils/toast";
|
| 9 |
+
import {
|
| 10 |
+
ChevronLeft, Printer, Download, Mail, Phone, Calendar,
|
| 11 |
+
Briefcase, CheckCircle2, AlertCircle, Palette, Upload, Trash2,
|
| 12 |
+
Camera, Shield, Award, Clock, Edit, X, Loader2
|
| 13 |
+
} from "lucide-react";
|
| 14 |
+
import AttendanceHeatmap from "@/components/AttendanceHeatmap";
|
| 15 |
+
|
| 16 |
+
// Theme styles configuration mapping
|
| 17 |
+
const themeStylesMap = {
|
| 18 |
+
Saffron: {
|
| 19 |
+
headerBg: "bg-gradient-to-tr from-amber-600 via-orange-500 to-red-600",
|
| 20 |
+
accentText: "text-orange-600 dark:text-orange-400",
|
| 21 |
+
accentBorder: "border-orange-500",
|
| 22 |
+
accentBg: "bg-orange-50/40 dark:bg-orange-950/20",
|
| 23 |
+
photoBorder: "from-amber-500 to-red-500",
|
| 24 |
+
dotColor: "bg-orange-500",
|
| 25 |
+
primaryHex: "#f97316",
|
| 26 |
+
headerHex1: "#d97706",
|
| 27 |
+
headerHex2: "#dc2626"
|
| 28 |
+
},
|
| 29 |
+
Emerald: {
|
| 30 |
+
headerBg: "bg-gradient-to-tr from-slate-900 via-emerald-950 to-teal-900",
|
| 31 |
+
accentText: "text-emerald-600 dark:text-emerald-400",
|
| 32 |
+
accentBorder: "border-emerald-500",
|
| 33 |
+
accentBg: "bg-emerald-50/40 dark:bg-emerald-950/20",
|
| 34 |
+
photoBorder: "from-emerald-500 to-teal-400",
|
| 35 |
+
dotColor: "bg-emerald-500",
|
| 36 |
+
primaryHex: "#10b981",
|
| 37 |
+
headerHex1: "#064e3b",
|
| 38 |
+
headerHex2: "#0f766e"
|
| 39 |
+
},
|
| 40 |
+
Charcoal: {
|
| 41 |
+
headerBg: "bg-gradient-to-tr from-zinc-900 via-slate-800 to-zinc-950",
|
| 42 |
+
accentText: "text-zinc-600 dark:text-zinc-400",
|
| 43 |
+
accentBorder: "border-zinc-500",
|
| 44 |
+
accentBg: "bg-zinc-50/40 dark:bg-zinc-900/20",
|
| 45 |
+
photoBorder: "from-zinc-500 to-slate-400",
|
| 46 |
+
dotColor: "bg-zinc-500",
|
| 47 |
+
primaryHex: "#6b7280",
|
| 48 |
+
headerHex1: "#18181b",
|
| 49 |
+
headerHex2: "#27272a"
|
| 50 |
+
},
|
| 51 |
+
"Navy Blue": {
|
| 52 |
+
headerBg: "bg-gradient-to-tr from-slate-900 via-blue-900 to-indigo-950",
|
| 53 |
+
accentText: "text-cyan-500 dark:text-cyan-400",
|
| 54 |
+
accentBorder: "border-cyan-500",
|
| 55 |
+
accentBg: "bg-slate-50/40 dark:bg-slate-900/20",
|
| 56 |
+
photoBorder: "from-cyan-500 to-emerald-400",
|
| 57 |
+
dotColor: "bg-cyan-500",
|
| 58 |
+
primaryHex: "#06b6d4",
|
| 59 |
+
headerHex1: "#0f172a",
|
| 60 |
+
headerHex2: "#1e3a8a"
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
export default function EmployeeDetailPage() {
|
| 65 |
+
const params = useParams();
|
| 66 |
+
const router = useRouter();
|
| 67 |
+
const queryClient = useQueryClient();
|
| 68 |
+
const employeeId = params.id;
|
| 69 |
+
const { toast } = useToast();
|
| 70 |
+
const [logoUploading, setLogoUploading] = useState(false);
|
| 71 |
+
const [photoTimestamp, setPhotoTimestamp] = useState(Date.now());
|
| 72 |
+
const [photoUploading, setPhotoUploading] = useState(false);
|
| 73 |
+
const [updatingWfh, setUpdatingWfh] = useState(false);
|
| 74 |
+
|
| 75 |
+
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 76 |
+
const file = e.target.files?.[0];
|
| 77 |
+
if (!file) return;
|
| 78 |
+
|
| 79 |
+
if (file.size > 2 * 1024 * 1024) {
|
| 80 |
+
toast.error("Image file must be under 2MB");
|
| 81 |
+
return;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
setPhotoUploading(true);
|
| 85 |
+
const formData = new FormData();
|
| 86 |
+
formData.append("employee_id", String(employee.id));
|
| 87 |
+
formData.append("pose_type", "front");
|
| 88 |
+
formData.append("file", file);
|
| 89 |
+
|
| 90 |
+
try {
|
| 91 |
+
const token = getAccessToken();
|
| 92 |
+
const res = await fetch(`${getBackendUrl()}/enrollment/upload`, {
|
| 93 |
+
method: "POST",
|
| 94 |
+
headers: {
|
| 95 |
+
"Authorization": `Bearer ${token}`
|
| 96 |
+
},
|
| 97 |
+
body: formData
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
const data = await res.json();
|
| 101 |
+
if (!res.ok) {
|
| 102 |
+
throw new Error(data.detail || "Failed to upload photo");
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
toast.success("Profile photo updated successfully!");
|
| 106 |
+
setPhotoTimestamp(Date.now());
|
| 107 |
+
queryClient.invalidateQueries({ queryKey: ["employee", employeeId] });
|
| 108 |
+
} catch (err: any) {
|
| 109 |
+
console.error(err);
|
| 110 |
+
toast.error(err.message || "Error uploading profile photo. Make sure a clear face is visible.");
|
| 111 |
+
} finally {
|
| 112 |
+
setPhotoUploading(false);
|
| 113 |
+
}
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
// Queries
|
| 117 |
+
const { data: employee, isLoading: loadingEmployee } = useQuery({
|
| 118 |
+
queryKey: ["employee", employeeId],
|
| 119 |
+
queryFn: () => fetchApi(`/employees/${employeeId}`)
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
const { data: attendance, isLoading: loadingAttendance } = useQuery({
|
| 123 |
+
queryKey: ["employee-attendance", employeeId],
|
| 124 |
+
queryFn: () => fetchApi(`/attendance/employee/${employeeId}`),
|
| 125 |
+
enabled: !!employeeId
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
const { data: settings } = useQuery({
|
| 129 |
+
queryKey: ["settings"],
|
| 130 |
+
queryFn: () => fetchApi("/settings/")
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
const { data: enrollmentStatus } = useQuery({
|
| 134 |
+
queryKey: ["enroll-status", employeeId],
|
| 135 |
+
queryFn: () => fetchApi(`/enrollment/status/${employeeId}`),
|
| 136 |
+
enabled: !!employeeId
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
// Settings parsing map
|
| 140 |
+
const settingsMap = useMemo(() => {
|
| 141 |
+
const map: Record<string, string> = {};
|
| 142 |
+
if (settings) {
|
| 143 |
+
settings.forEach((s: any) => {
|
| 144 |
+
map[s.key] = s.value;
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
return map;
|
| 148 |
+
}, [settings]);
|
| 149 |
+
|
| 150 |
+
const companyName = settingsMap["COMPANY_NAME"] || "NetraID Enterprise";
|
| 151 |
+
const companyLogo = settingsMap["COMPANY_LOGO"] || "";
|
| 152 |
+
const badgeTheme = settingsMap["BADGE_THEME_COLOR"] || "Navy Blue";
|
| 153 |
+
const badgePattern = settingsMap["BADGE_PATTERN_TYPE"] || "Indian Mandala";
|
| 154 |
+
|
| 155 |
+
const themeStyles = themeStylesMap[badgeTheme as keyof typeof themeStylesMap] || themeStylesMap["Navy Blue"];
|
| 156 |
+
|
| 157 |
+
// Save Settings Mutation
|
| 158 |
+
const saveSettingMutation = useMutation({
|
| 159 |
+
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
| 160 |
+
fetchApi(`/settings/${key}`, { method: "PUT", body: JSON.stringify({ value }) }),
|
| 161 |
+
onSuccess: () => {
|
| 162 |
+
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
| 163 |
+
toast.success("ID Card configuration updated!");
|
| 164 |
+
},
|
| 165 |
+
onError: (err: any) => {
|
| 166 |
+
toast.error(err.message || "Failed to update configuration.");
|
| 167 |
+
}
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
const toggleWfhMutation = useMutation({
|
| 171 |
+
mutationFn: (allow_wfh: boolean) =>
|
| 172 |
+
fetchApi(`/employees/${employeeId}`, {
|
| 173 |
+
method: "PUT",
|
| 174 |
+
body: JSON.stringify({ allow_wfh })
|
| 175 |
+
}),
|
| 176 |
+
onSuccess: (data: any) => {
|
| 177 |
+
queryClient.invalidateQueries({ queryKey: ["employee", employeeId] });
|
| 178 |
+
toast.success(data.allow_wfh ? "WFH permission granted!" : "WFH permission revoked.");
|
| 179 |
+
},
|
| 180 |
+
onError: (err: any) => {
|
| 181 |
+
toast.error(err.message || "Failed to update WFH permission.");
|
| 182 |
+
},
|
| 183 |
+
onSettled: () => {
|
| 184 |
+
setUpdatingWfh(false);
|
| 185 |
+
}
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
const handleToggleWfh = () => {
|
| 189 |
+
setUpdatingWfh(true);
|
| 190 |
+
toggleWfhMutation.mutate(!employee.allow_wfh);
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
// Departments query for dropdown list
|
| 194 |
+
const { data: departments } = useQuery({
|
| 195 |
+
queryKey: ["departments"],
|
| 196 |
+
queryFn: () => fetchApi("/departments/")
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
// Edit employee details states
|
| 200 |
+
const [showEditDialog, setShowEditDialog] = useState(false);
|
| 201 |
+
const [editName, setEditName] = useState("");
|
| 202 |
+
const [editEmail, setEditEmail] = useState("");
|
| 203 |
+
const [editPhone, setEditPhone] = useState("");
|
| 204 |
+
const [editDesignation, setEditDesignation] = useState("");
|
| 205 |
+
const [editJoiningDate, setEditJoiningDate] = useState("");
|
| 206 |
+
const [editStatus, setEditStatus] = useState("Active");
|
| 207 |
+
const [editDeptId, setEditDeptId] = useState("");
|
| 208 |
+
|
| 209 |
+
const handleOpenEditDialog = () => {
|
| 210 |
+
if (!employee) return;
|
| 211 |
+
setEditName(employee.name || "");
|
| 212 |
+
setEditEmail(employee.email || "");
|
| 213 |
+
setEditPhone(employee.phone || "");
|
| 214 |
+
setEditDesignation(employee.designation || "");
|
| 215 |
+
setEditJoiningDate(employee.joining_date || "");
|
| 216 |
+
setEditStatus(employee.status || "Active");
|
| 217 |
+
setEditDeptId(employee.department_id ? employee.department_id.toString() : "");
|
| 218 |
+
setShowEditDialog(true);
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
const updateEmployeeMutation = useMutation({
|
| 222 |
+
mutationFn: (payload: any) =>
|
| 223 |
+
fetchApi(`/employees/${employeeId}`, {
|
| 224 |
+
method: "PUT",
|
| 225 |
+
body: JSON.stringify(payload)
|
| 226 |
+
}),
|
| 227 |
+
onSuccess: () => {
|
| 228 |
+
queryClient.invalidateQueries({ queryKey: ["employee", employeeId] });
|
| 229 |
+
toast.success("Employee profile updated successfully!");
|
| 230 |
+
setShowEditDialog(false);
|
| 231 |
+
},
|
| 232 |
+
onError: (err: any) => {
|
| 233 |
+
toast.error(err.message || "Failed to update employee details.");
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
const handleEditSubmit = (e: React.FormEvent) => {
|
| 238 |
+
e.preventDefault();
|
| 239 |
+
const payload = {
|
| 240 |
+
name: editName,
|
| 241 |
+
email: editEmail,
|
| 242 |
+
phone: editPhone || null,
|
| 243 |
+
designation: editDesignation || null,
|
| 244 |
+
joining_date: editJoiningDate,
|
| 245 |
+
status: editStatus,
|
| 246 |
+
department_id: editDeptId ? parseInt(editDeptId) : null
|
| 247 |
+
};
|
| 248 |
+
updateEmployeeMutation.mutate(payload);
|
| 249 |
+
};
|
| 250 |
+
|
| 251 |
+
const handleSettingChange = (key: string, value: string) => {
|
| 252 |
+
saveSettingMutation.mutate({ key, value });
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
// SVG Badge Watermark helper component
|
| 256 |
+
const BadgeWatermark = ({ type }: { type: string }) => {
|
| 257 |
+
switch (type) {
|
| 258 |
+
case "Indian Mandala":
|
| 259 |
+
return (
|
| 260 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-[0.06] text-slate-800 dark:text-slate-100 z-0 overflow-hidden badge-watermark-container">
|
| 261 |
+
<svg className="w-[110%] h-[110%]" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.4">
|
| 262 |
+
<circle cx="50" cy="50" r="42" strokeDasharray="1 1.5" />
|
| 263 |
+
<circle cx="50" cy="50" r="35" />
|
| 264 |
+
<circle cx="50" cy="50" r="28" strokeDasharray="0.5 1" />
|
| 265 |
+
<circle cx="50" cy="50" r="21" />
|
| 266 |
+
<circle cx="50" cy="50" r="14" strokeDasharray="1 1" />
|
| 267 |
+
<circle cx="50" cy="50" r="7" />
|
| 268 |
+
{Array.from({ length: 24 }).map((_, i) => {
|
| 269 |
+
const angle = (i * 15 * Math.PI) / 180;
|
| 270 |
+
const x1 = 50 + 7 * Math.cos(angle);
|
| 271 |
+
const y1 = 50 + 7 * Math.sin(angle);
|
| 272 |
+
const x2 = 50 + 35 * Math.cos(angle);
|
| 273 |
+
const y2 = 50 + 35 * Math.sin(angle);
|
| 274 |
+
const cx1 = 50 + 20 * Math.cos(angle - 0.08);
|
| 275 |
+
const cy1 = 50 + 20 * Math.sin(angle - 0.08);
|
| 276 |
+
return (
|
| 277 |
+
<g key={i}>
|
| 278 |
+
<line x1={x1} y1={y1} x2={x2} y2={y2} />
|
| 279 |
+
<path d={`M ${x1} ${y1} Q ${cx1} ${cy1} ${x2} ${y2}`} strokeWidth="0.25" />
|
| 280 |
+
</g>
|
| 281 |
+
);
|
| 282 |
+
})}
|
| 283 |
+
</svg>
|
| 284 |
+
</div>
|
| 285 |
+
);
|
| 286 |
+
case "Corporate Waves":
|
| 287 |
+
return (
|
| 288 |
+
<div className="absolute inset-0 pointer-events-none opacity-[0.05] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
|
| 289 |
+
<svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
|
| 290 |
+
<path d="M-20,40 C20,20 40,60 60,40 C80,20 100,60 120,40" />
|
| 291 |
+
<path d="M-20,50 C20,30 40,70 60,50 C80,30 100,70 120,50" strokeDasharray="1 1" />
|
| 292 |
+
<path d="M-20,60 C20,40 40,80 60,60 C80,40 100,80 120,60" />
|
| 293 |
+
<path d="M-20,70 C20,50 40,90 60,70 C80,50 100,90 120,70" strokeDasharray="0.5 1" />
|
| 294 |
+
</svg>
|
| 295 |
+
</div>
|
| 296 |
+
);
|
| 297 |
+
case "Cyber Grid":
|
| 298 |
+
return (
|
| 299 |
+
<div className="absolute inset-0 pointer-events-none opacity-[0.03] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
|
| 300 |
+
<svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
|
| 301 |
+
{Array.from({ length: 11 }).map((_, i) => (
|
| 302 |
+
<g key={i}>
|
| 303 |
+
<line x1="0" y1={i * 10} x2="100" y2={i * 10} />
|
| 304 |
+
<line x1={i * 10} y1="0" x2={i * 10} y2="100" />
|
| 305 |
+
</g>
|
| 306 |
+
))}
|
| 307 |
+
</svg>
|
| 308 |
+
</div>
|
| 309 |
+
);
|
| 310 |
+
case "None":
|
| 311 |
+
default:
|
| 312 |
+
return null;
|
| 313 |
+
}
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
// High-Res Canvas Badge Generator
|
| 317 |
+
const handleDownloadBadge = async () => {
|
| 318 |
+
if (!employee) return;
|
| 319 |
+
try {
|
| 320 |
+
const canvas = document.createElement("canvas");
|
| 321 |
+
const ctx = canvas.getContext("2d");
|
| 322 |
+
if (!ctx) return;
|
| 323 |
+
|
| 324 |
+
canvas.width = 600;
|
| 325 |
+
canvas.height = 900;
|
| 326 |
+
|
| 327 |
+
// 1. Background round rect
|
| 328 |
+
ctx.fillStyle = "#ffffff";
|
| 329 |
+
ctx.beginPath();
|
| 330 |
+
if (typeof ctx.roundRect === "function") {
|
| 331 |
+
ctx.roundRect(0, 0, 600, 900, 30);
|
| 332 |
+
} else {
|
| 333 |
+
const x = 0, y = 0, width = 600, height = 900, radius = 30;
|
| 334 |
+
ctx.moveTo(x + radius, y);
|
| 335 |
+
ctx.lineTo(x + width - radius, y);
|
| 336 |
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
| 337 |
+
ctx.lineTo(x + width, y + height - radius);
|
| 338 |
+
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
| 339 |
+
ctx.lineTo(x + radius, y + height);
|
| 340 |
+
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
| 341 |
+
ctx.lineTo(x, y + radius);
|
| 342 |
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
| 343 |
+
}
|
| 344 |
+
ctx.fill();
|
| 345 |
+
ctx.strokeStyle = "#cbd5e1";
|
| 346 |
+
ctx.lineWidth = 4;
|
| 347 |
+
ctx.stroke();
|
| 348 |
+
|
| 349 |
+
// 2. Draw watermark patterns on Canvas
|
| 350 |
+
const cx = 300;
|
| 351 |
+
const cy = 450;
|
| 352 |
+
if (badgePattern === "Indian Mandala") {
|
| 353 |
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.08)";
|
| 354 |
+
ctx.lineWidth = 1.5;
|
| 355 |
+
ctx.beginPath(); ctx.arc(cx, cy, 252, 0, Math.PI * 2); ctx.stroke();
|
| 356 |
+
ctx.beginPath(); ctx.arc(cx, cy, 210, 0, Math.PI * 2); ctx.stroke();
|
| 357 |
+
ctx.beginPath(); ctx.arc(cx, cy, 168, 0, Math.PI * 2); ctx.stroke();
|
| 358 |
+
ctx.beginPath(); ctx.arc(cx, cy, 126, 0, Math.PI * 2); ctx.stroke();
|
| 359 |
+
ctx.beginPath(); ctx.arc(cx, cy, 84, 0, Math.PI * 2); ctx.stroke();
|
| 360 |
+
ctx.beginPath(); ctx.arc(cx, cy, 42, 0, Math.PI * 2); ctx.stroke();
|
| 361 |
+
for (let i = 0; i < 24; i++) {
|
| 362 |
+
const angle = (i * 15 * Math.PI) / 180;
|
| 363 |
+
ctx.beginPath();
|
| 364 |
+
ctx.moveTo(cx + 42 * Math.cos(angle), cy + 42 * Math.sin(angle));
|
| 365 |
+
ctx.lineTo(cx + 210 * Math.cos(angle), cy + 210 * Math.sin(angle));
|
| 366 |
+
ctx.stroke();
|
| 367 |
+
}
|
| 368 |
+
} else if (badgePattern === "Corporate Waves") {
|
| 369 |
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.06)";
|
| 370 |
+
ctx.lineWidth = 2.5;
|
| 371 |
+
ctx.beginPath();
|
| 372 |
+
ctx.moveTo(-50, 420);
|
| 373 |
+
ctx.bezierCurveTo(150, 220, 350, 620, 650, 420);
|
| 374 |
+
ctx.stroke();
|
| 375 |
+
ctx.beginPath();
|
| 376 |
+
ctx.moveTo(-50, 500);
|
| 377 |
+
ctx.bezierCurveTo(150, 300, 350, 700, 650, 500);
|
| 378 |
+
ctx.stroke();
|
| 379 |
+
} else if (badgePattern === "Cyber Grid") {
|
| 380 |
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.04)";
|
| 381 |
+
ctx.lineWidth = 1;
|
| 382 |
+
for (let i = 0; i <= 900; i += 60) {
|
| 383 |
+
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(600, i); ctx.stroke();
|
| 384 |
+
}
|
| 385 |
+
for (let i = 0; i <= 600; i += 60) {
|
| 386 |
+
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, 900); ctx.stroke();
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
// 3. Draw gradient header
|
| 391 |
+
const gradient = ctx.createLinearGradient(0, 0, 600, 0);
|
| 392 |
+
gradient.addColorStop(0, themeStyles.headerHex1);
|
| 393 |
+
gradient.addColorStop(1, themeStyles.headerHex2);
|
| 394 |
+
ctx.fillStyle = gradient;
|
| 395 |
+
ctx.beginPath();
|
| 396 |
+
if (typeof ctx.roundRect === "function") {
|
| 397 |
+
ctx.roundRect(0, 0, 600, 200, [30, 30, 0, 0]);
|
| 398 |
+
} else {
|
| 399 |
+
const x = 0, y = 0, width = 600, height = 200, radius = 30;
|
| 400 |
+
ctx.moveTo(x + radius, y);
|
| 401 |
+
ctx.lineTo(x + width - radius, y);
|
| 402 |
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
| 403 |
+
ctx.lineTo(x + width, y + height);
|
| 404 |
+
ctx.lineTo(x, y + height);
|
| 405 |
+
ctx.lineTo(x, y + radius);
|
| 406 |
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
| 407 |
+
}
|
| 408 |
+
ctx.fill();
|
| 409 |
+
|
| 410 |
+
// Lanyard punch hole
|
| 411 |
+
ctx.fillStyle = "#f1f5f9";
|
| 412 |
+
ctx.beginPath();
|
| 413 |
+
if (typeof ctx.roundRect === "function") {
|
| 414 |
+
ctx.roundRect(260, 20, 80, 20, 10);
|
| 415 |
+
} else {
|
| 416 |
+
ctx.rect(260, 20, 80, 20);
|
| 417 |
+
}
|
| 418 |
+
ctx.fill();
|
| 419 |
+
ctx.fillStyle = "#0f172a";
|
| 420 |
+
ctx.beginPath();
|
| 421 |
+
if (typeof ctx.roundRect === "function") {
|
| 422 |
+
ctx.roundRect(270, 25, 60, 10, 5);
|
| 423 |
+
} else {
|
| 424 |
+
ctx.rect(270, 25, 60, 10);
|
| 425 |
+
}
|
| 426 |
+
ctx.fill();
|
| 427 |
+
|
| 428 |
+
// Image loader
|
| 429 |
+
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
| 430 |
+
return new Promise((resolve, reject) => {
|
| 431 |
+
const img = new Image();
|
| 432 |
+
img.crossOrigin = "anonymous";
|
| 433 |
+
img.onload = () => resolve(img);
|
| 434 |
+
img.onerror = () => reject(new Error("Failed to load: " + src));
|
| 435 |
+
img.src = src;
|
| 436 |
+
});
|
| 437 |
+
};
|
| 438 |
+
|
| 439 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
| 440 |
+
const photoSrc = `${baseUrl}/uploads/${employee.employee_id}/front.jpg?t=${photoTimestamp}`;
|
| 441 |
+
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${employee.employee_id}`;
|
| 442 |
+
|
| 443 |
+
let logoImg: HTMLImageElement | null = null;
|
| 444 |
+
if (companyLogo) {
|
| 445 |
+
try {
|
| 446 |
+
logoImg = await loadImage(companyLogo);
|
| 447 |
+
} catch {}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
let photoImg: HTMLImageElement | null = null;
|
| 451 |
+
try {
|
| 452 |
+
photoImg = await loadImage(photoSrc);
|
| 453 |
+
} catch {}
|
| 454 |
+
|
| 455 |
+
let qrImg: HTMLImageElement | null = null;
|
| 456 |
+
try {
|
| 457 |
+
qrImg = await loadImage(qrSrc);
|
| 458 |
+
} catch {}
|
| 459 |
+
|
| 460 |
+
// Logo rendering
|
| 461 |
+
if (logoImg) {
|
| 462 |
+
const logoAspectRatio = logoImg.width / logoImg.height;
|
| 463 |
+
const logoHeight = 45;
|
| 464 |
+
const logoWidth = logoHeight * logoAspectRatio;
|
| 465 |
+
ctx.drawImage(logoImg, 50, 75, logoWidth, logoHeight);
|
| 466 |
+
ctx.fillStyle = "#ffffff";
|
| 467 |
+
ctx.font = "bold 24px sans-serif";
|
| 468 |
+
ctx.textAlign = "left";
|
| 469 |
+
ctx.fillText(companyName, 65 + logoWidth, 106);
|
| 470 |
+
} else {
|
| 471 |
+
ctx.fillStyle = themeStyles.primaryHex;
|
| 472 |
+
ctx.font = "black 28px sans-serif";
|
| 473 |
+
ctx.textAlign = "center";
|
| 474 |
+
ctx.fillText("NETRAID", 300, 100);
|
| 475 |
+
ctx.fillStyle = "#ffffff";
|
| 476 |
+
ctx.font = "bold 16px sans-serif";
|
| 477 |
+
ctx.fillText(companyName.toUpperCase(), 300, 130);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// Profile avatar container
|
| 481 |
+
const photoX = 200;
|
| 482 |
+
const photoY = 220;
|
| 483 |
+
const photoSize = 200;
|
| 484 |
+
|
| 485 |
+
ctx.strokeStyle = themeStyles.primaryHex;
|
| 486 |
+
ctx.lineWidth = 6;
|
| 487 |
+
ctx.beginPath();
|
| 488 |
+
ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2 + 6, 0, Math.PI * 2);
|
| 489 |
+
ctx.stroke();
|
| 490 |
+
|
| 491 |
+
ctx.save();
|
| 492 |
+
ctx.beginPath();
|
| 493 |
+
ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2, 0, Math.PI * 2);
|
| 494 |
+
ctx.clip();
|
| 495 |
+
if (photoImg) {
|
| 496 |
+
ctx.drawImage(photoImg, photoX, photoY, photoSize, photoSize);
|
| 497 |
+
} else {
|
| 498 |
+
ctx.fillStyle = "#f1f5f9";
|
| 499 |
+
ctx.fillRect(photoX, photoY, photoSize, photoSize);
|
| 500 |
+
ctx.fillStyle = "#94a3b8";
|
| 501 |
+
ctx.font = "bold 80px sans-serif";
|
| 502 |
+
ctx.textAlign = "center";
|
| 503 |
+
ctx.textBaseline = "middle";
|
| 504 |
+
ctx.fillText("?", photoX + photoSize / 2, photoY + photoSize / 2);
|
| 505 |
+
}
|
| 506 |
+
ctx.restore();
|
| 507 |
+
|
| 508 |
+
// Holographic official seal
|
| 509 |
+
ctx.save();
|
| 510 |
+
const sealX = photoX + photoSize - 35;
|
| 511 |
+
const sealY = photoY + photoSize - 35;
|
| 512 |
+
const sealSize = 45;
|
| 513 |
+
const sealGrad = ctx.createLinearGradient(sealX, sealY, sealX + sealSize, sealY + sealSize);
|
| 514 |
+
sealGrad.addColorStop(0, "#fbbf24");
|
| 515 |
+
sealGrad.addColorStop(0.5, "#fb923c");
|
| 516 |
+
sealGrad.addColorStop(1, "#fde047");
|
| 517 |
+
ctx.fillStyle = sealGrad;
|
| 518 |
+
ctx.beginPath();
|
| 519 |
+
ctx.arc(sealX + sealSize / 2, sealY + sealSize / 2, sealSize / 2, 0, Math.PI * 2);
|
| 520 |
+
ctx.fill();
|
| 521 |
+
ctx.strokeStyle = "#ffffff";
|
| 522 |
+
ctx.lineWidth = 2.5;
|
| 523 |
+
ctx.stroke();
|
| 524 |
+
ctx.strokeStyle = "#451a03";
|
| 525 |
+
ctx.lineWidth = 3.5;
|
| 526 |
+
ctx.beginPath();
|
| 527 |
+
ctx.moveTo(sealX + 13, sealY + 22);
|
| 528 |
+
ctx.lineTo(sealX + 20, sealY + 29);
|
| 529 |
+
ctx.lineTo(sealX + 32, sealY + 16);
|
| 530 |
+
ctx.stroke();
|
| 531 |
+
ctx.restore();
|
| 532 |
+
|
| 533 |
+
// Text details
|
| 534 |
+
ctx.fillStyle = "#0f172a";
|
| 535 |
+
ctx.font = "bold 32px sans-serif";
|
| 536 |
+
ctx.textAlign = "center";
|
| 537 |
+
ctx.fillText(employee.name.toUpperCase(), 300, 480);
|
| 538 |
+
|
| 539 |
+
ctx.fillStyle = themeStyles.primaryHex;
|
| 540 |
+
ctx.font = "bold 20px sans-serif";
|
| 541 |
+
ctx.fillText(employee.designation?.toUpperCase() || "STAFF MEMBER", 300, 515);
|
| 542 |
+
|
| 543 |
+
ctx.strokeStyle = "#f1f5f9";
|
| 544 |
+
ctx.lineWidth = 2;
|
| 545 |
+
ctx.beginPath(); ctx.moveTo(100, 545); ctx.lineTo(500, 545); ctx.stroke();
|
| 546 |
+
|
| 547 |
+
// Metadata labels
|
| 548 |
+
ctx.textAlign = "left";
|
| 549 |
+
ctx.fillStyle = "#64748b";
|
| 550 |
+
ctx.font = "bold 14px sans-serif";
|
| 551 |
+
ctx.fillText("EMPLOYEE ID", 100, 580);
|
| 552 |
+
ctx.fillText("DEPARTMENT", 320, 580);
|
| 553 |
+
ctx.fillText("DATE OF JOIN", 100, 640);
|
| 554 |
+
ctx.fillText("STATUS", 320, 640);
|
| 555 |
+
|
| 556 |
+
// Metadata values
|
| 557 |
+
ctx.fillStyle = "#0f172a";
|
| 558 |
+
ctx.font = "bold 18px sans-serif";
|
| 559 |
+
ctx.fillText(employee.employee_id, 100, 605);
|
| 560 |
+
ctx.fillText(employee.department?.name?.toUpperCase() || "GENERAL", 320, 605);
|
| 561 |
+
|
| 562 |
+
const joinDate = employee.joining_date ? new Date(employee.joining_date).toLocaleDateString("en-US", {
|
| 563 |
+
year: "numeric", month: "short", day: "numeric"
|
| 564 |
+
}) : "N/A";
|
| 565 |
+
ctx.fillText(joinDate.toUpperCase(), 100, 665);
|
| 566 |
+
ctx.fillStyle = themeStyles.primaryHex;
|
| 567 |
+
ctx.fillText("VERIFIED", 320, 665);
|
| 568 |
+
|
| 569 |
+
// QR Code
|
| 570 |
+
const qrSize = 130;
|
| 571 |
+
const qrX = 235;
|
| 572 |
+
const qrY = 710;
|
| 573 |
+
|
| 574 |
+
ctx.fillStyle = "#f8fafc";
|
| 575 |
+
ctx.beginPath();
|
| 576 |
+
if (typeof ctx.roundRect === "function") {
|
| 577 |
+
ctx.roundRect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30, 15);
|
| 578 |
+
} else {
|
| 579 |
+
ctx.rect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30);
|
| 580 |
+
}
|
| 581 |
+
ctx.fill();
|
| 582 |
+
ctx.strokeStyle = "#e2e8f0";
|
| 583 |
+
ctx.lineWidth = 2;
|
| 584 |
+
ctx.stroke();
|
| 585 |
+
|
| 586 |
+
if (qrImg) {
|
| 587 |
+
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
| 588 |
+
} else {
|
| 589 |
+
ctx.strokeStyle = "#cbd5e1";
|
| 590 |
+
ctx.strokeRect(qrX, qrY, qrSize, qrSize);
|
| 591 |
+
ctx.fillStyle = "#94a3b8";
|
| 592 |
+
ctx.font = "12px sans-serif";
|
| 593 |
+
ctx.textAlign = "center";
|
| 594 |
+
ctx.fillText("QR CODE", qrX + qrSize / 2, qrY + qrSize / 2);
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
ctx.fillStyle = "#64748b";
|
| 598 |
+
ctx.font = "bold 12px sans-serif";
|
| 599 |
+
ctx.textAlign = "center";
|
| 600 |
+
ctx.fillText("SCAN AS BACKUP IF KIOSK FACE RECOGNITION FAILS", 300, 875);
|
| 601 |
+
|
| 602 |
+
// Trigger download
|
| 603 |
+
const dataUrl = canvas.toDataURL("image/png");
|
| 604 |
+
const link = document.createElement("a");
|
| 605 |
+
link.download = `ID_Card_${employee.employee_id}.png`;
|
| 606 |
+
link.href = dataUrl;
|
| 607 |
+
link.click();
|
| 608 |
+
toast.success("Badge PNG downloaded successfully!");
|
| 609 |
+
} catch (err: any) {
|
| 610 |
+
console.error(err);
|
| 611 |
+
toast.error("Failed to generate download: " + err.message);
|
| 612 |
+
}
|
| 613 |
+
};
|
| 614 |
+
|
| 615 |
+
if (loadingEmployee || loadingAttendance) {
|
| 616 |
+
return (
|
| 617 |
+
<SidebarLayout>
|
| 618 |
+
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
|
| 619 |
+
<div className="skeleton h-10 w-48" />
|
| 620 |
+
<div className="skeleton h-64 w-full max-w-4xl" />
|
| 621 |
+
</div>
|
| 622 |
+
</SidebarLayout>
|
| 623 |
+
);
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
if (!employee) {
|
| 627 |
+
return (
|
| 628 |
+
<SidebarLayout>
|
| 629 |
+
<div className="text-center py-20 bg-white border border-slate-200 rounded-3xl p-8 max-w-md mx-auto space-y-4">
|
| 630 |
+
<AlertCircle className="w-12 h-12 text-rose-500 mx-auto" />
|
| 631 |
+
<h2 className="text-lg font-bold text-slate-900">Personnel Record Not Found</h2>
|
| 632 |
+
<p className="text-xs text-slate-500 leading-normal">The requested employee registration could not be located in the central secure database.</p>
|
| 633 |
+
<button onClick={() => router.push("/employees")} className="btn-primary text-xs uppercase py-2.5 px-5 rounded-xl">Back to Employees</button>
|
| 634 |
+
</div>
|
| 635 |
+
</SidebarLayout>
|
| 636 |
+
);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
// Attendance stats counts
|
| 640 |
+
const totalDays = attendance?.length || 0;
|
| 641 |
+
const presentDays = attendance?.filter((r: any) => r.status === "Present").length || 0;
|
| 642 |
+
const lateDays = attendance?.filter((r: any) => r.status === "Late").length || 0;
|
| 643 |
+
const halfDays = attendance?.filter((r: any) => r.status === "Half Day").length || 0;
|
| 644 |
+
const attendanceScore = totalDays > 0 ? Math.round(((presentDays + lateDays + halfDays * 0.5) / totalDays) * 100) : 0;
|
| 645 |
+
|
| 646 |
+
return (
|
| 647 |
+
<SidebarLayout>
|
| 648 |
+
<div className="space-y-6 max-w-7xl page-enter relative text-slate-800 print-reset-container">
|
| 649 |
+
|
| 650 |
+
{/* Breadcrumb Header */}
|
| 651 |
+
<div className="flex items-center justify-between pb-5 border-b border-slate-250/60 no-print">
|
| 652 |
+
<div className="space-y-1.5">
|
| 653 |
+
<button
|
| 654 |
+
onClick={() => router.push("/employees")}
|
| 655 |
+
className="flex items-center gap-1.5 text-slate-500 hover:text-slate-900 transition-colors text-[11px] font-semibold uppercase tracking-wider"
|
| 656 |
+
>
|
| 657 |
+
<ChevronLeft className="w-3.5 h-3.5" />
|
| 658 |
+
Back to Employees
|
| 659 |
+
</button>
|
| 660 |
+
<div className="flex items-center gap-3">
|
| 661 |
+
<h1 className="text-xl font-black text-slate-900 tracking-tight leading-none">Employee Profile</h1>
|
| 662 |
+
<span className={`badge ${employee.status === "Active" ? "badge-emerald" : "badge-slate"} flex items-center gap-1`}>
|
| 663 |
+
{employee.status === "Active" && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
| 664 |
+
{employee.status}
|
| 665 |
+
</span>
|
| 666 |
+
</div>
|
| 667 |
+
</div>
|
| 668 |
+
|
| 669 |
+
<div className="flex items-center gap-2">
|
| 670 |
+
<button
|
| 671 |
+
onClick={handleOpenEditDialog}
|
| 672 |
+
className="flex items-center gap-2 text-[11.5px] font-bold text-slate-700 hover:text-slate-900 bg-white hover:bg-slate-50 border border-slate-200 px-4 py-2.5 rounded-xl transition-all cursor-pointer shadow-sm"
|
| 673 |
+
>
|
| 674 |
+
<Edit className="w-3.5 h-3.5 text-slate-500" />
|
| 675 |
+
Edit Details
|
| 676 |
+
</button>
|
| 677 |
+
<button
|
| 678 |
+
onClick={() => router.push(`/enroll/${employee.id}`)}
|
| 679 |
+
className="flex items-center gap-2 text-[11.5px] font-bold text-blue-600 hover:text-white bg-white hover:bg-blue-600 border border-blue-200 hover:border-blue-600 px-4 py-2.5 rounded-xl transition-all cursor-pointer shadow-sm"
|
| 680 |
+
>
|
| 681 |
+
<Camera className="w-3.5 h-3.5" />
|
| 682 |
+
Biometric Enrollment
|
| 683 |
+
</button>
|
| 684 |
+
</div>
|
| 685 |
+
</div>
|
| 686 |
+
|
| 687 |
+
{/* ─── Profile Content Grid ─── */}
|
| 688 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start print-reset-container">
|
| 689 |
+
|
| 690 |
+
{/* LEFT AREA: Employee Details & Ledger Table (lg:col-span-8) */}
|
| 691 |
+
<div className="lg:col-span-7 space-y-6 no-print">
|
| 692 |
+
|
| 693 |
+
{/* Profile Hero Card */}
|
| 694 |
+
<div className="bg-white border border-slate-200 rounded-3xl overflow-hidden shadow-xs relative">
|
| 695 |
+
{/* Cover Gradient Banner */}
|
| 696 |
+
<div className={`h-24 ${themeStyles.headerBg} relative`}>
|
| 697 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.08),transparent_80%)]" />
|
| 698 |
+
</div>
|
| 699 |
+
|
| 700 |
+
{/* Avatar & Key details */}
|
| 701 |
+
<div className="px-6 pb-6 relative">
|
| 702 |
+
<div className="flex flex-col sm:flex-row sm:items-end gap-4 -mt-10 mb-4">
|
| 703 |
+
{/* Profile photo */}
|
| 704 |
+
<label className={`relative w-20 h-20 rounded-2xl p-0.5 bg-gradient-to-tr ${themeStyles.photoBorder} shadow-md shrink-0 cursor-pointer group`}>
|
| 705 |
+
<input
|
| 706 |
+
type="file"
|
| 707 |
+
accept="image/*"
|
| 708 |
+
className="hidden"
|
| 709 |
+
onChange={handlePhotoUpload}
|
| 710 |
+
disabled={photoUploading}
|
| 711 |
+
/>
|
| 712 |
+
<div className="w-full h-full rounded-2xl overflow-hidden border-2 border-white bg-slate-100 relative">
|
| 713 |
+
<img
|
| 714 |
+
src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${employee.employee_id}/front.jpg?t=${photoTimestamp}`}
|
| 715 |
+
alt={employee.name}
|
| 716 |
+
onError={(e) => {
|
| 717 |
+
(e.target as HTMLImageElement).src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%2394a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
| 718 |
+
}}
|
| 719 |
+
className="w-full h-full object-cover"
|
| 720 |
+
/>
|
| 721 |
+
|
| 722 |
+
{/* Hover Overlay */}
|
| 723 |
+
<div className="absolute inset-0 bg-black/60 flex flex-col items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
| 724 |
+
<Camera className="w-4 h-4 text-white" />
|
| 725 |
+
<span className="text-[7px] font-bold mt-0.5 uppercase tracking-wider text-white">Edit Photo</span>
|
| 726 |
+
</div>
|
| 727 |
+
|
| 728 |
+
{/* Loading Spinner */}
|
| 729 |
+
{photoUploading && (
|
| 730 |
+
<div className="absolute inset-0 bg-black/75 flex items-center justify-center text-white">
|
| 731 |
+
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 732 |
+
</div>
|
| 733 |
+
)}
|
| 734 |
+
</div>
|
| 735 |
+
</label>
|
| 736 |
+
|
| 737 |
+
<div className="space-y-1 min-w-0">
|
| 738 |
+
<h2 className="text-lg font-black text-slate-900 dark:text-white leading-none truncate">{employee.name}</h2>
|
| 739 |
+
<p className="text-xs font-semibold text-slate-550 font-mono flex items-center gap-1.5">
|
| 740 |
+
<span className="bg-slate-100 dark:bg-white/[0.03] px-1.5 py-0.5 rounded border border-slate-200 dark:border-white/5 dark:text-slate-350">{employee.employee_id}</span>
|
| 741 |
+
<span>·</span>
|
| 742 |
+
<span className="text-slate-700 dark:text-slate-300">{employee.designation || "Staff Member"}</span>
|
| 743 |
+
</p>
|
| 744 |
+
<p className="text-[10px] text-slate-400 dark:text-slate-450 font-bold uppercase tracking-wider">
|
| 745 |
+
{employee.department?.name || "General Department"}
|
| 746 |
+
</p>
|
| 747 |
+
</div>
|
| 748 |
+
</div>
|
| 749 |
+
|
| 750 |
+
{/* Data fields grid */}
|
| 751 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-[11.5px] border-t border-slate-100 dark:border-white/5 pt-4">
|
| 752 |
+
<div className="flex items-center gap-2 bg-slate-50/50 dark:bg-white/[0.03] p-3 rounded-xl border border-slate-150 dark:border-white/5">
|
| 753 |
+
<Mail className="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" />
|
| 754 |
+
<span className="truncate font-medium text-slate-700 dark:text-slate-300" title={employee.email}>{employee.email}</span>
|
| 755 |
+
</div>
|
| 756 |
+
<div className="flex items-center gap-2 bg-slate-50/50 dark:bg-white/[0.03] p-3 rounded-xl border border-slate-150 dark:border-white/5">
|
| 757 |
+
<Phone className="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" />
|
| 758 |
+
<span className="font-medium text-slate-700 dark:text-slate-300">{employee.phone || "No mobile registered"}</span>
|
| 759 |
+
</div>
|
| 760 |
+
<div className="flex items-center gap-2 bg-slate-50/50 dark:bg-white/[0.03] p-3 rounded-xl border border-slate-150 dark:border-white/5">
|
| 761 |
+
<Calendar className="w-4 h-4 text-slate-400 dark:text-slate-500 shrink-0" />
|
| 762 |
+
<span className="font-medium text-slate-700 dark:text-slate-300">Joined: <span className="font-mono">{employee.joining_date}</span></span>
|
| 763 |
+
</div>
|
| 764 |
+
</div>
|
| 765 |
+
|
| 766 |
+
{/* WFH Permission Toggle */}
|
| 767 |
+
<div className="flex items-center justify-between border-t border-slate-100 dark:border-white/5 pt-4 mt-4 text-xs">
|
| 768 |
+
<div className="space-y-0.5">
|
| 769 |
+
<span className="font-bold text-slate-550 dark:text-slate-400 uppercase tracking-wider text-[9.5px]">Work From Home (WFH) Permission</span>
|
| 770 |
+
<p className="text-[10px] text-slate-450 dark:text-slate-500 font-semibold leading-none">Bypasses geofencing restrictions for this employee</p>
|
| 771 |
+
</div>
|
| 772 |
+
<button
|
| 773 |
+
type="button"
|
| 774 |
+
disabled={updatingWfh}
|
| 775 |
+
onClick={handleToggleWfh}
|
| 776 |
+
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
| 777 |
+
employee.allow_wfh ? "bg-emerald-500" : "bg-slate-200 dark:bg-slate-800"
|
| 778 |
+
} ${updatingWfh ? "opacity-60 cursor-not-allowed" : ""}`}
|
| 779 |
+
>
|
| 780 |
+
<span
|
| 781 |
+
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-xs transition duration-200 ease-in-out ${
|
| 782 |
+
employee.allow_wfh ? "translate-x-4" : "translate-x-0"
|
| 783 |
+
}`}
|
| 784 |
+
/>
|
| 785 |
+
</button>
|
| 786 |
+
</div>
|
| 787 |
+
|
| 788 |
+
{/* Attendance Rate Progress Block */}
|
| 789 |
+
<div className="border-t border-slate-100 pt-4 mt-4">
|
| 790 |
+
<div className="flex justify-between items-center text-xs mb-1.5">
|
| 791 |
+
<span className="font-bold text-slate-500 uppercase tracking-wider text-[9.5px]">Attendance Score</span>
|
| 792 |
+
<span className={`font-black ${themeStyles.accentText} font-mono`}>
|
| 793 |
+
{attendanceScore}%
|
| 794 |
+
</span>
|
| 795 |
+
</div>
|
| 796 |
+
<div className="h-2 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
| 797 |
+
<div
|
| 798 |
+
className={`h-full bg-gradient-to-r ${themeStyles.photoBorder}`}
|
| 799 |
+
style={{ width: `${attendanceScore}%` }}
|
| 800 |
+
/>
|
| 801 |
+
</div>
|
| 802 |
+
<p className="text-[10px] text-slate-450 mt-1 font-semibold">
|
| 803 |
+
{totalDays === 0
|
| 804 |
+
? "No attendance records registered yet."
|
| 805 |
+
: attendanceScore >= 90
|
| 806 |
+
? "Exemplary consistency - Security clearance status is active."
|
| 807 |
+
: attendanceScore >= 75
|
| 808 |
+
? "Standard compliance - Maintained within acceptable limits."
|
| 809 |
+
: "Requires review - Attendance score falls below benchmark."}
|
| 810 |
+
</p>
|
| 811 |
+
</div>
|
| 812 |
+
</div>
|
| 813 |
+
</div>
|
| 814 |
+
|
| 815 |
+
{/* Attendance Stats Row */}
|
| 816 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
| 817 |
+
<div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-slate-400">
|
| 818 |
+
<span className="text-[9.5px] text-slate-400 font-bold uppercase tracking-wider">Logged Days</span>
|
| 819 |
+
<span className="text-xl font-bold text-slate-900 leading-none mt-1.5 font-mono">{totalDays}</span>
|
| 820 |
+
</div>
|
| 821 |
+
<div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-emerald-500">
|
| 822 |
+
<span className="text-[9.5px] text-emerald-600 font-bold uppercase tracking-wider">Present</span>
|
| 823 |
+
<span className="text-xl font-bold text-emerald-600 leading-none mt-1.5 font-mono">{presentDays}</span>
|
| 824 |
+
</div>
|
| 825 |
+
<div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-amber-500">
|
| 826 |
+
<span className="text-[9.5px] text-amber-600 font-bold uppercase tracking-wider">Late Arrivals</span>
|
| 827 |
+
<span className="text-xl font-bold text-amber-600 leading-none mt-1.5 font-mono">{lateDays}</span>
|
| 828 |
+
</div>
|
| 829 |
+
<div className="p-3.5 rounded-2xl border border-slate-200 bg-white flex flex-col justify-between h-[75px] shadow-xs hover:border-slate-350 transition-all duration-200 border-l-4 border-l-indigo-650">
|
| 830 |
+
<span className="text-[9.5px] text-indigo-650 font-bold uppercase tracking-wider">Half Days</span>
|
| 831 |
+
<span className="text-xl font-bold text-indigo-650 leading-none mt-1.5 font-mono">{halfDays}</span>
|
| 832 |
+
</div>
|
| 833 |
+
</div>
|
| 834 |
+
|
| 835 |
+
{/* Attendance History Ledger */}
|
| 836 |
+
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-4">
|
| 837 |
+
<div className="flex items-center justify-between pb-2">
|
| 838 |
+
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest font-mono">Attendance Ledger (Past 30 Days)</h3>
|
| 839 |
+
</div>
|
| 840 |
+
<div className="overflow-x-auto border border-slate-200 rounded-2xl">
|
| 841 |
+
<table className="w-full text-left border-collapse text-[11.5px]">
|
| 842 |
+
<thead>
|
| 843 |
+
<tr className="border-b border-slate-200 bg-slate-50 text-slate-500 uppercase tracking-wider font-mono">
|
| 844 |
+
<th className="py-2.5 px-4 font-semibold">Date</th>
|
| 845 |
+
<th className="py-2.5 px-4 font-semibold">Check In</th>
|
| 846 |
+
<th className="py-2.5 px-4 font-semibold">Check Out</th>
|
| 847 |
+
<th className="py-2.5 px-4 font-semibold">Hours Worked</th>
|
| 848 |
+
<th className="py-2.5 px-4 font-semibold text-center">Status</th>
|
| 849 |
+
</tr>
|
| 850 |
+
</thead>
|
| 851 |
+
<tbody className="divide-y divide-slate-100 text-slate-700">
|
| 852 |
+
{!attendance || attendance.length === 0 ? (
|
| 853 |
+
<tr>
|
| 854 |
+
<td colSpan={5} className="py-12 text-center text-slate-400 italic">No attendance records stored for this user.</td>
|
| 855 |
+
</tr>
|
| 856 |
+
) : (
|
| 857 |
+
attendance.map((rec: any) => (
|
| 858 |
+
<tr key={rec.id} className="hover:bg-slate-50/40 transition-colors">
|
| 859 |
+
<td className="py-2.5 px-4 font-mono text-slate-550">{rec.date}</td>
|
| 860 |
+
<td className="py-2.5 px-4 font-mono text-slate-800">
|
| 861 |
+
{rec.check_in ? parseDateTime(rec.check_in)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"}) : "—"}
|
| 862 |
+
</td>
|
| 863 |
+
<td className="py-2.5 px-4 font-mono text-slate-800">
|
| 864 |
+
{rec.check_out ? parseDateTime(rec.check_out)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"}) : "—"}
|
| 865 |
+
</td>
|
| 866 |
+
<td className="py-2.5 px-4 font-mono text-slate-650">
|
| 867 |
+
{rec.working_hours ? `${rec.working_hours.toFixed(1)} hrs` : "—"}
|
| 868 |
+
</td>
|
| 869 |
+
<td className="py-2.5 px-4 text-center">
|
| 870 |
+
<span className={`inline-block text-[8.5px] font-semibold px-2 py-0.5 rounded-full border ${
|
| 871 |
+
rec.status === "Present" ? "bg-emerald-50 border-emerald-250 text-emerald-700" :
|
| 872 |
+
rec.status === "Late" ? "bg-amber-50 border-amber-250 text-amber-700" :
|
| 873 |
+
rec.status === "Half Day" ? "bg-indigo-50 border-indigo-250 text-indigo-750" :
|
| 874 |
+
"bg-rose-50 border-rose-250 text-rose-700"
|
| 875 |
+
}`}>
|
| 876 |
+
{rec.status}
|
| 877 |
+
</span>
|
| 878 |
+
</td>
|
| 879 |
+
</tr>
|
| 880 |
+
))
|
| 881 |
+
)}
|
| 882 |
+
</tbody>
|
| 883 |
+
</table>
|
| 884 |
+
</div>
|
| 885 |
+
</div>
|
| 886 |
+
|
| 887 |
+
{/* Attendance Heatmap Grid */}
|
| 888 |
+
<AttendanceHeatmap employeeId={employee.id} />
|
| 889 |
+
</div>
|
| 890 |
+
|
| 891 |
+
{/* RIGHT AREA: Badge Live Preview & Customizer (lg:col-span-4) */}
|
| 892 |
+
<div className="lg:col-span-5 space-y-6 print-reset-container">
|
| 893 |
+
|
| 894 |
+
{/* ID Badge Live Preview Card */}
|
| 895 |
+
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col items-center print-reset-container">
|
| 896 |
+
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mb-4 font-mono no-print">
|
| 897 |
+
Badge Live Preview
|
| 898 |
+
</div>
|
| 899 |
+
|
| 900 |
+
{/* Printable Badge Container */}
|
| 901 |
+
<div
|
| 902 |
+
id="printable-id-card-wrap"
|
| 903 |
+
className="w-[280px] h-[438px] bg-white rounded-[24px] border border-slate-200 shadow-md overflow-hidden relative flex flex-col select-none font-sans"
|
| 904 |
+
>
|
| 905 |
+
{/* Watermark Pattern */}
|
| 906 |
+
<BadgeWatermark type={badgePattern} />
|
| 907 |
+
|
| 908 |
+
{/* Lanyard punch hole detail */}
|
| 909 |
+
<div className="absolute top-3.5 left-1/2 -translate-x-1/2 w-10 h-3 bg-slate-100 rounded-full border border-slate-200/50 flex items-center justify-center pointer-events-none no-print">
|
| 910 |
+
<div className="w-6 h-1 bg-slate-300 rounded-full" />
|
| 911 |
+
</div>
|
| 912 |
+
|
| 913 |
+
{/* Header: Company Name & Logo */}
|
| 914 |
+
<div className={`h-[90px] ${themeStyles.headerBg} relative flex flex-col justify-end px-4.5 pb-2.5 shrink-0`}>
|
| 915 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.1),transparent_70%)] pointer-events-none" />
|
| 916 |
+
|
| 917 |
+
<div className="flex items-center gap-2.5 mt-2 relative z-10">
|
| 918 |
+
{companyLogo ? (
|
| 919 |
+
<img
|
| 920 |
+
src={companyLogo}
|
| 921 |
+
alt="Logo"
|
| 922 |
+
className="h-7 max-w-[80px] object-contain shrink-0"
|
| 923 |
+
/>
|
| 924 |
+
) : (
|
| 925 |
+
<div className="w-6 h-6 rounded-lg bg-white/20 backdrop-blur-xs flex items-center justify-center text-white text-[9px] font-black tracking-tighter shadow-sm font-mono shrink-0 border border-white/10">
|
| 926 |
+
NID
|
| 927 |
+
</div>
|
| 928 |
+
)}
|
| 929 |
+
<div className="flex flex-col min-w-0">
|
| 930 |
+
<span className="text-[10.5px] font-black tracking-wider text-white uppercase truncate">
|
| 931 |
+
{companyName}
|
| 932 |
+
</span>
|
| 933 |
+
<span className="text-[7px] font-bold text-white/80 tracking-widest uppercase">
|
| 934 |
+
SECURED IDENTITY CARD
|
| 935 |
+
</span>
|
| 936 |
+
</div>
|
| 937 |
+
</div>
|
| 938 |
+
</div>
|
| 939 |
+
|
| 940 |
+
{/* Body Content */}
|
| 941 |
+
<div className="flex-1 flex flex-col items-center pt-5 px-5 relative bg-transparent z-10">
|
| 942 |
+
{/* Photo container */}
|
| 943 |
+
<div className={`relative w-[100px] h-[100px] rounded-full p-1 bg-gradient-to-tr ${themeStyles.photoBorder} shadow-sm shrink-0`}>
|
| 944 |
+
<div className="w-full h-full rounded-full overflow-hidden border-2 border-white bg-slate-100">
|
| 945 |
+
<img
|
| 946 |
+
src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${employee.employee_id}/front.jpg?t=${photoTimestamp}`}
|
| 947 |
+
alt={employee.name}
|
| 948 |
+
onError={(e) => {
|
| 949 |
+
(e.target as HTMLImageElement).src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%2394a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
| 950 |
+
}}
|
| 951 |
+
className="w-full h-full object-cover"
|
| 952 |
+
/>
|
| 953 |
+
</div>
|
| 954 |
+
{/* Official checkmark seal */}
|
| 955 |
+
<div className="absolute -bottom-1 -right-1 bg-gradient-to-tr from-amber-400 via-orange-400 to-yellow-300 text-amber-950 font-bold border border-white rounded-full w-5 h-5 flex items-center justify-center shadow-md z-10 pointer-events-none">
|
| 956 |
+
<svg className="w-3 h-3 stroke-amber-950" viewBox="0 0 24 24" fill="none" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
| 957 |
+
<polyline points="20 6 9 17 4 12" />
|
| 958 |
+
</svg>
|
| 959 |
+
</div>
|
| 960 |
+
</div>
|
| 961 |
+
|
| 962 |
+
{/* Name and title details */}
|
| 963 |
+
<div className="text-center mt-3 space-y-0.5">
|
| 964 |
+
<h3 className="text-[13.5px] font-extrabold text-slate-900 tracking-tight uppercase leading-tight">
|
| 965 |
+
{employee.name}
|
| 966 |
+
</h3>
|
| 967 |
+
<p className={`text-[9.5px] font-bold ${themeStyles.accentText} tracking-widest uppercase`}>
|
| 968 |
+
{employee.designation || "Staff Member"}
|
| 969 |
+
</p>
|
| 970 |
+
</div>
|
| 971 |
+
|
| 972 |
+
{/* Info table */}
|
| 973 |
+
<div className="grid grid-cols-2 gap-x-3 gap-y-2 w-full border-t border-slate-100 mt-4 pt-3 bg-transparent">
|
| 974 |
+
<div>
|
| 975 |
+
<span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 976 |
+
Employee ID
|
| 977 |
+
</span>
|
| 978 |
+
<span className="text-[9px] font-extrabold text-slate-800 tracking-tight block">
|
| 979 |
+
{employee.employee_id}
|
| 980 |
+
</span>
|
| 981 |
+
</div>
|
| 982 |
+
<div>
|
| 983 |
+
<span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 984 |
+
Department
|
| 985 |
+
</span>
|
| 986 |
+
<span className="text-[9px] font-extrabold text-slate-800 tracking-tight block truncate uppercase">
|
| 987 |
+
{employee.department?.name || "General"}
|
| 988 |
+
</span>
|
| 989 |
+
</div>
|
| 990 |
+
<div>
|
| 991 |
+
<span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 992 |
+
Date of Join
|
| 993 |
+
</span>
|
| 994 |
+
<span className="text-[9px] font-extrabold text-slate-800 tracking-tight block">
|
| 995 |
+
{employee.joining_date}
|
| 996 |
+
</span>
|
| 997 |
+
</div>
|
| 998 |
+
<div>
|
| 999 |
+
<span className="text-[7px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 1000 |
+
Security Status
|
| 1001 |
+
</span>
|
| 1002 |
+
<span className={`text-[9px] font-bold ${themeStyles.accentText} tracking-tight flex items-center gap-1`}>
|
| 1003 |
+
<span className={`w-1.2 h-1.2 rounded-full ${themeStyles.dotColor}`} />
|
| 1004 |
+
VERIFIED
|
| 1005 |
+
</span>
|
| 1006 |
+
</div>
|
| 1007 |
+
</div>
|
| 1008 |
+
</div>
|
| 1009 |
+
|
| 1010 |
+
{/* Footer QR fallback */}
|
| 1011 |
+
<div className="bg-slate-50/80 border-t border-slate-100 h-[100px] flex items-center justify-between px-5 pb-2 shrink-0 z-10">
|
| 1012 |
+
<div className="flex flex-col min-w-0 pr-1.5">
|
| 1013 |
+
<span className="text-[7.5px] font-black text-slate-900 tracking-wider uppercase font-mono">
|
| 1014 |
+
SCAN TO VERIFY
|
| 1015 |
+
</span>
|
| 1016 |
+
<p className="text-[6.5px] text-slate-450 font-medium leading-snug mt-0.5 max-w-[115px] font-mono">
|
| 1017 |
+
Scan this backup barcode QR badge if Kiosk face matching fails.
|
| 1018 |
+
</p>
|
| 1019 |
+
</div>
|
| 1020 |
+
<div className="w-[64px] h-[64px] bg-white rounded-lg border border-slate-200/80 p-1 flex items-center justify-center shadow-2xs shrink-0">
|
| 1021 |
+
<img
|
| 1022 |
+
src={`https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=${employee.employee_id}`}
|
| 1023 |
+
alt="QR"
|
| 1024 |
+
className="w-full h-full object-contain"
|
| 1025 |
+
/>
|
| 1026 |
+
</div>
|
| 1027 |
+
</div>
|
| 1028 |
+
</div>
|
| 1029 |
+
|
| 1030 |
+
{/* Actions print/download */}
|
| 1031 |
+
<div className="flex gap-3.5 w-full max-w-[280px] pt-5 no-print">
|
| 1032 |
+
<button
|
| 1033 |
+
onClick={() => window.print()}
|
| 1034 |
+
className={`flex-1 h-10 ${themeStyles.headerBg} hover:opacity-90 text-white font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg active:scale-[0.98] border border-white/10 cursor-pointer`}
|
| 1035 |
+
>
|
| 1036 |
+
<Printer className="w-3.5 h-3.5 text-white/90" />
|
| 1037 |
+
Print ID
|
| 1038 |
+
</button>
|
| 1039 |
+
<button
|
| 1040 |
+
onClick={handleDownloadBadge}
|
| 1041 |
+
className="flex-1 h-10 bg-white hover:bg-slate-50 border border-slate-200 text-slate-800 font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm hover:shadow-md active:scale-[0.98] cursor-pointer"
|
| 1042 |
+
>
|
| 1043 |
+
<Download className="w-3.5 h-3.5 text-slate-650" />
|
| 1044 |
+
Download
|
| 1045 |
+
</button>
|
| 1046 |
+
</div>
|
| 1047 |
+
</div>
|
| 1048 |
+
|
| 1049 |
+
{/* ID Badge Design Editor Card */}
|
| 1050 |
+
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-4.5 no-print">
|
| 1051 |
+
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest font-mono">ID Badge Customizer</h3>
|
| 1052 |
+
|
| 1053 |
+
{/* Company Name */}
|
| 1054 |
+
<div className="space-y-1.5">
|
| 1055 |
+
<label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Company Name</label>
|
| 1056 |
+
<input
|
| 1057 |
+
type="text"
|
| 1058 |
+
value={companyName}
|
| 1059 |
+
onChange={(e) => handleSettingChange("COMPANY_NAME", e.target.value)}
|
| 1060 |
+
placeholder="Enter organization name"
|
| 1061 |
+
className="input-field h-9 text-[12.5px] bg-white border-slate-200 rounded-xl px-3 focus:border-slate-800 transition-all font-semibold"
|
| 1062 |
+
/>
|
| 1063 |
+
</div>
|
| 1064 |
+
|
| 1065 |
+
{/* Company Logo Upload */}
|
| 1066 |
+
<div className="space-y-1.5">
|
| 1067 |
+
<label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Branding Logo</label>
|
| 1068 |
+
{companyLogo ? (
|
| 1069 |
+
<div className="relative w-full h-20 border border-slate-200 rounded-xl overflow-hidden bg-slate-50 flex items-center justify-center p-2 group">
|
| 1070 |
+
<img src={companyLogo} alt="Logo preview" className="max-w-full max-h-full object-contain" />
|
| 1071 |
+
<button
|
| 1072 |
+
type="button"
|
| 1073 |
+
onClick={() => handleSettingChange("COMPANY_LOGO", "")}
|
| 1074 |
+
className="absolute inset-0 bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-bold gap-1 cursor-pointer"
|
| 1075 |
+
>
|
| 1076 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 1077 |
+
Remove Logo
|
| 1078 |
+
</button>
|
| 1079 |
+
</div>
|
| 1080 |
+
) : (
|
| 1081 |
+
<label className="w-full h-20 border border-dashed border-slate-350 hover:border-slate-450 rounded-xl flex flex-col items-center justify-center gap-1 cursor-pointer bg-slate-50 hover:bg-slate-100/50 transition-all">
|
| 1082 |
+
<Upload className="w-4 h-4 text-slate-450" />
|
| 1083 |
+
<span className="text-[10px] text-slate-500 font-semibold">Upload Logo (Max 500KB)</span>
|
| 1084 |
+
<input
|
| 1085 |
+
type="file"
|
| 1086 |
+
accept="image/*"
|
| 1087 |
+
className="hidden"
|
| 1088 |
+
disabled={logoUploading}
|
| 1089 |
+
onChange={(e) => {
|
| 1090 |
+
const file = e.target.files?.[0];
|
| 1091 |
+
if (!file) return;
|
| 1092 |
+
if (file.size > 512 * 1025) {
|
| 1093 |
+
toast.error("Logo must be under 500KB");
|
| 1094 |
+
return;
|
| 1095 |
+
}
|
| 1096 |
+
setLogoUploading(true);
|
| 1097 |
+
const reader = new FileReader();
|
| 1098 |
+
reader.onloadend = () => {
|
| 1099 |
+
handleSettingChange("COMPANY_LOGO", reader.result as string);
|
| 1100 |
+
setLogoUploading(false);
|
| 1101 |
+
};
|
| 1102 |
+
reader.readAsDataURL(file);
|
| 1103 |
+
}}
|
| 1104 |
+
/>
|
| 1105 |
+
</label>
|
| 1106 |
+
)}
|
| 1107 |
+
</div>
|
| 1108 |
+
|
| 1109 |
+
{/* Theme Color Swatches */}
|
| 1110 |
+
<div className="space-y-1.5">
|
| 1111 |
+
<label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Badge Color Theme</label>
|
| 1112 |
+
<div className="grid grid-cols-2 gap-2">
|
| 1113 |
+
{Object.keys(themeStylesMap).map((themeName) => {
|
| 1114 |
+
const styles = themeStylesMap[themeName as keyof typeof themeStylesMap];
|
| 1115 |
+
const isActive = badgeTheme === themeName;
|
| 1116 |
+
return (
|
| 1117 |
+
<button
|
| 1118 |
+
key={themeName}
|
| 1119 |
+
type="button"
|
| 1120 |
+
onClick={() => handleSettingChange("BADGE_THEME_COLOR", themeName)}
|
| 1121 |
+
className={`flex items-center gap-2 p-2 rounded-xl border text-[11px] font-bold transition-all cursor-pointer ${
|
| 1122 |
+
isActive
|
| 1123 |
+
? "bg-slate-900 border-slate-900 text-white shadow-xs"
|
| 1124 |
+
: "bg-white border-slate-200 text-slate-700 hover:bg-slate-55 hover:border-slate-300"
|
| 1125 |
+
}`}
|
| 1126 |
+
>
|
| 1127 |
+
<span className={`w-3 h-3 rounded-full shrink-0 ${styles.headerBg}`} />
|
| 1128 |
+
{themeName}
|
| 1129 |
+
</button>
|
| 1130 |
+
);
|
| 1131 |
+
})}
|
| 1132 |
+
</div>
|
| 1133 |
+
</div>
|
| 1134 |
+
|
| 1135 |
+
{/* Background Pattern Type Choices */}
|
| 1136 |
+
<div className="space-y-1.5">
|
| 1137 |
+
<label className="block text-[9.5px] font-bold text-slate-500 uppercase tracking-wider">Badge Background Pattern</label>
|
| 1138 |
+
<div className="grid grid-cols-2 gap-2">
|
| 1139 |
+
{[
|
| 1140 |
+
{ key: "None", label: "None" },
|
| 1141 |
+
{ key: "Indian Mandala", label: "Mandala" },
|
| 1142 |
+
{ key: "Corporate Waves", label: "Waves" },
|
| 1143 |
+
{ key: "Cyber Grid", label: "Grid" }
|
| 1144 |
+
].map((pattern) => {
|
| 1145 |
+
const isActive = badgePattern === pattern.key;
|
| 1146 |
+
return (
|
| 1147 |
+
<button
|
| 1148 |
+
key={pattern.key}
|
| 1149 |
+
type="button"
|
| 1150 |
+
onClick={() => handleSettingChange("BADGE_PATTERN_TYPE", pattern.key)}
|
| 1151 |
+
className={`flex flex-col items-center justify-center p-2 rounded-xl border text-center transition-all cursor-pointer h-12 ${
|
| 1152 |
+
isActive
|
| 1153 |
+
? "bg-slate-900 border-slate-900 text-white shadow-xs"
|
| 1154 |
+
: "bg-white border-slate-200 text-slate-700 hover:bg-slate-55 hover:border-slate-300"
|
| 1155 |
+
}`}
|
| 1156 |
+
>
|
| 1157 |
+
<span className="text-[10px] font-bold tracking-wide">{pattern.label}</span>
|
| 1158 |
+
</button>
|
| 1159 |
+
);
|
| 1160 |
+
})}
|
| 1161 |
+
</div>
|
| 1162 |
+
</div>
|
| 1163 |
+
|
| 1164 |
+
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-slate-50 border border-slate-200 text-slate-650 text-[10px]">
|
| 1165 |
+
<Clock className="w-3.5 h-3.5 text-slate-450 shrink-0 mt-0.5" />
|
| 1166 |
+
<span>
|
| 1167 |
+
Adjusting these configurations updates the organization-wide card design template automatically.
|
| 1168 |
+
</span>
|
| 1169 |
+
</div>
|
| 1170 |
+
</div>
|
| 1171 |
+
</div>
|
| 1172 |
+
</div>
|
| 1173 |
+
|
| 1174 |
+
{/* Print Styling Injected locally for single page clean print */}
|
| 1175 |
+
<style dangerouslySetInnerHTML={{ __html: `
|
| 1176 |
+
@media print {
|
| 1177 |
+
/* Hide sidebar, headers, footers and any elements marked no-print */
|
| 1178 |
+
aside, header, footer, .no-print, button, input, select, [role="navigation"], .ambient-bg, .mesh-bg {
|
| 1179 |
+
display: none !important;
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
@page {
|
| 1183 |
+
size: portrait;
|
| 1184 |
+
margin: 0;
|
| 1185 |
+
}
|
| 1186 |
+
|
| 1187 |
+
/* Reset container layout models so they don't center, shift or clip the content */
|
| 1188 |
+
html, body, html.dark, body.dark {
|
| 1189 |
+
margin: 0 !important;
|
| 1190 |
+
padding: 0 !important;
|
| 1191 |
+
width: 100% !important;
|
| 1192 |
+
height: 100% !important;
|
| 1193 |
+
overflow: hidden !important;
|
| 1194 |
+
background-color: white !important;
|
| 1195 |
+
background: white !important;
|
| 1196 |
+
position: relative !important;
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
/* Reset only parent layout hierarchy, leaving internal elements of card intact */
|
| 1200 |
+
main,
|
| 1201 |
+
body > div,
|
| 1202 |
+
#sidebar-layout-container,
|
| 1203 |
+
.sidebar-layout-content,
|
| 1204 |
+
.print-reset-container,
|
| 1205 |
+
.page-enter,
|
| 1206 |
+
.dark main,
|
| 1207 |
+
.dark body > div,
|
| 1208 |
+
.dark #sidebar-layout-container,
|
| 1209 |
+
.dark .sidebar-layout-content,
|
| 1210 |
+
.dark .print-reset-container,
|
| 1211 |
+
.dark .page-enter {
|
| 1212 |
+
border: none !important;
|
| 1213 |
+
box-shadow: none !important;
|
| 1214 |
+
background: transparent !important;
|
| 1215 |
+
background-color: transparent !important;
|
| 1216 |
+
padding: 0 !important;
|
| 1217 |
+
margin: 0 !important;
|
| 1218 |
+
height: auto !important;
|
| 1219 |
+
min-height: 0 !important;
|
| 1220 |
+
overflow: visible !important;
|
| 1221 |
+
position: static !important;
|
| 1222 |
+
width: auto !important;
|
| 1223 |
+
display: block !important;
|
| 1224 |
+
/* Clear transform/animations that would trap fixed/absolute positioning context */
|
| 1225 |
+
transform: none !important;
|
| 1226 |
+
animation: none !important;
|
| 1227 |
+
transition: none !important;
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
/* Center and display only the card wrapper */
|
| 1231 |
+
#printable-id-card-wrap {
|
| 1232 |
+
display: flex !important;
|
| 1233 |
+
flex-direction: column !important;
|
| 1234 |
+
visibility: visible !important;
|
| 1235 |
+
position: fixed !important;
|
| 1236 |
+
left: 50% !important;
|
| 1237 |
+
top: 50% !important;
|
| 1238 |
+
transform: translate(-50%, -50%) scale(1.1) !important;
|
| 1239 |
+
border: 1px solid #cbd5e1 !important;
|
| 1240 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
| 1241 |
+
border-radius: 24px !important;
|
| 1242 |
+
background-color: white !important;
|
| 1243 |
+
width: 280px !important;
|
| 1244 |
+
height: 438px !important;
|
| 1245 |
+
margin: 0 !important;
|
| 1246 |
+
overflow: hidden !important;
|
| 1247 |
+
page-break-inside: avoid;
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
#printable-id-card-wrap * {
|
| 1251 |
+
visibility: visible !important;
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
/* Force print watermark color & visibility */
|
| 1255 |
+
.badge-watermark-container,
|
| 1256 |
+
.dark .badge-watermark-container {
|
| 1257 |
+
color: #475569 !important;
|
| 1258 |
+
opacity: 0.12 !important;
|
| 1259 |
+
}
|
| 1260 |
+
|
| 1261 |
+
* {
|
| 1262 |
+
-webkit-print-color-adjust: exact !important;
|
| 1263 |
+
print-color-adjust: exact !important;
|
| 1264 |
+
}
|
| 1265 |
+
}
|
| 1266 |
+
` }} />
|
| 1267 |
+
|
| 1268 |
+
{/* ─── Edit Employee Modal ─── */}
|
| 1269 |
+
{showEditDialog && (
|
| 1270 |
+
<div className="modal-backdrop">
|
| 1271 |
+
<div className="modal-content max-w-lg">
|
| 1272 |
+
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
|
| 1273 |
+
<div>
|
| 1274 |
+
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider text-slate-800 dark:text-slate-100">Edit Profile Details</h3>
|
| 1275 |
+
</div>
|
| 1276 |
+
<button
|
| 1277 |
+
onClick={() => setShowEditDialog(false)}
|
| 1278 |
+
className="p-2 rounded-xl hover:bg-slate-100 text-slate-500 hover:text-slate-750 transition-all cursor-pointer"
|
| 1279 |
+
>
|
| 1280 |
+
<X className="w-4 h-4" />
|
| 1281 |
+
</button>
|
| 1282 |
+
</div>
|
| 1283 |
+
|
| 1284 |
+
<form onSubmit={handleEditSubmit} className="space-y-4 text-slate-800">
|
| 1285 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 1286 |
+
<div className="space-y-1.5">
|
| 1287 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Full Name</label>
|
| 1288 |
+
<input type="text" required value={editName} onChange={(e) => setEditName(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
|
| 1289 |
+
</div>
|
| 1290 |
+
<div className="space-y-1.5">
|
| 1291 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Email Address</label>
|
| 1292 |
+
<input type="email" required value={editEmail} onChange={(e) => setEditEmail(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
|
| 1293 |
+
</div>
|
| 1294 |
+
</div>
|
| 1295 |
+
|
| 1296 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 1297 |
+
<div className="space-y-1.5">
|
| 1298 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Phone Number</label>
|
| 1299 |
+
<input type="text" placeholder="+91 98765 43210" value={editPhone || ""} onChange={(e) => setEditPhone(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
|
| 1300 |
+
</div>
|
| 1301 |
+
<div className="space-y-1.5">
|
| 1302 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Designation</label>
|
| 1303 |
+
<input type="text" value={editDesignation} onChange={(e) => setEditDesignation(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
|
| 1304 |
+
</div>
|
| 1305 |
+
</div>
|
| 1306 |
+
|
| 1307 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 1308 |
+
<div className="space-y-1.5">
|
| 1309 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Department</label>
|
| 1310 |
+
<div className="relative">
|
| 1311 |
+
<select value={editDeptId} onChange={(e) => setEditDeptId(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3 appearance-none cursor-pointer pr-8">
|
| 1312 |
+
<option value="">Select Department</option>
|
| 1313 |
+
{departments?.map((d: any) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
| 1314 |
+
</select>
|
| 1315 |
+
</div>
|
| 1316 |
+
</div>
|
| 1317 |
+
<div className="space-y-1.5">
|
| 1318 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Joining Date</label>
|
| 1319 |
+
<input type="date" value={editJoiningDate} onChange={(e) => setEditJoiningDate(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3" />
|
| 1320 |
+
</div>
|
| 1321 |
+
</div>
|
| 1322 |
+
|
| 1323 |
+
<div className="space-y-1.5">
|
| 1324 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Status</label>
|
| 1325 |
+
<select value={editStatus} onChange={(e) => setEditStatus(e.target.value)} className="input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl w-full px-3 appearance-none cursor-pointer">
|
| 1326 |
+
<option value="Active">Active</option>
|
| 1327 |
+
<option value="Inactive">Inactive</option>
|
| 1328 |
+
</select>
|
| 1329 |
+
</div>
|
| 1330 |
+
|
| 1331 |
+
<div className="flex justify-end gap-2.5 pt-3 border-t border-slate-100">
|
| 1332 |
+
<button
|
| 1333 |
+
type="button"
|
| 1334 |
+
onClick={() => setShowEditDialog(false)}
|
| 1335 |
+
className="btn-ghost text-[11.5px] h-9 px-4 rounded-xl cursor-pointer hover:bg-slate-100"
|
| 1336 |
+
>
|
| 1337 |
+
Cancel
|
| 1338 |
+
</button>
|
| 1339 |
+
<button
|
| 1340 |
+
type="submit"
|
| 1341 |
+
disabled={updateEmployeeMutation.isPending}
|
| 1342 |
+
className="btn-primary text-[11.5px] h-9 px-4 flex items-center gap-1.5 rounded-xl cursor-pointer"
|
| 1343 |
+
>
|
| 1344 |
+
{updateEmployeeMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
| 1345 |
+
Save Changes
|
| 1346 |
+
</button>
|
| 1347 |
+
</div>
|
| 1348 |
+
</form>
|
| 1349 |
+
</div>
|
| 1350 |
+
</div>
|
| 1351 |
+
)}
|
| 1352 |
+
</div>
|
| 1353 |
+
</SidebarLayout>
|
| 1354 |
+
);
|
| 1355 |
+
}
|
frontend/app/employees/page.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
| 11 |
} from "lucide-react";
|
| 12 |
import { useToast } from "@/app/utils/toast";
|
| 13 |
import Link from "next/link";
|
|
|
|
| 14 |
|
| 15 |
// Avatar gradient styles
|
| 16 |
const avatarColors = [
|
|
@@ -65,6 +66,7 @@ function InputField({ label, required, children }: { label: string; required?: b
|
|
| 65 |
|
| 66 |
export default function EmployeesPage() {
|
| 67 |
const queryClient = useQueryClient();
|
|
|
|
| 68 |
const { toast } = useToast();
|
| 69 |
const [search, setSearch] = useState("");
|
| 70 |
const [deptFilter, setDeptFilter] = useState("");
|
|
@@ -75,7 +77,6 @@ export default function EmployeesPage() {
|
|
| 75 |
const [importMessage, setImportMessage] = useState<string | null>(null);
|
| 76 |
const [importErrors, setImportErrors] = useState<string[]>([]);
|
| 77 |
const [submitting, setSubmitting] = useState(false);
|
| 78 |
-
const [selectedEmployee, setSelectedEmployee] = useState<any | null>(null);
|
| 79 |
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
|
| 80 |
const [deleteConfirmName, setDeleteConfirmName] = useState<string>("");
|
| 81 |
|
|
@@ -91,6 +92,7 @@ export default function EmployeesPage() {
|
|
| 91 |
const [deptId, setDeptId] = useState("");
|
| 92 |
const [createUserLogin, setCreateUserLogin] = useState(false);
|
| 93 |
const [password, setPassword] = useState("");
|
|
|
|
| 94 |
|
| 95 |
const { data: departments } = useQuery({
|
| 96 |
queryKey: ["departments"],
|
|
@@ -109,14 +111,6 @@ export default function EmployeesPage() {
|
|
| 109 |
}
|
| 110 |
});
|
| 111 |
|
| 112 |
-
const { data: empAttendance, isLoading: loadingEmpAttendance } = useQuery({
|
| 113 |
-
queryKey: ["employee-attendance", selectedEmployee?.id],
|
| 114 |
-
queryFn: () => {
|
| 115 |
-
if (!selectedEmployee) return [];
|
| 116 |
-
return fetchApi(`/attendance/employee/${selectedEmployee.id}`);
|
| 117 |
-
},
|
| 118 |
-
enabled: !!selectedEmployee
|
| 119 |
-
});
|
| 120 |
|
| 121 |
const createMutation = useMutation({
|
| 122 |
mutationFn: (payload: any) => fetchApi("/employees/", { method: "POST", body: JSON.stringify(payload) }),
|
|
@@ -145,6 +139,7 @@ export default function EmployeesPage() {
|
|
| 145 |
setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
|
| 146 |
setJoiningDate(getLocalDateString()); setStatusVal("Active");
|
| 147 |
setDeptId(""); setCreateUserLogin(false); setPassword("");
|
|
|
|
| 148 |
setPhoneError(null);
|
| 149 |
setSubmissionError(null);
|
| 150 |
};
|
|
@@ -196,7 +191,8 @@ export default function EmployeesPage() {
|
|
| 196 |
employee_id: empId, name, email, phone: phone || null,
|
| 197 |
designation: designation || null, joining_date: joiningDate,
|
| 198 |
status: statusVal, department_id: deptId ? parseInt(deptId) : null,
|
| 199 |
-
create_user_login: createUserLogin
|
|
|
|
| 200 |
};
|
| 201 |
if (createUserLogin) payload.password = password;
|
| 202 |
createMutation.mutate(payload);
|
|
@@ -233,23 +229,6 @@ export default function EmployeesPage() {
|
|
| 233 |
}
|
| 234 |
};
|
| 235 |
|
| 236 |
-
const handleExportEmployee = async () => {
|
| 237 |
-
if (!selectedEmployee) return;
|
| 238 |
-
try {
|
| 239 |
-
const responseBlob = await fetchApi(`/reports/export?report_type=employee&employee_id=${selectedEmployee.id}&format=csv`);
|
| 240 |
-
const url = window.URL.createObjectURL(responseBlob);
|
| 241 |
-
const a = document.createElement("a");
|
| 242 |
-
a.href = url;
|
| 243 |
-
a.download = `attendance_${selectedEmployee.name.replace(/\s+/g, "_")}.csv`;
|
| 244 |
-
document.body.appendChild(a);
|
| 245 |
-
a.click();
|
| 246 |
-
a.remove();
|
| 247 |
-
window.URL.revokeObjectURL(url);
|
| 248 |
-
toast.success("Employee ledger exported successfully.");
|
| 249 |
-
} catch (err: any) {
|
| 250 |
-
toast.error(err.message || "Failed to export CSV.");
|
| 251 |
-
}
|
| 252 |
-
};
|
| 253 |
|
| 254 |
const inputCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all w-full";
|
| 255 |
const selectCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all appearance-none cursor-pointer w-full pr-8";
|
|
@@ -368,7 +347,7 @@ export default function EmployeesPage() {
|
|
| 368 |
employees?.map((emp: any) => {
|
| 369 |
const avatarColor = avatarColors[emp.id % avatarColors.length];
|
| 370 |
return (
|
| 371 |
-
<tr key={emp.id} className="group/row cursor-pointer hover:bg-white/[0.015]" onClick={() =>
|
| 372 |
<td className="py-3.5 px-5">
|
| 373 |
<div className="flex items-center gap-3">
|
| 374 |
<EmployeeAvatar emp={emp} size="md" />
|
|
@@ -543,6 +522,27 @@ export default function EmployeesPage() {
|
|
| 543 |
)}
|
| 544 |
</div>
|
| 545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 547 |
<button
|
| 548 |
type="button"
|
|
@@ -647,156 +647,6 @@ export default function EmployeesPage() {
|
|
| 647 |
)}
|
| 648 |
</SidebarLayout>
|
| 649 |
|
| 650 |
-
{/* ─── Employee Details & Attendance History Modal ─── */}
|
| 651 |
-
{selectedEmployee && (
|
| 652 |
-
<div className="modal-backdrop z-50">
|
| 653 |
-
<div className="modal-content max-w-2xl bg-white border border-zinc-200 text-zinc-900 shadow-2xl">
|
| 654 |
-
{/* Header info */}
|
| 655 |
-
<div className="flex items-start justify-between pb-5 border-b border-zinc-100">
|
| 656 |
-
<div className="flex items-center gap-4">
|
| 657 |
-
<EmployeeAvatar emp={selectedEmployee} size="lg" />
|
| 658 |
-
<div>
|
| 659 |
-
<h3 className="text-base font-bold text-zinc-900 leading-tight">
|
| 660 |
-
{selectedEmployee.name}
|
| 661 |
-
</h3>
|
| 662 |
-
<p className="text-xs text-zinc-550 mt-1 flex items-center gap-1.5">
|
| 663 |
-
<span className="font-mono text-zinc-500 bg-zinc-100 px-1.5 py-0.5 rounded border border-zinc-200">{selectedEmployee.employee_id}</span>
|
| 664 |
-
<span>·</span>
|
| 665 |
-
<span className="font-semibold text-zinc-700">{selectedEmployee.designation || "No Title"}</span>
|
| 666 |
-
</p>
|
| 667 |
-
<p className="text-[10px] text-zinc-400 font-bold mt-1 uppercase tracking-wider">
|
| 668 |
-
{selectedEmployee.department?.name || "General Department"}
|
| 669 |
-
</p>
|
| 670 |
-
</div>
|
| 671 |
-
</div>
|
| 672 |
-
|
| 673 |
-
<div className="flex items-center gap-2">
|
| 674 |
-
<button
|
| 675 |
-
onClick={handleExportEmployee}
|
| 676 |
-
className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-100 border border-zinc-200 hover:bg-zinc-200 rounded-xl text-[11px] font-bold text-zinc-700 transition-all cursor-pointer"
|
| 677 |
-
title="Export employee attendance data"
|
| 678 |
-
>
|
| 679 |
-
<Download className="w-3.5 h-3.5 text-zinc-500" />
|
| 680 |
-
Export CSV
|
| 681 |
-
</button>
|
| 682 |
-
<button
|
| 683 |
-
onClick={() => setSelectedEmployee(null)}
|
| 684 |
-
className="p-2 rounded-xl hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
|
| 685 |
-
>
|
| 686 |
-
<X className="w-4.5 h-4.5" />
|
| 687 |
-
</button>
|
| 688 |
-
</div>
|
| 689 |
-
</div>
|
| 690 |
-
|
| 691 |
-
{/* Profile fields details grid */}
|
| 692 |
-
<div className="grid grid-cols-1 md:grid-cols-3 gap-3.5 py-4 border-b border-zinc-100 text-[11.5px] text-zinc-600">
|
| 693 |
-
<div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
|
| 694 |
-
<Mail className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
|
| 695 |
-
<span className="truncate font-medium text-zinc-700" title={selectedEmployee.email}>{selectedEmployee.email}</span>
|
| 696 |
-
</div>
|
| 697 |
-
<div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
|
| 698 |
-
<Phone className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
|
| 699 |
-
<span className="font-medium text-zinc-700">{selectedEmployee.phone || "No phone added"}</span>
|
| 700 |
-
</div>
|
| 701 |
-
<div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
|
| 702 |
-
<Calendar className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
|
| 703 |
-
<span className="font-medium text-zinc-700">Joined: <span className="font-mono">{selectedEmployee.joining_date}</span></span>
|
| 704 |
-
</div>
|
| 705 |
-
</div>
|
| 706 |
-
|
| 707 |
-
{/* Stats Summary cards */}
|
| 708 |
-
<div className="grid grid-cols-4 gap-3 py-4">
|
| 709 |
-
{(() => {
|
| 710 |
-
const total = empAttendance?.length || 0;
|
| 711 |
-
const present = empAttendance?.filter((r: any) => r.status === "Present").length || 0;
|
| 712 |
-
const late = empAttendance?.filter((r: any) => r.status === "Late").length || 0;
|
| 713 |
-
const half = empAttendance?.filter((r: any) => r.status === "Half Day").length || 0;
|
| 714 |
-
|
| 715 |
-
const cardCls = "p-3 rounded-xl border border-zinc-150 bg-zinc-50/30 flex flex-col justify-between h-[64px] shadow-sm";
|
| 716 |
-
return (
|
| 717 |
-
<>
|
| 718 |
-
<div className={cardCls}>
|
| 719 |
-
<span className="text-[9px] text-zinc-500 font-bold uppercase tracking-wider">Logged Days</span>
|
| 720 |
-
<span className="text-base font-bold text-zinc-800 leading-none mt-1 font-mono">{total}</span>
|
| 721 |
-
</div>
|
| 722 |
-
<div className={cardCls}>
|
| 723 |
-
<span className="text-[9px] text-emerald-600 font-bold uppercase tracking-wider">Present</span>
|
| 724 |
-
<span className="text-base font-bold text-emerald-600 leading-none mt-1 font-mono">{present}</span>
|
| 725 |
-
</div>
|
| 726 |
-
<div className={cardCls}>
|
| 727 |
-
<span className="text-[9px] text-amber-600 font-bold uppercase tracking-wider">Late Arrivals</span>
|
| 728 |
-
<span className="text-base font-bold text-amber-600 leading-none mt-1 font-mono">{late}</span>
|
| 729 |
-
</div>
|
| 730 |
-
<div className={cardCls}>
|
| 731 |
-
<span className="text-[9px] text-indigo-600 font-bold uppercase tracking-wider">Half Days</span>
|
| 732 |
-
<span className="text-base font-bold text-indigo-650 leading-none mt-1 font-mono">{half}</span>
|
| 733 |
-
</div>
|
| 734 |
-
</>
|
| 735 |
-
);
|
| 736 |
-
})()}
|
| 737 |
-
</div>
|
| 738 |
-
|
| 739 |
-
{/* Attendance Ledger Table */}
|
| 740 |
-
<div className="mt-2 space-y-2">
|
| 741 |
-
<h4 className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest font-mono">Attendance Ledger (Past 30 Days)</h4>
|
| 742 |
-
<div className="overflow-y-auto max-h-[200px] border border-zinc-200 rounded-xl bg-white shadow-sm">
|
| 743 |
-
<table className="w-full text-left border-collapse text-[11px]">
|
| 744 |
-
<thead>
|
| 745 |
-
<tr className="border-b border-zinc-200 bg-zinc-50 text-zinc-500 uppercase tracking-wider font-mono">
|
| 746 |
-
<th className="py-2.5 px-4 font-semibold">Date</th>
|
| 747 |
-
<th className="py-2.5 px-4 font-semibold">Check In</th>
|
| 748 |
-
<th className="py-2.5 px-4 font-semibold">Check Out</th>
|
| 749 |
-
<th className="py-2.5 px-4 font-semibold">Hours</th>
|
| 750 |
-
<th className="py-2.5 px-4 font-semibold text-center">Status</th>
|
| 751 |
-
</tr>
|
| 752 |
-
</thead>
|
| 753 |
-
<tbody className="divide-y divide-zinc-100 text-zinc-700">
|
| 754 |
-
{loadingEmpAttendance ? (
|
| 755 |
-
Array.from({ length: 3 }).map((_, i) => (
|
| 756 |
-
<tr key={i}>
|
| 757 |
-
{Array.from({ length: 5 }).map((_, j) => (
|
| 758 |
-
<td key={j} className="py-3 px-4"><div className="skeleton h-3 w-16" /></td>
|
| 759 |
-
))}
|
| 760 |
-
</tr>
|
| 761 |
-
))
|
| 762 |
-
) : !empAttendance || empAttendance.length === 0 ? (
|
| 763 |
-
<tr>
|
| 764 |
-
<td colSpan={5} className="py-8 text-center text-zinc-450 italic">No attendance records stored.</td>
|
| 765 |
-
</tr>
|
| 766 |
-
) : (
|
| 767 |
-
empAttendance.map((rec: any) => (
|
| 768 |
-
<tr key={rec.id} className="hover:bg-zinc-50/50 transition-colors">
|
| 769 |
-
<td className="py-2.5 px-4 font-mono text-zinc-550">{rec.date}</td>
|
| 770 |
-
<td className="py-2.5 px-4 font-mono text-zinc-800">
|
| 771 |
-
{rec.check_in ? parseDateTime(rec.check_in)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
|
| 772 |
-
</td>
|
| 773 |
-
<td className="py-2.5 px-4 font-mono text-zinc-800">
|
| 774 |
-
{rec.check_out ? parseDateTime(rec.check_out)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
|
| 775 |
-
</td>
|
| 776 |
-
<td className="py-2.5 px-4 font-mono text-zinc-700">
|
| 777 |
-
{rec.working_hours ? `${rec.working_hours.toFixed(1)} hrs` : "—"}
|
| 778 |
-
</td>
|
| 779 |
-
<td className="py-2.5 px-4 text-center">
|
| 780 |
-
<span className={`inline-block text-[8.5px] font-semibold px-2 py-0.5 rounded-full border ${
|
| 781 |
-
rec.status === "Present" ? "bg-emerald-50 border-emerald-200 text-emerald-700" :
|
| 782 |
-
rec.status === "Late" ? "bg-amber-50 border-amber-200 text-amber-700" :
|
| 783 |
-
rec.status === "Half Day" ? "bg-indigo-50 border-indigo-200 text-indigo-750" :
|
| 784 |
-
"bg-rose-50 border-rose-200 text-rose-700"
|
| 785 |
-
}`}>
|
| 786 |
-
{rec.status}
|
| 787 |
-
</span>
|
| 788 |
-
</td>
|
| 789 |
-
</tr>
|
| 790 |
-
))
|
| 791 |
-
)}
|
| 792 |
-
</tbody>
|
| 793 |
-
</table>
|
| 794 |
-
</div>
|
| 795 |
-
</div>
|
| 796 |
-
</div>
|
| 797 |
-
</div>
|
| 798 |
-
)}
|
| 799 |
-
|
| 800 |
{/* Delete Confirmation Modal */}
|
| 801 |
{deleteConfirmId !== null && (
|
| 802 |
<div className="modal-backdrop z-50">
|
|
|
|
| 11 |
} from "lucide-react";
|
| 12 |
import { useToast } from "@/app/utils/toast";
|
| 13 |
import Link from "next/link";
|
| 14 |
+
import { useRouter } from "next/navigation";
|
| 15 |
|
| 16 |
// Avatar gradient styles
|
| 17 |
const avatarColors = [
|
|
|
|
| 66 |
|
| 67 |
export default function EmployeesPage() {
|
| 68 |
const queryClient = useQueryClient();
|
| 69 |
+
const router = useRouter();
|
| 70 |
const { toast } = useToast();
|
| 71 |
const [search, setSearch] = useState("");
|
| 72 |
const [deptFilter, setDeptFilter] = useState("");
|
|
|
|
| 77 |
const [importMessage, setImportMessage] = useState<string | null>(null);
|
| 78 |
const [importErrors, setImportErrors] = useState<string[]>([]);
|
| 79 |
const [submitting, setSubmitting] = useState(false);
|
|
|
|
| 80 |
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
|
| 81 |
const [deleteConfirmName, setDeleteConfirmName] = useState<string>("");
|
| 82 |
|
|
|
|
| 92 |
const [deptId, setDeptId] = useState("");
|
| 93 |
const [createUserLogin, setCreateUserLogin] = useState(false);
|
| 94 |
const [password, setPassword] = useState("");
|
| 95 |
+
const [allowWfh, setAllowWfh] = useState(false);
|
| 96 |
|
| 97 |
const { data: departments } = useQuery({
|
| 98 |
queryKey: ["departments"],
|
|
|
|
| 111 |
}
|
| 112 |
});
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
const createMutation = useMutation({
|
| 116 |
mutationFn: (payload: any) => fetchApi("/employees/", { method: "POST", body: JSON.stringify(payload) }),
|
|
|
|
| 139 |
setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
|
| 140 |
setJoiningDate(getLocalDateString()); setStatusVal("Active");
|
| 141 |
setDeptId(""); setCreateUserLogin(false); setPassword("");
|
| 142 |
+
setAllowWfh(false);
|
| 143 |
setPhoneError(null);
|
| 144 |
setSubmissionError(null);
|
| 145 |
};
|
|
|
|
| 191 |
employee_id: empId, name, email, phone: phone || null,
|
| 192 |
designation: designation || null, joining_date: joiningDate,
|
| 193 |
status: statusVal, department_id: deptId ? parseInt(deptId) : null,
|
| 194 |
+
create_user_login: createUserLogin,
|
| 195 |
+
allow_wfh: allowWfh
|
| 196 |
};
|
| 197 |
if (createUserLogin) payload.password = password;
|
| 198 |
createMutation.mutate(payload);
|
|
|
|
| 229 |
}
|
| 230 |
};
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
const inputCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all w-full";
|
| 234 |
const selectCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all appearance-none cursor-pointer w-full pr-8";
|
|
|
|
| 347 |
employees?.map((emp: any) => {
|
| 348 |
const avatarColor = avatarColors[emp.id % avatarColors.length];
|
| 349 |
return (
|
| 350 |
+
<tr key={emp.id} className="group/row cursor-pointer hover:bg-white/[0.015]" onClick={() => router.push(`/employees/${emp.id}`)}>
|
| 351 |
<td className="py-3.5 px-5">
|
| 352 |
<div className="flex items-center gap-3">
|
| 353 |
<EmployeeAvatar emp={emp} size="md" />
|
|
|
|
| 522 |
)}
|
| 523 |
</div>
|
| 524 |
|
| 525 |
+
{/* WFH Permission Toggle */}
|
| 526 |
+
<div className="flex items-center justify-between p-4 rounded-2xl bg-white/[0.02] border border-white/5">
|
| 527 |
+
<div>
|
| 528 |
+
<p className="text-[12.5px] font-bold text-slate-800">Work From Home (WFH) Allowed</p>
|
| 529 |
+
<p className="text-[10px] text-slate-450 mt-0.5">Bypasses geofenced location checks for this employee</p>
|
| 530 |
+
</div>
|
| 531 |
+
<button
|
| 532 |
+
type="button"
|
| 533 |
+
onClick={() => setAllowWfh(!allowWfh)}
|
| 534 |
+
className={`relative inline-flex h-5.5 w-10 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
| 535 |
+
allowWfh ? "bg-emerald-500" : "bg-slate-200 dark:bg-slate-800"
|
| 536 |
+
}`}
|
| 537 |
+
>
|
| 538 |
+
<span
|
| 539 |
+
className={`pointer-events-none inline-block h-4.5 w-4.5 transform rounded-full bg-white shadow-xs transition duration-200 ease-in-out ${
|
| 540 |
+
allowWfh ? "translate-x-4.5" : "translate-x-0"
|
| 541 |
+
}`}
|
| 542 |
+
/>
|
| 543 |
+
</button>
|
| 544 |
+
</div>
|
| 545 |
+
|
| 546 |
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 547 |
<button
|
| 548 |
type="button"
|
|
|
|
| 647 |
)}
|
| 648 |
</SidebarLayout>
|
| 649 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
{/* Delete Confirmation Modal */}
|
| 651 |
{deleteConfirmId !== null && (
|
| 652 |
<div className="modal-backdrop z-50">
|
frontend/app/enroll/[id]/page.tsx
CHANGED
|
@@ -5,10 +5,12 @@ import { useParams, useRouter } from "next/navigation";
|
|
| 5 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 6 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 7 |
import { fetchApi, getBackendUrl } from "@/app/utils/api";
|
|
|
|
| 8 |
import {
|
| 9 |
Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video,
|
| 10 |
RefreshCw, AlertCircle, Trash2, Play, Pause, Save, RotateCcw, Shield, Activity, Sparkles,
|
| 11 |
-
User, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Smile, Meh, Lightbulb, Sun, Glasses
|
|
|
|
| 12 |
} from "lucide-react";
|
| 13 |
|
| 14 |
interface PoseInfo {
|
|
@@ -88,6 +90,7 @@ export default function EnrollPage() {
|
|
| 88 |
const router = useRouter();
|
| 89 |
const queryClient = useQueryClient();
|
| 90 |
const employeeId = params.id;
|
|
|
|
| 91 |
|
| 92 |
// State Machine for biometric scanner:
|
| 93 |
// "idle": Pre-start screen
|
|
@@ -130,6 +133,140 @@ export default function EnrollPage() {
|
|
| 130 |
enabled: !!employeeId
|
| 131 |
});
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
const clearMutation = useMutation({
|
| 134 |
mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }),
|
| 135 |
onSuccess: () => {
|
|
@@ -188,6 +325,330 @@ export default function EnrollPage() {
|
|
| 188 |
}
|
| 189 |
};
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
// Webcam controls
|
| 192 |
const startWebcam = async () => {
|
| 193 |
setErrorMsg(null);
|
|
@@ -387,7 +848,7 @@ export default function EnrollPage() {
|
|
| 387 |
|
| 388 |
return (
|
| 389 |
<SidebarLayout>
|
| 390 |
-
<div className="space-y-6 max-w-5xl page-enter relative">
|
| 391 |
{/* CSS Scanner Animations style block */}
|
| 392 |
<style>{`
|
| 393 |
@keyframes scanline {
|
|
@@ -541,9 +1002,6 @@ export default function EnrollPage() {
|
|
| 541 |
{/* Upload Fallback File Option */}
|
| 542 |
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
|
| 543 |
<h4 className="text-[11.5px] font-bold text-slate-700 uppercase tracking-wider">Manual Photo Upload</h4>
|
| 544 |
-
<p className="text-[10px] text-slate-500 leading-normal">
|
| 545 |
-
If the employee cannot use a live camera, select a target pose and upload a photo from disk.
|
| 546 |
-
</p>
|
| 547 |
<div className="flex gap-2">
|
| 548 |
<select
|
| 549 |
value={selectedPose}
|
|
@@ -572,9 +1030,6 @@ export default function EnrollPage() {
|
|
| 572 |
{/* Right: Big visual grid checklist */}
|
| 573 |
<div className="md:col-span-2 bg-white border border-slate-200 rounded-2xl p-6 shadow-sm space-y-4">
|
| 574 |
<h3 className="text-sm font-black text-slate-900 tracking-tight">Facial Pose Checklist</h3>
|
| 575 |
-
<p className="text-xs text-slate-500">
|
| 576 |
-
To capture accurate biometric details under varying orientations and lighting, we index 10 distinct facial angles.
|
| 577 |
-
</p>
|
| 578 |
|
| 579 |
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3.5 pt-2">
|
| 580 |
{POSE_KEYS.map((key) => {
|
|
@@ -850,50 +1305,328 @@ export default function EnrollPage() {
|
|
| 850 |
|
| 851 |
{/* ─── State 5: SUCCESS SCREEN ─── */}
|
| 852 |
{captureState === "success" && (
|
| 853 |
-
<div className="max-w-
|
| 854 |
-
<div className="
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
<
|
| 858 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 859 |
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
</div>
|
| 883 |
-
)}
|
| 884 |
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
</div>
|
| 896 |
)}
|
|
|
|
|
|
|
| 897 |
</div>
|
| 898 |
|
| 899 |
{/* Clear Biometric Confirmation Modal */}
|
|
|
|
| 5 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 6 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 7 |
import { fetchApi, getBackendUrl } from "@/app/utils/api";
|
| 8 |
+
import { useToast } from "@/app/utils/toast";
|
| 9 |
import {
|
| 10 |
Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video,
|
| 11 |
RefreshCw, AlertCircle, Trash2, Play, Pause, Save, RotateCcw, Shield, Activity, Sparkles,
|
| 12 |
+
User, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Smile, Meh, Lightbulb, Sun, Glasses,
|
| 13 |
+
Printer, Download
|
| 14 |
} from "lucide-react";
|
| 15 |
|
| 16 |
interface PoseInfo {
|
|
|
|
| 90 |
const router = useRouter();
|
| 91 |
const queryClient = useQueryClient();
|
| 92 |
const employeeId = params.id;
|
| 93 |
+
const { toast } = useToast();
|
| 94 |
|
| 95 |
// State Machine for biometric scanner:
|
| 96 |
// "idle": Pre-start screen
|
|
|
|
| 133 |
enabled: !!employeeId
|
| 134 |
});
|
| 135 |
|
| 136 |
+
const { data: settings } = useQuery({
|
| 137 |
+
queryKey: ["settings"],
|
| 138 |
+
queryFn: () => fetchApi("/settings/")
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
const settingsMap = React.useMemo(() => {
|
| 142 |
+
const map: Record<string, string> = {};
|
| 143 |
+
if (settings) {
|
| 144 |
+
settings.forEach((s: any) => {
|
| 145 |
+
map[s.key] = s.value;
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
return map;
|
| 149 |
+
}, [settings]);
|
| 150 |
+
|
| 151 |
+
const companyName = settingsMap["COMPANY_NAME"] || "NetraID Enterprise";
|
| 152 |
+
const companyLogo = settingsMap["COMPANY_LOGO"] || "";
|
| 153 |
+
const badgeTheme = settingsMap["BADGE_THEME_COLOR"] || "Navy Blue";
|
| 154 |
+
const badgePattern = settingsMap["BADGE_PATTERN_TYPE"] || "Indian Mandala";
|
| 155 |
+
|
| 156 |
+
const themeStyles = React.useMemo(() => {
|
| 157 |
+
switch (badgeTheme) {
|
| 158 |
+
case "Saffron":
|
| 159 |
+
return {
|
| 160 |
+
headerBg: "bg-gradient-to-tr from-amber-600 via-orange-500 to-red-600",
|
| 161 |
+
accentText: "text-orange-600 dark:text-orange-400",
|
| 162 |
+
accentBorder: "border-orange-500",
|
| 163 |
+
accentBg: "bg-orange-50/40 dark:bg-orange-950/20",
|
| 164 |
+
photoBorder: "from-amber-500 to-red-500",
|
| 165 |
+
dotColor: "bg-orange-500",
|
| 166 |
+
primaryHex: "#f97316",
|
| 167 |
+
headerHex1: "#d97706",
|
| 168 |
+
headerHex2: "#dc2626"
|
| 169 |
+
};
|
| 170 |
+
case "Emerald":
|
| 171 |
+
return {
|
| 172 |
+
headerBg: "bg-gradient-to-tr from-slate-900 via-emerald-950 to-teal-900",
|
| 173 |
+
accentText: "text-emerald-600 dark:text-emerald-400",
|
| 174 |
+
accentBorder: "border-emerald-500",
|
| 175 |
+
accentBg: "bg-emerald-50/40 dark:bg-emerald-950/20",
|
| 176 |
+
photoBorder: "from-emerald-500 to-teal-400",
|
| 177 |
+
dotColor: "bg-emerald-500",
|
| 178 |
+
primaryHex: "#10b981",
|
| 179 |
+
headerHex1: "#064e3b",
|
| 180 |
+
headerHex2: "#0f766e"
|
| 181 |
+
};
|
| 182 |
+
case "Charcoal":
|
| 183 |
+
return {
|
| 184 |
+
headerBg: "bg-gradient-to-tr from-zinc-900 via-slate-800 to-zinc-950",
|
| 185 |
+
accentText: "text-zinc-650 dark:text-zinc-400",
|
| 186 |
+
accentBorder: "border-zinc-500",
|
| 187 |
+
accentBg: "bg-zinc-50/40 dark:bg-zinc-900/20",
|
| 188 |
+
photoBorder: "from-zinc-500 to-slate-400",
|
| 189 |
+
dotColor: "bg-zinc-500",
|
| 190 |
+
primaryHex: "#6b7280",
|
| 191 |
+
headerHex1: "#18181b",
|
| 192 |
+
headerHex2: "#27272a"
|
| 193 |
+
};
|
| 194 |
+
case "Navy Blue":
|
| 195 |
+
default:
|
| 196 |
+
return {
|
| 197 |
+
headerBg: "bg-gradient-to-tr from-slate-900 via-blue-900 to-indigo-950",
|
| 198 |
+
accentText: "text-cyan-500 dark:text-cyan-400",
|
| 199 |
+
accentBorder: "border-cyan-500",
|
| 200 |
+
accentBg: "bg-slate-50/40 dark:bg-slate-900/20",
|
| 201 |
+
photoBorder: "from-cyan-500 to-emerald-400",
|
| 202 |
+
dotColor: "bg-cyan-500",
|
| 203 |
+
primaryHex: "#06b6d4",
|
| 204 |
+
headerHex1: "#0f172a",
|
| 205 |
+
headerHex2: "#1e3a8a"
|
| 206 |
+
};
|
| 207 |
+
}
|
| 208 |
+
}, [badgeTheme]);
|
| 209 |
+
|
| 210 |
+
const BadgeWatermark = ({ type }: { type: string }) => {
|
| 211 |
+
switch (type) {
|
| 212 |
+
case "Indian Mandala":
|
| 213 |
+
return (
|
| 214 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-[0.06] text-slate-800 dark:text-slate-100 z-0 overflow-hidden badge-watermark-container">
|
| 215 |
+
<svg className="w-[110%] h-[110%]" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.4">
|
| 216 |
+
<circle cx="50" cy="50" r="42" strokeDasharray="1 1.5" />
|
| 217 |
+
<circle cx="50" cy="50" r="35" />
|
| 218 |
+
<circle cx="50" cy="50" r="28" strokeDasharray="0.5 1" />
|
| 219 |
+
<circle cx="50" cy="50" r="21" />
|
| 220 |
+
<circle cx="50" cy="50" r="14" strokeDasharray="1 1" />
|
| 221 |
+
<circle cx="50" cy="50" r="7" />
|
| 222 |
+
{Array.from({ length: 24 }).map((_, i) => {
|
| 223 |
+
const angle = (i * 15 * Math.PI) / 180;
|
| 224 |
+
const x1 = 50 + 7 * Math.cos(angle);
|
| 225 |
+
const y1 = 50 + 7 * Math.sin(angle);
|
| 226 |
+
const x2 = 50 + 35 * Math.cos(angle);
|
| 227 |
+
const y2 = 50 + 35 * Math.sin(angle);
|
| 228 |
+
const cx1 = 50 + 20 * Math.cos(angle - 0.08);
|
| 229 |
+
const cy1 = 50 + 20 * Math.sin(angle - 0.08);
|
| 230 |
+
return (
|
| 231 |
+
<g key={i}>
|
| 232 |
+
<line x1={x1} y1={y1} x2={x2} y2={y2} />
|
| 233 |
+
<path d={`M ${x1} ${y1} Q ${cx1} ${cy1} ${x2} ${y2}`} strokeWidth="0.25" />
|
| 234 |
+
</g>
|
| 235 |
+
);
|
| 236 |
+
})}
|
| 237 |
+
</svg>
|
| 238 |
+
</div>
|
| 239 |
+
);
|
| 240 |
+
case "Corporate Waves":
|
| 241 |
+
return (
|
| 242 |
+
<div className="absolute inset-0 pointer-events-none opacity-[0.05] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
|
| 243 |
+
<svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
|
| 244 |
+
<path d="M-20,40 C20,20 40,60 60,40 C80,20 100,60 120,40" />
|
| 245 |
+
<path d="M-20,50 C20,30 40,70 60,50 C80,30 100,70 120,50" strokeDasharray="1 1" />
|
| 246 |
+
<path d="M-20,60 C20,40 40,80 60,60 C80,40 100,80 120,60" />
|
| 247 |
+
<path d="M-20,70 C20,50 40,90 60,70 C80,50 100,90 120,70" strokeDasharray="0.5 1" />
|
| 248 |
+
</svg>
|
| 249 |
+
</div>
|
| 250 |
+
);
|
| 251 |
+
case "Cyber Grid":
|
| 252 |
+
return (
|
| 253 |
+
<div className="absolute inset-0 pointer-events-none opacity-[0.03] text-slate-800 dark:text-slate-100 z-0 badge-watermark-container">
|
| 254 |
+
<svg className="w-full h-full" viewBox="0 0 100 100" fill="none" stroke="currentColor" strokeWidth="0.5">
|
| 255 |
+
{Array.from({ length: 11 }).map((_, i) => (
|
| 256 |
+
<g key={i}>
|
| 257 |
+
<line x1="0" y1={i * 10} x2="100" y2={i * 10} />
|
| 258 |
+
<line x1={i * 10} y1="0" x2={i * 10} y2="100" />
|
| 259 |
+
</g>
|
| 260 |
+
))}
|
| 261 |
+
</svg>
|
| 262 |
+
</div>
|
| 263 |
+
);
|
| 264 |
+
case "None":
|
| 265 |
+
default:
|
| 266 |
+
return null;
|
| 267 |
+
}
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
const clearMutation = useMutation({
|
| 271 |
mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }),
|
| 272 |
onSuccess: () => {
|
|
|
|
| 325 |
}
|
| 326 |
};
|
| 327 |
|
| 328 |
+
const handleDownloadBadge = async () => {
|
| 329 |
+
if (!employee) return;
|
| 330 |
+
try {
|
| 331 |
+
const canvas = document.createElement("canvas");
|
| 332 |
+
const ctx = canvas.getContext("2d");
|
| 333 |
+
if (!ctx) return;
|
| 334 |
+
|
| 335 |
+
// Set standard high-resolution dimensions for printing (e.g., 600x900 for 2:3 aspect ratio)
|
| 336 |
+
canvas.width = 600;
|
| 337 |
+
canvas.height = 900;
|
| 338 |
+
|
| 339 |
+
// 1. Draw rounded background card
|
| 340 |
+
ctx.fillStyle = "#ffffff";
|
| 341 |
+
ctx.beginPath();
|
| 342 |
+
if (typeof ctx.roundRect === "function") {
|
| 343 |
+
ctx.roundRect(0, 0, 600, 900, 30);
|
| 344 |
+
} else {
|
| 345 |
+
const x = 0, y = 0, width = 600, height = 900, radius = 30;
|
| 346 |
+
ctx.moveTo(x + radius, y);
|
| 347 |
+
ctx.lineTo(x + width - radius, y);
|
| 348 |
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
| 349 |
+
ctx.lineTo(x + width, y + height - radius);
|
| 350 |
+
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
| 351 |
+
ctx.lineTo(x + radius, y + height);
|
| 352 |
+
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
| 353 |
+
ctx.lineTo(x, y + radius);
|
| 354 |
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
| 355 |
+
}
|
| 356 |
+
ctx.fill();
|
| 357 |
+
ctx.strokeStyle = "#cbd5e1"; // lighter border
|
| 358 |
+
ctx.lineWidth = 4;
|
| 359 |
+
ctx.stroke();
|
| 360 |
+
|
| 361 |
+
// Draw custom background pattern
|
| 362 |
+
const cx = 300;
|
| 363 |
+
const cy = 450;
|
| 364 |
+
if (badgePattern === "Indian Mandala") {
|
| 365 |
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.08)";
|
| 366 |
+
ctx.lineWidth = 1.5;
|
| 367 |
+
// Concentric rings
|
| 368 |
+
ctx.beginPath(); ctx.arc(cx, cy, 252, 0, Math.PI * 2); ctx.stroke();
|
| 369 |
+
ctx.beginPath(); ctx.arc(cx, cy, 210, 0, Math.PI * 2); ctx.stroke();
|
| 370 |
+
ctx.beginPath(); ctx.arc(cx, cy, 168, 0, Math.PI * 2); ctx.stroke();
|
| 371 |
+
ctx.beginPath(); ctx.arc(cx, cy, 126, 0, Math.PI * 2); ctx.stroke();
|
| 372 |
+
ctx.beginPath(); ctx.arc(cx, cy, 84, 0, Math.PI * 2); ctx.stroke();
|
| 373 |
+
ctx.beginPath(); ctx.arc(cx, cy, 42, 0, Math.PI * 2); ctx.stroke();
|
| 374 |
+
// Rays & Arches
|
| 375 |
+
for (let i = 0; i < 24; i++) {
|
| 376 |
+
const angle = (i * 15 * Math.PI) / 180;
|
| 377 |
+
ctx.beginPath();
|
| 378 |
+
ctx.moveTo(cx + 42 * Math.cos(angle), cy + 42 * Math.sin(angle));
|
| 379 |
+
ctx.lineTo(cx + 210 * Math.cos(angle), cy + 210 * Math.sin(angle));
|
| 380 |
+
ctx.stroke();
|
| 381 |
+
}
|
| 382 |
+
} else if (badgePattern === "Corporate Waves") {
|
| 383 |
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.06)";
|
| 384 |
+
ctx.lineWidth = 2.5;
|
| 385 |
+
ctx.beginPath();
|
| 386 |
+
ctx.moveTo(-50, 420);
|
| 387 |
+
ctx.bezierCurveTo(150, 220, 350, 620, 650, 420);
|
| 388 |
+
ctx.stroke();
|
| 389 |
+
ctx.beginPath();
|
| 390 |
+
ctx.moveTo(-50, 500);
|
| 391 |
+
ctx.bezierCurveTo(150, 300, 350, 700, 650, 500);
|
| 392 |
+
ctx.stroke();
|
| 393 |
+
} else if (badgePattern === "Cyber Grid") {
|
| 394 |
+
ctx.strokeStyle = "rgba(148, 163, 184, 0.04)";
|
| 395 |
+
ctx.lineWidth = 1;
|
| 396 |
+
for (let i = 0; i <= 900; i += 60) {
|
| 397 |
+
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(600, i); ctx.stroke();
|
| 398 |
+
}
|
| 399 |
+
for (let i = 0; i <= 600; i += 60) {
|
| 400 |
+
ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, 900); ctx.stroke();
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
// 2. Draw Header Area (Dynamic gradient based on active theme)
|
| 405 |
+
const gradient = ctx.createLinearGradient(0, 0, 600, 0);
|
| 406 |
+
gradient.addColorStop(0, themeStyles.headerHex1);
|
| 407 |
+
gradient.addColorStop(1, themeStyles.headerHex2);
|
| 408 |
+
ctx.fillStyle = gradient;
|
| 409 |
+
ctx.beginPath();
|
| 410 |
+
if (typeof ctx.roundRect === "function") {
|
| 411 |
+
ctx.roundRect(0, 0, 600, 200, [30, 30, 0, 0]);
|
| 412 |
+
} else {
|
| 413 |
+
const x = 0, y = 0, width = 600, height = 200, radius = 30;
|
| 414 |
+
ctx.moveTo(x + radius, y);
|
| 415 |
+
ctx.lineTo(x + width - radius, y);
|
| 416 |
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
| 417 |
+
ctx.lineTo(x + width, y + height);
|
| 418 |
+
ctx.lineTo(x, y + height);
|
| 419 |
+
ctx.lineTo(x, y + radius);
|
| 420 |
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
| 421 |
+
}
|
| 422 |
+
ctx.fill();
|
| 423 |
+
|
| 424 |
+
// 3. Draw Lanyard Punch Hole representation (premium visual detail)
|
| 425 |
+
ctx.fillStyle = "#f1f5f9";
|
| 426 |
+
ctx.beginPath();
|
| 427 |
+
if (typeof ctx.roundRect === "function") {
|
| 428 |
+
ctx.roundRect(260, 20, 80, 20, 10);
|
| 429 |
+
} else {
|
| 430 |
+
ctx.rect(260, 20, 80, 20);
|
| 431 |
+
}
|
| 432 |
+
ctx.fill();
|
| 433 |
+
ctx.fillStyle = "#0f172a";
|
| 434 |
+
ctx.beginPath();
|
| 435 |
+
if (typeof ctx.roundRect === "function") {
|
| 436 |
+
ctx.roundRect(270, 25, 60, 10, 5);
|
| 437 |
+
} else {
|
| 438 |
+
ctx.rect(270, 25, 60, 10);
|
| 439 |
+
}
|
| 440 |
+
ctx.fill();
|
| 441 |
+
|
| 442 |
+
// Helper function to load an image
|
| 443 |
+
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
| 444 |
+
return new Promise((resolve, reject) => {
|
| 445 |
+
const img = new Image();
|
| 446 |
+
img.crossOrigin = "anonymous"; // Avoid CORS taint
|
| 447 |
+
img.onload = () => resolve(img);
|
| 448 |
+
img.onerror = () => reject(new Error("Failed to load image: " + src));
|
| 449 |
+
img.src = src;
|
| 450 |
+
});
|
| 451 |
+
};
|
| 452 |
+
|
| 453 |
+
// Load logo, photo, and QR code
|
| 454 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
| 455 |
+
const photoSrc = `${baseUrl}/uploads/${employee.employee_id}/front.jpg`;
|
| 456 |
+
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${employee.employee_id}`;
|
| 457 |
+
|
| 458 |
+
let logoImg: HTMLImageElement | null = null;
|
| 459 |
+
if (companyLogo) {
|
| 460 |
+
try {
|
| 461 |
+
logoImg = await loadImage(companyLogo);
|
| 462 |
+
} catch (e) {
|
| 463 |
+
console.warn("Could not load company logo for canvas", e);
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
let photoImg: HTMLImageElement | null = null;
|
| 468 |
+
try {
|
| 469 |
+
photoImg = await loadImage(photoSrc);
|
| 470 |
+
} catch (e) {
|
| 471 |
+
console.warn("Could not load profile photo for canvas", e);
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
let qrImg: HTMLImageElement | null = null;
|
| 475 |
+
try {
|
| 476 |
+
qrImg = await loadImage(qrSrc);
|
| 477 |
+
} catch (e) {
|
| 478 |
+
console.warn("Could not load QR code for canvas", e);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// Draw Company Logo
|
| 482 |
+
if (logoImg) {
|
| 483 |
+
const logoAspectRatio = logoImg.width / logoImg.height;
|
| 484 |
+
const logoHeight = 45;
|
| 485 |
+
const logoWidth = logoHeight * logoAspectRatio;
|
| 486 |
+
ctx.drawImage(logoImg, 50, 75, logoWidth, logoHeight);
|
| 487 |
+
|
| 488 |
+
// Draw Company Name next to logo
|
| 489 |
+
ctx.fillStyle = "#ffffff";
|
| 490 |
+
ctx.font = "bold 24px sans-serif";
|
| 491 |
+
ctx.textAlign = "left";
|
| 492 |
+
ctx.fillText(companyName, 65 + logoWidth, 106);
|
| 493 |
+
} else {
|
| 494 |
+
// Draw default badge branding text
|
| 495 |
+
ctx.fillStyle = themeStyles.primaryHex;
|
| 496 |
+
ctx.font = "black 28px sans-serif";
|
| 497 |
+
ctx.textAlign = "center";
|
| 498 |
+
ctx.fillText("NETRAID", 300, 100);
|
| 499 |
+
|
| 500 |
+
ctx.fillStyle = "#ffffff";
|
| 501 |
+
ctx.font = "bold 16px sans-serif";
|
| 502 |
+
ctx.fillText(companyName.toUpperCase(), 300, 130);
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
// Draw Profile Picture Container (with double border)
|
| 506 |
+
const photoX = 200;
|
| 507 |
+
const photoY = 220;
|
| 508 |
+
const photoSize = 200;
|
| 509 |
+
|
| 510 |
+
// Draw Photo Outer border (theme accent color)
|
| 511 |
+
ctx.strokeStyle = themeStyles.primaryHex;
|
| 512 |
+
ctx.lineWidth = 6;
|
| 513 |
+
ctx.beginPath();
|
| 514 |
+
ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2 + 6, 0, Math.PI * 2);
|
| 515 |
+
ctx.stroke();
|
| 516 |
+
|
| 517 |
+
// Clip and Draw Photo
|
| 518 |
+
ctx.save();
|
| 519 |
+
ctx.beginPath();
|
| 520 |
+
ctx.arc(photoX + photoSize / 2, photoY + photoSize / 2, photoSize / 2, 0, Math.PI * 2);
|
| 521 |
+
ctx.clip();
|
| 522 |
+
if (photoImg) {
|
| 523 |
+
ctx.drawImage(photoImg, photoX, photoY, photoSize, photoSize);
|
| 524 |
+
} else {
|
| 525 |
+
// Draw placeholder avatar
|
| 526 |
+
ctx.fillStyle = "#f1f5f9";
|
| 527 |
+
ctx.fillRect(photoX, photoY, photoSize, photoSize);
|
| 528 |
+
ctx.fillStyle = "#94a3b8";
|
| 529 |
+
ctx.font = "bold 80px sans-serif";
|
| 530 |
+
ctx.textAlign = "center";
|
| 531 |
+
ctx.textBaseline = "middle";
|
| 532 |
+
ctx.fillText("?", photoX + photoSize / 2, photoY + photoSize / 2);
|
| 533 |
+
}
|
| 534 |
+
ctx.restore();
|
| 535 |
+
|
| 536 |
+
// Draw Holographic checkmark seal on Canvas
|
| 537 |
+
ctx.save();
|
| 538 |
+
const sealX = photoX + photoSize - 35;
|
| 539 |
+
const sealY = photoY + photoSize - 35;
|
| 540 |
+
const sealSize = 45;
|
| 541 |
+
const sealGrad = ctx.createLinearGradient(sealX, sealY, sealX + sealSize, sealY + sealSize);
|
| 542 |
+
sealGrad.addColorStop(0, "#fbbf24");
|
| 543 |
+
sealGrad.addColorStop(0.5, "#fb923c");
|
| 544 |
+
sealGrad.addColorStop(1, "#fde047");
|
| 545 |
+
ctx.fillStyle = sealGrad;
|
| 546 |
+
ctx.beginPath();
|
| 547 |
+
ctx.arc(sealX + sealSize / 2, sealY + sealSize / 2, sealSize / 2, 0, Math.PI * 2);
|
| 548 |
+
ctx.fill();
|
| 549 |
+
ctx.strokeStyle = "#ffffff";
|
| 550 |
+
ctx.lineWidth = 2.5;
|
| 551 |
+
ctx.stroke();
|
| 552 |
+
// Draw small tick
|
| 553 |
+
ctx.strokeStyle = "#451a03"; // deep amber
|
| 554 |
+
ctx.lineWidth = 3.5;
|
| 555 |
+
ctx.beginPath();
|
| 556 |
+
ctx.moveTo(sealX + 13, sealY + 22);
|
| 557 |
+
ctx.lineTo(sealX + 20, sealY + 29);
|
| 558 |
+
ctx.lineTo(sealX + 32, sealY + 16);
|
| 559 |
+
ctx.stroke();
|
| 560 |
+
ctx.restore();
|
| 561 |
+
|
| 562 |
+
// Draw Employee Details
|
| 563 |
+
ctx.fillStyle = "#0f172a"; // slate-900
|
| 564 |
+
ctx.font = "bold 32px sans-serif";
|
| 565 |
+
ctx.textAlign = "center";
|
| 566 |
+
ctx.fillText(employee.name.toUpperCase(), 300, 480);
|
| 567 |
+
|
| 568 |
+
ctx.fillStyle = themeStyles.primaryHex; // theme color designation
|
| 569 |
+
ctx.font = "bold 20px sans-serif";
|
| 570 |
+
ctx.fillText(employee.designation?.toUpperCase() || "STAFF MEMBER", 300, 515);
|
| 571 |
+
|
| 572 |
+
// Separator Line
|
| 573 |
+
ctx.strokeStyle = "#f1f5f9";
|
| 574 |
+
ctx.lineWidth = 2;
|
| 575 |
+
ctx.beginPath();
|
| 576 |
+
ctx.moveTo(100, 545);
|
| 577 |
+
ctx.lineTo(500, 545);
|
| 578 |
+
ctx.stroke();
|
| 579 |
+
|
| 580 |
+
// Draw metadata labels
|
| 581 |
+
ctx.textAlign = "left";
|
| 582 |
+
ctx.fillStyle = "#64748b"; // slate-500
|
| 583 |
+
ctx.font = "bold 14px sans-serif";
|
| 584 |
+
ctx.fillText("EMPLOYEE ID", 100, 580);
|
| 585 |
+
ctx.fillText("DEPARTMENT", 320, 580);
|
| 586 |
+
ctx.fillText("DATE OF JOIN", 100, 640);
|
| 587 |
+
ctx.fillText("STATUS", 320, 640);
|
| 588 |
+
|
| 589 |
+
// Draw metadata values
|
| 590 |
+
ctx.fillStyle = "#0f172a"; // slate-900
|
| 591 |
+
ctx.font = "bold 18px sans-serif";
|
| 592 |
+
ctx.fillText(employee.employee_id, 100, 605);
|
| 593 |
+
ctx.fillText(employee.department?.name?.toUpperCase() || "GENERAL", 320, 605);
|
| 594 |
+
|
| 595 |
+
const joinDate = employee.joining_date ? new Date(employee.joining_date).toLocaleDateString("en-US", {
|
| 596 |
+
year: "numeric", month: "short", day: "numeric"
|
| 597 |
+
}) : "N/A";
|
| 598 |
+
ctx.fillText(joinDate.toUpperCase(), 100, 665);
|
| 599 |
+
|
| 600 |
+
// Draw status with verified indicator
|
| 601 |
+
ctx.fillStyle = themeStyles.primaryHex;
|
| 602 |
+
ctx.fillText("VERIFIED", 320, 665);
|
| 603 |
+
|
| 604 |
+
// 4. Bottom section: QR Code
|
| 605 |
+
const qrSize = 130;
|
| 606 |
+
const qrX = 235;
|
| 607 |
+
const qrY = 710;
|
| 608 |
+
|
| 609 |
+
// Draw QR border / background card
|
| 610 |
+
ctx.fillStyle = "#f8fafc";
|
| 611 |
+
ctx.beginPath();
|
| 612 |
+
if (typeof ctx.roundRect === "function") {
|
| 613 |
+
ctx.roundRect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30, 15);
|
| 614 |
+
} else {
|
| 615 |
+
ctx.rect(qrX - 15, qrY - 15, qrSize + 30, qrSize + 30);
|
| 616 |
+
}
|
| 617 |
+
ctx.fill();
|
| 618 |
+
ctx.strokeStyle = "#e2e8f0";
|
| 619 |
+
ctx.lineWidth = 2;
|
| 620 |
+
ctx.stroke();
|
| 621 |
+
|
| 622 |
+
if (qrImg) {
|
| 623 |
+
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
| 624 |
+
} else {
|
| 625 |
+
// Draw placeholder QR
|
| 626 |
+
ctx.strokeStyle = "#cbd5e1";
|
| 627 |
+
ctx.strokeRect(qrX, qrY, qrSize, qrSize);
|
| 628 |
+
ctx.fillStyle = "#94a3b8";
|
| 629 |
+
ctx.font = "12px sans-serif";
|
| 630 |
+
ctx.textAlign = "center";
|
| 631 |
+
ctx.fillText("QR CODE", qrX + qrSize / 2, qrY + qrSize / 2);
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
ctx.fillStyle = "#64748b"; // slate-500
|
| 635 |
+
ctx.font = "bold 12px sans-serif";
|
| 636 |
+
ctx.textAlign = "center";
|
| 637 |
+
ctx.fillText("SCAN AS BACKUP IF KIOSK FACE RECOGNITION FAILS", 300, 875);
|
| 638 |
+
|
| 639 |
+
// Trigger download of the image
|
| 640 |
+
const dataUrl = canvas.toDataURL("image/png");
|
| 641 |
+
const link = document.createElement("a");
|
| 642 |
+
link.download = `ID_Card_${employee.employee_id}.png`;
|
| 643 |
+
link.href = dataUrl;
|
| 644 |
+
link.click();
|
| 645 |
+
toast.success("ID Card PNG downloaded successfully!");
|
| 646 |
+
} catch (err: any) {
|
| 647 |
+
console.error(err);
|
| 648 |
+
toast.error("Failed to generate and download ID Card image: " + err.message);
|
| 649 |
+
}
|
| 650 |
+
};
|
| 651 |
+
|
| 652 |
// Webcam controls
|
| 653 |
const startWebcam = async () => {
|
| 654 |
setErrorMsg(null);
|
|
|
|
| 848 |
|
| 849 |
return (
|
| 850 |
<SidebarLayout>
|
| 851 |
+
<div className="space-y-6 max-w-5xl page-enter relative print-reset-container">
|
| 852 |
{/* CSS Scanner Animations style block */}
|
| 853 |
<style>{`
|
| 854 |
@keyframes scanline {
|
|
|
|
| 1002 |
{/* Upload Fallback File Option */}
|
| 1003 |
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
|
| 1004 |
<h4 className="text-[11.5px] font-bold text-slate-700 uppercase tracking-wider">Manual Photo Upload</h4>
|
|
|
|
|
|
|
|
|
|
| 1005 |
<div className="flex gap-2">
|
| 1006 |
<select
|
| 1007 |
value={selectedPose}
|
|
|
|
| 1030 |
{/* Right: Big visual grid checklist */}
|
| 1031 |
<div className="md:col-span-2 bg-white border border-slate-200 rounded-2xl p-6 shadow-sm space-y-4">
|
| 1032 |
<h3 className="text-sm font-black text-slate-900 tracking-tight">Facial Pose Checklist</h3>
|
|
|
|
|
|
|
|
|
|
| 1033 |
|
| 1034 |
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3.5 pt-2">
|
| 1035 |
{POSE_KEYS.map((key) => {
|
|
|
|
| 1305 |
|
| 1306 |
{/* ─── State 5: SUCCESS SCREEN ─── */}
|
| 1307 |
{captureState === "success" && (
|
| 1308 |
+
<div className="max-w-5xl mx-auto space-y-6 print-reset-container">
|
| 1309 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start animate-scaleIn print-reset-container">
|
| 1310 |
+
|
| 1311 |
+
{/* Left Column: Success message box & Control Panel */}
|
| 1312 |
+
<div className="bg-white border border-slate-200 rounded-3xl p-8 shadow-md text-center space-y-6 no-print">
|
| 1313 |
+
<div className="relative w-20 h-20 mx-auto bg-emerald-50 rounded-full flex items-center justify-center border border-emerald-100 success-circle">
|
| 1314 |
+
<svg className="w-10 h-10 text-emerald-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
| 1315 |
+
<polyline points="20 6 9 17 4 12" className="success-check" />
|
| 1316 |
+
</svg>
|
| 1317 |
+
</div>
|
| 1318 |
|
| 1319 |
+
<div className="space-y-2">
|
| 1320 |
+
<h2 className="text-xl font-bold text-slate-900 tracking-tight">Biometric Profile Secured</h2>
|
| 1321 |
+
<p className="text-xs text-slate-550 leading-relaxed">
|
| 1322 |
+
All 10 facial profiles and mathematical vectors have been successfully registered for <strong className="text-slate-800">{employee?.name}</strong>. The kiosk scan terminal is now ready to verify attendance.
|
| 1323 |
+
</p>
|
| 1324 |
+
</div>
|
| 1325 |
|
| 1326 |
+
<div className="p-4 bg-slate-50 rounded-2xl text-left border border-slate-150 space-y-2">
|
| 1327 |
+
<h4 className="text-[11px] font-bold text-slate-700 uppercase tracking-wider font-mono">System Integrity Verification</h4>
|
| 1328 |
+
<ul className="text-[10px] text-slate-500 font-mono space-y-1">
|
| 1329 |
+
<li className="flex items-center gap-1.5 text-emerald-600">
|
| 1330 |
+
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
| 1331 |
+
10/10 BIOMETRIC POSES RECORDED
|
| 1332 |
+
</li>
|
| 1333 |
+
<li className="flex items-center gap-1.5 text-emerald-600">
|
| 1334 |
+
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
| 1335 |
+
HNSW VECTOR INDEX UPDATED
|
| 1336 |
+
</li>
|
| 1337 |
+
<li className="flex items-center gap-1.5 text-emerald-600">
|
| 1338 |
+
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
| 1339 |
+
BACKUP SECURITY QR CODE ENCODED
|
| 1340 |
+
</li>
|
| 1341 |
+
</ul>
|
| 1342 |
+
</div>
|
| 1343 |
|
| 1344 |
+
<div className="grid grid-cols-2 gap-3 pt-2">
|
| 1345 |
+
<button
|
| 1346 |
+
onClick={() => router.push("/employees")}
|
| 1347 |
+
className="h-10 bg-white hover:bg-slate-55 border border-slate-250 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl transition-all shadow-sm cursor-pointer"
|
| 1348 |
+
>
|
| 1349 |
+
Employees List
|
| 1350 |
+
</button>
|
|
|
|
|
|
|
| 1351 |
|
| 1352 |
+
<button
|
| 1353 |
+
onClick={startAutoCapture}
|
| 1354 |
+
className="h-10 bg-slate-100 hover:bg-slate-150 border border-slate-200 text-slate-800 font-bold text-xs uppercase tracking-wider rounded-xl transition-all cursor-pointer"
|
| 1355 |
+
>
|
| 1356 |
+
Re-enroll
|
| 1357 |
+
</button>
|
| 1358 |
+
</div>
|
| 1359 |
+
|
| 1360 |
+
<div className="border-t border-slate-100 pt-5 space-y-3">
|
| 1361 |
+
<p className="text-[10.5px] text-slate-450 leading-relaxed">
|
| 1362 |
+
Generate the physical identification card below. Keep a digital copy or print immediately.
|
| 1363 |
+
</p>
|
| 1364 |
+
|
| 1365 |
+
<div className="flex gap-3">
|
| 1366 |
+
<button
|
| 1367 |
+
onClick={() => window.print()}
|
| 1368 |
+
className={`flex-1 h-11 ${themeStyles.headerBg} hover:opacity-90 text-white font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg active:scale-[0.98] border border-white/10 cursor-pointer`}
|
| 1369 |
+
>
|
| 1370 |
+
<Printer className="w-4 h-4 text-white/90" />
|
| 1371 |
+
Print ID Badge
|
| 1372 |
+
</button>
|
| 1373 |
+
|
| 1374 |
+
<button
|
| 1375 |
+
onClick={handleDownloadBadge}
|
| 1376 |
+
className="flex-1 h-11 bg-white hover:bg-slate-50 border border-slate-200 text-slate-800 font-extrabold text-[11px] uppercase tracking-widest rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm hover:shadow-md active:scale-[0.98] cursor-pointer"
|
| 1377 |
+
>
|
| 1378 |
+
<Download className="w-4 h-4 text-slate-650" />
|
| 1379 |
+
Download PNG
|
| 1380 |
+
</button>
|
| 1381 |
+
</div>
|
| 1382 |
+
</div>
|
| 1383 |
+
</div>
|
| 1384 |
+
|
| 1385 |
+
{/* Right Column: ID Card Gorgeous 3D Preview */}
|
| 1386 |
+
<div className="flex flex-col items-center justify-center print-reset-container">
|
| 1387 |
+
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-widest mb-3 font-mono no-print">
|
| 1388 |
+
Badge Live Preview ({badgeTheme} + {badgePattern})
|
| 1389 |
+
</div>
|
| 1390 |
+
|
| 1391 |
+
{/* Printable ID Card Element */}
|
| 1392 |
+
<div
|
| 1393 |
+
id="printable-id-card-wrap"
|
| 1394 |
+
className="w-[320px] h-[500px] bg-white rounded-[24px] border border-slate-200 shadow-[0_15px_40px_rgba(0,0,0,0.08)] overflow-hidden relative flex flex-col transition-all hover:scale-[1.01] hover:shadow-[0_20px_50px_rgba(0,0,0,0.12)] duration-300 select-none font-sans animate-fadeIn"
|
| 1395 |
+
>
|
| 1396 |
+
{/* Watermark Pattern Overlay */}
|
| 1397 |
+
<BadgeWatermark type={badgePattern} />
|
| 1398 |
+
|
| 1399 |
+
{/* Holographic Glossy Overlay (Premium aesthetic) */}
|
| 1400 |
+
<div className="absolute inset-0 bg-linear-to-tr from-white/0 via-white/5 to-white/10 pointer-events-none z-10" />
|
| 1401 |
+
|
| 1402 |
+
{/* Lanyard punch hole detail */}
|
| 1403 |
+
<div className="absolute top-3.5 left-1/2 -translate-x-1/2 w-10 h-3 bg-slate-100 rounded-full border border-slate-200/50 flex items-center justify-center pointer-events-none no-print">
|
| 1404 |
+
<div className="w-6 h-1 bg-slate-300 rounded-full" />
|
| 1405 |
+
</div>
|
| 1406 |
+
|
| 1407 |
+
{/* Header: Company Name & Logo */}
|
| 1408 |
+
<div className={`h-[105px] ${themeStyles.headerBg} relative flex flex-col justify-end px-5 pb-3`}>
|
| 1409 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.1),transparent_70%)] pointer-events-none" />
|
| 1410 |
+
|
| 1411 |
+
<div className="flex items-center gap-3.5 mt-2 relative z-10">
|
| 1412 |
+
{companyLogo ? (
|
| 1413 |
+
<img
|
| 1414 |
+
src={companyLogo}
|
| 1415 |
+
alt="Logo"
|
| 1416 |
+
className="h-8 max-w-[90px] object-contain shrink-0"
|
| 1417 |
+
/>
|
| 1418 |
+
) : (
|
| 1419 |
+
<div className="w-7 h-7 rounded-lg bg-white/20 backdrop-blur-xs flex items-center justify-center text-white text-[10px] font-black tracking-tighter shadow-sm font-mono shrink-0 border border-white/10">
|
| 1420 |
+
NID
|
| 1421 |
+
</div>
|
| 1422 |
+
)}
|
| 1423 |
+
<div className="flex flex-col min-w-0">
|
| 1424 |
+
<span className="text-[12px] font-black tracking-wider text-white uppercase truncate">
|
| 1425 |
+
{companyName}
|
| 1426 |
+
</span>
|
| 1427 |
+
<span className="text-[7.5px] font-bold text-white/80 tracking-widest uppercase">
|
| 1428 |
+
SECURED IDENTITY CARD
|
| 1429 |
+
</span>
|
| 1430 |
+
</div>
|
| 1431 |
+
</div>
|
| 1432 |
+
</div>
|
| 1433 |
+
|
| 1434 |
+
{/* Body Content */}
|
| 1435 |
+
<div className="flex-1 flex flex-col items-center pt-7 px-6 relative bg-transparent z-10">
|
| 1436 |
+
|
| 1437 |
+
{/* Employee Profile Image Container */}
|
| 1438 |
+
<div className={`relative w-[120px] h-[120px] rounded-full p-1 bg-gradient-to-tr ${themeStyles.photoBorder} shadow-md`}>
|
| 1439 |
+
<div className="w-full h-full rounded-full overflow-hidden border-2 border-white bg-slate-100">
|
| 1440 |
+
<img
|
| 1441 |
+
src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${employee?.employee_id}/front.jpg`}
|
| 1442 |
+
alt={employee?.name}
|
| 1443 |
+
onError={(e) => {
|
| 1444 |
+
(e.target as HTMLImageElement).src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="%2394a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
| 1445 |
+
}}
|
| 1446 |
+
className="w-full h-full object-cover"
|
| 1447 |
+
/>
|
| 1448 |
+
</div>
|
| 1449 |
+
|
| 1450 |
+
{/* Holographic Official Seal */}
|
| 1451 |
+
<div className="absolute -bottom-1.5 -right-1.5 bg-gradient-to-tr from-amber-400 via-orange-400 to-yellow-300 text-amber-950 font-bold border-2 border-white rounded-full w-6 h-6 flex items-center justify-center shadow-md z-10 pointer-events-none">
|
| 1452 |
+
<svg className="w-3.5 h-3.5 stroke-amber-950" viewBox="0 0 24 24" fill="none" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
| 1453 |
+
<polyline points="20 6 9 17 4 12" />
|
| 1454 |
+
</svg>
|
| 1455 |
+
</div>
|
| 1456 |
+
</div>
|
| 1457 |
+
|
| 1458 |
+
{/* Employee Identity details */}
|
| 1459 |
+
<div className="text-center mt-4 space-y-0.5">
|
| 1460 |
+
<h3 className="text-base font-extrabold text-slate-900 tracking-tight uppercase leading-tight">
|
| 1461 |
+
{employee?.name}
|
| 1462 |
+
</h3>
|
| 1463 |
+
<p className={`text-[11px] font-bold ${themeStyles.accentText} tracking-widest uppercase`}>
|
| 1464 |
+
{employee?.designation || "Staff Member"}
|
| 1465 |
+
</p>
|
| 1466 |
+
</div>
|
| 1467 |
+
|
| 1468 |
+
{/* Meta Fields Table */}
|
| 1469 |
+
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 w-full border-t border-slate-100 mt-5 pt-3.5 bg-transparent">
|
| 1470 |
+
<div>
|
| 1471 |
+
<span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 1472 |
+
Employee ID
|
| 1473 |
+
</span>
|
| 1474 |
+
<span className="text-[10px] font-extrabold text-slate-800 tracking-tight block">
|
| 1475 |
+
{employee?.employee_id}
|
| 1476 |
+
</span>
|
| 1477 |
+
</div>
|
| 1478 |
+
<div>
|
| 1479 |
+
<span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 1480 |
+
Department
|
| 1481 |
+
</span>
|
| 1482 |
+
<span className="text-[10px] font-extrabold text-slate-800 tracking-tight block truncate uppercase">
|
| 1483 |
+
{employee?.department?.name || "General"}
|
| 1484 |
+
</span>
|
| 1485 |
+
</div>
|
| 1486 |
+
<div>
|
| 1487 |
+
<span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 1488 |
+
Date of Join
|
| 1489 |
+
</span>
|
| 1490 |
+
<span className="text-[10px] font-extrabold text-slate-800 tracking-tight block">
|
| 1491 |
+
{employee?.joining_date ? new Date(employee.joining_date).toLocaleDateString("en-US", {
|
| 1492 |
+
year: "numeric", month: "short", day: "numeric"
|
| 1493 |
+
}) : "N/A"}
|
| 1494 |
+
</span>
|
| 1495 |
+
</div>
|
| 1496 |
+
<div>
|
| 1497 |
+
<span className="text-[7.5px] font-extrabold text-slate-400 uppercase tracking-widest block font-mono">
|
| 1498 |
+
Security Status
|
| 1499 |
+
</span>
|
| 1500 |
+
<span className={`text-[10px] font-bold ${themeStyles.accentText} tracking-tight flex items-center gap-1`}>
|
| 1501 |
+
<span className={`w-1.5 h-1.5 rounded-full ${themeStyles.dotColor}`} />
|
| 1502 |
+
VERIFIED
|
| 1503 |
+
</span>
|
| 1504 |
+
</div>
|
| 1505 |
+
</div>
|
| 1506 |
+
</div>
|
| 1507 |
+
|
| 1508 |
+
{/* QR Code Fallback Section */}
|
| 1509 |
+
<div className="bg-slate-50/80 backdrop-blur-xs border-t border-slate-100 h-[120px] flex items-center justify-between px-6 pb-2.5 shrink-0 z-10">
|
| 1510 |
+
<div className="flex flex-col min-w-0 pr-2">
|
| 1511 |
+
<span className="text-[8px] font-black text-slate-900 tracking-wider uppercase font-mono">
|
| 1512 |
+
SCAN TO VERIFY
|
| 1513 |
+
</span>
|
| 1514 |
+
<p className="text-[7px] text-slate-450 font-medium leading-snug mt-0.5 max-w-[130px] font-mono">
|
| 1515 |
+
If facial scanner recognition fails, scan this backup QR code at Kiosk terminal.
|
| 1516 |
+
</p>
|
| 1517 |
+
</div>
|
| 1518 |
+
|
| 1519 |
+
{/* QR Code Container */}
|
| 1520 |
+
<div className="w-[75px] h-[75px] bg-white rounded-lg border border-slate-200/80 p-1 flex items-center justify-center shadow-2xs shrink-0 font-mono">
|
| 1521 |
+
<img
|
| 1522 |
+
src={`https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${employee?.employee_id}`}
|
| 1523 |
+
alt="QR Code"
|
| 1524 |
+
className="w-full h-full object-contain"
|
| 1525 |
+
/>
|
| 1526 |
+
</div>
|
| 1527 |
+
</div>
|
| 1528 |
+
</div>
|
| 1529 |
+
</div>
|
| 1530 |
</div>
|
| 1531 |
+
|
| 1532 |
+
{/* Print Styling Injected locally */}
|
| 1533 |
+
<style dangerouslySetInnerHTML={{ __html: `
|
| 1534 |
+
@media print {
|
| 1535 |
+
/* Hide sidebar, headers, footers and any elements marked no-print */
|
| 1536 |
+
aside, header, footer, .no-print, button, input, select, [role="navigation"], .ambient-bg, .mesh-bg {
|
| 1537 |
+
display: none !important;
|
| 1538 |
+
}
|
| 1539 |
+
|
| 1540 |
+
@page {
|
| 1541 |
+
size: portrait;
|
| 1542 |
+
margin: 0;
|
| 1543 |
+
}
|
| 1544 |
+
|
| 1545 |
+
/* Reset container layout models so they don't center, shift or clip the content */
|
| 1546 |
+
html, body, html.dark, body.dark {
|
| 1547 |
+
margin: 0 !important;
|
| 1548 |
+
padding: 0 !important;
|
| 1549 |
+
width: 100% !important;
|
| 1550 |
+
height: 100% !important;
|
| 1551 |
+
overflow: hidden !important;
|
| 1552 |
+
background-color: white !important;
|
| 1553 |
+
background: white !important;
|
| 1554 |
+
position: relative !important;
|
| 1555 |
+
}
|
| 1556 |
+
|
| 1557 |
+
/* Reset only parent layout hierarchy, leaving internal elements of card intact */
|
| 1558 |
+
main,
|
| 1559 |
+
body > div,
|
| 1560 |
+
#sidebar-layout-container,
|
| 1561 |
+
.sidebar-layout-content,
|
| 1562 |
+
.print-reset-container,
|
| 1563 |
+
.page-enter,
|
| 1564 |
+
.dark main,
|
| 1565 |
+
.dark body > div,
|
| 1566 |
+
.dark #sidebar-layout-container,
|
| 1567 |
+
.dark .sidebar-layout-content,
|
| 1568 |
+
.dark .print-reset-container,
|
| 1569 |
+
.dark .page-enter {
|
| 1570 |
+
border: none !important;
|
| 1571 |
+
box-shadow: none !important;
|
| 1572 |
+
background: transparent !important;
|
| 1573 |
+
background-color: transparent !important;
|
| 1574 |
+
padding: 0 !important;
|
| 1575 |
+
margin: 0 !important;
|
| 1576 |
+
height: auto !important;
|
| 1577 |
+
min-height: 0 !important;
|
| 1578 |
+
overflow: visible !important;
|
| 1579 |
+
position: static !important;
|
| 1580 |
+
width: auto !important;
|
| 1581 |
+
display: block !important;
|
| 1582 |
+
/* Clear transform/animations that would trap fixed/absolute positioning context */
|
| 1583 |
+
transform: none !important;
|
| 1584 |
+
animation: none !important;
|
| 1585 |
+
transition: none !important;
|
| 1586 |
+
}
|
| 1587 |
+
|
| 1588 |
+
/* Center and display only the card wrapper */
|
| 1589 |
+
#printable-id-card-wrap {
|
| 1590 |
+
display: flex !important;
|
| 1591 |
+
flex-direction: column !important;
|
| 1592 |
+
visibility: visible !important;
|
| 1593 |
+
position: fixed !important;
|
| 1594 |
+
left: 50% !important;
|
| 1595 |
+
top: 50% !important;
|
| 1596 |
+
transform: translate(-50%, -50%) scale(1.1) !important;
|
| 1597 |
+
border: 1px solid #cbd5e1 !important;
|
| 1598 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
|
| 1599 |
+
border-radius: 24px !important;
|
| 1600 |
+
background-color: white !important;
|
| 1601 |
+
width: 320px !important;
|
| 1602 |
+
height: 500px !important;
|
| 1603 |
+
margin: 0 !important;
|
| 1604 |
+
overflow: hidden !important;
|
| 1605 |
+
page-break-inside: avoid;
|
| 1606 |
+
}
|
| 1607 |
+
|
| 1608 |
+
#printable-id-card-wrap * {
|
| 1609 |
+
visibility: visible !important;
|
| 1610 |
+
}
|
| 1611 |
+
|
| 1612 |
+
/* Force print watermark color & visibility */
|
| 1613 |
+
.badge-watermark-container,
|
| 1614 |
+
.dark .badge-watermark-container {
|
| 1615 |
+
color: #475569 !important;
|
| 1616 |
+
opacity: 0.12 !important;
|
| 1617 |
+
}
|
| 1618 |
+
|
| 1619 |
+
/* Ensure background colors and images print properly */
|
| 1620 |
+
* {
|
| 1621 |
+
-webkit-print-color-adjust: exact !important;
|
| 1622 |
+
print-color-adjust: exact !important;
|
| 1623 |
+
}
|
| 1624 |
+
}
|
| 1625 |
+
` }} />
|
| 1626 |
</div>
|
| 1627 |
)}
|
| 1628 |
+
|
| 1629 |
+
|
| 1630 |
</div>
|
| 1631 |
|
| 1632 |
{/* Clear Biometric Confirmation Modal */}
|
frontend/app/globals.css
CHANGED
|
@@ -2,37 +2,37 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
/* ─── Design Tokens (
|
| 6 |
:root {
|
| 7 |
-
--bg-base: #
|
| 8 |
-
--bg-surface: #ffffff; /*
|
| 9 |
-
--bg-elevated: #
|
| 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: #
|
| 26 |
-
--text-faint: #
|
| 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 ─── */
|
|
@@ -198,6 +198,15 @@ select.input-field option {
|
|
| 198 |
transition: background-color 5000s ease-in-out 0s;
|
| 199 |
}
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
/* Hide native Chrome autofill key/card icons to prevent overlap with Lucide icons */
|
| 202 |
.input-field::-webkit-contacts-auto-fill-button,
|
| 203 |
.input-field::-webkit-credentials-auto-fill-button {
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
/* ─── Design Tokens (Premium Slate & Modern Corporate Theme) ─── */
|
| 6 |
:root {
|
| 7 |
+
--bg-base: #f8fafc; /* Slate 50 - soft grey-blue */
|
| 8 |
+
--bg-surface: #ffffff; /* White card base */
|
| 9 |
+
--bg-elevated: #f1f5f9; /* Slate 100 */
|
| 10 |
+
--bg-overlay: rgba(255, 255, 255, 0.96);
|
| 11 |
+
|
| 12 |
+
--accent-primary: #0f172a; /* Slate 900 */
|
| 13 |
+
--accent-cyan: #06b6d4; /* Cyan 500 */
|
| 14 |
+
--accent-indigo: #4f46e5; /* Indigo 600 */
|
| 15 |
+
--accent-emerald: #10b981; /* Emerald 500 */
|
| 16 |
+
--accent-amber: #f59e0b; /* Amber 500 */
|
| 17 |
+
--accent-rose: #ef4444; /* Rose 500 */
|
| 18 |
+
|
| 19 |
+
--border-subtle: #f1f5f9; /* Slate 100 */
|
| 20 |
+
--border-medium: #e2e8f0; /* Slate 200 */
|
| 21 |
+
--border-strong: #cbd5e1; /* Slate 300 */
|
| 22 |
+
|
| 23 |
+
--text-primary: #0f172a; /* Slate 900 */
|
| 24 |
+
--text-secondary: #334155; /* Slate 700 */
|
| 25 |
+
--text-muted: #64748b; /* Slate 500 */
|
| 26 |
+
--text-faint: #94a3b8; /* Slate 400 */
|
| 27 |
+
|
| 28 |
+
--glow-blue: 0 0 20px rgba(59, 130, 246, 0.08);
|
| 29 |
+
--glow-cyan: 0 0 20px rgba(6, 182, 212, 0.12);
|
| 30 |
+
--radius-sm: 8px;
|
| 31 |
+
--radius-md: 12px;
|
| 32 |
+
--radius-lg: 16px;
|
| 33 |
+
--radius-xl: 20px;
|
| 34 |
+
--radius-2xl: 24px;
|
| 35 |
+
--radius-3xl: 32px;
|
| 36 |
}
|
| 37 |
|
| 38 |
/* ─── Base Reset ─── */
|
|
|
|
| 198 |
transition: background-color 5000s ease-in-out 0s;
|
| 199 |
}
|
| 200 |
|
| 201 |
+
.dark .input-field:-webkit-autofill,
|
| 202 |
+
.dark .input-field:-webkit-autofill:hover,
|
| 203 |
+
.dark .input-field:-webkit-autofill:focus,
|
| 204 |
+
.dark .input-field:-webkit-autofill:active {
|
| 205 |
+
-webkit-text-fill-color: var(--text-primary) !important;
|
| 206 |
+
-webkit-box-shadow: 0 0 0px 1000px #18181b inset !important;
|
| 207 |
+
box-shadow: 0 0 0px 1000px #18181b inset !important;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
/* Hide native Chrome autofill key/card icons to prevent overlap with Lucide icons */
|
| 211 |
.input-field::-webkit-contacts-auto-fill-button,
|
| 212 |
.input-field::-webkit-credentials-auto-fill-button {
|
frontend/app/kiosk/page.tsx
CHANGED
|
@@ -3,10 +3,12 @@
|
|
| 3 |
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
import {
|
| 5 |
Camera, UserCheck, ShieldAlert, HelpCircle, Maximize, Minimize,
|
| 6 |
-
Volume2, VolumeX, Clock as ClockIcon, Play, Wifi, Fingerprint, Shield
|
|
|
|
| 7 |
} from "lucide-react";
|
| 8 |
import { getBackendUrl } from "@/app/utils/api";
|
| 9 |
import { useToast } from "@/app/utils/toast";
|
|
|
|
| 10 |
|
| 11 |
function formatTime12h(timeStr: string | null | undefined): string {
|
| 12 |
if (!timeStr) return "";
|
|
@@ -33,7 +35,8 @@ export default function KioskPage() {
|
|
| 33 |
const [currentDate, setCurrentDate] = useState("");
|
| 34 |
const [matchTime, setMatchTime] = useState("");
|
| 35 |
const [scanning, setScanning] = useState(false);
|
| 36 |
-
const [scanStatus, setScanStatus] = useState<"idle" | "success" | "spoof" | "unknown" | "maintenance" | "no_employees" | "ask_checkout" | "locked">("idle");
|
|
|
|
| 37 |
const [scanResult, setScanResult] = useState<any>(null);
|
| 38 |
const [scanFeedback, setScanFeedback] = useState<string | null>(null);
|
| 39 |
const [cameraLabel] = useState("Main Entrance");
|
|
@@ -41,13 +44,37 @@ export default function KioskPage() {
|
|
| 41 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 42 |
const [profileImageError, setProfileImageError] = useState(false);
|
| 43 |
const [lastImage, setLastImage] = useState<string | null>(null);
|
|
|
|
| 44 |
const [engineMode, setEngineMode] = useState<string>("LOADING ENGINE...");
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 47 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 48 |
const streamRef = useRef<MediaStream | null>(null);
|
| 49 |
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
| 50 |
const cooldownRef = useRef(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
// Clock
|
| 53 |
useEffect(() => {
|
|
@@ -61,6 +88,62 @@ export default function KioskPage() {
|
|
| 61 |
return () => clearInterval(t);
|
| 62 |
}, []);
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
// Fetch engine state dynamically on mount
|
| 65 |
useEffect(() => {
|
| 66 |
const checkEngine = async () => {
|
|
@@ -96,7 +179,7 @@ export default function KioskPage() {
|
|
| 96 |
}
|
| 97 |
};
|
| 98 |
|
| 99 |
-
const startKiosk = async () => {
|
| 100 |
setScanFeedback(null);
|
| 101 |
try {
|
| 102 |
const stream = await navigator.mediaDevices.getUserMedia({
|
|
@@ -111,8 +194,13 @@ export default function KioskPage() {
|
|
| 111 |
}
|
| 112 |
|
| 113 |
setKioskActive(true);
|
| 114 |
-
setScanStatus(
|
| 115 |
-
intervalRef.current
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
} catch (err) {
|
| 117 |
console.error(err);
|
| 118 |
toast.error("Unable to access camera. Please check browser permissions and ensure no other application is using it.");
|
|
@@ -130,8 +218,43 @@ export default function KioskPage() {
|
|
| 130 |
setScanStatus("idle");
|
| 131 |
setScanResult(null);
|
| 132 |
setScanFeedback(null);
|
|
|
|
| 133 |
};
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
useEffect(() => {
|
| 136 |
return () => {
|
| 137 |
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
@@ -149,29 +272,98 @@ export default function KioskPage() {
|
|
| 149 |
// Check if the video is actually ready and playing
|
| 150 |
if (video.readyState < 2) return;
|
| 151 |
|
| 152 |
-
canvas.width = 640; canvas.height =
|
| 153 |
ctx.translate(canvas.width, 0); ctx.scale(-1, 1);
|
| 154 |
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 155 |
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 156 |
|
| 157 |
const base64 = canvas.toDataURL("image/jpeg", 0.82);
|
| 158 |
setLastImage(base64);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
setScanning(true);
|
| 160 |
try {
|
| 161 |
// Connect to the backend running at the configured URL
|
| 162 |
const url = `${getBackendUrl()}/kiosk/scan`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
const res = await fetch(url, {
|
| 164 |
method: "POST",
|
| 165 |
headers: { "Content-Type": "application/json" },
|
| 166 |
-
body: JSON.stringify(
|
| 167 |
});
|
| 168 |
if (!res.ok) throw new Error("Scan failed");
|
| 169 |
const data = await res.json();
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
if (data.status === "no_face") {
|
| 172 |
setScanFeedback("Align your face in frame");
|
|
|
|
| 173 |
} else if (data.status === "multiple_faces") {
|
| 174 |
setScanFeedback("One person at a time");
|
|
|
|
| 175 |
} else {
|
| 176 |
setScanFeedback(null);
|
| 177 |
handleResult(data);
|
|
@@ -179,20 +371,34 @@ export default function KioskPage() {
|
|
| 179 |
} catch (err) {
|
| 180 |
console.error("Scan API connection error:", err);
|
| 181 |
setScanFeedback("Connection error");
|
|
|
|
| 182 |
} finally {
|
| 183 |
setScanning(false);
|
| 184 |
}
|
| 185 |
};
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
const confirmCheckout = async () => {
|
| 188 |
if (!lastImage) return;
|
| 189 |
setScanning(true);
|
| 190 |
try {
|
| 191 |
const url = `${getBackendUrl()}/kiosk/scan`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
const res = await fetch(url, {
|
| 193 |
method: "POST",
|
| 194 |
headers: { "Content-Type": "application/json" },
|
| 195 |
-
body: JSON.stringify(
|
| 196 |
});
|
| 197 |
if (!res.ok) throw new Error("Checkout confirmation failed");
|
| 198 |
const data = await res.json();
|
|
@@ -205,7 +411,84 @@ export default function KioskPage() {
|
|
| 205 |
}
|
| 206 |
};
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
const handleResult = (data: any) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
if (data.status === "success") {
|
| 210 |
setProfileImageError(false);
|
| 211 |
setScanStatus("success"); setScanResult(data);
|
|
@@ -226,9 +509,13 @@ export default function KioskPage() {
|
|
| 226 |
console.error("Autoplay voice greeting failed:", err);
|
| 227 |
});
|
| 228 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
} else if (data.status === "locked") {
|
| 230 |
setScanStatus("locked"); setScanResult(data);
|
| 231 |
-
triggerCooldown(
|
| 232 |
} else if (data.status === "spoof_detected") {
|
| 233 |
setScanStatus("spoof"); setScanResult(data);
|
| 234 |
triggerCooldown(3000);
|
|
@@ -241,20 +528,115 @@ export default function KioskPage() {
|
|
| 241 |
} else if (data.status === "no_employees") {
|
| 242 |
setScanStatus("no_employees"); setScanResult(data);
|
| 243 |
triggerCooldown(4000);
|
|
|
|
|
|
|
|
|
|
| 244 |
}
|
| 245 |
};
|
| 246 |
|
| 247 |
const triggerCooldown = (ms: number) => {
|
|
|
|
| 248 |
cooldownRef.current = true;
|
| 249 |
-
setTimeout(() => {
|
| 250 |
cooldownRef.current = false;
|
| 251 |
setScanStatus("idle");
|
| 252 |
setScanResult(null);
|
|
|
|
|
|
|
| 253 |
}, ms);
|
| 254 |
};
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
return (
|
| 257 |
<div className="min-h-screen bg-[var(--bg-base)] text-[var(--text-primary)] flex flex-col relative select-none overflow-hidden font-sans">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
{/* Background Grid Mesh */}
|
| 259 |
<div className="absolute inset-0 mesh-bg pointer-events-none" />
|
| 260 |
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[350px] bg-slate-500/5 blur-[120px] pointer-events-none rounded-full" />
|
|
@@ -283,11 +665,17 @@ export default function KioskPage() {
|
|
| 283 |
</div>
|
| 284 |
</div>
|
| 285 |
|
| 286 |
-
{/* Clock */}
|
| 287 |
-
<div className="
|
| 288 |
<p className="text-base font-bold font-mono tracking-tight text-[var(--text-primary)] leading-none tabular-nums">
|
| 289 |
{currentTime || "00:00:00"}
|
| 290 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
</div>
|
| 292 |
|
| 293 |
{/* Controls */}
|
|
@@ -356,7 +744,7 @@ export default function KioskPage() {
|
|
| 356 |
</div>
|
| 357 |
|
| 358 |
<button
|
| 359 |
-
onClick={startKiosk}
|
| 360 |
className="btn-primary h-10 px-8 text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all"
|
| 361 |
>
|
| 362 |
<Play className="w-4 h-4 fill-current" />
|
|
@@ -375,6 +763,26 @@ export default function KioskPage() {
|
|
| 375 |
autoPlay playsInline muted
|
| 376 |
/>
|
| 377 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
{/* Scanning overlay: active standby state */}
|
| 379 |
{kioskActive && scanStatus === "idle" && (
|
| 380 |
<>
|
|
@@ -398,136 +806,271 @@ export default function KioskPage() {
|
|
| 398 |
|
| 399 |
{/* SUCCESS screen */}
|
| 400 |
{kioskActive && scanStatus === "success" && (
|
| 401 |
-
<div className="absolute inset-0
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
</div>
|
| 420 |
|
| 421 |
{/* Name and Designation */}
|
| 422 |
-
<div className="space-y-
|
| 423 |
-
<h2 className="text-
|
| 424 |
{scanResult?.employee?.name || "Employee"}
|
| 425 |
</h2>
|
| 426 |
-
<p className="text-[11px] text-
|
| 427 |
-
{scanResult?.employee?.designation || "
|
| 428 |
</p>
|
| 429 |
</div>
|
| 430 |
|
| 431 |
-
{/*
|
| 432 |
-
<div className="
|
| 433 |
-
<
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
| 440 |
</span>
|
| 441 |
-
{scanResult?.attendance?.working_hours > 0 && (
|
| 442 |
-
<div className="mt-1 px-3 py-1 bg-zinc-50 border border-zinc-200 rounded-lg">
|
| 443 |
-
<p className="text-[9px] text-zinc-400 font-bold uppercase tracking-wider">Time Worked Today</p>
|
| 444 |
-
<p className="text-xs font-bold text-zinc-755 font-mono mt-0.5">{scanResult.attendance.working_hours.toFixed(2)} hours</p>
|
| 445 |
-
</div>
|
| 446 |
-
)}
|
| 447 |
-
{scanResult?.confidence && (
|
| 448 |
-
<p className="text-[8.5px] text-zinc-400 font-mono mt-1">
|
| 449 |
-
Confidence: {(scanResult.confidence * 100).toFixed(0)}% · {(scanResult.liveness_score * 100).toFixed(0)}% Real
|
| 450 |
-
</p>
|
| 451 |
-
)}
|
| 452 |
</div>
|
| 453 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
</div>
|
| 455 |
</div>
|
| 456 |
)}
|
| 457 |
|
| 458 |
{/* ASK_CHECKOUT screen */}
|
| 459 |
{kioskActive && scanStatus === "ask_checkout" && (
|
| 460 |
-
<div className="absolute inset-0
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 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-
|
| 481 |
{scanResult?.employee?.name || "Employee"}
|
| 482 |
</h2>
|
| 483 |
-
<p className="text-[11.5px] text-
|
| 484 |
-
Already checked in at <span className="font-mono font-bold text-
|
| 485 |
</p>
|
| 486 |
</div>
|
| 487 |
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
<
|
|
|
|
| 491 |
{scanResult?.working_hours_so_far?.toFixed(2)} hours
|
| 492 |
-
</
|
| 493 |
</div>
|
| 494 |
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
<
|
|
|
|
| 498 |
<button
|
| 499 |
onClick={confirmCheckout}
|
| 500 |
disabled={scanning}
|
| 501 |
-
className="px-
|
| 502 |
>
|
| 503 |
Yes, Check Out
|
| 504 |
</button>
|
| 505 |
<button
|
| 506 |
onClick={() => {
|
| 507 |
-
|
| 508 |
-
setScanResult(null);
|
| 509 |
}}
|
| 510 |
disabled={scanning}
|
| 511 |
-
className="px-
|
| 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
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
</div>
|
| 528 |
-
<
|
| 529 |
-
|
| 530 |
-
{scanResult?.message || "Your attendance is locked for today. Emergency re-entry must be approved by an Admin."}
|
| 531 |
</p>
|
| 532 |
</div>
|
| 533 |
</div>
|
|
@@ -535,43 +1078,78 @@ export default function KioskPage() {
|
|
| 535 |
|
| 536 |
{/* SPOOF screen */}
|
| 537 |
{kioskActive && scanStatus === "spoof" && (
|
| 538 |
-
<div className="absolute inset-0
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
</div>
|
| 559 |
-
<
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
</div>
|
| 562 |
</div>
|
| 563 |
)}
|
| 564 |
|
| 565 |
{/* MAINTENANCE screen */}
|
| 566 |
{kioskActive && scanStatus === "maintenance" && (
|
| 567 |
-
<div className="absolute inset-0
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
</div>
|
| 572 |
-
<
|
| 573 |
-
|
| 574 |
-
|
|
|
|
|
|
|
|
|
|
| 575 |
</p>
|
| 576 |
</div>
|
| 577 |
</div>
|
|
@@ -579,19 +1157,70 @@ export default function KioskPage() {
|
|
| 579 |
|
| 580 |
{/* NO_EMPLOYEES screen */}
|
| 581 |
{kioskActive && scanStatus === "no_employees" && (
|
| 582 |
-
<div className="absolute inset-0
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
</div>
|
| 587 |
-
<
|
| 588 |
-
|
| 589 |
-
|
|
|
|
|
|
|
|
|
|
| 590 |
</p>
|
| 591 |
</div>
|
| 592 |
</div>
|
| 593 |
)}
|
| 594 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
{/* Frame Info HUD Overlay */}
|
| 596 |
{kioskActive && (
|
| 597 |
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between pointer-events-none z-10">
|
|
|
|
| 3 |
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
import {
|
| 5 |
Camera, UserCheck, ShieldAlert, HelpCircle, Maximize, Minimize,
|
| 6 |
+
Volume2, VolumeX, Clock as ClockIcon, Play, Wifi, Fingerprint, Shield,
|
| 7 |
+
QrCode, Loader2
|
| 8 |
} from "lucide-react";
|
| 9 |
import { getBackendUrl } from "@/app/utils/api";
|
| 10 |
import { useToast } from "@/app/utils/toast";
|
| 11 |
+
import jsQR from "jsqr";
|
| 12 |
|
| 13 |
function formatTime12h(timeStr: string | null | undefined): string {
|
| 14 |
if (!timeStr) return "";
|
|
|
|
| 35 |
const [currentDate, setCurrentDate] = useState("");
|
| 36 |
const [matchTime, setMatchTime] = useState("");
|
| 37 |
const [scanning, setScanning] = useState(false);
|
| 38 |
+
const [scanStatus, setScanStatus] = useState<"idle" | "success" | "spoof" | "unknown" | "maintenance" | "no_employees" | "ask_checkout" | "locked" | "needs_qr" | "location_error">("idle");
|
| 39 |
+
const [coords, setCoords] = useState<{ latitude: number | null; longitude: number | null }>({ latitude: null, longitude: null });
|
| 40 |
const [scanResult, setScanResult] = useState<any>(null);
|
| 41 |
const [scanFeedback, setScanFeedback] = useState<string | null>(null);
|
| 42 |
const [cameraLabel] = useState("Main Entrance");
|
|
|
|
| 44 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 45 |
const [profileImageError, setProfileImageError] = useState(false);
|
| 46 |
const [lastImage, setLastImage] = useState<string | null>(null);
|
| 47 |
+
const [qrDetectedData, setQrDetectedData] = useState<string | null>(null);
|
| 48 |
const [engineMode, setEngineMode] = useState<string>("LOADING ENGINE...");
|
| 49 |
+
const [qrCodeVal, setQrCodeVal] = useState("");
|
| 50 |
+
const [qrError, setQrError] = useState<string | null>(null);
|
| 51 |
+
const [faceBbox, setFaceBbox] = useState<number[] | null>(null);
|
| 52 |
|
| 53 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 54 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 55 |
const streamRef = useRef<MediaStream | null>(null);
|
| 56 |
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
| 57 |
const cooldownRef = useRef(false);
|
| 58 |
+
const cooldownTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
| 59 |
+
const statusRef = useRef<string>("idle");
|
| 60 |
+
const kioskActiveRef = useRef(false);
|
| 61 |
+
const scanFunctionRef = useRef<(() => void) | undefined>(undefined);
|
| 62 |
+
const startKioskRef = useRef<any>(null);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
statusRef.current = scanStatus;
|
| 66 |
+
}, [scanStatus]);
|
| 67 |
+
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
kioskActiveRef.current = kioskActive;
|
| 70 |
+
}, [kioskActive]);
|
| 71 |
+
|
| 72 |
+
const clearCooldownTimeout = () => {
|
| 73 |
+
if (cooldownTimeoutRef.current) {
|
| 74 |
+
clearTimeout(cooldownTimeoutRef.current);
|
| 75 |
+
cooldownTimeoutRef.current = null;
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
|
| 79 |
// Clock
|
| 80 |
useEffect(() => {
|
|
|
|
| 88 |
return () => clearInterval(t);
|
| 89 |
}, []);
|
| 90 |
|
| 91 |
+
useEffect(() => {
|
| 92 |
+
if (typeof window !== "undefined" && navigator.geolocation) {
|
| 93 |
+
const geoId = navigator.geolocation.watchPosition(
|
| 94 |
+
(position) => {
|
| 95 |
+
setCoords({
|
| 96 |
+
latitude: position.coords.latitude,
|
| 97 |
+
longitude: position.coords.longitude
|
| 98 |
+
});
|
| 99 |
+
},
|
| 100 |
+
(err) => {
|
| 101 |
+
console.error("Kiosk geolocation error:", err);
|
| 102 |
+
},
|
| 103 |
+
{ enableHighAccuracy: true, timeout: 10000 }
|
| 104 |
+
);
|
| 105 |
+
return () => navigator.geolocation.clearWatch(geoId);
|
| 106 |
+
}
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
+
const playQrChime = () => {
|
| 110 |
+
if (typeof window !== "undefined") {
|
| 111 |
+
try {
|
| 112 |
+
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
| 113 |
+
const audioCtx = new AudioContextClass();
|
| 114 |
+
if (audioCtx.state === "suspended") {
|
| 115 |
+
audioCtx.resume();
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const osc1 = audioCtx.createOscillator();
|
| 119 |
+
const gain1 = audioCtx.createGain();
|
| 120 |
+
osc1.connect(gain1);
|
| 121 |
+
gain1.connect(audioCtx.destination);
|
| 122 |
+
osc1.type = "sine";
|
| 123 |
+
osc1.frequency.setValueAtTime(587.33, audioCtx.currentTime); // D5
|
| 124 |
+
gain1.gain.setValueAtTime(0.35, audioCtx.currentTime);
|
| 125 |
+
gain1.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
|
| 126 |
+
osc1.start(audioCtx.currentTime);
|
| 127 |
+
osc1.stop(audioCtx.currentTime + 0.15);
|
| 128 |
+
|
| 129 |
+
const osc2 = audioCtx.createOscillator();
|
| 130 |
+
const gain2 = audioCtx.createGain();
|
| 131 |
+
osc2.connect(gain2);
|
| 132 |
+
gain2.connect(audioCtx.destination);
|
| 133 |
+
osc2.type = "sine";
|
| 134 |
+
osc2.frequency.setValueAtTime(880, audioCtx.currentTime + 0.08); // A5
|
| 135 |
+
gain2.gain.setValueAtTime(0.40, audioCtx.currentTime + 0.08);
|
| 136 |
+
gain2.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.35);
|
| 137 |
+
osc2.start(audioCtx.currentTime + 0.08);
|
| 138 |
+
osc2.stop(audioCtx.currentTime + 0.35);
|
| 139 |
+
} catch (err) {
|
| 140 |
+
console.error("Failed to play QR beep chime:", err);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
|
| 147 |
// Fetch engine state dynamically on mount
|
| 148 |
useEffect(() => {
|
| 149 |
const checkEngine = async () => {
|
|
|
|
| 179 |
}
|
| 180 |
};
|
| 181 |
|
| 182 |
+
const startKiosk = async (initialStatus: "idle" | "needs_qr" = "idle") => {
|
| 183 |
setScanFeedback(null);
|
| 184 |
try {
|
| 185 |
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
setKioskActive(true);
|
| 197 |
+
setScanStatus(initialStatus);
|
| 198 |
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
| 199 |
+
intervalRef.current = setInterval(() => {
|
| 200 |
+
if (scanFunctionRef.current) {
|
| 201 |
+
scanFunctionRef.current();
|
| 202 |
+
}
|
| 203 |
+
}, 1000);
|
| 204 |
} catch (err) {
|
| 205 |
console.error(err);
|
| 206 |
toast.error("Unable to access camera. Please check browser permissions and ensure no other application is using it.");
|
|
|
|
| 218 |
setScanStatus("idle");
|
| 219 |
setScanResult(null);
|
| 220 |
setScanFeedback(null);
|
| 221 |
+
setFaceBbox(null);
|
| 222 |
};
|
| 223 |
|
| 224 |
+
// Global key listener for Backspace shortcut
|
| 225 |
+
useEffect(() => {
|
| 226 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 227 |
+
// Ignore key shortcut if user is actively typing in an input/textarea
|
| 228 |
+
if (
|
| 229 |
+
document.activeElement?.tagName === "INPUT" ||
|
| 230 |
+
document.activeElement?.tagName === "TEXTAREA"
|
| 231 |
+
) {
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if (e.key === "Backspace") {
|
| 236 |
+
e.preventDefault(); // Prevent browser history back navigation
|
| 237 |
+
clearCooldownTimeout();
|
| 238 |
+
cooldownRef.current = false;
|
| 239 |
+
|
| 240 |
+
if (!kioskActiveRef.current) {
|
| 241 |
+
if (startKioskRef.current) startKioskRef.current("needs_qr");
|
| 242 |
+
} else {
|
| 243 |
+
setScanStatus("needs_qr");
|
| 244 |
+
setScanResult(null);
|
| 245 |
+
setQrCodeVal("");
|
| 246 |
+
setQrError(null);
|
| 247 |
+
setFaceBbox(null);
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
window.addEventListener("keydown", handleKeyDown);
|
| 253 |
+
return () => {
|
| 254 |
+
window.removeEventListener("keydown", handleKeyDown);
|
| 255 |
+
};
|
| 256 |
+
}, []);
|
| 257 |
+
|
| 258 |
useEffect(() => {
|
| 259 |
return () => {
|
| 260 |
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
|
|
| 272 |
// Check if the video is actually ready and playing
|
| 273 |
if (video.readyState < 2) return;
|
| 274 |
|
| 275 |
+
canvas.width = 640; canvas.height = 360;
|
| 276 |
ctx.translate(canvas.width, 0); ctx.scale(-1, 1);
|
| 277 |
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 278 |
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 279 |
|
| 280 |
const base64 = canvas.toDataURL("image/jpeg", 0.82);
|
| 281 |
setLastImage(base64);
|
| 282 |
+
|
| 283 |
+
// Frontend-side QR code detection using jsQR (100% reliable) - Checked in all states!
|
| 284 |
+
let detectedQrCode: string | null = null;
|
| 285 |
+
try {
|
| 286 |
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
| 287 |
+
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
| 288 |
+
if (code) {
|
| 289 |
+
detectedQrCode = code.data;
|
| 290 |
+
console.log("jsQR detected QR code:", detectedQrCode);
|
| 291 |
+
setScanFeedback("QR Badge Detected: " + detectedQrCode);
|
| 292 |
+
if (!qrDetectedData) {
|
| 293 |
+
playQrChime();
|
| 294 |
+
setQrDetectedData(detectedQrCode);
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
} catch (err) {
|
| 298 |
+
console.error("jsQR parsing error:", err);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Fallback to native BarcodeDetector if jsQR missed it and BarcodeDetector is available
|
| 302 |
+
if (!detectedQrCode && typeof window !== "undefined" && "BarcodeDetector" in window) {
|
| 303 |
+
try {
|
| 304 |
+
const detector = new (window as any).BarcodeDetector({ formats: ["qr_code", "code_128", "code_39"] });
|
| 305 |
+
const barcodes = await detector.detect(canvas);
|
| 306 |
+
if (barcodes && barcodes.length > 0) {
|
| 307 |
+
detectedQrCode = barcodes[0].rawValue;
|
| 308 |
+
console.log("BarcodeDetector detected QR code:", detectedQrCode);
|
| 309 |
+
setScanFeedback("QR Badge Detected: " + detectedQrCode);
|
| 310 |
+
if (!qrDetectedData) {
|
| 311 |
+
playQrChime();
|
| 312 |
+
setQrDetectedData(detectedQrCode);
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
} catch (err) {
|
| 316 |
+
console.error("BarcodeDetector error:", err);
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// If a QR code is detected, submit immediately and bypass standard face scans
|
| 321 |
+
if (detectedQrCode) {
|
| 322 |
+
if (statusRef.current === "needs_qr" && scanResult?.employee?.id) {
|
| 323 |
+
autoSubmitQR(scanResult.employee.id, detectedQrCode);
|
| 324 |
+
} else {
|
| 325 |
+
autoSubmitDirectQR(detectedQrCode, base64);
|
| 326 |
+
}
|
| 327 |
+
return;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
// If the kiosk is in needs_qr mode but no QR code was scanned, don't execute face scans
|
| 331 |
+
if (statusRef.current !== "idle") {
|
| 332 |
+
return;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
setScanning(true);
|
| 336 |
try {
|
| 337 |
// Connect to the backend running at the configured URL
|
| 338 |
const url = `${getBackendUrl()}/kiosk/scan`;
|
| 339 |
+
const payloadBody: any = { image: base64, camera: cameraLabel };
|
| 340 |
+
if (detectedQrCode) {
|
| 341 |
+
payloadBody.qr_code = detectedQrCode;
|
| 342 |
+
}
|
| 343 |
+
if (coords.latitude !== null && coords.longitude !== null) {
|
| 344 |
+
payloadBody.latitude = coords.latitude;
|
| 345 |
+
payloadBody.longitude = coords.longitude;
|
| 346 |
+
}
|
| 347 |
const res = await fetch(url, {
|
| 348 |
method: "POST",
|
| 349 |
headers: { "Content-Type": "application/json" },
|
| 350 |
+
body: JSON.stringify(payloadBody)
|
| 351 |
});
|
| 352 |
if (!res.ok) throw new Error("Scan failed");
|
| 353 |
const data = await res.json();
|
| 354 |
|
| 355 |
+
// Discard response if state changed while request was in-flight (e.g. Backspace pressed)
|
| 356 |
+
if (statusRef.current !== "idle") {
|
| 357 |
+
console.log("Discarding in-flight face scan response: state changed to", statusRef.current);
|
| 358 |
+
return;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
if (data.status === "no_face") {
|
| 362 |
setScanFeedback("Align your face in frame");
|
| 363 |
+
setFaceBbox(null);
|
| 364 |
} else if (data.status === "multiple_faces") {
|
| 365 |
setScanFeedback("One person at a time");
|
| 366 |
+
setFaceBbox(null);
|
| 367 |
} else {
|
| 368 |
setScanFeedback(null);
|
| 369 |
handleResult(data);
|
|
|
|
| 371 |
} catch (err) {
|
| 372 |
console.error("Scan API connection error:", err);
|
| 373 |
setScanFeedback("Connection error");
|
| 374 |
+
setFaceBbox(null);
|
| 375 |
} finally {
|
| 376 |
setScanning(false);
|
| 377 |
}
|
| 378 |
};
|
| 379 |
|
| 380 |
+
useEffect(() => {
|
| 381 |
+
scanFunctionRef.current = captureAndScan;
|
| 382 |
+
});
|
| 383 |
+
|
| 384 |
+
useEffect(() => {
|
| 385 |
+
startKioskRef.current = startKiosk;
|
| 386 |
+
});
|
| 387 |
+
|
| 388 |
const confirmCheckout = async () => {
|
| 389 |
if (!lastImage) return;
|
| 390 |
setScanning(true);
|
| 391 |
try {
|
| 392 |
const url = `${getBackendUrl()}/kiosk/scan`;
|
| 393 |
+
const bodyPayload: any = { image: lastImage, camera: cameraLabel, confirm_checkout: true };
|
| 394 |
+
if (coords.latitude !== null && coords.longitude !== null) {
|
| 395 |
+
bodyPayload.latitude = coords.latitude;
|
| 396 |
+
bodyPayload.longitude = coords.longitude;
|
| 397 |
+
}
|
| 398 |
const res = await fetch(url, {
|
| 399 |
method: "POST",
|
| 400 |
headers: { "Content-Type": "application/json" },
|
| 401 |
+
body: JSON.stringify(bodyPayload)
|
| 402 |
});
|
| 403 |
if (!res.ok) throw new Error("Checkout confirmation failed");
|
| 404 |
const data = await res.json();
|
|
|
|
| 411 |
}
|
| 412 |
};
|
| 413 |
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
const autoSubmitDirectQR = async (qrVal: string, frameImg: string) => {
|
| 417 |
+
if (cooldownRef.current || scanning) return;
|
| 418 |
+
setScanning(true);
|
| 419 |
+
setQrError(null);
|
| 420 |
+
try {
|
| 421 |
+
const url = `${getBackendUrl()}/kiosk/scan`;
|
| 422 |
+
const bodyPayload: any = {
|
| 423 |
+
image: frameImg,
|
| 424 |
+
camera: cameraLabel,
|
| 425 |
+
qr_code: qrVal.trim(),
|
| 426 |
+
qr_only: true
|
| 427 |
+
};
|
| 428 |
+
if (coords.latitude !== null && coords.longitude !== null) {
|
| 429 |
+
bodyPayload.latitude = coords.latitude;
|
| 430 |
+
bodyPayload.longitude = coords.longitude;
|
| 431 |
+
}
|
| 432 |
+
const res = await fetch(url, {
|
| 433 |
+
method: "POST",
|
| 434 |
+
headers: { "Content-Type": "application/json" },
|
| 435 |
+
body: JSON.stringify(bodyPayload)
|
| 436 |
+
});
|
| 437 |
+
const data = await res.json();
|
| 438 |
+
if (!res.ok) {
|
| 439 |
+
throw new Error(data.detail || data.message || "QR scan registration failed");
|
| 440 |
+
}
|
| 441 |
+
handleResult(data);
|
| 442 |
+
} catch (err: any) {
|
| 443 |
+
console.error("Auto Direct QR scan error:", err);
|
| 444 |
+
setQrError(err.message || "QR Code scan failed.");
|
| 445 |
+
setQrDetectedData(null);
|
| 446 |
+
} finally {
|
| 447 |
+
setScanning(false);
|
| 448 |
+
}
|
| 449 |
+
};
|
| 450 |
+
|
| 451 |
+
const autoSubmitQR = async (employeeId: number, qrVal: string) => {
|
| 452 |
+
if (cooldownRef.current || scanning) return;
|
| 453 |
+
setScanning(true);
|
| 454 |
+
setQrError(null);
|
| 455 |
+
try {
|
| 456 |
+
const url = `${getBackendUrl()}/kiosk/confirm-qr`;
|
| 457 |
+
const bodyPayload: any = {
|
| 458 |
+
employee_id: employeeId,
|
| 459 |
+
qr_code: qrVal.trim(),
|
| 460 |
+
camera: cameraLabel
|
| 461 |
+
};
|
| 462 |
+
if (coords.latitude !== null && coords.longitude !== null) {
|
| 463 |
+
bodyPayload.latitude = coords.latitude;
|
| 464 |
+
bodyPayload.longitude = coords.longitude;
|
| 465 |
+
}
|
| 466 |
+
const res = await fetch(url, {
|
| 467 |
+
method: "POST",
|
| 468 |
+
headers: { "Content-Type": "application/json" },
|
| 469 |
+
body: JSON.stringify(bodyPayload)
|
| 470 |
+
});
|
| 471 |
+
const data = await res.json();
|
| 472 |
+
if (!res.ok) {
|
| 473 |
+
throw new Error(data.detail || "QR Verification failed");
|
| 474 |
+
}
|
| 475 |
+
handleResult(data);
|
| 476 |
+
} catch (err: any) {
|
| 477 |
+
console.error("Auto QR verification error:", err);
|
| 478 |
+
setQrError(err.message || "QR Code verification failed.");
|
| 479 |
+
setQrDetectedData(null);
|
| 480 |
+
} finally {
|
| 481 |
+
setScanning(false);
|
| 482 |
+
}
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
const handleResult = (data: any) => {
|
| 486 |
+
setQrDetectedData(null);
|
| 487 |
+
if (data.bbox) {
|
| 488 |
+
setFaceBbox(data.bbox);
|
| 489 |
+
} else {
|
| 490 |
+
setFaceBbox(null);
|
| 491 |
+
}
|
| 492 |
if (data.status === "success") {
|
| 493 |
setProfileImageError(false);
|
| 494 |
setScanStatus("success"); setScanResult(data);
|
|
|
|
| 509 |
console.error("Autoplay voice greeting failed:", err);
|
| 510 |
});
|
| 511 |
}
|
| 512 |
+
} else if (data.status === "needs_qr") {
|
| 513 |
+
setQrCodeVal("");
|
| 514 |
+
setQrError(null);
|
| 515 |
+
setScanStatus("needs_qr"); setScanResult(data);
|
| 516 |
} else if (data.status === "locked") {
|
| 517 |
setScanStatus("locked"); setScanResult(data);
|
| 518 |
+
triggerCooldown(3500);
|
| 519 |
} else if (data.status === "spoof_detected") {
|
| 520 |
setScanStatus("spoof"); setScanResult(data);
|
| 521 |
triggerCooldown(3000);
|
|
|
|
| 528 |
} else if (data.status === "no_employees") {
|
| 529 |
setScanStatus("no_employees"); setScanResult(data);
|
| 530 |
triggerCooldown(4000);
|
| 531 |
+
} else if (data.status === "location_error") {
|
| 532 |
+
setScanStatus("location_error"); setScanResult(data);
|
| 533 |
+
triggerCooldown(5000);
|
| 534 |
}
|
| 535 |
};
|
| 536 |
|
| 537 |
const triggerCooldown = (ms: number) => {
|
| 538 |
+
clearCooldownTimeout();
|
| 539 |
cooldownRef.current = true;
|
| 540 |
+
cooldownTimeoutRef.current = setTimeout(() => {
|
| 541 |
cooldownRef.current = false;
|
| 542 |
setScanStatus("idle");
|
| 543 |
setScanResult(null);
|
| 544 |
+
setFaceBbox(null);
|
| 545 |
+
cooldownTimeoutRef.current = null;
|
| 546 |
}, ms);
|
| 547 |
};
|
| 548 |
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
const resetToIdleWithCooldown = (ms: number) => {
|
| 552 |
+
clearCooldownTimeout();
|
| 553 |
+
setScanStatus("idle");
|
| 554 |
+
setScanResult(null);
|
| 555 |
+
setFaceBbox(null);
|
| 556 |
+
cooldownRef.current = true;
|
| 557 |
+
cooldownTimeoutRef.current = setTimeout(() => {
|
| 558 |
+
cooldownRef.current = false;
|
| 559 |
+
cooldownTimeoutRef.current = null;
|
| 560 |
+
}, ms);
|
| 561 |
+
};
|
| 562 |
+
|
| 563 |
+
let boxBorderColor = "border-zinc-400";
|
| 564 |
+
let boxGlow = "shadow-[0_0_15px_rgba(161,161,170,0.4)]";
|
| 565 |
+
let labelText = "Detecting...";
|
| 566 |
+
let labelBg = "bg-zinc-800 text-white";
|
| 567 |
+
|
| 568 |
+
if (scanStatus === "success" || scanStatus === "ask_checkout") {
|
| 569 |
+
boxBorderColor = "border-emerald-500";
|
| 570 |
+
boxGlow = "shadow-[0_0_20px_rgba(16,185,129,0.5)]";
|
| 571 |
+
labelText = scanResult?.employee?.name ? `MATCHED: ${scanResult.employee.name}` : "VERIFIED";
|
| 572 |
+
labelBg = "bg-emerald-600 text-white";
|
| 573 |
+
} else if (scanStatus === "spoof") {
|
| 574 |
+
boxBorderColor = "border-rose-600";
|
| 575 |
+
boxGlow = "shadow-[0_0_20px_rgba(220,38,38,0.5)]";
|
| 576 |
+
labelText = "SPOOF DETECTED";
|
| 577 |
+
labelBg = "bg-rose-600 text-white";
|
| 578 |
+
} else if (scanStatus === "unknown") {
|
| 579 |
+
boxBorderColor = "border-rose-500";
|
| 580 |
+
boxGlow = "shadow-[0_0_15px_rgba(244,63,94,0.4)]";
|
| 581 |
+
labelText = "UNKNOWN PERSON";
|
| 582 |
+
labelBg = "bg-rose-500 text-white";
|
| 583 |
+
} else if (scanStatus === "needs_qr") {
|
| 584 |
+
boxBorderColor = "border-amber-500";
|
| 585 |
+
boxGlow = "shadow-[0_0_20px_rgba(245,158,11,0.5)]";
|
| 586 |
+
labelText = "IDENTITY CONFIRMATION";
|
| 587 |
+
labelBg = "bg-amber-600 text-white";
|
| 588 |
+
} else if (scanStatus === "locked") {
|
| 589 |
+
boxBorderColor = "border-rose-600";
|
| 590 |
+
boxGlow = "shadow-[0_0_20px_rgba(220,38,38,0.5)]";
|
| 591 |
+
labelText = "LOCKED";
|
| 592 |
+
labelBg = "bg-rose-600 text-white";
|
| 593 |
+
} else if (scanStatus === "location_error") {
|
| 594 |
+
boxBorderColor = "border-rose-600 animate-pulse";
|
| 595 |
+
boxGlow = "shadow-[0_0_20px_rgba(220,38,38,0.5)]";
|
| 596 |
+
labelText = scanResult?.message || "OUTSIDE ALLOWED AREA";
|
| 597 |
+
labelBg = "bg-rose-600 text-white";
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
return (
|
| 601 |
<div className="min-h-screen bg-[var(--bg-base)] text-[var(--text-primary)] flex flex-col relative select-none overflow-hidden font-sans">
|
| 602 |
+
<style>{`
|
| 603 |
+
@keyframes fadeIn {
|
| 604 |
+
from { opacity: 0; }
|
| 605 |
+
to { opacity: 1; }
|
| 606 |
+
}
|
| 607 |
+
@keyframes scaleUp {
|
| 608 |
+
from { transform: scale(0.94); opacity: 0; }
|
| 609 |
+
to { transform: scale(1); opacity: 1; }
|
| 610 |
+
}
|
| 611 |
+
@keyframes bounceIn {
|
| 612 |
+
0% { transform: scale(0.3); opacity: 0; }
|
| 613 |
+
50% { transform: scale(1.05); }
|
| 614 |
+
70% { transform: scale(0.95); }
|
| 615 |
+
100% { transform: scale(1); opacity: 1; }
|
| 616 |
+
}
|
| 617 |
+
@keyframes rotate-hud {
|
| 618 |
+
from { transform: rotate(0deg); }
|
| 619 |
+
to { transform: rotate(360deg); }
|
| 620 |
+
}
|
| 621 |
+
@keyframes rotate-hud-reverse {
|
| 622 |
+
from { transform: rotate(0deg); }
|
| 623 |
+
to { transform: rotate(-360deg); }
|
| 624 |
+
}
|
| 625 |
+
.animate-rotate-hud {
|
| 626 |
+
animation: rotate-hud 15s linear infinite;
|
| 627 |
+
}
|
| 628 |
+
.animate-rotate-hud-reverse {
|
| 629 |
+
animation: rotate-hud-reverse 10s linear infinite;
|
| 630 |
+
}
|
| 631 |
+
@keyframes scan-laser {
|
| 632 |
+
0% { top: 0%; opacity: 0.8; }
|
| 633 |
+
50% { top: 100%; opacity: 0.8; }
|
| 634 |
+
100% { top: 0%; opacity: 0.8; }
|
| 635 |
+
}
|
| 636 |
+
.animate-scan-laser {
|
| 637 |
+
animation: scan-laser 2s ease-in-out infinite;
|
| 638 |
+
}
|
| 639 |
+
`}</style>
|
| 640 |
{/* Background Grid Mesh */}
|
| 641 |
<div className="absolute inset-0 mesh-bg pointer-events-none" />
|
| 642 |
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[350px] bg-slate-500/5 blur-[120px] pointer-events-none rounded-full" />
|
|
|
|
| 665 |
</div>
|
| 666 |
</div>
|
| 667 |
|
| 668 |
+
{/* Clock & GPS Indicator */}
|
| 669 |
+
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center justify-center gap-1 z-20">
|
| 670 |
<p className="text-base font-bold font-mono tracking-tight text-[var(--text-primary)] leading-none tabular-nums">
|
| 671 |
{currentTime || "00:00:00"}
|
| 672 |
</p>
|
| 673 |
+
{coords.latitude !== null && (
|
| 674 |
+
<div className="flex items-center gap-1 px-2 py-0.5 rounded-lg border border-slate-750/30 bg-slate-950/80 text-[8.5px] font-mono text-slate-450 select-none animate-fadeIn flex-row shrink-0">
|
| 675 |
+
<span className="w-1.2 h-1.2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
|
| 676 |
+
<span>GPS ACTIVE: {coords.latitude.toFixed(4)}, {coords.longitude?.toFixed(4)}</span>
|
| 677 |
+
</div>
|
| 678 |
+
)}
|
| 679 |
</div>
|
| 680 |
|
| 681 |
{/* Controls */}
|
|
|
|
| 744 |
</div>
|
| 745 |
|
| 746 |
<button
|
| 747 |
+
onClick={() => startKiosk()}
|
| 748 |
className="btn-primary h-10 px-8 text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all"
|
| 749 |
>
|
| 750 |
<Play className="w-4 h-4 fill-current" />
|
|
|
|
| 763 |
autoPlay playsInline muted
|
| 764 |
/>
|
| 765 |
|
| 766 |
+
{/* Bounding Box Overlay */}
|
| 767 |
+
{kioskActive && faceBbox && scanStatus !== "needs_qr" && (
|
| 768 |
+
<div
|
| 769 |
+
className="absolute pointer-events-none z-20"
|
| 770 |
+
style={{
|
| 771 |
+
left: `${((640 - faceBbox[2]) / 640) * 100}%`,
|
| 772 |
+
top: `${(faceBbox[1] / 360) * 100}%`,
|
| 773 |
+
width: `${((faceBbox[2] - faceBbox[0]) / 640) * 100}%`,
|
| 774 |
+
height: `${((faceBbox[3] - faceBbox[1]) / 360) * 100}%`,
|
| 775 |
+
transition: "all 0.2s ease-out",
|
| 776 |
+
}}
|
| 777 |
+
>
|
| 778 |
+
<div className={`absolute inset-0 border-2 rounded-xl ${boxBorderColor} ${boxGlow} transition-all duration-300`}>
|
| 779 |
+
<div className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-0.5 rounded text-[8.5px] font-mono font-bold tracking-wider uppercase whitespace-nowrap shadow-md ${labelBg} transition-all duration-300`}>
|
| 780 |
+
{labelText}
|
| 781 |
+
</div>
|
| 782 |
+
</div>
|
| 783 |
+
</div>
|
| 784 |
+
)}
|
| 785 |
+
|
| 786 |
{/* Scanning overlay: active standby state */}
|
| 787 |
{kioskActive && scanStatus === "idle" && (
|
| 788 |
<>
|
|
|
|
| 806 |
|
| 807 |
{/* SUCCESS screen */}
|
| 808 |
{kioskActive && scanStatus === "success" && (
|
| 809 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-emerald-500/30 shadow-[inset_0_0_40px_rgba(16,185,129,0.15)] rounded-2xl z-30">
|
| 810 |
+
{/* HUD Corner Tech Brackets */}
|
| 811 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-emerald-500/70 rounded-tl-md" />
|
| 812 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-emerald-500/70 rounded-tr-md" />
|
| 813 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-emerald-500/70 rounded-bl-md" />
|
| 814 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-emerald-500/70 rounded-br-md" />
|
| 815 |
+
|
| 816 |
+
<div className="space-y-4 max-w-sm w-full animate-fade-in flex flex-col items-center">
|
| 817 |
+
|
| 818 |
+
{/* Floating Top Badge */}
|
| 819 |
+
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 font-mono text-[9px] font-bold uppercase tracking-widest animate-bounceIn">
|
| 820 |
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
| 821 |
+
Biometrics Verified
|
| 822 |
+
</div>
|
| 823 |
+
|
| 824 |
+
{/* Profile Avatar with Tech HUD Rings */}
|
| 825 |
+
<div className="relative w-28 h-28 flex items-center justify-center my-1.5">
|
| 826 |
+
{/* Rotating outer ring */}
|
| 827 |
+
<div className="absolute inset-0 border border-emerald-500/30 border-dashed rounded-full animate-rotate-hud" />
|
| 828 |
+
{/* Counter-rotating middle ring */}
|
| 829 |
+
<div className="absolute inset-1.5 border border-emerald-400/20 border-dashed rounded-full animate-rotate-hud-reverse" />
|
| 830 |
+
{/* Inner glowing circle wrapper */}
|
| 831 |
+
<div className="absolute inset-3 rounded-full bg-slate-900 border-2 border-emerald-500/80 p-0.5 flex items-center justify-center overflow-hidden shadow-[0_0_20px_rgba(16,185,129,0.4)]">
|
| 832 |
+
{scanResult?.employee?.employee_id && !profileImageError ? (
|
| 833 |
+
<img
|
| 834 |
+
src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
|
| 835 |
+
alt={scanResult.employee.name}
|
| 836 |
+
className="w-full h-full object-cover rounded-full"
|
| 837 |
+
onError={() => setProfileImageError(true)}
|
| 838 |
+
/>
|
| 839 |
+
) : (
|
| 840 |
+
<div className="w-full h-full rounded-full bg-slate-800 flex items-center justify-center text-emerald-400 font-bold text-2xl tracking-tighter">
|
| 841 |
+
{scanResult?.employee?.name
|
| 842 |
+
? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
|
| 843 |
+
: "PK"}
|
| 844 |
+
</div>
|
| 845 |
+
)}
|
| 846 |
+
</div>
|
| 847 |
+
|
| 848 |
+
{/* Floating check status indicator */}
|
| 849 |
+
<div className="absolute bottom-1 right-1 w-6 h-6 rounded-full bg-emerald-500 border border-slate-950 flex items-center justify-center text-white shadow-md animate-pulse">
|
| 850 |
+
<UserCheck className="w-3.5 h-3.5" />
|
| 851 |
+
</div>
|
| 852 |
</div>
|
| 853 |
|
| 854 |
{/* Name and Designation */}
|
| 855 |
+
<div className="space-y-1">
|
| 856 |
+
<h2 className="text-xl font-extrabold text-white tracking-tight leading-none drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]">
|
| 857 |
{scanResult?.employee?.name || "Employee"}
|
| 858 |
</h2>
|
| 859 |
+
<p className="text-[11px] text-slate-400 font-mono tracking-wide uppercase">
|
| 860 |
+
{scanResult?.employee?.designation || "Staff"} · ID: {scanResult?.employee?.employee_id}
|
| 861 |
</p>
|
| 862 |
</div>
|
| 863 |
|
| 864 |
+
{/* Grid details block */}
|
| 865 |
+
<div className="w-full bg-slate-900/60 border border-slate-800/80 rounded-xl p-3 max-w-[280px] grid grid-cols-2 gap-2 text-center divide-x divide-slate-800">
|
| 866 |
+
<div>
|
| 867 |
+
<span className="text-[8px] text-slate-500 font-bold uppercase tracking-wider block">Logged Time</span>
|
| 868 |
+
<span className="text-sm font-extrabold text-white font-mono tracking-tight tabular-nums block mt-0.5">
|
| 869 |
+
{matchTime}
|
| 870 |
+
</span>
|
| 871 |
+
</div>
|
| 872 |
+
<div>
|
| 873 |
+
<span className="text-[8px] text-slate-500 font-bold uppercase tracking-wider block">Action Status</span>
|
| 874 |
+
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-widest block mt-1">
|
| 875 |
+
{scanResult?.attendance_type || "CHECK-IN"}
|
| 876 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
</div>
|
| 878 |
</div>
|
| 879 |
+
|
| 880 |
+
{/* Working Hours (Only visible on Checkout success) */}
|
| 881 |
+
{scanResult?.attendance?.working_hours > 0 && (
|
| 882 |
+
<div className="w-full bg-slate-900/60 border border-slate-800/80 rounded-xl p-2.5 max-w-[280px]">
|
| 883 |
+
<span className="text-[8.5px] text-slate-450 font-bold uppercase tracking-wider block">Time Logged Today</span>
|
| 884 |
+
<span className="text-xs font-bold text-emerald-400 font-mono mt-0.5 block">{scanResult.attendance.working_hours.toFixed(2)} hours</span>
|
| 885 |
+
</div>
|
| 886 |
+
)}
|
| 887 |
+
|
| 888 |
+
{/* Biometric Scores Info */}
|
| 889 |
+
{scanResult?.confidence && (
|
| 890 |
+
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded bg-slate-900/40 text-[9px] text-slate-500 font-mono border border-slate-800/60">
|
| 891 |
+
<span>Conf: <strong className="text-slate-355">{(scanResult.confidence * 100).toFixed(0)}%</strong></span>
|
| 892 |
+
<span className="w-1 h-1 rounded-full bg-slate-805" />
|
| 893 |
+
<span>Liveness: <strong className="text-emerald-500/80">{(scanResult.liveness_score * 100).toFixed(0)}%</strong></span>
|
| 894 |
+
</div>
|
| 895 |
+
)}
|
| 896 |
+
|
| 897 |
</div>
|
| 898 |
</div>
|
| 899 |
)}
|
| 900 |
|
| 901 |
{/* ASK_CHECKOUT screen */}
|
| 902 |
{kioskActive && scanStatus === "ask_checkout" && (
|
| 903 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-cyan-500/30 shadow-[inset_0_0_40px_rgba(6,182,212,0.15)] rounded-2xl z-30">
|
| 904 |
+
{/* HUD Corner Tech Brackets */}
|
| 905 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-cyan-500/70 rounded-tl-md" />
|
| 906 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-cyan-500/70 rounded-tr-md" />
|
| 907 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-cyan-500/70 rounded-bl-md" />
|
| 908 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-cyan-500/70 rounded-br-md" />
|
| 909 |
+
|
| 910 |
+
<div className="space-y-4 max-w-sm w-full animate-fade-in flex flex-col items-center">
|
| 911 |
+
|
| 912 |
+
{/* Floating Top Badge */}
|
| 913 |
+
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 font-mono text-[9px] font-bold uppercase tracking-widest animate-bounceIn">
|
| 914 |
+
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
| 915 |
+
Checkout Prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
</div>
|
| 917 |
|
| 918 |
+
{/* Profile Avatar with Tech HUD Rings */}
|
| 919 |
+
<div className="relative w-28 h-28 flex items-center justify-center my-1.5">
|
| 920 |
+
{/* Rotating outer ring */}
|
| 921 |
+
<div className="absolute inset-0 border border-cyan-500/30 border-dashed rounded-full animate-rotate-hud" />
|
| 922 |
+
{/* Counter-rotating middle ring */}
|
| 923 |
+
<div className="absolute inset-1.5 border border-cyan-400/20 border-dashed rounded-full animate-rotate-hud-reverse" />
|
| 924 |
+
{/* Inner glowing circle wrapper */}
|
| 925 |
+
<div className="absolute inset-3 rounded-full bg-slate-900 border-2 border-cyan-500/80 p-0.5 flex items-center justify-center overflow-hidden shadow-[0_0_20px_rgba(6,182,212,0.4)]">
|
| 926 |
+
{scanResult?.employee?.employee_id && !profileImageError ? (
|
| 927 |
+
<img
|
| 928 |
+
src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
|
| 929 |
+
alt={scanResult.employee.name}
|
| 930 |
+
className="w-full h-full object-cover rounded-full"
|
| 931 |
+
onError={() => setProfileImageError(true)}
|
| 932 |
+
/>
|
| 933 |
+
) : (
|
| 934 |
+
<div className="w-full h-full rounded-full bg-slate-800 flex items-center justify-center text-cyan-400 font-bold text-2xl tracking-tighter">
|
| 935 |
+
{scanResult?.employee?.name
|
| 936 |
+
? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
|
| 937 |
+
: "PK"}
|
| 938 |
+
</div>
|
| 939 |
+
)}
|
| 940 |
+
</div>
|
| 941 |
+
|
| 942 |
+
{/* Floating check status indicator */}
|
| 943 |
+
<div className="absolute bottom-1 right-1 w-6 h-6 rounded-full bg-cyan-500 border border-slate-950 flex items-center justify-center text-white shadow-md animate-pulse">
|
| 944 |
+
<HelpCircle className="w-3.5 h-3.5" />
|
| 945 |
+
</div>
|
| 946 |
+
</div>
|
| 947 |
+
|
| 948 |
+
{/* Name and Warning */}
|
| 949 |
<div className="space-y-1">
|
| 950 |
+
<h2 className="text-xl font-extrabold text-white tracking-tight leading-none drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]">
|
| 951 |
{scanResult?.employee?.name || "Employee"}
|
| 952 |
</h2>
|
| 953 |
+
<p className="text-[11.5px] text-slate-350 font-medium">
|
| 954 |
+
Already checked in today at <span className="font-mono font-bold text-cyan-400">{formatTime12h(scanResult?.attendance?.check_in)}</span>
|
| 955 |
</p>
|
| 956 |
</div>
|
| 957 |
|
| 958 |
+
{/* Working Hours Box */}
|
| 959 |
+
<div className="w-full bg-slate-900/60 border border-slate-800/80 rounded-xl p-2.5 max-w-[280px]">
|
| 960 |
+
<span className="text-[8.5px] text-slate-505 font-bold uppercase tracking-wider block">Working Hours So Far</span>
|
| 961 |
+
<span className="text-sm font-extrabold text-white font-mono tracking-tight mt-0.5 block">
|
| 962 |
{scanResult?.working_hours_so_far?.toFixed(2)} hours
|
| 963 |
+
</span>
|
| 964 |
</div>
|
| 965 |
|
| 966 |
+
{/* Actions Confirmation */}
|
| 967 |
+
<div className="space-y-3 pt-1 w-full max-w-[280px]">
|
| 968 |
+
<p className="text-xs font-semibold text-slate-300">Do you want to Check Out now?</p>
|
| 969 |
+
<div className="flex items-center justify-center gap-3">
|
| 970 |
<button
|
| 971 |
onClick={confirmCheckout}
|
| 972 |
disabled={scanning}
|
| 973 |
+
className="flex-1 px-4 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white text-xs font-extrabold uppercase tracking-wider shadow-[0_0_15px_rgba(6,182,212,0.35)] cursor-pointer hover:scale-[1.03] active:scale-[0.97] transition-all disabled:opacity-50"
|
| 974 |
>
|
| 975 |
Yes, Check Out
|
| 976 |
</button>
|
| 977 |
<button
|
| 978 |
onClick={() => {
|
| 979 |
+
resetToIdleWithCooldown(5000);
|
|
|
|
| 980 |
}}
|
| 981 |
disabled={scanning}
|
| 982 |
+
className="flex-1 px-4 py-2.5 rounded-xl bg-transparent hover:bg-slate-900 text-slate-300 text-xs font-bold uppercase tracking-wider border border-slate-850 cursor-pointer active:scale-[0.97] transition-all disabled:opacity-50"
|
| 983 |
>
|
| 984 |
+
No, Stay
|
| 985 |
</button>
|
| 986 |
</div>
|
| 987 |
</div>
|
| 988 |
+
|
| 989 |
+
</div>
|
| 990 |
+
</div>
|
| 991 |
+
)}
|
| 992 |
+
|
| 993 |
+
{/* NEEDS_QR screen */}
|
| 994 |
+
{kioskActive && scanStatus === "needs_qr" && (
|
| 995 |
+
<div className="absolute inset-0 bg-slate-950/35 backdrop-blur-[1px] flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-amber-500/30 shadow-[inset_0_0_40px_rgba(245,158,11,0.15)] rounded-2xl z-30">
|
| 996 |
+
{/* HUD Corner Tech Brackets */}
|
| 997 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-amber-500/70 rounded-tl-md" />
|
| 998 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-amber-500/70 rounded-tr-md" />
|
| 999 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-amber-500/70 rounded-bl-md" />
|
| 1000 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-amber-500/70 rounded-br-md" />
|
| 1001 |
+
|
| 1002 |
+
<div className="space-y-4 max-w-sm w-full animate-fade-in flex flex-col items-center">
|
| 1003 |
+
|
| 1004 |
+
{/* Floating Top Badge */}
|
| 1005 |
+
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-400 font-mono text-[9px] font-bold uppercase tracking-widest">
|
| 1006 |
+
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
| 1007 |
+
Automatic QR Scanner
|
| 1008 |
+
</div>
|
| 1009 |
+
|
| 1010 |
+
{/* Interactive Holographic Scanner Graphic */}
|
| 1011 |
+
<div className="relative w-28 h-28 flex items-center justify-center my-2">
|
| 1012 |
+
{/* Outer pulsing ring */}
|
| 1013 |
+
<div className="absolute inset-0 border border-amber-500/30 rounded-2xl animate-pulse" style={{ animationDuration: "2s" }} />
|
| 1014 |
+
{/* Scanning laser line inside the box */}
|
| 1015 |
+
<div className="absolute left-2 right-2 h-[2px] bg-amber-400/80 shadow-[0_0_8px_rgba(245,158,11,0.8)] animate-scan-laser top-0" />
|
| 1016 |
+
|
| 1017 |
+
{/* QR Code Icon / Symbol in center */}
|
| 1018 |
+
<div className="w-16 h-16 rounded-xl bg-slate-900 border border-amber-500/40 flex items-center justify-center text-amber-400/90 shadow-[0_0_15px_rgba(245,158,11,0.15)]">
|
| 1019 |
+
<Fingerprint className="w-9 h-9 animate-pulse" />
|
| 1020 |
+
</div>
|
| 1021 |
+
</div>
|
| 1022 |
+
|
| 1023 |
+
<div className="space-y-1">
|
| 1024 |
+
<h2 className="text-lg font-bold text-white tracking-tight">Scan Employee Badge</h2>
|
| 1025 |
+
{scanResult?.employee?.name && (
|
| 1026 |
+
<p className="text-[11.5px] text-slate-350 font-medium animate-fadeIn">
|
| 1027 |
+
Matched: <span className="font-bold text-amber-400">{scanResult.employee.name}</span>
|
| 1028 |
+
</p>
|
| 1029 |
+
)}
|
| 1030 |
+
<p className="text-[11px] text-slate-400 leading-relaxed max-w-[285px] mt-1">
|
| 1031 |
+
Hold your employee QR badge up to the camera. The scanner will register it automatically.
|
| 1032 |
+
</p>
|
| 1033 |
+
</div>
|
| 1034 |
+
|
| 1035 |
+
{qrError && (
|
| 1036 |
+
<p className="text-[10px] text-rose-455 font-mono font-bold uppercase tracking-wider bg-rose-500/10 py-1 px-3 rounded border border-rose-500/20">{qrError}</p>
|
| 1037 |
+
)}
|
| 1038 |
+
|
| 1039 |
+
<div className="flex flex-col items-center gap-2 pt-2 w-full max-w-[240px]">
|
| 1040 |
+
<button
|
| 1041 |
+
type="button"
|
| 1042 |
+
onClick={() => {
|
| 1043 |
+
resetToIdleWithCooldown(5000);
|
| 1044 |
+
}}
|
| 1045 |
+
className="w-full py-2 rounded-xl bg-transparent hover:bg-slate-900 text-slate-300 text-xs font-bold uppercase tracking-wider border border-slate-800 cursor-pointer active:scale-[0.97] transition-all"
|
| 1046 |
+
>
|
| 1047 |
+
Cancel
|
| 1048 |
+
</button>
|
| 1049 |
+
</div>
|
| 1050 |
+
|
| 1051 |
</div>
|
| 1052 |
</div>
|
| 1053 |
)}
|
| 1054 |
|
| 1055 |
{/* LOCKED screen */}
|
| 1056 |
{kioskActive && scanStatus === "locked" && (
|
| 1057 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-rose-500/30 shadow-[inset_0_0_40px_rgba(239,68,68,0.15)] rounded-2xl z-30">
|
| 1058 |
+
{/* HUD Corner Tech Brackets */}
|
| 1059 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-rose-500/70 rounded-tl-md" />
|
| 1060 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-rose-500/70 rounded-tr-md" />
|
| 1061 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-rose-500/70 rounded-bl-md" />
|
| 1062 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-rose-500/70 rounded-br-md" />
|
| 1063 |
+
|
| 1064 |
+
<div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
|
| 1065 |
+
<div className="w-12 h-12 rounded-full bg-rose-500/10 border border-rose-500/30 flex items-center justify-center text-rose-500 shadow-[0_0_15px_rgba(239,68,68,0.2)] animate-bounce">
|
| 1066 |
+
<ShieldAlert className="w-6 h-6" />
|
| 1067 |
+
</div>
|
| 1068 |
+
<div className="space-y-1">
|
| 1069 |
+
<h2 className="text-lg font-bold text-white tracking-tight">Attendance Locked</h2>
|
| 1070 |
+
<p className="text-[9.5px] text-rose-400 font-mono font-bold uppercase tracking-wider">Security Restriction</p>
|
| 1071 |
</div>
|
| 1072 |
+
<p className="text-xs text-slate-355 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-850 max-w-[260px]">
|
| 1073 |
+
{scanResult?.message || "Your attendance logging is locked for today. Re-entry must be authorized by an administrator."}
|
|
|
|
| 1074 |
</p>
|
| 1075 |
</div>
|
| 1076 |
</div>
|
|
|
|
| 1078 |
|
| 1079 |
{/* SPOOF screen */}
|
| 1080 |
{kioskActive && scanStatus === "spoof" && (
|
| 1081 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-rose-500/30 shadow-[inset_0_0_40px_rgba(239,68,68,0.15)] rounded-2xl z-30">
|
| 1082 |
+
{/* HUD Corner Tech Brackets */}
|
| 1083 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-rose-500/70 rounded-tl-md" />
|
| 1084 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-rose-500/70 rounded-tr-md" />
|
| 1085 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-rose-500/70 rounded-bl-md" />
|
| 1086 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-rose-500/70 rounded-br-md" />
|
| 1087 |
+
|
| 1088 |
+
<div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
|
| 1089 |
+
<div className="w-12 h-12 rounded-full bg-rose-500/10 border border-rose-500/30 flex items-center justify-center text-rose-500 shadow-[0_0_15px_rgba(239,68,68,0.2)] animate-pulse">
|
| 1090 |
+
<ShieldAlert className="w-6 h-6" />
|
| 1091 |
+
</div>
|
| 1092 |
+
<div className="space-y-1">
|
| 1093 |
+
<h2 className="text-lg font-bold text-white tracking-tight">Spoof Detected</h2>
|
| 1094 |
+
<p className="text-[9.5px] text-rose-400 font-mono font-bold uppercase tracking-wider">Anti-Spoofing Alarm</p>
|
| 1095 |
+
</div>
|
| 1096 |
+
<div className="space-y-2">
|
| 1097 |
+
<p className="text-xs text-rose-355 leading-relaxed font-semibold">
|
| 1098 |
+
{scanResult?.message || "Liveness verification failed. Presentation attack suspected."}
|
| 1099 |
+
</p>
|
| 1100 |
+
{scanResult?.liveness_score !== undefined && (
|
| 1101 |
+
<p className="text-[9px] text-slate-500 font-mono bg-slate-900/50 py-1 px-3 rounded-lg border border-slate-855 inline-block">
|
| 1102 |
+
Liveness Score: <strong className="text-rose-400">{scanResult.liveness_score.toFixed(3)}</strong>
|
| 1103 |
+
</p>
|
| 1104 |
+
)}
|
| 1105 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
</div>
|
| 1107 |
</div>
|
| 1108 |
)}
|
| 1109 |
|
| 1110 |
{/* UNKNOWN screen */}
|
| 1111 |
{kioskActive && scanStatus === "unknown" && (
|
| 1112 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-slate-700/30 shadow-[inset_0_0_40px_rgba(148,163,184,0.15)] rounded-2xl z-30">
|
| 1113 |
+
{/* HUD Corner Tech Brackets */}
|
| 1114 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-slate-500/40 rounded-tl-md" />
|
| 1115 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-slate-500/40 rounded-tr-md" />
|
| 1116 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-slate-500/40 rounded-bl-md" />
|
| 1117 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-slate-500/40 rounded-br-md" />
|
| 1118 |
+
|
| 1119 |
+
<div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
|
| 1120 |
+
<div className="w-12 h-12 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-400 shadow-md">
|
| 1121 |
+
<HelpCircle className="w-6 h-6" />
|
| 1122 |
</div>
|
| 1123 |
+
<div className="space-y-1">
|
| 1124 |
+
<h2 className="text-lg font-bold text-white tracking-tight">Not Recognized</h2>
|
| 1125 |
+
<p className="text-[9.5px] text-slate-500 font-mono font-bold uppercase tracking-wider text-center">Identity Unknown</p>
|
| 1126 |
+
</div>
|
| 1127 |
+
<p className="text-xs text-slate-400 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-855 max-w-[260px]">
|
| 1128 |
+
{scanResult?.message || "Biometric pattern does not match any registered employee records."}
|
| 1129 |
+
</p>
|
| 1130 |
</div>
|
| 1131 |
</div>
|
| 1132 |
)}
|
| 1133 |
|
| 1134 |
{/* MAINTENANCE screen */}
|
| 1135 |
{kioskActive && scanStatus === "maintenance" && (
|
| 1136 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-slate-700/30 shadow-[inset_0_0_40px_rgba(148,163,184,0.15)] rounded-2xl z-30">
|
| 1137 |
+
{/* HUD Corner Tech Brackets */}
|
| 1138 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-slate-500/40 rounded-tl-md" />
|
| 1139 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-slate-500/40 rounded-tr-md" />
|
| 1140 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-slate-500/40 rounded-bl-md" />
|
| 1141 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-slate-500/40 rounded-br-md" />
|
| 1142 |
+
|
| 1143 |
+
<div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
|
| 1144 |
+
<div className="w-12 h-12 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-450 shadow-md">
|
| 1145 |
+
<ShieldAlert className="w-6 h-6" />
|
| 1146 |
</div>
|
| 1147 |
+
<div className="space-y-1">
|
| 1148 |
+
<h2 className="text-lg font-bold text-white tracking-tight">Kiosk Offline</h2>
|
| 1149 |
+
<p className="text-[9.5px] text-slate-500 font-mono font-bold uppercase tracking-wider">Maintenance Mode</p>
|
| 1150 |
+
</div>
|
| 1151 |
+
<p className="text-xs text-slate-400 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-855 max-w-[260px]">
|
| 1152 |
+
{scanResult?.message || "Biometric scans are temporarily suspended for system database syncing."}
|
| 1153 |
</p>
|
| 1154 |
</div>
|
| 1155 |
</div>
|
|
|
|
| 1157 |
|
| 1158 |
{/* NO_EMPLOYEES screen */}
|
| 1159 |
{kioskActive && scanStatus === "no_employees" && (
|
| 1160 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeInUp border border-amber-500/30 shadow-[inset_0_0_40px_rgba(245,158,11,0.15)] rounded-2xl z-30">
|
| 1161 |
+
{/* HUD Corner Tech Brackets */}
|
| 1162 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-amber-500/70 rounded-tl-md" />
|
| 1163 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-amber-500/70 rounded-tr-md" />
|
| 1164 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-amber-500/70 rounded-bl-md" />
|
| 1165 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-amber-500/70 rounded-br-md" />
|
| 1166 |
+
|
| 1167 |
+
<div className="space-y-4 max-w-xs animate-fade-in flex flex-col items-center">
|
| 1168 |
+
<div className="w-12 h-12 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center text-amber-555 shadow-md">
|
| 1169 |
+
<UserCheck className="w-6 h-6 animate-pulse" />
|
| 1170 |
</div>
|
| 1171 |
+
<div className="space-y-1">
|
| 1172 |
+
<h2 className="text-lg font-bold text-white tracking-tight">Setup Required</h2>
|
| 1173 |
+
<p className="text-[9.5px] text-amber-400 font-mono font-bold uppercase tracking-wider">Empty Database</p>
|
| 1174 |
+
</div>
|
| 1175 |
+
<p className="text-xs text-slate-400 leading-relaxed font-medium bg-slate-900/60 p-3 rounded-xl border border-slate-855 max-w-[260px]">
|
| 1176 |
+
{scanResult?.message || "Please enroll employees in the dashboard before attempting kiosk logging."}
|
| 1177 |
</p>
|
| 1178 |
</div>
|
| 1179 |
</div>
|
| 1180 |
)}
|
| 1181 |
|
| 1182 |
+
{/* Sci-Fi QR Scan Processing Overlay */}
|
| 1183 |
+
{kioskActive && qrDetectedData && (
|
| 1184 |
+
<div className="absolute inset-0 bg-slate-950/85 backdrop-blur-md flex flex-col items-center justify-center p-6 text-center animate-fadeIn border-2 border-emerald-500/50 shadow-[inset_0_0_50px_rgba(16,185,129,0.3)] rounded-2xl z-40">
|
| 1185 |
+
{/* HUD Tech Corner Brackets */}
|
| 1186 |
+
<div className="absolute top-4 left-4 w-6 h-6 border-t-2 border-l-2 border-emerald-400 rounded-tl-md animate-pulse" />
|
| 1187 |
+
<div className="absolute top-4 right-4 w-6 h-6 border-t-2 border-r-2 border-emerald-400 rounded-tr-md animate-pulse" />
|
| 1188 |
+
<div className="absolute bottom-4 left-4 w-6 h-6 border-b-2 border-l-2 border-emerald-400 rounded-bl-md animate-pulse" />
|
| 1189 |
+
<div className="absolute bottom-4 right-4 w-6 h-6 border-b-2 border-r-2 border-emerald-400 rounded-br-md animate-pulse" />
|
| 1190 |
+
|
| 1191 |
+
{/* Rotating Matrix Code Rings */}
|
| 1192 |
+
<div className="relative w-32 h-32 flex items-center justify-center mb-4">
|
| 1193 |
+
{/* Glowing Pulsing Outer Ring */}
|
| 1194 |
+
<div className="absolute inset-0 border border-emerald-400/40 rounded-full animate-ping" style={{ animationDuration: "1s" }} />
|
| 1195 |
+
{/* Concentric rotating dashes */}
|
| 1196 |
+
<div className="absolute inset-2 border-2 border-emerald-500 border-dashed rounded-full animate-rotate-hud" />
|
| 1197 |
+
<div className="absolute inset-4 border border-emerald-400/20 border-dashed rounded-full animate-rotate-hud-reverse" />
|
| 1198 |
+
{/* Inner Target Ring */}
|
| 1199 |
+
<div className="absolute inset-6 rounded-full bg-slate-900 border-2 border-emerald-400 shadow-[0_0_25px_rgba(16,185,129,0.5)] flex items-center justify-center overflow-hidden">
|
| 1200 |
+
<QrCode className="w-10 h-10 text-emerald-400 animate-pulse" />
|
| 1201 |
+
</div>
|
| 1202 |
+
</div>
|
| 1203 |
+
|
| 1204 |
+
<div className="space-y-2 max-w-xs flex flex-col items-center">
|
| 1205 |
+
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 font-mono text-[9px] font-bold uppercase tracking-widest animate-pulse">
|
| 1206 |
+
Decrypted Badge
|
| 1207 |
+
</span>
|
| 1208 |
+
<h3 className="text-sm font-extrabold text-white tracking-widest uppercase">QR Code Registered</h3>
|
| 1209 |
+
<p className="text-[10px] text-slate-400 font-mono tracking-wider break-all bg-slate-900/90 px-3 py-1 border border-slate-800 rounded-lg">
|
| 1210 |
+
ID: {qrDetectedData}
|
| 1211 |
+
</p>
|
| 1212 |
+
</div>
|
| 1213 |
+
|
| 1214 |
+
{/* Glowing status loading bar */}
|
| 1215 |
+
<div className="mt-5 w-44 bg-slate-900 border border-slate-800 h-2.5 rounded-full overflow-hidden relative">
|
| 1216 |
+
<div className="absolute top-0 bottom-0 left-0 bg-gradient-to-r from-emerald-500 to-teal-400 animate-[scan-laser_1.5s_ease-in-out_infinite] w-20 rounded-full" />
|
| 1217 |
+
</div>
|
| 1218 |
+
<span className="mt-2.5 text-[9px] font-bold text-emerald-400 tracking-wider uppercase font-mono animate-pulse">
|
| 1219 |
+
Verifying Credentials...
|
| 1220 |
+
</span>
|
| 1221 |
+
</div>
|
| 1222 |
+
)}
|
| 1223 |
+
|
| 1224 |
{/* Frame Info HUD Overlay */}
|
| 1225 |
{kioskActive && (
|
| 1226 |
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between pointer-events-none z-10">
|
frontend/app/reports/page.tsx
CHANGED
|
@@ -23,6 +23,11 @@ const FORMATS = [
|
|
| 23 |
{ value: "csv", label: "CSV Flatfile", desc: ".csv Flat data" },
|
| 24 |
];
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export default function ReportsPage() {
|
| 27 |
const { toast } = useToast();
|
| 28 |
const [reportType, setReportType] = useState("daily");
|
|
@@ -34,19 +39,53 @@ export default function ReportsPage() {
|
|
| 34 |
const [endDate, setEndDate] = useState(() => getLocalDateString());
|
| 35 |
const [employeeId, setEmployeeId] = useState("");
|
| 36 |
const [departmentId, setDepartmentId] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const [exporting, setExporting] = useState(false);
|
| 38 |
const [success, setSuccess] = useState(false);
|
| 39 |
|
|
|
|
| 40 |
const { data: departments } = useQuery({ queryKey: ["departments"], queryFn: () => fetchApi("/departments/") });
|
| 41 |
const { data: employees } = useQuery({ queryKey: ["employees-list"], queryFn: () => fetchApi("/employees/") });
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
const handleExport = async (e: React.FormEvent) => {
|
| 44 |
e.preventDefault();
|
| 45 |
setExporting(true); setSuccess(false);
|
| 46 |
try {
|
| 47 |
-
const params = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
if (employeeId) params.push(`employee_id=${employeeId}`);
|
| 49 |
if (departmentId) params.push(`department_id=${departmentId}`);
|
|
|
|
|
|
|
|
|
|
| 50 |
const blob: Blob = await fetchApi(`/reports/export?${params.join("&")}`);
|
| 51 |
const url = window.URL.createObjectURL(blob);
|
| 52 |
const a = document.createElement("a");
|
|
@@ -68,16 +107,16 @@ export default function ReportsPage() {
|
|
| 68 |
|
| 69 |
return (
|
| 70 |
<SidebarLayout>
|
| 71 |
-
<div className="space-y-6 max-w-4xl page-enter">
|
| 72 |
{/* Header */}
|
| 73 |
-
<div className="pb-5 border-b border-
|
| 74 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Reports & Export</h1>
|
| 75 |
</div>
|
| 76 |
|
| 77 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
| 78 |
{/* Form */}
|
| 79 |
-
<div className="lg:col-span-2 glass-card rounded-2xl border border-
|
| 80 |
-
<div className="flex items-center gap-2.5 border-b border-
|
| 81 |
<div className="w-8 h-8 rounded-lg bg-zinc-100 flex items-center justify-center border border-zinc-200">
|
| 82 |
<FileDown className="w-4 h-4 text-zinc-700" />
|
| 83 |
</div>
|
|
@@ -103,7 +142,7 @@ export default function ReportsPage() {
|
|
| 103 |
onClick={() => setReportType(rt.value)}
|
| 104 |
className={`text-left p-3.5 rounded-xl border flex gap-3 transition-all cursor-pointer ${
|
| 105 |
isActive
|
| 106 |
-
? "bg-zinc-
|
| 107 |
: "bg-white border-zinc-200 text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800"
|
| 108 |
}`}
|
| 109 |
>
|
|
@@ -174,7 +213,7 @@ export default function ReportsPage() {
|
|
| 174 |
</div>
|
| 175 |
|
| 176 |
{/* Optional filters */}
|
| 177 |
-
<div className="grid grid-cols-2 gap-3.5 pt-4 border-t border-
|
| 178 |
<div className="space-y-1.5">
|
| 179 |
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
| 180 |
Department <span className="opacity-50 normal-case">(optional)</span>
|
|
@@ -201,6 +240,89 @@ export default function ReportsPage() {
|
|
| 201 |
</div>
|
| 202 |
</div>
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
{/* Submit */}
|
| 205 |
<button
|
| 206 |
type="submit"
|
|
@@ -218,7 +340,7 @@ export default function ReportsPage() {
|
|
| 218 |
|
| 219 |
{/* Sidebar info */}
|
| 220 |
<div className="space-y-4 md:col-span-1">
|
| 221 |
-
<div className="glass-card rounded-2xl border border-
|
| 222 |
<div className="flex items-center gap-2 mb-1">
|
| 223 |
<Layers className="w-4 h-4 text-zinc-700" />
|
| 224 |
<h3 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Audit Protocols</h3>
|
|
@@ -226,12 +348,12 @@ export default function ReportsPage() {
|
|
| 226 |
<div className="space-y-3.5 text-[11.5px] text-[var(--text-secondary)] leading-relaxed">
|
| 227 |
{[
|
| 228 |
"Reports compile directly from the master SQL presence ledgers.",
|
| 229 |
-
"
|
| 230 |
"Grace periods and overtime values recalculate dynamically.",
|
| 231 |
"Failed anti-spoof events are highlighted in transaction files.",
|
| 232 |
].map((note, i) => (
|
| 233 |
<div key={i} className="flex items-start gap-2.5">
|
| 234 |
-
<div className="w-1.5 h-1.5 rounded-full bg-slate-
|
| 235 |
<span>{note}</span>
|
| 236 |
</div>
|
| 237 |
))}
|
|
@@ -239,7 +361,7 @@ export default function ReportsPage() {
|
|
| 239 |
</div>
|
| 240 |
|
| 241 |
{success && (
|
| 242 |
-
<div className="flex items-start gap-3 p-4 rounded-2xl bg-emerald-50 border border-emerald-
|
| 243 |
<CheckCircle2 className="w-4.5 h-4.5 shrink-0 mt-0.5" />
|
| 244 |
<div>
|
| 245 |
<p className="text-[12px] font-bold uppercase tracking-wider">Report Generated</p>
|
|
@@ -251,6 +373,72 @@ export default function ReportsPage() {
|
|
| 251 |
)}
|
| 252 |
</div>
|
| 253 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
</div>
|
| 255 |
</SidebarLayout>
|
| 256 |
);
|
|
|
|
| 23 |
{ value: "csv", label: "CSV Flatfile", desc: ".csv Flat data" },
|
| 24 |
];
|
| 25 |
|
| 26 |
+
const COLUMNS_LIST = [
|
| 27 |
+
"Date", "Employee ID", "Name", "Department",
|
| 28 |
+
"Check In", "Check Out", "Hours Worked", "Overtime", "Status"
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
export default function ReportsPage() {
|
| 32 |
const { toast } = useToast();
|
| 33 |
const [reportType, setReportType] = useState("daily");
|
|
|
|
| 39 |
const [endDate, setEndDate] = useState(() => getLocalDateString());
|
| 40 |
const [employeeId, setEmployeeId] = useState("");
|
| 41 |
const [departmentId, setDepartmentId] = useState("");
|
| 42 |
+
|
| 43 |
+
// Custom Query Builder states
|
| 44 |
+
const [selectedColumns, setSelectedColumns] = useState<string[]>(COLUMNS_LIST);
|
| 45 |
+
const [minHours, setMinHours] = useState<string>("");
|
| 46 |
+
const [maxHours, setMaxHours] = useState<string>("");
|
| 47 |
+
|
| 48 |
const [exporting, setExporting] = useState(false);
|
| 49 |
const [success, setSuccess] = useState(false);
|
| 50 |
|
| 51 |
+
// Core list queries
|
| 52 |
const { data: departments } = useQuery({ queryKey: ["departments"], queryFn: () => fetchApi("/departments/") });
|
| 53 |
const { data: employees } = useQuery({ queryKey: ["employees-list"], queryFn: () => fetchApi("/employees/") });
|
| 54 |
|
| 55 |
+
// Preview Query
|
| 56 |
+
const { data: previewData, isLoading: loadingPreview } = useQuery({
|
| 57 |
+
queryKey: ["reports-preview", startDate, endDate, employeeId, departmentId, minHours, maxHours, selectedColumns],
|
| 58 |
+
queryFn: () => {
|
| 59 |
+
const params = [
|
| 60 |
+
`start_date=${startDate}`,
|
| 61 |
+
`end_date=${endDate}`,
|
| 62 |
+
`columns=${selectedColumns.join(",")}`
|
| 63 |
+
];
|
| 64 |
+
if (employeeId) params.push(`employee_id=${employeeId}`);
|
| 65 |
+
if (departmentId) params.push(`department_id=${departmentId}`);
|
| 66 |
+
if (minHours) params.push(`min_hours=${minHours}`);
|
| 67 |
+
if (maxHours) params.push(`max_hours=${maxHours}`);
|
| 68 |
+
|
| 69 |
+
return fetchApi(`/reports/preview?${params.join("&")}`);
|
| 70 |
+
}
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
const handleExport = async (e: React.FormEvent) => {
|
| 74 |
e.preventDefault();
|
| 75 |
setExporting(true); setSuccess(false);
|
| 76 |
try {
|
| 77 |
+
const params = [
|
| 78 |
+
`report_type=${reportType}`,
|
| 79 |
+
`format=${format}`,
|
| 80 |
+
`start_date=${startDate}`,
|
| 81 |
+
`end_date=${endDate}`,
|
| 82 |
+
`columns=${selectedColumns.join(",")}`
|
| 83 |
+
];
|
| 84 |
if (employeeId) params.push(`employee_id=${employeeId}`);
|
| 85 |
if (departmentId) params.push(`department_id=${departmentId}`);
|
| 86 |
+
if (minHours) params.push(`min_hours=${minHours}`);
|
| 87 |
+
if (maxHours) params.push(`max_hours=${maxHours}`);
|
| 88 |
+
|
| 89 |
const blob: Blob = await fetchApi(`/reports/export?${params.join("&")}`);
|
| 90 |
const url = window.URL.createObjectURL(blob);
|
| 91 |
const a = document.createElement("a");
|
|
|
|
| 107 |
|
| 108 |
return (
|
| 109 |
<SidebarLayout>
|
| 110 |
+
<div className="space-y-6 max-w-4xl page-enter text-slate-850">
|
| 111 |
{/* Header */}
|
| 112 |
+
<div className="pb-5 border-b border-slate-200">
|
| 113 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Reports & Export</h1>
|
| 114 |
</div>
|
| 115 |
|
| 116 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
| 117 |
{/* Form */}
|
| 118 |
+
<div className="lg:col-span-2 glass-card rounded-2xl border border-slate-200 p-6 space-y-6 shadow-2xs">
|
| 119 |
+
<div className="flex items-center gap-2.5 border-b border-slate-100 pb-4">
|
| 120 |
<div className="w-8 h-8 rounded-lg bg-zinc-100 flex items-center justify-center border border-zinc-200">
|
| 121 |
<FileDown className="w-4 h-4 text-zinc-700" />
|
| 122 |
</div>
|
|
|
|
| 142 |
onClick={() => setReportType(rt.value)}
|
| 143 |
className={`text-left p-3.5 rounded-xl border flex gap-3 transition-all cursor-pointer ${
|
| 144 |
isActive
|
| 145 |
+
? "bg-zinc-55 border-zinc-300 text-zinc-950 shadow-sm"
|
| 146 |
: "bg-white border-zinc-200 text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800"
|
| 147 |
}`}
|
| 148 |
>
|
|
|
|
| 213 |
</div>
|
| 214 |
|
| 215 |
{/* Optional filters */}
|
| 216 |
+
<div className="grid grid-cols-2 gap-3.5 pt-4 border-t border-slate-100">
|
| 217 |
<div className="space-y-1.5">
|
| 218 |
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
| 219 |
Department <span className="opacity-50 normal-case">(optional)</span>
|
|
|
|
| 240 |
</div>
|
| 241 |
</div>
|
| 242 |
|
| 243 |
+
{/* ─── Custom Query Builder Panel ─── */}
|
| 244 |
+
<div className="space-y-4 pt-4 border-t border-slate-100 bg-slate-50/20 p-4.5 rounded-2xl border border-slate-100">
|
| 245 |
+
<div className="flex items-center gap-2">
|
| 246 |
+
<Layers className="w-4 h-4 text-zinc-650" />
|
| 247 |
+
<h3 className="text-[11px] font-bold text-zinc-650 uppercase tracking-wider">Custom Query Fields</h3>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Column checkboxes */}
|
| 251 |
+
<div className="space-y-2">
|
| 252 |
+
<label className="block text-[9px] font-bold text-slate-450 uppercase tracking-wider">
|
| 253 |
+
Include Columns
|
| 254 |
+
</label>
|
| 255 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
| 256 |
+
{COLUMNS_LIST.map((col) => {
|
| 257 |
+
const isChecked = selectedColumns.includes(col);
|
| 258 |
+
return (
|
| 259 |
+
<button
|
| 260 |
+
key={col}
|
| 261 |
+
type="button"
|
| 262 |
+
onClick={() => {
|
| 263 |
+
if (isChecked) {
|
| 264 |
+
if (selectedColumns.length > 1) {
|
| 265 |
+
setSelectedColumns(selectedColumns.filter(c => c !== col));
|
| 266 |
+
} else {
|
| 267 |
+
toast.error("Please select at least one column");
|
| 268 |
+
}
|
| 269 |
+
} else {
|
| 270 |
+
setSelectedColumns([...selectedColumns, col]);
|
| 271 |
+
}
|
| 272 |
+
}}
|
| 273 |
+
className={`flex items-center gap-2 p-2 rounded-xl border text-[11px] font-semibold cursor-pointer select-none transition-all ${
|
| 274 |
+
isChecked
|
| 275 |
+
? "bg-zinc-950 border-zinc-950 text-white shadow-2xs"
|
| 276 |
+
: "bg-white border-slate-200 text-slate-450 hover:bg-slate-50 hover:text-slate-700"
|
| 277 |
+
}`}
|
| 278 |
+
>
|
| 279 |
+
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
| 280 |
+
isChecked ? "bg-white border-white text-zinc-950" : "bg-white border-slate-200"
|
| 281 |
+
}`}>
|
| 282 |
+
{isChecked && <Check className="w-2.5 h-2.5 stroke-[3.5]" />}
|
| 283 |
+
</div>
|
| 284 |
+
<span className="truncate">{col}</span>
|
| 285 |
+
</button>
|
| 286 |
+
);
|
| 287 |
+
})}
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
{/* Working hours limits */}
|
| 292 |
+
<div className="grid grid-cols-2 gap-3.5 pt-2">
|
| 293 |
+
<div className="space-y-1.5">
|
| 294 |
+
<label className="block text-[9px] font-bold text-slate-450 uppercase tracking-wider">
|
| 295 |
+
Min Hours Worked <span className="opacity-60 normal-case">(optional)</span>
|
| 296 |
+
</label>
|
| 297 |
+
<input
|
| 298 |
+
type="number"
|
| 299 |
+
step="0.5"
|
| 300 |
+
min="0"
|
| 301 |
+
max="24"
|
| 302 |
+
placeholder="E.g. 4.0"
|
| 303 |
+
value={minHours}
|
| 304 |
+
onChange={(e) => setMinHours(e.target.value)}
|
| 305 |
+
className="input-field h-9 text-[12px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl px-3 transition-all w-full"
|
| 306 |
+
/>
|
| 307 |
+
</div>
|
| 308 |
+
<div className="space-y-1.5">
|
| 309 |
+
<label className="block text-[9px] font-bold text-slate-450 uppercase tracking-wider">
|
| 310 |
+
Max Hours Worked <span className="opacity-60 normal-case">(optional)</span>
|
| 311 |
+
</label>
|
| 312 |
+
<input
|
| 313 |
+
type="number"
|
| 314 |
+
step="0.5"
|
| 315 |
+
min="0"
|
| 316 |
+
max="24"
|
| 317 |
+
placeholder="E.g. 9.5"
|
| 318 |
+
value={maxHours}
|
| 319 |
+
onChange={(e) => setMaxHours(e.target.value)}
|
| 320 |
+
className="input-field h-9 text-[12px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl px-3 transition-all w-full"
|
| 321 |
+
/>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
|
| 326 |
{/* Submit */}
|
| 327 |
<button
|
| 328 |
type="submit"
|
|
|
|
| 340 |
|
| 341 |
{/* Sidebar info */}
|
| 342 |
<div className="space-y-4 md:col-span-1">
|
| 343 |
+
<div className="glass-card rounded-2xl border border-slate-200 p-5 space-y-4.5 bg-slate-50/10">
|
| 344 |
<div className="flex items-center gap-2 mb-1">
|
| 345 |
<Layers className="w-4 h-4 text-zinc-700" />
|
| 346 |
<h3 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Audit Protocols</h3>
|
|
|
|
| 348 |
<div className="space-y-3.5 text-[11.5px] text-[var(--text-secondary)] leading-relaxed">
|
| 349 |
{[
|
| 350 |
"Reports compile directly from the master SQL presence ledgers.",
|
| 351 |
+
"Custom column filtering dynamically adjusts Excel/PDF tables.",
|
| 352 |
"Grace periods and overtime values recalculate dynamically.",
|
| 353 |
"Failed anti-spoof events are highlighted in transaction files.",
|
| 354 |
].map((note, i) => (
|
| 355 |
<div key={i} className="flex items-start gap-2.5">
|
| 356 |
+
<div className="w-1.5 h-1.5 rounded-full bg-slate-400 mt-1.5 shrink-0" />
|
| 357 |
<span>{note}</span>
|
| 358 |
</div>
|
| 359 |
))}
|
|
|
|
| 361 |
</div>
|
| 362 |
|
| 363 |
{success && (
|
| 364 |
+
<div className="flex items-start gap-3 p-4 rounded-2xl bg-emerald-50 border border-emerald-200 text-emerald-800 animate-fadeInUp">
|
| 365 |
<CheckCircle2 className="w-4.5 h-4.5 shrink-0 mt-0.5" />
|
| 366 |
<div>
|
| 367 |
<p className="text-[12px] font-bold uppercase tracking-wider">Report Generated</p>
|
|
|
|
| 373 |
)}
|
| 374 |
</div>
|
| 375 |
</div>
|
| 376 |
+
|
| 377 |
+
{/* ─── Live Preview Grid Card ─── */}
|
| 378 |
+
<div className="glass-card rounded-2xl border border-slate-200 overflow-hidden shadow-2xs">
|
| 379 |
+
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50/10">
|
| 380 |
+
<div className="flex items-center gap-2">
|
| 381 |
+
<div className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-pulse" />
|
| 382 |
+
<h2 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">
|
| 383 |
+
Live Data Preview ({previewData?.total_count || 0} matching logs)
|
| 384 |
+
</h2>
|
| 385 |
+
</div>
|
| 386 |
+
<span className="text-[9.5px] font-mono text-slate-400 font-bold uppercase">
|
| 387 |
+
Showing first 8 records
|
| 388 |
+
</span>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<div className="overflow-x-auto scrollbar-thin">
|
| 392 |
+
{loadingPreview ? (
|
| 393 |
+
<div className="p-8 flex items-center justify-center text-slate-400 text-xs gap-2">
|
| 394 |
+
<Loader2 className="w-4 h-4 animate-spin text-slate-650" />
|
| 395 |
+
<span>Computing report preview...</span>
|
| 396 |
+
</div>
|
| 397 |
+
) : !previewData?.records || previewData.records.length === 0 ? (
|
| 398 |
+
<div className="p-12 text-center text-slate-400 italic text-xs">
|
| 399 |
+
No attendance logs found matching these search criteria. Try adjusting dates or filters.
|
| 400 |
+
</div>
|
| 401 |
+
) : (
|
| 402 |
+
<table className="w-full text-left border-collapse text-[11px] min-w-[700px]">
|
| 403 |
+
<thead>
|
| 404 |
+
<tr className="border-b border-slate-200 bg-slate-50 text-slate-500 uppercase tracking-wider font-mono">
|
| 405 |
+
{previewData.columns.map((col: string) => (
|
| 406 |
+
<th key={col} className="py-2.5 px-4 font-semibold">{col}</th>
|
| 407 |
+
))}
|
| 408 |
+
</tr>
|
| 409 |
+
</thead>
|
| 410 |
+
<tbody className="divide-y divide-slate-100 text-slate-700 font-sans">
|
| 411 |
+
{previewData.records.slice(0, 8).map((row: any, rIdx: number) => (
|
| 412 |
+
<tr key={rIdx} className="hover:bg-slate-50/40 transition-colors">
|
| 413 |
+
{previewData.columns.map((col: string) => {
|
| 414 |
+
const val = row[col];
|
| 415 |
+
return (
|
| 416 |
+
<td key={col} className="py-2.5 px-4 font-mono text-slate-800">
|
| 417 |
+
{col === "Status" ? (
|
| 418 |
+
<span className={`inline-block text-[8.5px] font-semibold px-1.5 py-0.5 rounded border ${
|
| 419 |
+
val === "Present" ? "bg-emerald-50 border-emerald-250 text-emerald-700" :
|
| 420 |
+
val === "Late" ? "bg-amber-50 border-amber-250 text-amber-700" :
|
| 421 |
+
val === "Half Day" ? "bg-indigo-50 border-indigo-250 text-indigo-750" :
|
| 422 |
+
"bg-rose-50 border-rose-250 text-rose-700"
|
| 423 |
+
}`}>
|
| 424 |
+
{val}
|
| 425 |
+
</span>
|
| 426 |
+
) : typeof val === "number" ? (
|
| 427 |
+
val.toFixed(2)
|
| 428 |
+
) : (
|
| 429 |
+
val || "-"
|
| 430 |
+
)}
|
| 431 |
+
</td>
|
| 432 |
+
);
|
| 433 |
+
})}
|
| 434 |
+
</tr>
|
| 435 |
+
))}
|
| 436 |
+
</tbody>
|
| 437 |
+
</table>
|
| 438 |
+
)}
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
</div>
|
| 443 |
</SidebarLayout>
|
| 444 |
);
|
frontend/app/settings/page.tsx
CHANGED
|
@@ -7,10 +7,10 @@ import { fetchApi } from "@/app/utils/api";
|
|
| 7 |
import { useToast } from "@/app/utils/toast";
|
| 8 |
import {
|
| 9 |
Check, Loader2, CheckCircle2, AlertCircle, Sliders, Info,
|
| 10 |
-
Fingerprint, Clock, Volume2
|
| 11 |
} from "lucide-react";
|
| 12 |
|
| 13 |
-
type SettingsTab = "biometrics" | "shift" | "voice" | "advanced";
|
| 14 |
|
| 15 |
export default function SettingsPage() {
|
| 16 |
const queryClient = useQueryClient();
|
|
@@ -19,6 +19,7 @@ export default function SettingsPage() {
|
|
| 19 |
const [updatingKey, setUpdatingKey] = useState<string | null>(null);
|
| 20 |
const [successKey, setSuccessKey] = useState<string | null>(null);
|
| 21 |
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
|
|
|
| 22 |
|
| 23 |
const { data: settings, isLoading } = useQuery({
|
| 24 |
queryKey: ["settings"],
|
|
@@ -51,8 +52,43 @@ export default function SettingsPage() {
|
|
| 51 |
saveMutation.mutate({ key, value: editValues[key] });
|
| 52 |
};
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
// Group settings by keys for our tabs
|
| 55 |
const getTabForSettings = (key: string): SettingsTab => {
|
|
|
|
|
|
|
| 56 |
if (key.includes("FACE") || key.includes("LIVENESS")) return "biometrics";
|
| 57 |
if (key.includes("SHIFT") || key.includes("CHECK") || key.includes("GRACE")) return "shift";
|
| 58 |
if (key.includes("VOICE") || key.includes("GREETING")) return "voice";
|
|
@@ -166,6 +202,97 @@ export default function SettingsPage() {
|
|
| 166 |
);
|
| 167 |
}
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
// Default Fallback
|
| 170 |
return (
|
| 171 |
<input
|
|
@@ -183,6 +310,8 @@ export default function SettingsPage() {
|
|
| 183 |
case "biometrics": return <Fingerprint className="w-4 h-4" />;
|
| 184 |
case "shift": return <Clock className="w-4 h-4" />;
|
| 185 |
case "voice": return <Volume2 className="w-4 h-4" />;
|
|
|
|
|
|
|
| 186 |
case "advanced": return <Sliders className="w-4 h-4" />;
|
| 187 |
}
|
| 188 |
};
|
|
@@ -192,6 +321,8 @@ export default function SettingsPage() {
|
|
| 192 |
case "biometrics": return "Biometrics & AI";
|
| 193 |
case "shift": return "Shift & Grace Hours";
|
| 194 |
case "voice": return "Voice Alerts";
|
|
|
|
|
|
|
| 195 |
case "advanced": return "Advanced Configurations";
|
| 196 |
}
|
| 197 |
};
|
|
@@ -201,6 +332,8 @@ export default function SettingsPage() {
|
|
| 201 |
case "biometrics": return "Configure facial matching similarity and liveness parameters";
|
| 202 |
case "shift": return "Manage daily check-in start, grace window, and shifts";
|
| 203 |
case "voice": return "Enable or disable text-to-speech audio feedback at terminals";
|
|
|
|
|
|
|
| 204 |
case "advanced": return "System variables and advanced parameters";
|
| 205 |
}
|
| 206 |
};
|
|
@@ -218,7 +351,7 @@ export default function SettingsPage() {
|
|
| 218 |
|
| 219 |
{/* Left Navigation: Vertical tabs */}
|
| 220 |
<div className="space-y-1 md:col-span-1">
|
| 221 |
-
{(["biometrics", "shift", "voice", "advanced"] as SettingsTab[]).map(tab => {
|
| 222 |
const isActive = activeTab === tab;
|
| 223 |
return (
|
| 224 |
<button
|
|
@@ -285,6 +418,7 @@ export default function SettingsPage() {
|
|
| 285 |
const getSettingIcon = (k: string) => {
|
| 286 |
if (k.includes("VOICE") || k.includes("GREETING")) return <Volume2 className="w-5 h-5" />;
|
| 287 |
if (k.includes("MAINTENANCE")) return <AlertCircle className="w-5 h-5" />;
|
|
|
|
| 288 |
return <Sliders className="w-5 h-5" />;
|
| 289 |
};
|
| 290 |
|
|
@@ -379,31 +513,62 @@ export default function SettingsPage() {
|
|
| 379 |
<div className="flex items-center gap-2.5 shrink-0">
|
| 380 |
{renderSettingControl(setting, isUpdating)}
|
| 381 |
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
| 401 |
</div>
|
| 402 |
</div>
|
| 403 |
);
|
| 404 |
})}
|
| 405 |
</div>
|
| 406 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
</div>
|
| 408 |
)}
|
| 409 |
</div>
|
|
@@ -426,6 +591,24 @@ export default function SettingsPage() {
|
|
| 426 |
</span>
|
| 427 |
</div>
|
| 428 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
</div>
|
| 430 |
</div>
|
| 431 |
</div>
|
|
|
|
| 7 |
import { useToast } from "@/app/utils/toast";
|
| 8 |
import {
|
| 9 |
Check, Loader2, CheckCircle2, AlertCircle, Sliders, Info,
|
| 10 |
+
Fingerprint, Clock, Volume2, Palette, Upload, Trash2, Camera, MapPin
|
| 11 |
} from "lucide-react";
|
| 12 |
|
| 13 |
+
type SettingsTab = "biometrics" | "shift" | "voice" | "branding" | "location" | "advanced";
|
| 14 |
|
| 15 |
export default function SettingsPage() {
|
| 16 |
const queryClient = useQueryClient();
|
|
|
|
| 19 |
const [updatingKey, setUpdatingKey] = useState<string | null>(null);
|
| 20 |
const [successKey, setSuccessKey] = useState<string | null>(null);
|
| 21 |
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
| 22 |
+
const [fetchingGps, setFetchingGps] = useState(false);
|
| 23 |
|
| 24 |
const { data: settings, isLoading } = useQuery({
|
| 25 |
queryKey: ["settings"],
|
|
|
|
| 52 |
saveMutation.mutate({ key, value: editValues[key] });
|
| 53 |
};
|
| 54 |
|
| 55 |
+
const handleSetCurrentLocation = () => {
|
| 56 |
+
if (typeof window !== "undefined" && navigator.geolocation) {
|
| 57 |
+
setFetchingGps(true);
|
| 58 |
+
navigator.geolocation.getCurrentPosition(
|
| 59 |
+
(position) => {
|
| 60 |
+
const lat = position.coords.latitude.toString();
|
| 61 |
+
const lon = position.coords.longitude.toString();
|
| 62 |
+
|
| 63 |
+
setEditValues(prev => ({
|
| 64 |
+
...prev,
|
| 65 |
+
LOCATION_LATITUDE: lat,
|
| 66 |
+
LOCATION_LONGITUDE: lon
|
| 67 |
+
}));
|
| 68 |
+
|
| 69 |
+
// Save both settings
|
| 70 |
+
saveMutation.mutate({ key: "LOCATION_LATITUDE", value: lat });
|
| 71 |
+
saveMutation.mutate({ key: "LOCATION_LONGITUDE", value: lon });
|
| 72 |
+
|
| 73 |
+
toast.success("Coordinates updated to current location!");
|
| 74 |
+
setFetchingGps(false);
|
| 75 |
+
},
|
| 76 |
+
(err) => {
|
| 77 |
+
console.error("GPS error:", err);
|
| 78 |
+
toast.error("Failed to get current location. Ensure GPS permission is granted.");
|
| 79 |
+
setFetchingGps(false);
|
| 80 |
+
},
|
| 81 |
+
{ enableHighAccuracy: true }
|
| 82 |
+
);
|
| 83 |
+
} else {
|
| 84 |
+
toast.error("Geolocation is not supported by your browser.");
|
| 85 |
+
}
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
// Group settings by keys for our tabs
|
| 89 |
const getTabForSettings = (key: string): SettingsTab => {
|
| 90 |
+
if (key.includes("LOCATION")) return "location";
|
| 91 |
+
if (key.includes("COMPANY") || key.includes("LOGO") || key.includes("BADGE")) return "branding";
|
| 92 |
if (key.includes("FACE") || key.includes("LIVENESS")) return "biometrics";
|
| 93 |
if (key.includes("SHIFT") || key.includes("CHECK") || key.includes("GRACE")) return "shift";
|
| 94 |
if (key.includes("VOICE") || key.includes("GREETING")) return "voice";
|
|
|
|
| 202 |
);
|
| 203 |
}
|
| 204 |
|
| 205 |
+
if (key === "BADGE_THEME_COLOR") {
|
| 206 |
+
return (
|
| 207 |
+
<select
|
| 208 |
+
value={val}
|
| 209 |
+
disabled={isUpdating}
|
| 210 |
+
onChange={(e) => {
|
| 211 |
+
setEditValues(prev => ({ ...prev, [key]: e.target.value }));
|
| 212 |
+
setUpdatingKey(key);
|
| 213 |
+
saveMutation.mutate({ key, value: e.target.value });
|
| 214 |
+
}}
|
| 215 |
+
className="input-field h-9 text-[12.5px] w-40 bg-white border-slate-250 rounded-xl px-3 focus:border-slate-800 transition-all font-semibold cursor-pointer text-center"
|
| 216 |
+
>
|
| 217 |
+
<option value="Navy Blue">Navy Blue</option>
|
| 218 |
+
<option value="Charcoal">Charcoal</option>
|
| 219 |
+
<option value="Emerald">Emerald</option>
|
| 220 |
+
<option value="Saffron">Saffron</option>
|
| 221 |
+
</select>
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
if (key === "BADGE_PATTERN_TYPE") {
|
| 226 |
+
return (
|
| 227 |
+
<select
|
| 228 |
+
value={val}
|
| 229 |
+
disabled={isUpdating}
|
| 230 |
+
onChange={(e) => {
|
| 231 |
+
setEditValues(prev => ({ ...prev, [key]: e.target.value }));
|
| 232 |
+
setUpdatingKey(key);
|
| 233 |
+
saveMutation.mutate({ key, value: e.target.value });
|
| 234 |
+
}}
|
| 235 |
+
className="input-field h-9 text-[12.5px] w-40 bg-white border-slate-250 rounded-xl px-3 focus:border-slate-800 transition-all font-semibold cursor-pointer text-center"
|
| 236 |
+
>
|
| 237 |
+
<option value="None">None</option>
|
| 238 |
+
<option value="Indian Mandala">Indian Mandala</option>
|
| 239 |
+
<option value="Corporate Waves">Corporate Waves</option>
|
| 240 |
+
<option value="Cyber Grid">Cyber Grid</option>
|
| 241 |
+
</select>
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
if (key === "COMPANY_LOGO") {
|
| 246 |
+
return (
|
| 247 |
+
<div className="flex flex-col items-center gap-3 w-full sm:w-64">
|
| 248 |
+
{val ? (
|
| 249 |
+
<div className="relative w-full h-24 border border-slate-200 rounded-xl overflow-hidden bg-slate-50 flex items-center justify-center p-2 group">
|
| 250 |
+
<img src={val} alt="Company Logo" className="max-w-full max-h-full object-contain" />
|
| 251 |
+
<button
|
| 252 |
+
type="button"
|
| 253 |
+
onClick={() => {
|
| 254 |
+
setEditValues(prev => ({ ...prev, [key]: "" }));
|
| 255 |
+
setUpdatingKey(key);
|
| 256 |
+
saveMutation.mutate({ key, value: "" });
|
| 257 |
+
}}
|
| 258 |
+
className="absolute inset-0 bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-bold gap-1 cursor-pointer"
|
| 259 |
+
>
|
| 260 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 261 |
+
Remove Logo
|
| 262 |
+
</button>
|
| 263 |
+
</div>
|
| 264 |
+
) : (
|
| 265 |
+
<label className="w-full h-24 border border-dashed border-slate-300 hover:border-slate-400 rounded-xl flex flex-col items-center justify-center gap-1.5 cursor-pointer bg-slate-50 hover:bg-slate-100/50 transition-all">
|
| 266 |
+
<Upload className="w-5 h-5 text-slate-450" />
|
| 267 |
+
<span className="text-[11px] text-slate-500 font-medium">Upload PNG/JPG (Max 500KB)</span>
|
| 268 |
+
<input
|
| 269 |
+
type="file"
|
| 270 |
+
accept="image/*"
|
| 271 |
+
className="hidden"
|
| 272 |
+
disabled={isUpdating}
|
| 273 |
+
onChange={(e) => {
|
| 274 |
+
const file = e.target.files?.[0];
|
| 275 |
+
if (!file) return;
|
| 276 |
+
if (file.size > 512 * 1024) {
|
| 277 |
+
toast.error("Logo must be under 500KB");
|
| 278 |
+
return;
|
| 279 |
+
}
|
| 280 |
+
const reader = new FileReader();
|
| 281 |
+
reader.onloadend = () => {
|
| 282 |
+
const base64String = reader.result as string;
|
| 283 |
+
setEditValues(prev => ({ ...prev, [key]: base64String }));
|
| 284 |
+
setUpdatingKey(key);
|
| 285 |
+
saveMutation.mutate({ key, value: base64String });
|
| 286 |
+
};
|
| 287 |
+
reader.readAsDataURL(file);
|
| 288 |
+
}}
|
| 289 |
+
/>
|
| 290 |
+
</label>
|
| 291 |
+
)}
|
| 292 |
+
</div>
|
| 293 |
+
);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
// Default Fallback
|
| 297 |
return (
|
| 298 |
<input
|
|
|
|
| 310 |
case "biometrics": return <Fingerprint className="w-4 h-4" />;
|
| 311 |
case "shift": return <Clock className="w-4 h-4" />;
|
| 312 |
case "voice": return <Volume2 className="w-4 h-4" />;
|
| 313 |
+
case "branding": return <Palette className="w-4 h-4" />;
|
| 314 |
+
case "location": return <MapPin className="w-4 h-4" />;
|
| 315 |
case "advanced": return <Sliders className="w-4 h-4" />;
|
| 316 |
}
|
| 317 |
};
|
|
|
|
| 321 |
case "biometrics": return "Biometrics & AI";
|
| 322 |
case "shift": return "Shift & Grace Hours";
|
| 323 |
case "voice": return "Voice Alerts";
|
| 324 |
+
case "branding": return "Branding & Badge";
|
| 325 |
+
case "location": return "Location Restriction";
|
| 326 |
case "advanced": return "Advanced Configurations";
|
| 327 |
}
|
| 328 |
};
|
|
|
|
| 332 |
case "biometrics": return "Configure facial matching similarity and liveness parameters";
|
| 333 |
case "shift": return "Manage daily check-in start, grace window, and shifts";
|
| 334 |
case "voice": return "Enable or disable text-to-speech audio feedback at terminals";
|
| 335 |
+
case "branding": return "Set organization logo and name for employee ID cards";
|
| 336 |
+
case "location": return "Set coordinates and allowed radius for geofenced kiosk attendance";
|
| 337 |
case "advanced": return "System variables and advanced parameters";
|
| 338 |
}
|
| 339 |
};
|
|
|
|
| 351 |
|
| 352 |
{/* Left Navigation: Vertical tabs */}
|
| 353 |
<div className="space-y-1 md:col-span-1">
|
| 354 |
+
{(["biometrics", "shift", "voice", "branding", "location", "advanced"] as SettingsTab[]).map(tab => {
|
| 355 |
const isActive = activeTab === tab;
|
| 356 |
return (
|
| 357 |
<button
|
|
|
|
| 418 |
const getSettingIcon = (k: string) => {
|
| 419 |
if (k.includes("VOICE") || k.includes("GREETING")) return <Volume2 className="w-5 h-5" />;
|
| 420 |
if (k.includes("MAINTENANCE")) return <AlertCircle className="w-5 h-5" />;
|
| 421 |
+
if (k.includes("RTSP")) return <Camera className="w-5 h-5" />;
|
| 422 |
return <Sliders className="w-5 h-5" />;
|
| 423 |
};
|
| 424 |
|
|
|
|
| 513 |
<div className="flex items-center gap-2.5 shrink-0">
|
| 514 |
{renderSettingControl(setting, isUpdating)}
|
| 515 |
|
| 516 |
+
{setting.key !== "COMPANY_LOGO" && setting.key !== "BADGE_THEME_COLOR" && setting.key !== "BADGE_PATTERN_TYPE" && (
|
| 517 |
+
<button
|
| 518 |
+
onClick={() => handleSave(setting.key)}
|
| 519 |
+
disabled={isUpdating || !isDirty}
|
| 520 |
+
className={`w-9 h-9 rounded-xl border flex items-center justify-center transition-all ${
|
| 521 |
+
isSuccess
|
| 522 |
+
? "bg-emerald-55 border-emerald-250 text-emerald-600 shadow-xs scale-105"
|
| 523 |
+
: isDirty
|
| 524 |
+
? "bg-slate-900 border-slate-900 text-white hover:bg-slate-800 hover:scale-105 active:scale-95 cursor-pointer shadow-md shadow-slate-900/10"
|
| 525 |
+
: "bg-slate-50 border-slate-200 text-slate-300 cursor-not-allowed"
|
| 526 |
+
}`}
|
| 527 |
+
>
|
| 528 |
+
{isUpdating ? (
|
| 529 |
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
| 530 |
+
) : isSuccess ? (
|
| 531 |
+
<CheckCircle2 className="w-3.5 h-3.5" />
|
| 532 |
+
) : (
|
| 533 |
+
<Check className="w-3.5 h-3.5" />
|
| 534 |
+
)}
|
| 535 |
+
</button>
|
| 536 |
+
)}
|
| 537 |
</div>
|
| 538 |
</div>
|
| 539 |
);
|
| 540 |
})}
|
| 541 |
</div>
|
| 542 |
)}
|
| 543 |
+
|
| 544 |
+
{/* Location Extra Actions */}
|
| 545 |
+
{activeTab === "location" && (
|
| 546 |
+
<div className="px-6 py-5 border-t border-slate-100/80 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-slate-50/20 transition-all duration-200">
|
| 547 |
+
<div className="flex-1 space-y-0.5">
|
| 548 |
+
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-100">Auto-Detect Coordinates</h3>
|
| 549 |
+
<p className="text-xs text-slate-450 leading-normal max-w-sm sm:max-w-md">
|
| 550 |
+
Automatically capture this device's current GPS latitude and longitude coordinates.
|
| 551 |
+
</p>
|
| 552 |
+
</div>
|
| 553 |
+
<button
|
| 554 |
+
type="button"
|
| 555 |
+
disabled={fetchingGps}
|
| 556 |
+
onClick={handleSetCurrentLocation}
|
| 557 |
+
className={`h-9.5 px-4 rounded-xl text-[12px] font-extrabold transition-all flex items-center gap-2 cursor-pointer shadow-sm hover:scale-[1.02] active:scale-[0.98] ${
|
| 558 |
+
fetchingGps
|
| 559 |
+
? "bg-slate-100 border border-slate-200 text-slate-400 cursor-not-allowed"
|
| 560 |
+
: "bg-slate-900 hover:bg-slate-800 text-white dark:bg-white dark:hover:bg-slate-100 dark:text-slate-900"
|
| 561 |
+
}`}
|
| 562 |
+
>
|
| 563 |
+
{fetchingGps ? (
|
| 564 |
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
| 565 |
+
) : (
|
| 566 |
+
<MapPin className="w-3.5 h-3.5" />
|
| 567 |
+
)}
|
| 568 |
+
Set to Current GPS Location
|
| 569 |
+
</button>
|
| 570 |
+
</div>
|
| 571 |
+
)}
|
| 572 |
</div>
|
| 573 |
)}
|
| 574 |
</div>
|
|
|
|
| 591 |
</span>
|
| 592 |
</div>
|
| 593 |
)}
|
| 594 |
+
|
| 595 |
+
{activeTab === "branding" && (
|
| 596 |
+
<div className="flex items-center gap-3 p-4 rounded-xl bg-zinc-50 border border-zinc-200 text-zinc-650 text-[11px] animate-fadeInUp">
|
| 597 |
+
<Info className="w-4 h-4 text-zinc-500 shrink-0" />
|
| 598 |
+
<span>
|
| 599 |
+
<strong>Tip:</strong> The company name and logo uploaded here will dynamically populate on all newly enrolled employee ID Cards and badge PDF prints.
|
| 600 |
+
</span>
|
| 601 |
+
</div>
|
| 602 |
+
)}
|
| 603 |
+
|
| 604 |
+
{activeTab === "location" && (
|
| 605 |
+
<div className="flex items-center gap-3 p-4 rounded-xl bg-zinc-50 border border-zinc-200 text-zinc-650 text-[11px] animate-fadeInUp">
|
| 606 |
+
<Info className="w-4 h-4 text-zinc-500 shrink-0" />
|
| 607 |
+
<span>
|
| 608 |
+
<strong>Note:</strong> When Location Restriction is enabled, kiosk swiping is only permitted for employees within the radius limit unless they have the WFH bypass permission.
|
| 609 |
+
</span>
|
| 610 |
+
</div>
|
| 611 |
+
)}
|
| 612 |
</div>
|
| 613 |
</div>
|
| 614 |
</div>
|
frontend/components/AttendanceHeatmap.tsx
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { useQuery } from "@tanstack/react-query";
|
| 5 |
+
import { fetchApi } from "@/app/utils/api";
|
| 6 |
+
import { Loader2, Calendar } from "lucide-react";
|
| 7 |
+
|
| 8 |
+
interface AttendanceHeatmapProps {
|
| 9 |
+
employeeId?: number;
|
| 10 |
+
title?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function AttendanceHeatmap({ employeeId, title }: AttendanceHeatmapProps) {
|
| 14 |
+
const { data: heatmapData, isLoading } = useQuery({
|
| 15 |
+
queryKey: ["attendance-heatmap", employeeId],
|
| 16 |
+
queryFn: () => {
|
| 17 |
+
const url = employeeId
|
| 18 |
+
? `/analytics/heatmap?employee_id=${employeeId}`
|
| 19 |
+
: `/analytics/heatmap`;
|
| 20 |
+
return fetchApi(url);
|
| 21 |
+
}
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// Generate date cells for the last 365 days (ending today)
|
| 25 |
+
const cells: { dateStr: string; dateObj: Date; count: number }[] = [];
|
| 26 |
+
const today = new Date();
|
| 27 |
+
|
| 28 |
+
// Start from 365 days ago, offset to start on a Sunday if possible
|
| 29 |
+
const totalDays = 365;
|
| 30 |
+
const startDate = new Date(today);
|
| 31 |
+
startDate.setDate(today.getDate() - totalDays);
|
| 32 |
+
|
| 33 |
+
// Align start date to Sunday of that week
|
| 34 |
+
const startDayOffset = startDate.getDay();
|
| 35 |
+
startDate.setDate(startDate.getDate() - startDayOffset);
|
| 36 |
+
|
| 37 |
+
const currentDateIter = new Date(startDate);
|
| 38 |
+
while (currentDateIter <= today) {
|
| 39 |
+
const dateStr = currentDateIter.toISOString().split("T")[0];
|
| 40 |
+
const count = heatmapData?.[dateStr] || 0;
|
| 41 |
+
cells.push({
|
| 42 |
+
dateStr,
|
| 43 |
+
dateObj: new Date(currentDateIter),
|
| 44 |
+
count
|
| 45 |
+
});
|
| 46 |
+
currentDateIter.setDate(currentDateIter.getDate() + 1);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Group cells by week (column)
|
| 50 |
+
const weeks: typeof cells[] = [];
|
| 51 |
+
let currentWeek: typeof cells = [];
|
| 52 |
+
|
| 53 |
+
cells.forEach((cell) => {
|
| 54 |
+
if (cell.dateObj.getDay() === 0 && currentWeek.length > 0) {
|
| 55 |
+
weeks.push(currentWeek);
|
| 56 |
+
currentWeek = [];
|
| 57 |
+
}
|
| 58 |
+
currentWeek.push(cell);
|
| 59 |
+
});
|
| 60 |
+
if (currentWeek.length > 0) {
|
| 61 |
+
weeks.push(currentWeek);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const maxCount = Math.max(...Object.values(heatmapData || {}).map(v => Number(v) || 0), 1);
|
| 65 |
+
|
| 66 |
+
const getIntensityClass = (count: number) => {
|
| 67 |
+
if (count === 0) return "bg-zinc-150 dark:bg-zinc-800/80 border-zinc-200/5";
|
| 68 |
+
|
| 69 |
+
if (employeeId) {
|
| 70 |
+
return "bg-emerald-500 dark:bg-emerald-400 border-emerald-600/10 shadow-[0_0_6px_rgba(16,185,129,0.15)]";
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const ratio = count / maxCount;
|
| 74 |
+
if (ratio <= 0.25) return "bg-emerald-100 dark:bg-emerald-950/40 border-emerald-250/20";
|
| 75 |
+
if (ratio <= 0.5) return "bg-emerald-300 dark:bg-emerald-800/60 border-emerald-450/20";
|
| 76 |
+
if (ratio <= 0.75) return "bg-emerald-500 dark:bg-emerald-600 border-emerald-550/20";
|
| 77 |
+
return "bg-emerald-700 dark:bg-emerald-400 border-emerald-850/20 shadow-[0_0_8px_rgba(16,185,129,0.2)]";
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const monthLabels: { label: string; colIndex: number }[] = [];
|
| 81 |
+
let lastMonth = -1;
|
| 82 |
+
weeks.forEach((week, colIdx) => {
|
| 83 |
+
const firstDay = week[0]?.dateObj;
|
| 84 |
+
if (firstDay && firstDay.getMonth() !== lastMonth) {
|
| 85 |
+
const label = firstDay.toLocaleString([], { month: "short" });
|
| 86 |
+
monthLabels.push({ label, colIndex: colIdx });
|
| 87 |
+
lastMonth = firstDay.getMonth();
|
| 88 |
+
}
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
return (
|
| 92 |
+
<div className="bg-white border border-zinc-100 rounded-xl p-5 shadow-xs">
|
| 93 |
+
<div className="flex items-center gap-2 mb-4 border-b border-zinc-50 pb-3">
|
| 94 |
+
<Calendar className="w-4 h-4 text-slate-450" />
|
| 95 |
+
<h3 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">
|
| 96 |
+
{title || (employeeId ? "Attendance Ledger Map" : "Company Activity Heatmap")}
|
| 97 |
+
</h3>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
{isLoading ? (
|
| 101 |
+
<div className="h-32 flex items-center justify-center text-slate-400 text-xs gap-2">
|
| 102 |
+
<Loader2 className="w-4 h-4 animate-spin text-slate-600" />
|
| 103 |
+
<span>Retrieving heatmap data...</span>
|
| 104 |
+
</div>
|
| 105 |
+
) : (
|
| 106 |
+
<div className="overflow-x-auto pb-2 scrollbar-thin">
|
| 107 |
+
<div className="min-w-[720px] flex flex-col space-y-1.5 select-none pr-2">
|
| 108 |
+
|
| 109 |
+
{/* Month Header Row */}
|
| 110 |
+
<div className="relative h-4 text-[9px] font-semibold text-slate-400 font-sans">
|
| 111 |
+
{monthLabels.map((ml, idx) => (
|
| 112 |
+
<div
|
| 113 |
+
key={idx}
|
| 114 |
+
className="absolute"
|
| 115 |
+
style={{ left: `${(ml.colIndex * 13) + 24}px` }}
|
| 116 |
+
>
|
| 117 |
+
{ml.label}
|
| 118 |
+
</div>
|
| 119 |
+
))}
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
{/* Grid Container */}
|
| 123 |
+
<div className="flex gap-[3.5px]">
|
| 124 |
+
|
| 125 |
+
{/* Day Labels Column */}
|
| 126 |
+
<div className="flex flex-col justify-between text-[8px] font-semibold text-slate-400 font-sans w-5 pt-0.5 h-[80px]">
|
| 127 |
+
<span>Mon</span>
|
| 128 |
+
<span>Wed</span>
|
| 129 |
+
<span>Fri</span>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
{/* Weeks (Columns) */}
|
| 133 |
+
<div className="flex gap-[3px] flex-1">
|
| 134 |
+
{weeks.map((week, wIdx) => (
|
| 135 |
+
<div key={wIdx} className="flex flex-col gap-[3px]">
|
| 136 |
+
{week.map((cell, dIdx) => {
|
| 137 |
+
const label = cell.dateObj.toLocaleDateString([], {
|
| 138 |
+
weekday: "short",
|
| 139 |
+
year: "numeric",
|
| 140 |
+
month: "short",
|
| 141 |
+
day: "numeric"
|
| 142 |
+
});
|
| 143 |
+
const tooltip = employeeId
|
| 144 |
+
? `${label}: ${cell.count > 0 ? "Present" : "Absent"}`
|
| 145 |
+
: `${label}: ${cell.count} checked in`;
|
| 146 |
+
|
| 147 |
+
return (
|
| 148 |
+
<div
|
| 149 |
+
key={dIdx}
|
| 150 |
+
title={tooltip}
|
| 151 |
+
className={`w-2.5 h-2.5 rounded-[2px] border transition-all duration-200 hover:scale-125 cursor-pointer ${getIntensityClass(cell.count)}`}
|
| 152 |
+
/>
|
| 153 |
+
);
|
| 154 |
+
})}
|
| 155 |
+
</div>
|
| 156 |
+
))}
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
{/* Legend Row */}
|
| 161 |
+
<div className="flex items-center justify-end gap-1.5 pt-3.5 text-[9px] font-mono text-slate-400 font-bold uppercase">
|
| 162 |
+
<span>Less</span>
|
| 163 |
+
<div className="w-2.5 h-2.5 rounded-[2px] bg-zinc-150 border border-zinc-200/5 shrink-0" />
|
| 164 |
+
{!employeeId && (
|
| 165 |
+
<>
|
| 166 |
+
<div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-100 dark:bg-emerald-950/40 border border-emerald-250/20 shrink-0" />
|
| 167 |
+
<div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-300 dark:bg-emerald-800/60 border border-emerald-450/20 shrink-0" />
|
| 168 |
+
<div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-500 dark:bg-emerald-600 border border-emerald-550/20 shrink-0" />
|
| 169 |
+
</>
|
| 170 |
+
)}
|
| 171 |
+
<div className="w-2.5 h-2.5 rounded-[2px] bg-emerald-700 dark:bg-emerald-400 border border-emerald-850/20 shrink-0" />
|
| 172 |
+
<span>More</span>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
);
|
| 180 |
+
}
|
frontend/components/SidebarLayout.tsx
CHANGED
|
@@ -336,7 +336,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 336 |
</aside>
|
| 337 |
|
| 338 |
{/* ─── Mobile Top Bar ─── */}
|
| 339 |
-
<div className="md:hidden fixed top-0 left-0 right-0 h-14 z-50 flex items-center justify-between px-5 border-b border-[var(--border-subtle)] bg-[var(--bg-surface)]/95 backdrop-blur-xl">
|
| 340 |
<div className="flex items-center gap-3">
|
| 341 |
<div className="relative inline-flex items-center justify-center w-10 h-10 rounded-xl bg-slate-900 border border-slate-700/80 shadow-[0_0_15px_rgba(6,182,212,0.25)] overflow-hidden shrink-0">
|
| 342 |
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
|
@@ -374,7 +374,7 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 374 |
{/* ─── Mobile Drawer ─── */}
|
| 375 |
{sidebarOpen && (
|
| 376 |
<div
|
| 377 |
-
className="md:hidden fixed inset-0 z-40 bg-black/40 backdrop-blur-xs animate-fade-in"
|
| 378 |
onClick={() => setSidebarOpen(false)}
|
| 379 |
>
|
| 380 |
<aside
|
|
|
|
| 336 |
</aside>
|
| 337 |
|
| 338 |
{/* ─── Mobile Top Bar ─── */}
|
| 339 |
+
<div className="md:hidden fixed top-0 left-0 right-0 h-14 z-50 flex items-center justify-between px-5 border-b border-[var(--border-subtle)] bg-[var(--bg-surface)]/95 backdrop-blur-xl no-print">
|
| 340 |
<div className="flex items-center gap-3">
|
| 341 |
<div className="relative inline-flex items-center justify-center w-10 h-10 rounded-xl bg-slate-900 border border-slate-700/80 shadow-[0_0_15px_rgba(6,182,212,0.25)] overflow-hidden shrink-0">
|
| 342 |
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
|
|
|
| 374 |
{/* ─── Mobile Drawer ─── */}
|
| 375 |
{sidebarOpen && (
|
| 376 |
<div
|
| 377 |
+
className="md:hidden fixed inset-0 z-40 bg-black/40 backdrop-blur-xs animate-fade-in no-print"
|
| 378 |
onClick={() => setSidebarOpen(false)}
|
| 379 |
>
|
| 380 |
<aside
|
frontend/next-env.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
/// <reference types="next" />
|
| 2 |
/// <reference types="next/image-types/global" />
|
|
|
|
| 3 |
|
| 4 |
// NOTE: This file should not be edited
|
| 5 |
-
// see https://nextjs.org/docs/app/
|
|
|
|
| 1 |
/// <reference types="next" />
|
| 2 |
/// <reference types="next/image-types/global" />
|
| 3 |
+
/// <reference path="./.next/types/routes.d.ts" />
|
| 4 |
|
| 5 |
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
frontend/package-lock.json
CHANGED
|
@@ -13,8 +13,9 @@
|
|
| 13 |
"echarts": "^5.5.0",
|
| 14 |
"echarts-for-react": "^3.0.2",
|
| 15 |
"framer-motion": "^11.0.8",
|
| 16 |
-
"
|
| 17 |
-
"
|
|
|
|
| 18 |
"react": "^19.0.0",
|
| 19 |
"react-dom": "^19.0.0",
|
| 20 |
"tailwind-merge": "^2.2.2"
|
|
@@ -43,9 +44,9 @@
|
|
| 43 |
}
|
| 44 |
},
|
| 45 |
"node_modules/@emnapi/runtime": {
|
| 46 |
-
"version": "1.11.
|
| 47 |
-
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.
|
| 48 |
-
"integrity": "sha512-
|
| 49 |
"license": "MIT",
|
| 50 |
"optional": true,
|
| 51 |
"dependencies": {
|
|
@@ -59,10 +60,20 @@
|
|
| 59 |
"license": "0BSD",
|
| 60 |
"optional": true
|
| 61 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
"node_modules/@img/sharp-darwin-arm64": {
|
| 63 |
-
"version": "0.
|
| 64 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.
|
| 65 |
-
"integrity": "sha512-
|
| 66 |
"cpu": [
|
| 67 |
"arm64"
|
| 68 |
],
|
|
@@ -78,13 +89,13 @@
|
|
| 78 |
"url": "https://opencollective.com/libvips"
|
| 79 |
},
|
| 80 |
"optionalDependencies": {
|
| 81 |
-
"@img/sharp-libvips-darwin-arm64": "1.
|
| 82 |
}
|
| 83 |
},
|
| 84 |
"node_modules/@img/sharp-darwin-x64": {
|
| 85 |
-
"version": "0.
|
| 86 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.
|
| 87 |
-
"integrity": "sha512-
|
| 88 |
"cpu": [
|
| 89 |
"x64"
|
| 90 |
],
|
|
@@ -100,13 +111,13 @@
|
|
| 100 |
"url": "https://opencollective.com/libvips"
|
| 101 |
},
|
| 102 |
"optionalDependencies": {
|
| 103 |
-
"@img/sharp-libvips-darwin-x64": "1.
|
| 104 |
}
|
| 105 |
},
|
| 106 |
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
| 107 |
-
"version": "1.
|
| 108 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.
|
| 109 |
-
"integrity": "sha512-
|
| 110 |
"cpu": [
|
| 111 |
"arm64"
|
| 112 |
],
|
|
@@ -120,9 +131,9 @@
|
|
| 120 |
}
|
| 121 |
},
|
| 122 |
"node_modules/@img/sharp-libvips-darwin-x64": {
|
| 123 |
-
"version": "1.
|
| 124 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.
|
| 125 |
-
"integrity": "sha512-
|
| 126 |
"cpu": [
|
| 127 |
"x64"
|
| 128 |
],
|
|
@@ -136,9 +147,9 @@
|
|
| 136 |
}
|
| 137 |
},
|
| 138 |
"node_modules/@img/sharp-libvips-linux-arm": {
|
| 139 |
-
"version": "1.
|
| 140 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.
|
| 141 |
-
"integrity": "sha512-
|
| 142 |
"cpu": [
|
| 143 |
"arm"
|
| 144 |
],
|
|
@@ -152,9 +163,9 @@
|
|
| 152 |
}
|
| 153 |
},
|
| 154 |
"node_modules/@img/sharp-libvips-linux-arm64": {
|
| 155 |
-
"version": "1.
|
| 156 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.
|
| 157 |
-
"integrity": "sha512-
|
| 158 |
"cpu": [
|
| 159 |
"arm64"
|
| 160 |
],
|
|
@@ -167,10 +178,42 @@
|
|
| 167 |
"url": "https://opencollective.com/libvips"
|
| 168 |
}
|
| 169 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
"node_modules/@img/sharp-libvips-linux-s390x": {
|
| 171 |
-
"version": "1.
|
| 172 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.
|
| 173 |
-
"integrity": "sha512-
|
| 174 |
"cpu": [
|
| 175 |
"s390x"
|
| 176 |
],
|
|
@@ -184,9 +227,9 @@
|
|
| 184 |
}
|
| 185 |
},
|
| 186 |
"node_modules/@img/sharp-libvips-linux-x64": {
|
| 187 |
-
"version": "1.
|
| 188 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.
|
| 189 |
-
"integrity": "sha512-
|
| 190 |
"cpu": [
|
| 191 |
"x64"
|
| 192 |
],
|
|
@@ -200,9 +243,9 @@
|
|
| 200 |
}
|
| 201 |
},
|
| 202 |
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
| 203 |
-
"version": "1.
|
| 204 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.
|
| 205 |
-
"integrity": "sha512-
|
| 206 |
"cpu": [
|
| 207 |
"arm64"
|
| 208 |
],
|
|
@@ -216,9 +259,9 @@
|
|
| 216 |
}
|
| 217 |
},
|
| 218 |
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
| 219 |
-
"version": "1.
|
| 220 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.
|
| 221 |
-
"integrity": "sha512-
|
| 222 |
"cpu": [
|
| 223 |
"x64"
|
| 224 |
],
|
|
@@ -232,9 +275,9 @@
|
|
| 232 |
}
|
| 233 |
},
|
| 234 |
"node_modules/@img/sharp-linux-arm": {
|
| 235 |
-
"version": "0.
|
| 236 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.
|
| 237 |
-
"integrity": "sha512-
|
| 238 |
"cpu": [
|
| 239 |
"arm"
|
| 240 |
],
|
|
@@ -250,13 +293,13 @@
|
|
| 250 |
"url": "https://opencollective.com/libvips"
|
| 251 |
},
|
| 252 |
"optionalDependencies": {
|
| 253 |
-
"@img/sharp-libvips-linux-arm": "1.
|
| 254 |
}
|
| 255 |
},
|
| 256 |
"node_modules/@img/sharp-linux-arm64": {
|
| 257 |
-
"version": "0.
|
| 258 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.
|
| 259 |
-
"integrity": "sha512-
|
| 260 |
"cpu": [
|
| 261 |
"arm64"
|
| 262 |
],
|
|
@@ -272,13 +315,57 @@
|
|
| 272 |
"url": "https://opencollective.com/libvips"
|
| 273 |
},
|
| 274 |
"optionalDependencies": {
|
| 275 |
-
"@img/sharp-libvips-linux-arm64": "1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
}
|
| 277 |
},
|
| 278 |
"node_modules/@img/sharp-linux-s390x": {
|
| 279 |
-
"version": "0.
|
| 280 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.
|
| 281 |
-
"integrity": "sha512-
|
| 282 |
"cpu": [
|
| 283 |
"s390x"
|
| 284 |
],
|
|
@@ -294,13 +381,13 @@
|
|
| 294 |
"url": "https://opencollective.com/libvips"
|
| 295 |
},
|
| 296 |
"optionalDependencies": {
|
| 297 |
-
"@img/sharp-libvips-linux-s390x": "1.
|
| 298 |
}
|
| 299 |
},
|
| 300 |
"node_modules/@img/sharp-linux-x64": {
|
| 301 |
-
"version": "0.
|
| 302 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.
|
| 303 |
-
"integrity": "sha512-
|
| 304 |
"cpu": [
|
| 305 |
"x64"
|
| 306 |
],
|
|
@@ -316,13 +403,13 @@
|
|
| 316 |
"url": "https://opencollective.com/libvips"
|
| 317 |
},
|
| 318 |
"optionalDependencies": {
|
| 319 |
-
"@img/sharp-libvips-linux-x64": "1.
|
| 320 |
}
|
| 321 |
},
|
| 322 |
"node_modules/@img/sharp-linuxmusl-arm64": {
|
| 323 |
-
"version": "0.
|
| 324 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.
|
| 325 |
-
"integrity": "sha512-
|
| 326 |
"cpu": [
|
| 327 |
"arm64"
|
| 328 |
],
|
|
@@ -338,13 +425,13 @@
|
|
| 338 |
"url": "https://opencollective.com/libvips"
|
| 339 |
},
|
| 340 |
"optionalDependencies": {
|
| 341 |
-
"@img/sharp-libvips-linuxmusl-arm64": "1.
|
| 342 |
}
|
| 343 |
},
|
| 344 |
"node_modules/@img/sharp-linuxmusl-x64": {
|
| 345 |
-
"version": "0.
|
| 346 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.
|
| 347 |
-
"integrity": "sha512-
|
| 348 |
"cpu": [
|
| 349 |
"x64"
|
| 350 |
],
|
|
@@ -360,20 +447,20 @@
|
|
| 360 |
"url": "https://opencollective.com/libvips"
|
| 361 |
},
|
| 362 |
"optionalDependencies": {
|
| 363 |
-
"@img/sharp-libvips-linuxmusl-x64": "1.
|
| 364 |
}
|
| 365 |
},
|
| 366 |
"node_modules/@img/sharp-wasm32": {
|
| 367 |
-
"version": "0.
|
| 368 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.
|
| 369 |
-
"integrity": "sha512-
|
| 370 |
"cpu": [
|
| 371 |
"wasm32"
|
| 372 |
],
|
| 373 |
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
| 374 |
"optional": true,
|
| 375 |
"dependencies": {
|
| 376 |
-
"@emnapi/runtime": "^1.
|
| 377 |
},
|
| 378 |
"engines": {
|
| 379 |
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
@@ -382,10 +469,29 @@
|
|
| 382 |
"url": "https://opencollective.com/libvips"
|
| 383 |
}
|
| 384 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
"node_modules/@img/sharp-win32-ia32": {
|
| 386 |
-
"version": "0.
|
| 387 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.
|
| 388 |
-
"integrity": "sha512-
|
| 389 |
"cpu": [
|
| 390 |
"ia32"
|
| 391 |
],
|
|
@@ -402,9 +508,9 @@
|
|
| 402 |
}
|
| 403 |
},
|
| 404 |
"node_modules/@img/sharp-win32-x64": {
|
| 405 |
-
"version": "0.
|
| 406 |
-
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.
|
| 407 |
-
"integrity": "sha512-
|
| 408 |
"cpu": [
|
| 409 |
"x64"
|
| 410 |
],
|
|
@@ -460,15 +566,15 @@
|
|
| 460 |
}
|
| 461 |
},
|
| 462 |
"node_modules/@next/env": {
|
| 463 |
-
"version": "15.
|
| 464 |
-
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.
|
| 465 |
-
"integrity": "sha512-
|
| 466 |
"license": "MIT"
|
| 467 |
},
|
| 468 |
"node_modules/@next/swc-darwin-arm64": {
|
| 469 |
-
"version": "15.
|
| 470 |
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.
|
| 471 |
-
"integrity": "sha512-
|
| 472 |
"cpu": [
|
| 473 |
"arm64"
|
| 474 |
],
|
|
@@ -482,9 +588,9 @@
|
|
| 482 |
}
|
| 483 |
},
|
| 484 |
"node_modules/@next/swc-darwin-x64": {
|
| 485 |
-
"version": "15.
|
| 486 |
-
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.
|
| 487 |
-
"integrity": "sha512-
|
| 488 |
"cpu": [
|
| 489 |
"x64"
|
| 490 |
],
|
|
@@ -498,9 +604,9 @@
|
|
| 498 |
}
|
| 499 |
},
|
| 500 |
"node_modules/@next/swc-linux-arm64-gnu": {
|
| 501 |
-
"version": "15.
|
| 502 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.
|
| 503 |
-
"integrity": "sha512-
|
| 504 |
"cpu": [
|
| 505 |
"arm64"
|
| 506 |
],
|
|
@@ -514,9 +620,9 @@
|
|
| 514 |
}
|
| 515 |
},
|
| 516 |
"node_modules/@next/swc-linux-arm64-musl": {
|
| 517 |
-
"version": "15.
|
| 518 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.
|
| 519 |
-
"integrity": "sha512-
|
| 520 |
"cpu": [
|
| 521 |
"arm64"
|
| 522 |
],
|
|
@@ -530,9 +636,9 @@
|
|
| 530 |
}
|
| 531 |
},
|
| 532 |
"node_modules/@next/swc-linux-x64-gnu": {
|
| 533 |
-
"version": "15.
|
| 534 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.
|
| 535 |
-
"integrity": "sha512-
|
| 536 |
"cpu": [
|
| 537 |
"x64"
|
| 538 |
],
|
|
@@ -546,9 +652,9 @@
|
|
| 546 |
}
|
| 547 |
},
|
| 548 |
"node_modules/@next/swc-linux-x64-musl": {
|
| 549 |
-
"version": "15.
|
| 550 |
-
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.
|
| 551 |
-
"integrity": "sha512-
|
| 552 |
"cpu": [
|
| 553 |
"x64"
|
| 554 |
],
|
|
@@ -562,9 +668,9 @@
|
|
| 562 |
}
|
| 563 |
},
|
| 564 |
"node_modules/@next/swc-win32-arm64-msvc": {
|
| 565 |
-
"version": "15.
|
| 566 |
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.
|
| 567 |
-
"integrity": "sha512-
|
| 568 |
"cpu": [
|
| 569 |
"arm64"
|
| 570 |
],
|
|
@@ -578,9 +684,9 @@
|
|
| 578 |
}
|
| 579 |
},
|
| 580 |
"node_modules/@next/swc-win32-x64-msvc": {
|
| 581 |
-
"version": "15.
|
| 582 |
-
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.
|
| 583 |
-
"integrity": "sha512-
|
| 584 |
"cpu": [
|
| 585 |
"x64"
|
| 586 |
],
|
|
@@ -631,19 +737,13 @@
|
|
| 631 |
"node": ">= 8"
|
| 632 |
}
|
| 633 |
},
|
| 634 |
-
"node_modules/@swc/counter": {
|
| 635 |
-
"version": "0.1.3",
|
| 636 |
-
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
| 637 |
-
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
| 638 |
-
"license": "Apache-2.0"
|
| 639 |
-
},
|
| 640 |
"node_modules/@swc/helpers": {
|
| 641 |
-
"version": "0.5.
|
| 642 |
-
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.
|
| 643 |
-
"integrity": "sha512-
|
| 644 |
"license": "Apache-2.0",
|
| 645 |
"dependencies": {
|
| 646 |
-
"tslib": "^2.
|
| 647 |
}
|
| 648 |
},
|
| 649 |
"node_modules/@swc/helpers/node_modules/tslib": {
|
|
@@ -729,6 +829,19 @@
|
|
| 729 |
"node": ">= 8"
|
| 730 |
}
|
| 731 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
"node_modules/arg": {
|
| 733 |
"version": "5.0.2",
|
| 734 |
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
|
@@ -846,17 +959,6 @@
|
|
| 846 |
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 847 |
}
|
| 848 |
},
|
| 849 |
-
"node_modules/busboy": {
|
| 850 |
-
"version": "1.6.0",
|
| 851 |
-
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
| 852 |
-
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
| 853 |
-
"dependencies": {
|
| 854 |
-
"streamsearch": "^1.1.0"
|
| 855 |
-
},
|
| 856 |
-
"engines": {
|
| 857 |
-
"node": ">=10.16.0"
|
| 858 |
-
}
|
| 859 |
-
},
|
| 860 |
"node_modules/camelcase-css": {
|
| 861 |
"version": "2.0.1",
|
| 862 |
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
|
@@ -940,51 +1042,6 @@
|
|
| 940 |
"node": ">=6"
|
| 941 |
}
|
| 942 |
},
|
| 943 |
-
"node_modules/color": {
|
| 944 |
-
"version": "4.2.3",
|
| 945 |
-
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
| 946 |
-
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
| 947 |
-
"license": "MIT",
|
| 948 |
-
"optional": true,
|
| 949 |
-
"dependencies": {
|
| 950 |
-
"color-convert": "^2.0.1",
|
| 951 |
-
"color-string": "^1.9.0"
|
| 952 |
-
},
|
| 953 |
-
"engines": {
|
| 954 |
-
"node": ">=12.5.0"
|
| 955 |
-
}
|
| 956 |
-
},
|
| 957 |
-
"node_modules/color-convert": {
|
| 958 |
-
"version": "2.0.1",
|
| 959 |
-
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
| 960 |
-
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
| 961 |
-
"license": "MIT",
|
| 962 |
-
"optional": true,
|
| 963 |
-
"dependencies": {
|
| 964 |
-
"color-name": "~1.1.4"
|
| 965 |
-
},
|
| 966 |
-
"engines": {
|
| 967 |
-
"node": ">=7.0.0"
|
| 968 |
-
}
|
| 969 |
-
},
|
| 970 |
-
"node_modules/color-name": {
|
| 971 |
-
"version": "1.1.4",
|
| 972 |
-
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
| 973 |
-
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
| 974 |
-
"license": "MIT",
|
| 975 |
-
"optional": true
|
| 976 |
-
},
|
| 977 |
-
"node_modules/color-string": {
|
| 978 |
-
"version": "1.9.1",
|
| 979 |
-
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
| 980 |
-
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
| 981 |
-
"license": "MIT",
|
| 982 |
-
"optional": true,
|
| 983 |
-
"dependencies": {
|
| 984 |
-
"color-name": "^1.0.0",
|
| 985 |
-
"simple-swizzle": "^0.2.2"
|
| 986 |
-
}
|
| 987 |
-
},
|
| 988 |
"node_modules/commander": {
|
| 989 |
"version": "4.1.1",
|
| 990 |
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
|
@@ -1265,13 +1322,6 @@
|
|
| 1265 |
"node": ">= 0.4"
|
| 1266 |
}
|
| 1267 |
},
|
| 1268 |
-
"node_modules/is-arrayish": {
|
| 1269 |
-
"version": "0.3.4",
|
| 1270 |
-
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
| 1271 |
-
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
| 1272 |
-
"license": "MIT",
|
| 1273 |
-
"optional": true
|
| 1274 |
-
},
|
| 1275 |
"node_modules/is-binary-path": {
|
| 1276 |
"version": "2.1.0",
|
| 1277 |
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
|
@@ -1344,6 +1394,12 @@
|
|
| 1344 |
"jiti": "bin/jiti.js"
|
| 1345 |
}
|
| 1346 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1347 |
"node_modules/lilconfig": {
|
| 1348 |
"version": "3.1.3",
|
| 1349 |
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
|
@@ -1365,12 +1421,12 @@
|
|
| 1365 |
"license": "MIT"
|
| 1366 |
},
|
| 1367 |
"node_modules/lucide-react": {
|
| 1368 |
-
"version": "0.
|
| 1369 |
-
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.
|
| 1370 |
-
"integrity": "sha512-
|
| 1371 |
"license": "ISC",
|
| 1372 |
"peerDependencies": {
|
| 1373 |
-
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
| 1374 |
}
|
| 1375 |
},
|
| 1376 |
"node_modules/merge2": {
|
|
@@ -1397,6 +1453,19 @@
|
|
| 1397 |
"node": ">=8.6"
|
| 1398 |
}
|
| 1399 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1400 |
"node_modules/motion-dom": {
|
| 1401 |
"version": "11.18.1",
|
| 1402 |
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
|
@@ -1443,16 +1512,13 @@
|
|
| 1443 |
}
|
| 1444 |
},
|
| 1445 |
"node_modules/next": {
|
| 1446 |
-
"version": "15.
|
| 1447 |
-
"resolved": "https://registry.npmjs.org/next/-/next-15.
|
| 1448 |
-
"integrity": "sha512-
|
| 1449 |
-
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
| 1450 |
"license": "MIT",
|
| 1451 |
"dependencies": {
|
| 1452 |
-
"@next/env": "15.
|
| 1453 |
-
"@swc/
|
| 1454 |
-
"@swc/helpers": "0.5.13",
|
| 1455 |
-
"busboy": "1.6.0",
|
| 1456 |
"caniuse-lite": "^1.0.30001579",
|
| 1457 |
"postcss": "8.4.31",
|
| 1458 |
"styled-jsx": "5.1.6"
|
|
@@ -1464,22 +1530,22 @@
|
|
| 1464 |
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
| 1465 |
},
|
| 1466 |
"optionalDependencies": {
|
| 1467 |
-
"@next/swc-darwin-arm64": "15.
|
| 1468 |
-
"@next/swc-darwin-x64": "15.
|
| 1469 |
-
"@next/swc-linux-arm64-gnu": "15.
|
| 1470 |
-
"@next/swc-linux-arm64-musl": "15.
|
| 1471 |
-
"@next/swc-linux-x64-gnu": "15.
|
| 1472 |
-
"@next/swc-linux-x64-musl": "15.
|
| 1473 |
-
"@next/swc-win32-arm64-msvc": "15.
|
| 1474 |
-
"@next/swc-win32-x64-msvc": "15.
|
| 1475 |
-
"sharp": "^0.
|
| 1476 |
},
|
| 1477 |
"peerDependencies": {
|
| 1478 |
"@opentelemetry/api": "^1.1.0",
|
| 1479 |
-
"@playwright/test": "^1.
|
| 1480 |
"babel-plugin-react-compiler": "*",
|
| 1481 |
-
"react": "^18.2.0 || 19.0.0-rc-
|
| 1482 |
-
"react-dom": "^18.2.0 || 19.0.0-rc-
|
| 1483 |
"sass": "^1.3.0"
|
| 1484 |
},
|
| 1485 |
"peerDependenciesMeta": {
|
|
@@ -1579,13 +1645,13 @@
|
|
| 1579 |
"license": "ISC"
|
| 1580 |
},
|
| 1581 |
"node_modules/picomatch": {
|
| 1582 |
-
"version": "
|
| 1583 |
-
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-
|
| 1584 |
-
"integrity": "sha512-
|
| 1585 |
"dev": true,
|
| 1586 |
"license": "MIT",
|
| 1587 |
"engines": {
|
| 1588 |
-
"node": ">=
|
| 1589 |
},
|
| 1590 |
"funding": {
|
| 1591 |
"url": "https://github.com/sponsors/jonschlinkert"
|
|
@@ -1839,6 +1905,19 @@
|
|
| 1839 |
"node": ">=8.10.0"
|
| 1840 |
}
|
| 1841 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1842 |
"node_modules/resolve": {
|
| 1843 |
"version": "1.22.12",
|
| 1844 |
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
|
@@ -1903,9 +1982,9 @@
|
|
| 1903 |
"license": "MIT"
|
| 1904 |
},
|
| 1905 |
"node_modules/semver": {
|
| 1906 |
-
"version": "7.8.
|
| 1907 |
-
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.
|
| 1908 |
-
"integrity": "sha512-
|
| 1909 |
"license": "ISC",
|
| 1910 |
"optional": true,
|
| 1911 |
"bin": {
|
|
@@ -1916,16 +1995,16 @@
|
|
| 1916 |
}
|
| 1917 |
},
|
| 1918 |
"node_modules/sharp": {
|
| 1919 |
-
"version": "0.
|
| 1920 |
-
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.
|
| 1921 |
-
"integrity": "sha512-
|
| 1922 |
"hasInstallScript": true,
|
| 1923 |
"license": "Apache-2.0",
|
| 1924 |
"optional": true,
|
| 1925 |
"dependencies": {
|
| 1926 |
-
"
|
| 1927 |
-
"detect-libc": "^2.
|
| 1928 |
-
"semver": "^7.
|
| 1929 |
},
|
| 1930 |
"engines": {
|
| 1931 |
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
@@ -1934,35 +2013,30 @@
|
|
| 1934 |
"url": "https://opencollective.com/libvips"
|
| 1935 |
},
|
| 1936 |
"optionalDependencies": {
|
| 1937 |
-
"@img/sharp-darwin-arm64": "0.
|
| 1938 |
-
"@img/sharp-darwin-x64": "0.
|
| 1939 |
-
"@img/sharp-libvips-darwin-arm64": "1.
|
| 1940 |
-
"@img/sharp-libvips-darwin-x64": "1.
|
| 1941 |
-
"@img/sharp-libvips-linux-arm": "1.
|
| 1942 |
-
"@img/sharp-libvips-linux-arm64": "1.
|
| 1943 |
-
"@img/sharp-libvips-linux-
|
| 1944 |
-
"@img/sharp-libvips-linux-
|
| 1945 |
-
"@img/sharp-libvips-
|
| 1946 |
-
"@img/sharp-libvips-
|
| 1947 |
-
"@img/sharp-
|
| 1948 |
-
"@img/sharp-
|
| 1949 |
-
"@img/sharp-linux-
|
| 1950 |
-
"@img/sharp-linux-
|
| 1951 |
-
"@img/sharp-
|
| 1952 |
-
"@img/sharp-
|
| 1953 |
-
"@img/sharp-
|
| 1954 |
-
"@img/sharp-
|
| 1955 |
-
"@img/sharp-
|
| 1956 |
-
|
| 1957 |
-
|
| 1958 |
-
|
| 1959 |
-
|
| 1960 |
-
|
| 1961 |
-
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
| 1962 |
-
"license": "MIT",
|
| 1963 |
-
"optional": true,
|
| 1964 |
-
"dependencies": {
|
| 1965 |
-
"is-arrayish": "^0.3.1"
|
| 1966 |
}
|
| 1967 |
},
|
| 1968 |
"node_modules/size-sensor": {
|
|
@@ -1980,14 +2054,6 @@
|
|
| 1980 |
"node": ">=0.10.0"
|
| 1981 |
}
|
| 1982 |
},
|
| 1983 |
-
"node_modules/streamsearch": {
|
| 1984 |
-
"version": "1.1.0",
|
| 1985 |
-
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
| 1986 |
-
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
| 1987 |
-
"engines": {
|
| 1988 |
-
"node": ">=10.0.0"
|
| 1989 |
-
}
|
| 1990 |
-
},
|
| 1991 |
"node_modules/styled-jsx": {
|
| 1992 |
"version": "5.1.6",
|
| 1993 |
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
|
@@ -2135,19 +2201,6 @@
|
|
| 2135 |
"url": "https://github.com/sponsors/SuperchupuDev"
|
| 2136 |
}
|
| 2137 |
},
|
| 2138 |
-
"node_modules/tinyglobby/node_modules/picomatch": {
|
| 2139 |
-
"version": "4.0.4",
|
| 2140 |
-
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
| 2141 |
-
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 2142 |
-
"dev": true,
|
| 2143 |
-
"license": "MIT",
|
| 2144 |
-
"engines": {
|
| 2145 |
-
"node": ">=12"
|
| 2146 |
-
},
|
| 2147 |
-
"funding": {
|
| 2148 |
-
"url": "https://github.com/sponsors/jonschlinkert"
|
| 2149 |
-
}
|
| 2150 |
-
},
|
| 2151 |
"node_modules/to-regex-range": {
|
| 2152 |
"version": "5.0.1",
|
| 2153 |
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
|
|
| 13 |
"echarts": "^5.5.0",
|
| 14 |
"echarts-for-react": "^3.0.2",
|
| 15 |
"framer-motion": "^11.0.8",
|
| 16 |
+
"jsqr": "^1.4.0",
|
| 17 |
+
"lucide-react": "^0.468.0",
|
| 18 |
+
"next": "^15.1.7",
|
| 19 |
"react": "^19.0.0",
|
| 20 |
"react-dom": "^19.0.0",
|
| 21 |
"tailwind-merge": "^2.2.2"
|
|
|
|
| 44 |
}
|
| 45 |
},
|
| 46 |
"node_modules/@emnapi/runtime": {
|
| 47 |
+
"version": "1.11.1",
|
| 48 |
+
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
| 49 |
+
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
| 50 |
"license": "MIT",
|
| 51 |
"optional": true,
|
| 52 |
"dependencies": {
|
|
|
|
| 60 |
"license": "0BSD",
|
| 61 |
"optional": true
|
| 62 |
},
|
| 63 |
+
"node_modules/@img/colour": {
|
| 64 |
+
"version": "1.1.0",
|
| 65 |
+
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
| 66 |
+
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
| 67 |
+
"license": "MIT",
|
| 68 |
+
"optional": true,
|
| 69 |
+
"engines": {
|
| 70 |
+
"node": ">=18"
|
| 71 |
+
}
|
| 72 |
+
},
|
| 73 |
"node_modules/@img/sharp-darwin-arm64": {
|
| 74 |
+
"version": "0.34.5",
|
| 75 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
| 76 |
+
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
| 77 |
"cpu": [
|
| 78 |
"arm64"
|
| 79 |
],
|
|
|
|
| 89 |
"url": "https://opencollective.com/libvips"
|
| 90 |
},
|
| 91 |
"optionalDependencies": {
|
| 92 |
+
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
| 93 |
}
|
| 94 |
},
|
| 95 |
"node_modules/@img/sharp-darwin-x64": {
|
| 96 |
+
"version": "0.34.5",
|
| 97 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
| 98 |
+
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
| 99 |
"cpu": [
|
| 100 |
"x64"
|
| 101 |
],
|
|
|
|
| 111 |
"url": "https://opencollective.com/libvips"
|
| 112 |
},
|
| 113 |
"optionalDependencies": {
|
| 114 |
+
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
| 115 |
}
|
| 116 |
},
|
| 117 |
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
| 118 |
+
"version": "1.2.4",
|
| 119 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
| 120 |
+
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
| 121 |
"cpu": [
|
| 122 |
"arm64"
|
| 123 |
],
|
|
|
|
| 131 |
}
|
| 132 |
},
|
| 133 |
"node_modules/@img/sharp-libvips-darwin-x64": {
|
| 134 |
+
"version": "1.2.4",
|
| 135 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
| 136 |
+
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
| 137 |
"cpu": [
|
| 138 |
"x64"
|
| 139 |
],
|
|
|
|
| 147 |
}
|
| 148 |
},
|
| 149 |
"node_modules/@img/sharp-libvips-linux-arm": {
|
| 150 |
+
"version": "1.2.4",
|
| 151 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
| 152 |
+
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
| 153 |
"cpu": [
|
| 154 |
"arm"
|
| 155 |
],
|
|
|
|
| 163 |
}
|
| 164 |
},
|
| 165 |
"node_modules/@img/sharp-libvips-linux-arm64": {
|
| 166 |
+
"version": "1.2.4",
|
| 167 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
| 168 |
+
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
| 169 |
"cpu": [
|
| 170 |
"arm64"
|
| 171 |
],
|
|
|
|
| 178 |
"url": "https://opencollective.com/libvips"
|
| 179 |
}
|
| 180 |
},
|
| 181 |
+
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
| 182 |
+
"version": "1.2.4",
|
| 183 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
| 184 |
+
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
| 185 |
+
"cpu": [
|
| 186 |
+
"ppc64"
|
| 187 |
+
],
|
| 188 |
+
"license": "LGPL-3.0-or-later",
|
| 189 |
+
"optional": true,
|
| 190 |
+
"os": [
|
| 191 |
+
"linux"
|
| 192 |
+
],
|
| 193 |
+
"funding": {
|
| 194 |
+
"url": "https://opencollective.com/libvips"
|
| 195 |
+
}
|
| 196 |
+
},
|
| 197 |
+
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
| 198 |
+
"version": "1.2.4",
|
| 199 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
| 200 |
+
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
| 201 |
+
"cpu": [
|
| 202 |
+
"riscv64"
|
| 203 |
+
],
|
| 204 |
+
"license": "LGPL-3.0-or-later",
|
| 205 |
+
"optional": true,
|
| 206 |
+
"os": [
|
| 207 |
+
"linux"
|
| 208 |
+
],
|
| 209 |
+
"funding": {
|
| 210 |
+
"url": "https://opencollective.com/libvips"
|
| 211 |
+
}
|
| 212 |
+
},
|
| 213 |
"node_modules/@img/sharp-libvips-linux-s390x": {
|
| 214 |
+
"version": "1.2.4",
|
| 215 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
| 216 |
+
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
| 217 |
"cpu": [
|
| 218 |
"s390x"
|
| 219 |
],
|
|
|
|
| 227 |
}
|
| 228 |
},
|
| 229 |
"node_modules/@img/sharp-libvips-linux-x64": {
|
| 230 |
+
"version": "1.2.4",
|
| 231 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
| 232 |
+
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
| 233 |
"cpu": [
|
| 234 |
"x64"
|
| 235 |
],
|
|
|
|
| 243 |
}
|
| 244 |
},
|
| 245 |
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
| 246 |
+
"version": "1.2.4",
|
| 247 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
| 248 |
+
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
| 249 |
"cpu": [
|
| 250 |
"arm64"
|
| 251 |
],
|
|
|
|
| 259 |
}
|
| 260 |
},
|
| 261 |
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
| 262 |
+
"version": "1.2.4",
|
| 263 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
| 264 |
+
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
| 265 |
"cpu": [
|
| 266 |
"x64"
|
| 267 |
],
|
|
|
|
| 275 |
}
|
| 276 |
},
|
| 277 |
"node_modules/@img/sharp-linux-arm": {
|
| 278 |
+
"version": "0.34.5",
|
| 279 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
| 280 |
+
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
| 281 |
"cpu": [
|
| 282 |
"arm"
|
| 283 |
],
|
|
|
|
| 293 |
"url": "https://opencollective.com/libvips"
|
| 294 |
},
|
| 295 |
"optionalDependencies": {
|
| 296 |
+
"@img/sharp-libvips-linux-arm": "1.2.4"
|
| 297 |
}
|
| 298 |
},
|
| 299 |
"node_modules/@img/sharp-linux-arm64": {
|
| 300 |
+
"version": "0.34.5",
|
| 301 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
| 302 |
+
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
| 303 |
"cpu": [
|
| 304 |
"arm64"
|
| 305 |
],
|
|
|
|
| 315 |
"url": "https://opencollective.com/libvips"
|
| 316 |
},
|
| 317 |
"optionalDependencies": {
|
| 318 |
+
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
| 319 |
+
}
|
| 320 |
+
},
|
| 321 |
+
"node_modules/@img/sharp-linux-ppc64": {
|
| 322 |
+
"version": "0.34.5",
|
| 323 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
| 324 |
+
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
| 325 |
+
"cpu": [
|
| 326 |
+
"ppc64"
|
| 327 |
+
],
|
| 328 |
+
"license": "Apache-2.0",
|
| 329 |
+
"optional": true,
|
| 330 |
+
"os": [
|
| 331 |
+
"linux"
|
| 332 |
+
],
|
| 333 |
+
"engines": {
|
| 334 |
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
| 335 |
+
},
|
| 336 |
+
"funding": {
|
| 337 |
+
"url": "https://opencollective.com/libvips"
|
| 338 |
+
},
|
| 339 |
+
"optionalDependencies": {
|
| 340 |
+
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
| 341 |
+
}
|
| 342 |
+
},
|
| 343 |
+
"node_modules/@img/sharp-linux-riscv64": {
|
| 344 |
+
"version": "0.34.5",
|
| 345 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
| 346 |
+
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
| 347 |
+
"cpu": [
|
| 348 |
+
"riscv64"
|
| 349 |
+
],
|
| 350 |
+
"license": "Apache-2.0",
|
| 351 |
+
"optional": true,
|
| 352 |
+
"os": [
|
| 353 |
+
"linux"
|
| 354 |
+
],
|
| 355 |
+
"engines": {
|
| 356 |
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
| 357 |
+
},
|
| 358 |
+
"funding": {
|
| 359 |
+
"url": "https://opencollective.com/libvips"
|
| 360 |
+
},
|
| 361 |
+
"optionalDependencies": {
|
| 362 |
+
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
| 363 |
}
|
| 364 |
},
|
| 365 |
"node_modules/@img/sharp-linux-s390x": {
|
| 366 |
+
"version": "0.34.5",
|
| 367 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
| 368 |
+
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
| 369 |
"cpu": [
|
| 370 |
"s390x"
|
| 371 |
],
|
|
|
|
| 381 |
"url": "https://opencollective.com/libvips"
|
| 382 |
},
|
| 383 |
"optionalDependencies": {
|
| 384 |
+
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
| 385 |
}
|
| 386 |
},
|
| 387 |
"node_modules/@img/sharp-linux-x64": {
|
| 388 |
+
"version": "0.34.5",
|
| 389 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
| 390 |
+
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
| 391 |
"cpu": [
|
| 392 |
"x64"
|
| 393 |
],
|
|
|
|
| 403 |
"url": "https://opencollective.com/libvips"
|
| 404 |
},
|
| 405 |
"optionalDependencies": {
|
| 406 |
+
"@img/sharp-libvips-linux-x64": "1.2.4"
|
| 407 |
}
|
| 408 |
},
|
| 409 |
"node_modules/@img/sharp-linuxmusl-arm64": {
|
| 410 |
+
"version": "0.34.5",
|
| 411 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
| 412 |
+
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
| 413 |
"cpu": [
|
| 414 |
"arm64"
|
| 415 |
],
|
|
|
|
| 425 |
"url": "https://opencollective.com/libvips"
|
| 426 |
},
|
| 427 |
"optionalDependencies": {
|
| 428 |
+
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
| 429 |
}
|
| 430 |
},
|
| 431 |
"node_modules/@img/sharp-linuxmusl-x64": {
|
| 432 |
+
"version": "0.34.5",
|
| 433 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
| 434 |
+
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
| 435 |
"cpu": [
|
| 436 |
"x64"
|
| 437 |
],
|
|
|
|
| 447 |
"url": "https://opencollective.com/libvips"
|
| 448 |
},
|
| 449 |
"optionalDependencies": {
|
| 450 |
+
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
| 451 |
}
|
| 452 |
},
|
| 453 |
"node_modules/@img/sharp-wasm32": {
|
| 454 |
+
"version": "0.34.5",
|
| 455 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
| 456 |
+
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
| 457 |
"cpu": [
|
| 458 |
"wasm32"
|
| 459 |
],
|
| 460 |
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
| 461 |
"optional": true,
|
| 462 |
"dependencies": {
|
| 463 |
+
"@emnapi/runtime": "^1.7.0"
|
| 464 |
},
|
| 465 |
"engines": {
|
| 466 |
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
|
|
| 469 |
"url": "https://opencollective.com/libvips"
|
| 470 |
}
|
| 471 |
},
|
| 472 |
+
"node_modules/@img/sharp-win32-arm64": {
|
| 473 |
+
"version": "0.34.5",
|
| 474 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
| 475 |
+
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
| 476 |
+
"cpu": [
|
| 477 |
+
"arm64"
|
| 478 |
+
],
|
| 479 |
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
| 480 |
+
"optional": true,
|
| 481 |
+
"os": [
|
| 482 |
+
"win32"
|
| 483 |
+
],
|
| 484 |
+
"engines": {
|
| 485 |
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
| 486 |
+
},
|
| 487 |
+
"funding": {
|
| 488 |
+
"url": "https://opencollective.com/libvips"
|
| 489 |
+
}
|
| 490 |
+
},
|
| 491 |
"node_modules/@img/sharp-win32-ia32": {
|
| 492 |
+
"version": "0.34.5",
|
| 493 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
| 494 |
+
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
| 495 |
"cpu": [
|
| 496 |
"ia32"
|
| 497 |
],
|
|
|
|
| 508 |
}
|
| 509 |
},
|
| 510 |
"node_modules/@img/sharp-win32-x64": {
|
| 511 |
+
"version": "0.34.5",
|
| 512 |
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
| 513 |
+
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
| 514 |
"cpu": [
|
| 515 |
"x64"
|
| 516 |
],
|
|
|
|
| 566 |
}
|
| 567 |
},
|
| 568 |
"node_modules/@next/env": {
|
| 569 |
+
"version": "15.5.19",
|
| 570 |
+
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
|
| 571 |
+
"integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
|
| 572 |
"license": "MIT"
|
| 573 |
},
|
| 574 |
"node_modules/@next/swc-darwin-arm64": {
|
| 575 |
+
"version": "15.5.19",
|
| 576 |
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
|
| 577 |
+
"integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
|
| 578 |
"cpu": [
|
| 579 |
"arm64"
|
| 580 |
],
|
|
|
|
| 588 |
}
|
| 589 |
},
|
| 590 |
"node_modules/@next/swc-darwin-x64": {
|
| 591 |
+
"version": "15.5.19",
|
| 592 |
+
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
|
| 593 |
+
"integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
|
| 594 |
"cpu": [
|
| 595 |
"x64"
|
| 596 |
],
|
|
|
|
| 604 |
}
|
| 605 |
},
|
| 606 |
"node_modules/@next/swc-linux-arm64-gnu": {
|
| 607 |
+
"version": "15.5.19",
|
| 608 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
|
| 609 |
+
"integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
|
| 610 |
"cpu": [
|
| 611 |
"arm64"
|
| 612 |
],
|
|
|
|
| 620 |
}
|
| 621 |
},
|
| 622 |
"node_modules/@next/swc-linux-arm64-musl": {
|
| 623 |
+
"version": "15.5.19",
|
| 624 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
|
| 625 |
+
"integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
|
| 626 |
"cpu": [
|
| 627 |
"arm64"
|
| 628 |
],
|
|
|
|
| 636 |
}
|
| 637 |
},
|
| 638 |
"node_modules/@next/swc-linux-x64-gnu": {
|
| 639 |
+
"version": "15.5.19",
|
| 640 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
|
| 641 |
+
"integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
|
| 642 |
"cpu": [
|
| 643 |
"x64"
|
| 644 |
],
|
|
|
|
| 652 |
}
|
| 653 |
},
|
| 654 |
"node_modules/@next/swc-linux-x64-musl": {
|
| 655 |
+
"version": "15.5.19",
|
| 656 |
+
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
|
| 657 |
+
"integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
|
| 658 |
"cpu": [
|
| 659 |
"x64"
|
| 660 |
],
|
|
|
|
| 668 |
}
|
| 669 |
},
|
| 670 |
"node_modules/@next/swc-win32-arm64-msvc": {
|
| 671 |
+
"version": "15.5.19",
|
| 672 |
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
|
| 673 |
+
"integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
|
| 674 |
"cpu": [
|
| 675 |
"arm64"
|
| 676 |
],
|
|
|
|
| 684 |
}
|
| 685 |
},
|
| 686 |
"node_modules/@next/swc-win32-x64-msvc": {
|
| 687 |
+
"version": "15.5.19",
|
| 688 |
+
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
|
| 689 |
+
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
|
| 690 |
"cpu": [
|
| 691 |
"x64"
|
| 692 |
],
|
|
|
|
| 737 |
"node": ">= 8"
|
| 738 |
}
|
| 739 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 740 |
"node_modules/@swc/helpers": {
|
| 741 |
+
"version": "0.5.15",
|
| 742 |
+
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
| 743 |
+
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
| 744 |
"license": "Apache-2.0",
|
| 745 |
"dependencies": {
|
| 746 |
+
"tslib": "^2.8.0"
|
| 747 |
}
|
| 748 |
},
|
| 749 |
"node_modules/@swc/helpers/node_modules/tslib": {
|
|
|
|
| 829 |
"node": ">= 8"
|
| 830 |
}
|
| 831 |
},
|
| 832 |
+
"node_modules/anymatch/node_modules/picomatch": {
|
| 833 |
+
"version": "2.3.2",
|
| 834 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
| 835 |
+
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
| 836 |
+
"dev": true,
|
| 837 |
+
"license": "MIT",
|
| 838 |
+
"engines": {
|
| 839 |
+
"node": ">=8.6"
|
| 840 |
+
},
|
| 841 |
+
"funding": {
|
| 842 |
+
"url": "https://github.com/sponsors/jonschlinkert"
|
| 843 |
+
}
|
| 844 |
+
},
|
| 845 |
"node_modules/arg": {
|
| 846 |
"version": "5.0.2",
|
| 847 |
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
|
|
|
| 959 |
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
| 960 |
}
|
| 961 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
"node_modules/camelcase-css": {
|
| 963 |
"version": "2.0.1",
|
| 964 |
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
|
|
|
| 1042 |
"node": ">=6"
|
| 1043 |
}
|
| 1044 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
"node_modules/commander": {
|
| 1046 |
"version": "4.1.1",
|
| 1047 |
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
|
|
|
| 1322 |
"node": ">= 0.4"
|
| 1323 |
}
|
| 1324 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1325 |
"node_modules/is-binary-path": {
|
| 1326 |
"version": "2.1.0",
|
| 1327 |
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
|
|
|
| 1394 |
"jiti": "bin/jiti.js"
|
| 1395 |
}
|
| 1396 |
},
|
| 1397 |
+
"node_modules/jsqr": {
|
| 1398 |
+
"version": "1.4.0",
|
| 1399 |
+
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
| 1400 |
+
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
| 1401 |
+
"license": "Apache-2.0"
|
| 1402 |
+
},
|
| 1403 |
"node_modules/lilconfig": {
|
| 1404 |
"version": "3.1.3",
|
| 1405 |
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
|
|
|
| 1421 |
"license": "MIT"
|
| 1422 |
},
|
| 1423 |
"node_modules/lucide-react": {
|
| 1424 |
+
"version": "0.468.0",
|
| 1425 |
+
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
|
| 1426 |
+
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
|
| 1427 |
"license": "ISC",
|
| 1428 |
"peerDependencies": {
|
| 1429 |
+
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
| 1430 |
}
|
| 1431 |
},
|
| 1432 |
"node_modules/merge2": {
|
|
|
|
| 1453 |
"node": ">=8.6"
|
| 1454 |
}
|
| 1455 |
},
|
| 1456 |
+
"node_modules/micromatch/node_modules/picomatch": {
|
| 1457 |
+
"version": "2.3.2",
|
| 1458 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
| 1459 |
+
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
| 1460 |
+
"dev": true,
|
| 1461 |
+
"license": "MIT",
|
| 1462 |
+
"engines": {
|
| 1463 |
+
"node": ">=8.6"
|
| 1464 |
+
},
|
| 1465 |
+
"funding": {
|
| 1466 |
+
"url": "https://github.com/sponsors/jonschlinkert"
|
| 1467 |
+
}
|
| 1468 |
+
},
|
| 1469 |
"node_modules/motion-dom": {
|
| 1470 |
"version": "11.18.1",
|
| 1471 |
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
|
|
|
| 1512 |
}
|
| 1513 |
},
|
| 1514 |
"node_modules/next": {
|
| 1515 |
+
"version": "15.5.19",
|
| 1516 |
+
"resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
|
| 1517 |
+
"integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
|
|
|
|
| 1518 |
"license": "MIT",
|
| 1519 |
"dependencies": {
|
| 1520 |
+
"@next/env": "15.5.19",
|
| 1521 |
+
"@swc/helpers": "0.5.15",
|
|
|
|
|
|
|
| 1522 |
"caniuse-lite": "^1.0.30001579",
|
| 1523 |
"postcss": "8.4.31",
|
| 1524 |
"styled-jsx": "5.1.6"
|
|
|
|
| 1530 |
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
| 1531 |
},
|
| 1532 |
"optionalDependencies": {
|
| 1533 |
+
"@next/swc-darwin-arm64": "15.5.19",
|
| 1534 |
+
"@next/swc-darwin-x64": "15.5.19",
|
| 1535 |
+
"@next/swc-linux-arm64-gnu": "15.5.19",
|
| 1536 |
+
"@next/swc-linux-arm64-musl": "15.5.19",
|
| 1537 |
+
"@next/swc-linux-x64-gnu": "15.5.19",
|
| 1538 |
+
"@next/swc-linux-x64-musl": "15.5.19",
|
| 1539 |
+
"@next/swc-win32-arm64-msvc": "15.5.19",
|
| 1540 |
+
"@next/swc-win32-x64-msvc": "15.5.19",
|
| 1541 |
+
"sharp": "^0.34.3"
|
| 1542 |
},
|
| 1543 |
"peerDependencies": {
|
| 1544 |
"@opentelemetry/api": "^1.1.0",
|
| 1545 |
+
"@playwright/test": "^1.51.1",
|
| 1546 |
"babel-plugin-react-compiler": "*",
|
| 1547 |
+
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
| 1548 |
+
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
| 1549 |
"sass": "^1.3.0"
|
| 1550 |
},
|
| 1551 |
"peerDependenciesMeta": {
|
|
|
|
| 1645 |
"license": "ISC"
|
| 1646 |
},
|
| 1647 |
"node_modules/picomatch": {
|
| 1648 |
+
"version": "4.0.4",
|
| 1649 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
| 1650 |
+
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 1651 |
"dev": true,
|
| 1652 |
"license": "MIT",
|
| 1653 |
"engines": {
|
| 1654 |
+
"node": ">=12"
|
| 1655 |
},
|
| 1656 |
"funding": {
|
| 1657 |
"url": "https://github.com/sponsors/jonschlinkert"
|
|
|
|
| 1905 |
"node": ">=8.10.0"
|
| 1906 |
}
|
| 1907 |
},
|
| 1908 |
+
"node_modules/readdirp/node_modules/picomatch": {
|
| 1909 |
+
"version": "2.3.2",
|
| 1910 |
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
| 1911 |
+
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
| 1912 |
+
"dev": true,
|
| 1913 |
+
"license": "MIT",
|
| 1914 |
+
"engines": {
|
| 1915 |
+
"node": ">=8.6"
|
| 1916 |
+
},
|
| 1917 |
+
"funding": {
|
| 1918 |
+
"url": "https://github.com/sponsors/jonschlinkert"
|
| 1919 |
+
}
|
| 1920 |
+
},
|
| 1921 |
"node_modules/resolve": {
|
| 1922 |
"version": "1.22.12",
|
| 1923 |
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
|
|
|
| 1982 |
"license": "MIT"
|
| 1983 |
},
|
| 1984 |
"node_modules/semver": {
|
| 1985 |
+
"version": "7.8.5",
|
| 1986 |
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
| 1987 |
+
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
| 1988 |
"license": "ISC",
|
| 1989 |
"optional": true,
|
| 1990 |
"bin": {
|
|
|
|
| 1995 |
}
|
| 1996 |
},
|
| 1997 |
"node_modules/sharp": {
|
| 1998 |
+
"version": "0.34.5",
|
| 1999 |
+
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
| 2000 |
+
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
| 2001 |
"hasInstallScript": true,
|
| 2002 |
"license": "Apache-2.0",
|
| 2003 |
"optional": true,
|
| 2004 |
"dependencies": {
|
| 2005 |
+
"@img/colour": "^1.0.0",
|
| 2006 |
+
"detect-libc": "^2.1.2",
|
| 2007 |
+
"semver": "^7.7.3"
|
| 2008 |
},
|
| 2009 |
"engines": {
|
| 2010 |
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
|
|
|
| 2013 |
"url": "https://opencollective.com/libvips"
|
| 2014 |
},
|
| 2015 |
"optionalDependencies": {
|
| 2016 |
+
"@img/sharp-darwin-arm64": "0.34.5",
|
| 2017 |
+
"@img/sharp-darwin-x64": "0.34.5",
|
| 2018 |
+
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
| 2019 |
+
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
| 2020 |
+
"@img/sharp-libvips-linux-arm": "1.2.4",
|
| 2021 |
+
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
| 2022 |
+
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
| 2023 |
+
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
| 2024 |
+
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
| 2025 |
+
"@img/sharp-libvips-linux-x64": "1.2.4",
|
| 2026 |
+
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
| 2027 |
+
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
| 2028 |
+
"@img/sharp-linux-arm": "0.34.5",
|
| 2029 |
+
"@img/sharp-linux-arm64": "0.34.5",
|
| 2030 |
+
"@img/sharp-linux-ppc64": "0.34.5",
|
| 2031 |
+
"@img/sharp-linux-riscv64": "0.34.5",
|
| 2032 |
+
"@img/sharp-linux-s390x": "0.34.5",
|
| 2033 |
+
"@img/sharp-linux-x64": "0.34.5",
|
| 2034 |
+
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
| 2035 |
+
"@img/sharp-linuxmusl-x64": "0.34.5",
|
| 2036 |
+
"@img/sharp-wasm32": "0.34.5",
|
| 2037 |
+
"@img/sharp-win32-arm64": "0.34.5",
|
| 2038 |
+
"@img/sharp-win32-ia32": "0.34.5",
|
| 2039 |
+
"@img/sharp-win32-x64": "0.34.5"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2040 |
}
|
| 2041 |
},
|
| 2042 |
"node_modules/size-sensor": {
|
|
|
|
| 2054 |
"node": ">=0.10.0"
|
| 2055 |
}
|
| 2056 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2057 |
"node_modules/styled-jsx": {
|
| 2058 |
"version": "5.1.6",
|
| 2059 |
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
|
|
|
| 2201 |
"url": "https://github.com/sponsors/SuperchupuDev"
|
| 2202 |
}
|
| 2203 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2204 |
"node_modules/to-regex-range": {
|
| 2205 |
"version": "5.0.1",
|
| 2206 |
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
frontend/package.json
CHANGED
|
@@ -14,6 +14,7 @@
|
|
| 14 |
"echarts": "^5.5.0",
|
| 15 |
"echarts-for-react": "^3.0.2",
|
| 16 |
"framer-motion": "^11.0.8",
|
|
|
|
| 17 |
"lucide-react": "^0.468.0",
|
| 18 |
"next": "^15.1.7",
|
| 19 |
"react": "^19.0.0",
|
|
|
|
| 14 |
"echarts": "^5.5.0",
|
| 15 |
"echarts-for-react": "^3.0.2",
|
| 16 |
"framer-motion": "^11.0.8",
|
| 17 |
+
"jsqr": "^1.4.0",
|
| 18 |
"lucide-react": "^0.468.0",
|
| 19 |
"next": "^15.1.7",
|
| 20 |
"react": "^19.0.0",
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|