Spaces:
Sleeping
Sleeping
Pavanupadhyay27 commited on
Commit Β·
be2ba8d
1
Parent(s): f4a356e
feat: complete biometric system features, self-onboarding alignment, bulk approval, and timezone trend fixes
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .vscode/settings.json +3 -0
- backend/app/api/v1/analytics.py +114 -10
- backend/app/api/v1/attendance.py +15 -5
- backend/app/api/v1/auth.py +204 -2
- backend/app/api/v1/companies.py +31 -0
- backend/app/api/v1/departments.py +4 -2
- backend/app/api/v1/devices.py +63 -0
- backend/app/api/v1/employees.py +107 -2
- backend/app/api/v1/enrollment.py +122 -68
- backend/app/api/v1/kiosk.py +302 -268
- backend/app/api/v1/notifications.py +73 -0
- backend/app/api/v1/policy.py +54 -0
- backend/app/api/v1/tickets.py +95 -12
- backend/app/api/v1/timeline.py +33 -0
- backend/app/core/attendance_policy.py +127 -0
- backend/app/core/config.py +1 -1
- backend/app/core/event_bus.py +37 -0
- backend/app/core/init_db.py +41 -0
- backend/app/core/rate_limiter.py +45 -0
- backend/app/core/security.py +13 -0
- backend/app/crud/crud.py +261 -6
- backend/app/main.py +43 -8
- backend/app/models/models.py +87 -2
- backend/app/schemas/schemas.py +113 -1
- backend/app/tests/test_production_features.py +5 -5
- backend/delete_all_employees.py +82 -0
- frontend/app/analytics/page.tsx +852 -0
- frontend/app/attendance/page.tsx +6 -6
- frontend/app/audit/page.tsx +198 -89
- frontend/app/calendar/page.tsx +482 -0
- frontend/app/dashboard/page.tsx +0 -0
- frontend/app/employees/[id]/page.tsx +2 -10
- frontend/app/employees/page.tsx +112 -15
- frontend/app/enroll/[id]/page.tsx +64 -33
- frontend/app/globals.css +216 -23
- frontend/app/holidays/page.tsx +269 -0
- frontend/app/kiosk/page.tsx +2 -2
- frontend/app/leaves/page.tsx +629 -0
- frontend/app/page.tsx +523 -179
- frontend/app/profile/page.tsx +87 -0
- frontend/app/reports/page.tsx +2 -85
- frontend/app/self-onboard/page.tsx +754 -0
- frontend/app/settings/page.tsx +0 -0
- frontend/app/tenants/page.tsx +0 -0
- frontend/app/tickets/page.tsx +847 -197
- frontend/app/utils/api.ts +9 -2
- frontend/components/AttendanceHeatmap.tsx +1 -1
- frontend/components/CommandPalette.tsx +98 -0
- frontend/components/SidebarLayout.tsx +172 -101
- frontend/next.config.js +4 -0
.vscode/settings.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"css.lint.unknownAtRules": "ignore"
|
| 3 |
+
}
|
backend/app/api/v1/analytics.py
CHANGED
|
@@ -85,26 +85,41 @@ def get_attendance_trends(
|
|
| 85 |
# Generate list of dates
|
| 86 |
date_list = [start_date + timedelta(days=i) for i in range(days)]
|
| 87 |
|
| 88 |
-
# Get total active employees
|
| 89 |
-
|
| 90 |
models.Employee.status == "Active"
|
| 91 |
-
)
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
trends = []
|
| 94 |
for d in date_list:
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 96 |
and_(
|
| 97 |
models.Attendance.date == d,
|
| 98 |
-
models.Attendance.status.in_(["Present", "Late", "Half Day"])
|
| 99 |
)
|
| 100 |
-
)
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
| 103 |
and_(
|
| 104 |
models.Attendance.date == d,
|
| 105 |
models.Attendance.status == "Late"
|
| 106 |
)
|
| 107 |
-
)
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
absent = max(0, total_active - present)
|
| 110 |
|
|
@@ -126,7 +141,10 @@ def get_department_distribution(
|
|
| 126 |
Returns employee and attendance counts by department for ECharts.
|
| 127 |
"""
|
| 128 |
today = date.today()
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
dist = []
|
| 132 |
for dept in departments:
|
|
@@ -228,7 +246,93 @@ def get_attendance_heatmap(
|
|
| 228 |
else:
|
| 229 |
query = query.filter(models.Attendance.status.in_(["Present", "Late", "Half Day"]))
|
| 230 |
|
| 231 |
-
results = query.group_by(models.Attendance.date).all()
|
| 232 |
|
| 233 |
heatmap_data = {r[0].isoformat(): r[1] for r in results}
|
| 234 |
return heatmap_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# Generate list of dates
|
| 86 |
date_list = [start_date + timedelta(days=i) for i in range(days)]
|
| 87 |
|
| 88 |
+
# Get total active employees for this company
|
| 89 |
+
query = db.query(func.count(models.Employee.id)).filter(
|
| 90 |
models.Employee.status == "Active"
|
| 91 |
+
)
|
| 92 |
+
if current_user.company_id is not None:
|
| 93 |
+
query = query.filter(models.Employee.company_id == current_user.company_id)
|
| 94 |
+
total_active = query.scalar() or 0
|
| 95 |
|
| 96 |
trends = []
|
| 97 |
for d in date_list:
|
| 98 |
+
# Present status checks (include WFH as active)
|
| 99 |
+
present_query = db.query(func.count(models.Attendance.id)).join(
|
| 100 |
+
models.Employee, models.Attendance.employee_id == models.Employee.id
|
| 101 |
+
).filter(
|
| 102 |
and_(
|
| 103 |
models.Attendance.date == d,
|
| 104 |
+
models.Attendance.status.in_(["Present", "Late", "Half Day", "WFH"])
|
| 105 |
)
|
| 106 |
+
)
|
| 107 |
+
if current_user.company_id is not None:
|
| 108 |
+
present_query = present_query.filter(models.Employee.company_id == current_user.company_id)
|
| 109 |
+
present = present_query.scalar() or 0
|
| 110 |
|
| 111 |
+
# Late status check
|
| 112 |
+
late_query = db.query(func.count(models.Attendance.id)).join(
|
| 113 |
+
models.Employee, models.Attendance.employee_id == models.Employee.id
|
| 114 |
+
).filter(
|
| 115 |
and_(
|
| 116 |
models.Attendance.date == d,
|
| 117 |
models.Attendance.status == "Late"
|
| 118 |
)
|
| 119 |
+
)
|
| 120 |
+
if current_user.company_id is not None:
|
| 121 |
+
late_query = late_query.filter(models.Employee.company_id == current_user.company_id)
|
| 122 |
+
late = late_query.scalar() or 0
|
| 123 |
|
| 124 |
absent = max(0, total_active - present)
|
| 125 |
|
|
|
|
| 141 |
Returns employee and attendance counts by department for ECharts.
|
| 142 |
"""
|
| 143 |
today = date.today()
|
| 144 |
+
if current_user.company_id is not None:
|
| 145 |
+
departments = db.query(models.Department).filter(models.Department.company_id == current_user.company_id).all()
|
| 146 |
+
else:
|
| 147 |
+
departments = db.query(models.Department).all()
|
| 148 |
|
| 149 |
dist = []
|
| 150 |
for dept in departments:
|
|
|
|
| 246 |
else:
|
| 247 |
query = query.filter(models.Attendance.status.in_(["Present", "Late", "Half Day"]))
|
| 248 |
|
| 249 |
+
results = query.group_by(models.AttendanceDate).all() if hasattr(models, 'AttendanceDate') else query.group_by(models.Attendance.date).all()
|
| 250 |
|
| 251 |
heatmap_data = {r[0].isoformat(): r[1] for r in results}
|
| 252 |
return heatmap_data
|
| 253 |
+
|
| 254 |
+
@router.get("/recognition")
|
| 255 |
+
def get_recognition_analytics(
|
| 256 |
+
db: Session = Depends(get_db),
|
| 257 |
+
current_user: models.User = Depends(checker_view)
|
| 258 |
+
):
|
| 259 |
+
company_id = current_user.company_id
|
| 260 |
+
logs_query = db.query(models.AttendanceLog)
|
| 261 |
+
if company_id is not None:
|
| 262 |
+
logs_query = logs_query.join(models.Employee).filter(models.Employee.company_id == company_id)
|
| 263 |
+
|
| 264 |
+
total_scans = logs_query.count()
|
| 265 |
+
spoofs = logs_query.filter(models.AttendanceLog.is_spoof == True).count()
|
| 266 |
+
|
| 267 |
+
avg_confidence = db.query(func.avg(models.AttendanceLog.confidence))
|
| 268 |
+
if company_id is not None:
|
| 269 |
+
avg_confidence = avg_confidence.join(models.Employee).filter(models.Employee.company_id == company_id)
|
| 270 |
+
avg_confidence_val = avg_confidence.scalar() or 0.0
|
| 271 |
+
|
| 272 |
+
avg_proc_time = db.query(func.avg(models.AttendanceLog.processing_time_ms))
|
| 273 |
+
if company_id is not None:
|
| 274 |
+
avg_proc_time = avg_proc_time.join(models.Employee).filter(models.Employee.company_id == company_id)
|
| 275 |
+
avg_proc_val = avg_proc_time.scalar() or 120.0 # fallback baseline ms
|
| 276 |
+
|
| 277 |
+
return {
|
| 278 |
+
"total_scans": total_scans,
|
| 279 |
+
"spoof_attempts": spoofs,
|
| 280 |
+
"average_confidence": round(float(avg_confidence_val), 2),
|
| 281 |
+
"average_processing_time_ms": round(float(avg_proc_val), 1)
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
@router.get("/occupancy")
|
| 285 |
+
def get_office_occupancy(
|
| 286 |
+
db: Session = Depends(get_db),
|
| 287 |
+
current_user: models.User = Depends(checker_view)
|
| 288 |
+
):
|
| 289 |
+
company_id = current_user.company_id
|
| 290 |
+
today = date.today()
|
| 291 |
+
|
| 292 |
+
active_emp_query = db.query(models.Employee).filter(models.Employee.status == "Active")
|
| 293 |
+
if company_id is not None:
|
| 294 |
+
active_emp_query = active_emp_query.filter(models.Employee.company_id == company_id)
|
| 295 |
+
total_strength = active_emp_query.count()
|
| 296 |
+
|
| 297 |
+
present_query = db.query(models.Attendance).join(models.Employee).filter(
|
| 298 |
+
and_(
|
| 299 |
+
models.Attendance.date == today,
|
| 300 |
+
models.Attendance.check_in.isnot(None),
|
| 301 |
+
models.Attendance.check_out.is_(None)
|
| 302 |
+
)
|
| 303 |
+
)
|
| 304 |
+
if company_id is not None:
|
| 305 |
+
present_query = present_query.filter(models.Employee.company_id == company_id)
|
| 306 |
+
|
| 307 |
+
occupied_count = present_query.count()
|
| 308 |
+
|
| 309 |
+
return {
|
| 310 |
+
"total_strength": total_strength,
|
| 311 |
+
"occupied_count": occupied_count,
|
| 312 |
+
"occupancy_rate_percentage": round((occupied_count / total_strength * 100) if total_strength > 0 else 0.0, 1)
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
@router.get("/late-trends")
|
| 316 |
+
def get_late_trends(
|
| 317 |
+
db: Session = Depends(get_db),
|
| 318 |
+
current_user: models.User = Depends(checker_view)
|
| 319 |
+
):
|
| 320 |
+
company_id = current_user.company_id
|
| 321 |
+
today = date.today()
|
| 322 |
+
start_date = today - timedelta(days=30)
|
| 323 |
+
|
| 324 |
+
query = db.query(
|
| 325 |
+
models.Attendance.date,
|
| 326 |
+
func.avg(models.Attendance.late_minutes)
|
| 327 |
+
).join(models.Employee).filter(
|
| 328 |
+
and_(
|
| 329 |
+
models.Attendance.date >= start_date,
|
| 330 |
+
models.Attendance.late_minutes > 0
|
| 331 |
+
)
|
| 332 |
+
)
|
| 333 |
+
if company_id is not None:
|
| 334 |
+
query = query.filter(models.Employee.company_id == company_id)
|
| 335 |
+
|
| 336 |
+
results = query.group_by(models.Attendance.date).order_by(models.Attendance.date.asc()).all()
|
| 337 |
+
|
| 338 |
+
return [{"date": r[0].isoformat(), "average_late_minutes": round(float(r[1]), 1)} for r in results]
|
backend/app/api/v1/attendance.py
CHANGED
|
@@ -20,22 +20,24 @@ def read_daily_attendance(
|
|
| 20 |
date_val: Optional[date] = None,
|
| 21 |
employee_id: Optional[int] = None,
|
| 22 |
department_id: Optional[int] = None,
|
|
|
|
| 23 |
db: Session = Depends(get_db),
|
| 24 |
current_user: models.User = Depends(checker_view)
|
| 25 |
):
|
|
|
|
| 26 |
if not date_val:
|
| 27 |
date_val = date.today()
|
| 28 |
if department_id:
|
| 29 |
dept = crud.get_department_by_id(db, department_id)
|
| 30 |
-
if not dept or (
|
| 31 |
raise HTTPException(status_code=404, detail="Department not found")
|
| 32 |
if employee_id:
|
| 33 |
emp = crud.get_employee_by_id(db, employee_id)
|
| 34 |
-
if not emp or (
|
| 35 |
raise HTTPException(status_code=404, detail="Employee not found")
|
| 36 |
|
| 37 |
return crud.get_daily_attendance(
|
| 38 |
-
db, date_val=date_val, employee_id=employee_id, department_id=department_id, company_id=
|
| 39 |
)
|
| 40 |
|
| 41 |
@router.put("/{id}", response_model=schemas.AttendanceOut)
|
|
@@ -163,10 +165,18 @@ def read_attendance_logs(
|
|
| 163 |
limit: int = 100,
|
| 164 |
employee_id: Optional[int] = None,
|
| 165 |
date_str: Optional[str] = None, # YYYY-MM-DD
|
|
|
|
| 166 |
db: Session = Depends(get_db),
|
| 167 |
-
current_user: models.User = Depends(
|
| 168 |
):
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
@router.get("/employee/{employee_id}", response_model=List[schemas.AttendanceOut])
|
| 172 |
def get_employee_attendance_history(
|
|
|
|
| 20 |
date_val: Optional[date] = None,
|
| 21 |
employee_id: Optional[int] = None,
|
| 22 |
department_id: Optional[int] = None,
|
| 23 |
+
company_id: Optional[int] = None,
|
| 24 |
db: Session = Depends(get_db),
|
| 25 |
current_user: models.User = Depends(checker_view)
|
| 26 |
):
|
| 27 |
+
target_company_id = current_user.company_id if current_user.company_id is not None else company_id
|
| 28 |
if not date_val:
|
| 29 |
date_val = date.today()
|
| 30 |
if department_id:
|
| 31 |
dept = crud.get_department_by_id(db, department_id)
|
| 32 |
+
if not dept or (target_company_id is not None and dept.company_id != target_company_id):
|
| 33 |
raise HTTPException(status_code=404, detail="Department not found")
|
| 34 |
if employee_id:
|
| 35 |
emp = crud.get_employee_by_id(db, employee_id)
|
| 36 |
+
if not emp or (target_company_id is not None and emp.company_id != target_company_id):
|
| 37 |
raise HTTPException(status_code=404, detail="Employee not found")
|
| 38 |
|
| 39 |
return crud.get_daily_attendance(
|
| 40 |
+
db, date_val=date_val, employee_id=employee_id, department_id=department_id, company_id=target_company_id
|
| 41 |
)
|
| 42 |
|
| 43 |
@router.put("/{id}", response_model=schemas.AttendanceOut)
|
|
|
|
| 165 |
limit: int = 100,
|
| 166 |
employee_id: Optional[int] = None,
|
| 167 |
date_str: Optional[str] = None, # YYYY-MM-DD
|
| 168 |
+
company_id: Optional[int] = None,
|
| 169 |
db: Session = Depends(get_db),
|
| 170 |
+
current_user: models.User = Depends(security.get_current_user)
|
| 171 |
):
|
| 172 |
+
role_name = current_user.role.name if current_user.role else "Employee"
|
| 173 |
+
if role_name == "Employee":
|
| 174 |
+
if not current_user.employee:
|
| 175 |
+
return []
|
| 176 |
+
employee_id = current_user.employee.id
|
| 177 |
+
|
| 178 |
+
target_company_id = current_user.company_id if current_user.company_id is not None else company_id
|
| 179 |
+
return crud.get_attendance_logs(db, company_id=target_company_id, skip=skip, limit=limit, employee_id=employee_id, date_str=date_str)
|
| 180 |
|
| 181 |
@router.get("/employee/{employee_id}", response_model=List[schemas.AttendanceOut])
|
| 182 |
def get_employee_attendance_history(
|
backend/app/api/v1/auth.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from fastapi.security import OAuth2PasswordRequestForm
|
| 3 |
from sqlalchemy.orm import Session
|
|
|
|
| 4 |
from datetime import timedelta
|
| 5 |
from jose import jwt, JWTError
|
| 6 |
|
|
@@ -11,9 +12,11 @@ from app.crud import crud
|
|
| 11 |
from app.schemas import schemas
|
| 12 |
from app.models import models
|
| 13 |
|
|
|
|
|
|
|
| 14 |
router = APIRouter()
|
| 15 |
|
| 16 |
-
@router.post("/login", response_model=schemas.Token)
|
| 17 |
def login(
|
| 18 |
request: Request,
|
| 19 |
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
@@ -35,6 +38,12 @@ def login(
|
|
| 35 |
detail="Incorrect email or password",
|
| 36 |
)
|
| 37 |
if not user.is_active:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
raise HTTPException(
|
| 39 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 40 |
detail="Inactive user account"
|
|
@@ -109,3 +118,196 @@ def read_users_me(
|
|
| 109 |
current_user: models.User = Depends(security.get_current_user)
|
| 110 |
):
|
| 111 |
return current_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form
|
| 2 |
from fastapi.security import OAuth2PasswordRequestForm
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
+
from sqlalchemy import select, and_
|
| 5 |
from datetime import timedelta
|
| 6 |
from jose import jwt, JWTError
|
| 7 |
|
|
|
|
| 12 |
from app.schemas import schemas
|
| 13 |
from app.models import models
|
| 14 |
|
| 15 |
+
from app.core.rate_limiter import check_login_rate_limit
|
| 16 |
+
|
| 17 |
router = APIRouter()
|
| 18 |
|
| 19 |
+
@router.post("/login", response_model=schemas.Token, dependencies=[Depends(check_login_rate_limit)])
|
| 20 |
def login(
|
| 21 |
request: Request,
|
| 22 |
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
|
|
| 38 |
detail="Incorrect email or password",
|
| 39 |
)
|
| 40 |
if not user.is_active:
|
| 41 |
+
role_name = user.role.name if user.role else "Employee"
|
| 42 |
+
if role_name in ["Admin", "HR"]:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 45 |
+
detail="Your admin account is pending approval by the Super Admin."
|
| 46 |
+
)
|
| 47 |
raise HTTPException(
|
| 48 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 49 |
detail="Inactive user account"
|
|
|
|
| 118 |
current_user: models.User = Depends(security.get_current_user)
|
| 119 |
):
|
| 120 |
return current_user
|
| 121 |
+
|
| 122 |
+
@router.post("/register-admin", status_code=status.HTTP_201_CREATED)
|
| 123 |
+
def register_admin(
|
| 124 |
+
payload: schemas.AdminRegister,
|
| 125 |
+
db: Session = Depends(get_db)
|
| 126 |
+
):
|
| 127 |
+
# 1. Check if company name already exists
|
| 128 |
+
existing_company = crud.get_company_by_name(db, name=payload.company_name)
|
| 129 |
+
if existing_company:
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 132 |
+
detail="Company name already registered"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# 2. Check if email already exists
|
| 136 |
+
existing_user = crud.get_user_by_email(db, email=payload.email)
|
| 137 |
+
if existing_user:
|
| 138 |
+
raise HTTPException(
|
| 139 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 140 |
+
detail="Email address already registered"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# 3. Create company with "Pending Approval" status
|
| 144 |
+
company_create = schemas.CompanyCreate(
|
| 145 |
+
name=payload.company_name,
|
| 146 |
+
status="Pending Approval",
|
| 147 |
+
admin_email=payload.email,
|
| 148 |
+
phone=payload.phone,
|
| 149 |
+
address=payload.address,
|
| 150 |
+
max_employees=100,
|
| 151 |
+
available_tokens=1000
|
| 152 |
+
)
|
| 153 |
+
db_company = crud.create_company(db, company=company_create)
|
| 154 |
+
|
| 155 |
+
# 4. Get Admin role
|
| 156 |
+
admin_role = crud.get_role_by_name(db, name="Admin")
|
| 157 |
+
if not admin_role:
|
| 158 |
+
raise HTTPException(
|
| 159 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 160 |
+
detail="Default Admin role not configured in the system"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# 5. Create user linked to the company
|
| 164 |
+
user_create = schemas.UserCreate(
|
| 165 |
+
email=payload.email,
|
| 166 |
+
password=payload.password,
|
| 167 |
+
role_id=admin_role.id
|
| 168 |
+
)
|
| 169 |
+
crud.create_user(db, user=user_create, company_id=db_company.id)
|
| 170 |
+
|
| 171 |
+
# 6. Ensure user account starts as Inactive / Pending Approval
|
| 172 |
+
db_user = crud.get_user_by_email(db, email=payload.email)
|
| 173 |
+
if db_user:
|
| 174 |
+
db_user.is_active = False
|
| 175 |
+
db.commit()
|
| 176 |
+
|
| 177 |
+
return {"message": "Registration successful. Your account is pending approval by the Super Admin."}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.get("/users", response_model=list[schemas.UserOut])
|
| 181 |
+
def get_all_users(
|
| 182 |
+
db: Session = Depends(get_db),
|
| 183 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin"]))
|
| 184 |
+
):
|
| 185 |
+
from sqlalchemy import select
|
| 186 |
+
return db.execute(select(models.User)).scalars().all()
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.put("/users/{user_id}", response_model=schemas.UserOut)
|
| 190 |
+
def update_user_status(
|
| 191 |
+
user_id: int,
|
| 192 |
+
payload: schemas.UserUpdate,
|
| 193 |
+
db: Session = Depends(get_db),
|
| 194 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin"]))
|
| 195 |
+
):
|
| 196 |
+
db_user = db.get(models.User, user_id)
|
| 197 |
+
if not db_user:
|
| 198 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 199 |
+
if payload.is_active is not None:
|
| 200 |
+
db_user.is_active = payload.is_active
|
| 201 |
+
if payload.role_id is not None:
|
| 202 |
+
db_user.role_id = payload.role_id
|
| 203 |
+
db.commit()
|
| 204 |
+
db.refresh(db_user)
|
| 205 |
+
return db_user
|
| 206 |
+
|
| 207 |
+
@router.get("/companies/check")
|
| 208 |
+
def check_company_name(name: str, db: Session = Depends(get_db)):
|
| 209 |
+
from sqlalchemy import func
|
| 210 |
+
company = db.execute(
|
| 211 |
+
select(models.Company).where(func.lower(models.Company.name) == name.strip().lower())
|
| 212 |
+
).scalar_one_or_none()
|
| 213 |
+
if not company:
|
| 214 |
+
raise HTTPException(status_code=404, detail="Company not found")
|
| 215 |
+
if company.status != "Active":
|
| 216 |
+
raise HTTPException(status_code=400, detail=f"Company status is '{company.status}'. Please contact support.")
|
| 217 |
+
return {"id": company.id, "name": company.name, "status": company.status}
|
| 218 |
+
|
| 219 |
+
@router.post("/register-pending", status_code=status.HTTP_201_CREATED)
|
| 220 |
+
def register_pending_employee(
|
| 221 |
+
payload: schemas.EmployeeRegister,
|
| 222 |
+
db: Session = Depends(get_db)
|
| 223 |
+
):
|
| 224 |
+
company = crud.get_company_by_id(db, company_id=payload.company_id)
|
| 225 |
+
if not company:
|
| 226 |
+
raise HTTPException(status_code=404, detail="Company not found")
|
| 227 |
+
|
| 228 |
+
existing_emp_email = db.execute(
|
| 229 |
+
select(models.Employee).where(models.Employee.email == payload.email)
|
| 230 |
+
).scalar_one_or_none()
|
| 231 |
+
if existing_emp_email:
|
| 232 |
+
raise HTTPException(status_code=400, detail="Employee email already exists")
|
| 233 |
+
|
| 234 |
+
existing_emp_id = db.execute(
|
| 235 |
+
select(models.Employee).where(
|
| 236 |
+
and_(
|
| 237 |
+
models.Employee.employee_id == payload.employee_id,
|
| 238 |
+
models.Employee.company_id == payload.company_id
|
| 239 |
+
)
|
| 240 |
+
)
|
| 241 |
+
).scalar_one_or_none()
|
| 242 |
+
if existing_emp_id:
|
| 243 |
+
raise HTTPException(status_code=400, detail="Employee ID already registered under this company")
|
| 244 |
+
|
| 245 |
+
existing_user = crud.get_user_by_email(db, email=payload.email)
|
| 246 |
+
if existing_user:
|
| 247 |
+
raise HTTPException(status_code=400, detail="User account with this email already exists")
|
| 248 |
+
|
| 249 |
+
role = db.execute(select(models.Role).where(models.Role.name == "Employee")).scalar_one_or_none()
|
| 250 |
+
if not role:
|
| 251 |
+
raise HTTPException(status_code=500, detail="Employee role not found in system database")
|
| 252 |
+
|
| 253 |
+
user_create = schemas.UserCreate(
|
| 254 |
+
email=payload.email,
|
| 255 |
+
password=payload.password,
|
| 256 |
+
role_id=role.id
|
| 257 |
+
)
|
| 258 |
+
db_user = crud.create_user(db, user=user_create, company_id=payload.company_id)
|
| 259 |
+
db_user.is_active = False
|
| 260 |
+
db.commit()
|
| 261 |
+
|
| 262 |
+
db_emp = models.Employee(
|
| 263 |
+
employee_id=payload.employee_id,
|
| 264 |
+
name=payload.name,
|
| 265 |
+
email=payload.email,
|
| 266 |
+
phone=payload.phone,
|
| 267 |
+
designation=payload.designation,
|
| 268 |
+
status="Pending Approval",
|
| 269 |
+
user_id=db_user.id,
|
| 270 |
+
company_id=payload.company_id
|
| 271 |
+
)
|
| 272 |
+
db.add(db_emp)
|
| 273 |
+
db.commit()
|
| 274 |
+
db.refresh(db_emp)
|
| 275 |
+
|
| 276 |
+
return {
|
| 277 |
+
"message": "Registration successful. Please complete your facial scans next.",
|
| 278 |
+
"employee_id": db_emp.id,
|
| 279 |
+
"employee_uuid": db_emp.employee_id
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
@router.post("/self-onboard/upload")
|
| 283 |
+
async def self_onboard_upload(
|
| 284 |
+
request: Request,
|
| 285 |
+
employee_id: int = Form(...),
|
| 286 |
+
pose_type: str = Form(...),
|
| 287 |
+
file: UploadFile = File(...),
|
| 288 |
+
db: Session = Depends(get_db)
|
| 289 |
+
):
|
| 290 |
+
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 291 |
+
if not employee:
|
| 292 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 293 |
+
|
| 294 |
+
if employee.status != "Pending Approval":
|
| 295 |
+
raise HTTPException(status_code=403, detail="Biometric enrollment is locked for active accounts. Please log in.")
|
| 296 |
+
|
| 297 |
+
try:
|
| 298 |
+
contents = await file.read()
|
| 299 |
+
if not contents:
|
| 300 |
+
raise HTTPException(status_code=400, detail="Received empty file. No image data was sent.")
|
| 301 |
+
except Exception as e:
|
| 302 |
+
raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}")
|
| 303 |
+
|
| 304 |
+
from app.api.v1.enrollment import enroll_employee_face_pose
|
| 305 |
+
return enroll_employee_face_pose(
|
| 306 |
+
db=db,
|
| 307 |
+
employee=employee,
|
| 308 |
+
pose_type=pose_type,
|
| 309 |
+
contents=contents,
|
| 310 |
+
ip_address=request.client.host if request.client else None,
|
| 311 |
+
user_agent=request.headers.get("user-agent"),
|
| 312 |
+
creator_user_id=None
|
| 313 |
+
)
|
backend/app/api/v1/companies.py
CHANGED
|
@@ -45,6 +45,26 @@ def create_company(
|
|
| 45 |
raise HTTPException(status_code=400, detail="Company name already exists")
|
| 46 |
|
| 47 |
db_company = crud.create_company(db, company=company)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
crud.create_audit_log(
|
| 49 |
db=db,
|
| 50 |
user_id=current_user.id,
|
|
@@ -70,6 +90,17 @@ def update_company(
|
|
| 70 |
old_status = db_company.status
|
| 71 |
updated = crud.update_company(db, company_id=id, company=company)
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
details = f"Updated company ID: {id}."
|
| 74 |
if company.status and company.status != old_status:
|
| 75 |
details += f" Status changed from '{old_status}' to '{company.status}'."
|
|
|
|
| 45 |
raise HTTPException(status_code=400, detail="Company name already exists")
|
| 46 |
|
| 47 |
db_company = crud.create_company(db, company=company)
|
| 48 |
+
|
| 49 |
+
# Create Company Admin User if email is provided
|
| 50 |
+
if company.admin_email:
|
| 51 |
+
existing_user = crud.get_user_by_email(db, company.admin_email)
|
| 52 |
+
if not existing_user:
|
| 53 |
+
admin_role = crud.get_role_by_name(db, "Admin")
|
| 54 |
+
if admin_role:
|
| 55 |
+
from app.core.security import get_password_hash
|
| 56 |
+
password_to_use = company.admin_password if company.admin_password else "Admin@NetraID2026"
|
| 57 |
+
hashed_pwd = get_password_hash(password_to_use)
|
| 58 |
+
new_admin = models.User(
|
| 59 |
+
email=company.admin_email,
|
| 60 |
+
hashed_password=hashed_pwd,
|
| 61 |
+
role_id=admin_role.id,
|
| 62 |
+
company_id=db_company.id,
|
| 63 |
+
is_active=True if company.status == "Active" else False
|
| 64 |
+
)
|
| 65 |
+
db.add(new_admin)
|
| 66 |
+
db.commit()
|
| 67 |
+
|
| 68 |
crud.create_audit_log(
|
| 69 |
db=db,
|
| 70 |
user_id=current_user.id,
|
|
|
|
| 90 |
old_status = db_company.status
|
| 91 |
updated = crud.update_company(db, company_id=id, company=company)
|
| 92 |
|
| 93 |
+
# Auto-activate administrators if company is marked Active
|
| 94 |
+
if company.status == "Active" and old_status != "Active":
|
| 95 |
+
from sqlalchemy import select
|
| 96 |
+
users_to_activate = db.execute(
|
| 97 |
+
select(models.User).where(models.User.company_id == id)
|
| 98 |
+
).scalars().all()
|
| 99 |
+
for u in users_to_activate:
|
| 100 |
+
if u.role and u.role.name in ["Admin", "HR"]:
|
| 101 |
+
u.is_active = True
|
| 102 |
+
db.commit()
|
| 103 |
+
|
| 104 |
details = f"Updated company ID: {id}."
|
| 105 |
if company.status and company.status != old_status:
|
| 106 |
details += f" Status changed from '{old_status}' to '{company.status}'."
|
backend/app/api/v1/departments.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
-
from typing import List
|
| 4 |
|
| 5 |
from app.core.database import get_db
|
| 6 |
from app.core import security
|
|
@@ -18,10 +18,12 @@ checker_manage = security.RoleChecker(["Super Admin", "Admin"])
|
|
| 18 |
def read_departments(
|
| 19 |
skip: int = 0,
|
| 20 |
limit: int = 100,
|
|
|
|
| 21 |
db: Session = Depends(get_db),
|
| 22 |
current_user: models.User = Depends(checker_view)
|
| 23 |
):
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
@router.get("/{id}", response_model=schemas.DepartmentOut)
|
| 27 |
def read_department(
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
|
| 5 |
from app.core.database import get_db
|
| 6 |
from app.core import security
|
|
|
|
| 18 |
def read_departments(
|
| 19 |
skip: int = 0,
|
| 20 |
limit: int = 100,
|
| 21 |
+
company_id: Optional[int] = None,
|
| 22 |
db: Session = Depends(get_db),
|
| 23 |
current_user: models.User = Depends(checker_view)
|
| 24 |
):
|
| 25 |
+
target_company_id = current_user.company_id if current_user.company_id is not None else company_id
|
| 26 |
+
return crud.get_departments(db, company_id=target_company_id, skip=skip, limit=limit)
|
| 27 |
|
| 28 |
@router.get("/{id}", response_model=schemas.DepartmentOut)
|
| 29 |
def read_department(
|
backend/app/api/v1/devices.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core import security
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
from app.schemas import schemas
|
| 9 |
+
from app.models import models
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
checker_staff = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 13 |
+
|
| 14 |
+
@router.get("/", response_model=List[schemas.DeviceOut])
|
| 15 |
+
def read_devices(
|
| 16 |
+
company_id: Optional[int] = None,
|
| 17 |
+
db: Session = Depends(get_db),
|
| 18 |
+
current_user: models.User = Depends(checker_staff)
|
| 19 |
+
):
|
| 20 |
+
role_name = current_user.role.name if current_user.role else "Employee"
|
| 21 |
+
target_company_id = current_user.company_id if current_user.company_id is not None else company_id
|
| 22 |
+
if role_name == "Super Admin" and target_company_id is None:
|
| 23 |
+
return crud.get_devices(db)
|
| 24 |
+
return crud.get_devices(db, company_id=target_company_id)
|
| 25 |
+
|
| 26 |
+
@router.post("/", response_model=schemas.DeviceOut, status_code=status.HTTP_201_CREATED)
|
| 27 |
+
def register_device(
|
| 28 |
+
request: Request,
|
| 29 |
+
device: schemas.DeviceCreate,
|
| 30 |
+
db: Session = Depends(get_db),
|
| 31 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin"]))
|
| 32 |
+
):
|
| 33 |
+
company_id = current_user.company_id
|
| 34 |
+
db_device = crud.create_device(db, device=device, company_id=company_id)
|
| 35 |
+
|
| 36 |
+
crud.create_audit_log(
|
| 37 |
+
db=db,
|
| 38 |
+
user_id=current_user.id,
|
| 39 |
+
action="Register Kiosk Device",
|
| 40 |
+
ip_address=request.client.host if request.client else None,
|
| 41 |
+
user_agent=request.headers.get("user-agent"),
|
| 42 |
+
details=f"Registered kiosk device '{device.name}' in branch '{device.branch}'",
|
| 43 |
+
company_id=company_id
|
| 44 |
+
)
|
| 45 |
+
return db_device
|
| 46 |
+
|
| 47 |
+
@router.put("/{id}", response_model=schemas.DeviceOut)
|
| 48 |
+
def update_device_metrics(
|
| 49 |
+
id: int,
|
| 50 |
+
payload: schemas.DeviceUpdate,
|
| 51 |
+
db: Session = Depends(get_db),
|
| 52 |
+
current_user: models.User = Depends(security.get_current_user)
|
| 53 |
+
):
|
| 54 |
+
db_device = crud.get_device_by_id(db, device_id=id)
|
| 55 |
+
if not db_device:
|
| 56 |
+
raise HTTPException(status_code=404, detail="Device not found")
|
| 57 |
+
|
| 58 |
+
# Check permissions
|
| 59 |
+
if current_user.role.name != "Super Admin" and db_device.company_id != current_user.company_id:
|
| 60 |
+
raise HTTPException(status_code=403, detail="Not authorized to configure this device")
|
| 61 |
+
|
| 62 |
+
updated = crud.update_device(db, device_id=id, device_update=payload)
|
| 63 |
+
return updated
|
backend/app/api/v1/employees.py
CHANGED
|
@@ -25,11 +25,13 @@ def read_employees(
|
|
| 25 |
search: Optional[str] = None,
|
| 26 |
department_id: Optional[int] = None,
|
| 27 |
status: Optional[str] = None,
|
|
|
|
| 28 |
db: Session = Depends(get_db),
|
| 29 |
current_user: models.User = Depends(checker_view)
|
| 30 |
):
|
|
|
|
| 31 |
return crud.get_employees(
|
| 32 |
-
db, company_id=
|
| 33 |
)
|
| 34 |
|
| 35 |
@router.get("/count")
|
|
@@ -37,12 +39,107 @@ def get_employee_count(
|
|
| 37 |
search: Optional[str] = None,
|
| 38 |
department_id: Optional[int] = None,
|
| 39 |
status: Optional[str] = None,
|
|
|
|
| 40 |
db: Session = Depends(get_db),
|
| 41 |
current_user: models.User = Depends(checker_view)
|
| 42 |
):
|
| 43 |
-
|
|
|
|
| 44 |
return {"count": count}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
@router.get("/{id}", response_model=schemas.EmployeeOut)
|
| 47 |
def read_employee(
|
| 48 |
id: int,
|
|
@@ -152,6 +249,13 @@ def update_employee(
|
|
| 152 |
updated = crud.update_employee(db, id=id, emp=emp)
|
| 153 |
if not updated:
|
| 154 |
raise HTTPException(status_code=404, detail="Employee not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
crud.create_audit_log(
|
| 157 |
db=db,
|
|
@@ -338,3 +442,4 @@ async def upload_avatar(
|
|
| 338 |
)
|
| 339 |
|
| 340 |
return {"message": "Avatar uploaded successfully"}
|
|
|
|
|
|
| 25 |
search: Optional[str] = None,
|
| 26 |
department_id: Optional[int] = None,
|
| 27 |
status: Optional[str] = None,
|
| 28 |
+
company_id: Optional[int] = None,
|
| 29 |
db: Session = Depends(get_db),
|
| 30 |
current_user: models.User = Depends(checker_view)
|
| 31 |
):
|
| 32 |
+
target_company_id = current_user.company_id if current_user.company_id is not None else company_id
|
| 33 |
return crud.get_employees(
|
| 34 |
+
db, company_id=target_company_id, skip=skip, limit=limit, search=search, department_id=department_id, status=status
|
| 35 |
)
|
| 36 |
|
| 37 |
@router.get("/count")
|
|
|
|
| 39 |
search: Optional[str] = None,
|
| 40 |
department_id: Optional[int] = None,
|
| 41 |
status: Optional[str] = None,
|
| 42 |
+
company_id: Optional[int] = None,
|
| 43 |
db: Session = Depends(get_db),
|
| 44 |
current_user: models.User = Depends(checker_view)
|
| 45 |
):
|
| 46 |
+
target_company_id = current_user.company_id if current_user.company_id is not None else company_id
|
| 47 |
+
count = crud.count_employees(db, company_id=target_company_id, search=search, department_id=department_id, status=status)
|
| 48 |
return {"count": count}
|
| 49 |
|
| 50 |
+
|
| 51 |
+
# --- Leave Requests Endpoints ---
|
| 52 |
+
|
| 53 |
+
@router.get("/leaves", response_model=List[schemas.LeaveRequestOut])
|
| 54 |
+
def list_leaves(
|
| 55 |
+
employee_id: Optional[int] = None,
|
| 56 |
+
db: Session = Depends(get_db),
|
| 57 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR", "Employee"]))
|
| 58 |
+
):
|
| 59 |
+
if current_user.role.name == "Employee":
|
| 60 |
+
if not current_user.employee:
|
| 61 |
+
raise HTTPException(status_code=400, detail="User is not linked to an employee profile")
|
| 62 |
+
target_employee_id = current_user.employee.id
|
| 63 |
+
else:
|
| 64 |
+
target_employee_id = employee_id
|
| 65 |
+
|
| 66 |
+
if target_employee_id:
|
| 67 |
+
emp = crud.get_employee_by_id(db, target_employee_id)
|
| 68 |
+
if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id):
|
| 69 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 70 |
+
return crud.get_leave_requests(db, employee_id=target_employee_id)
|
| 71 |
+
|
| 72 |
+
leaves = crud.get_leave_requests(db)
|
| 73 |
+
if current_user.company_id is not None:
|
| 74 |
+
leaves = [l for l in leaves if l.employee and l.employee.company_id == current_user.company_id]
|
| 75 |
+
return leaves
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.post("/leaves", response_model=schemas.LeaveRequestOut, status_code=status.HTTP_201_CREATED)
|
| 79 |
+
def apply_leave(
|
| 80 |
+
req: schemas.LeaveRequestCreate,
|
| 81 |
+
db: Session = Depends(get_db),
|
| 82 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR", "Employee"]))
|
| 83 |
+
):
|
| 84 |
+
if current_user.role.name == "Employee":
|
| 85 |
+
if not current_user.employee:
|
| 86 |
+
raise HTTPException(status_code=400, detail="User is not linked to an employee profile")
|
| 87 |
+
if req.employee_id != current_user.employee.id:
|
| 88 |
+
raise HTTPException(status_code=403, detail="You can only apply leave for yourself")
|
| 89 |
+
else:
|
| 90 |
+
emp = crud.get_employee_by_id(db, req.employee_id)
|
| 91 |
+
if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id):
|
| 92 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 93 |
+
|
| 94 |
+
return crud.create_leave_request(db, req, employee_id=req.employee_id)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.put("/leaves/{id}", response_model=schemas.LeaveRequestOut)
|
| 98 |
+
def update_leave(
|
| 99 |
+
id: int,
|
| 100 |
+
data: schemas.LeaveRequestUpdate,
|
| 101 |
+
db: Session = Depends(get_db),
|
| 102 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR"]))
|
| 103 |
+
):
|
| 104 |
+
db_req = db.get(models.LeaveRequest, id)
|
| 105 |
+
if not db_req:
|
| 106 |
+
raise HTTPException(status_code=404, detail="Leave request not found")
|
| 107 |
+
|
| 108 |
+
emp = crud.get_employee_by_id(db, db_req.employee_id)
|
| 109 |
+
if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id):
|
| 110 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 111 |
+
|
| 112 |
+
updated = crud.update_leave_status(db, id=id, status=data.status, admin_user_id=current_user.id)
|
| 113 |
+
if not updated:
|
| 114 |
+
raise HTTPException(status_code=404, detail="Leave request not found")
|
| 115 |
+
return updated
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.delete("/leaves/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 119 |
+
def delete_leave(
|
| 120 |
+
id: int,
|
| 121 |
+
db: Session = Depends(get_db),
|
| 122 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR", "Employee"]))
|
| 123 |
+
):
|
| 124 |
+
db_req = db.get(models.LeaveRequest, id)
|
| 125 |
+
if not db_req:
|
| 126 |
+
raise HTTPException(status_code=404, detail="Leave request not found")
|
| 127 |
+
|
| 128 |
+
if current_user.role.name == "Employee":
|
| 129 |
+
if not current_user.employee or db_req.employee_id != current_user.employee.id:
|
| 130 |
+
raise HTTPException(status_code=403, detail="You can only withdraw your own leave requests")
|
| 131 |
+
if db_req.status != "Pending":
|
| 132 |
+
raise HTTPException(status_code=400, detail="You can only withdraw pending leave requests")
|
| 133 |
+
else:
|
| 134 |
+
emp = crud.get_employee_by_id(db, db_req.employee_id)
|
| 135 |
+
if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id):
|
| 136 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 137 |
+
|
| 138 |
+
db.delete(db_req)
|
| 139 |
+
db.commit()
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
@router.get("/{id}", response_model=schemas.EmployeeOut)
|
| 144 |
def read_employee(
|
| 145 |
id: int,
|
|
|
|
| 249 |
updated = crud.update_employee(db, id=id, emp=emp)
|
| 250 |
if not updated:
|
| 251 |
raise HTTPException(status_code=404, detail="Employee not found")
|
| 252 |
+
|
| 253 |
+
if emp.status is not None and db_emp.user_id:
|
| 254 |
+
db_user = db.get(models.User, db_emp.user_id)
|
| 255 |
+
if db_user:
|
| 256 |
+
db_user.is_active = (emp.status == "Active")
|
| 257 |
+
db.add(db_user)
|
| 258 |
+
db.commit()
|
| 259 |
|
| 260 |
crud.create_audit_log(
|
| 261 |
db=db,
|
|
|
|
| 442 |
)
|
| 443 |
|
| 444 |
return {"message": "Avatar uploaded successfully"}
|
| 445 |
+
|
backend/app/api/v1/enrollment.py
CHANGED
|
@@ -18,42 +18,20 @@ router = APIRouter()
|
|
| 18 |
|
| 19 |
checker_manage = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
):
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
if
|
| 33 |
-
|
| 34 |
-
emp_ids = [e.id for e in all_emps]
|
| 35 |
-
emp_uuids = [e.employee_id for e in all_emps]
|
| 36 |
-
raise HTTPException(
|
| 37 |
-
status_code=404,
|
| 38 |
-
detail=f"Employee not found. Received employee_id: {employee_id} (type: {type(employee_id).__name__}). Existing PK IDs: {emp_ids}, String IDs: {emp_uuids}"
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
# Read file bytes
|
| 42 |
-
try:
|
| 43 |
-
contents = await file.read()
|
| 44 |
-
if not contents:
|
| 45 |
-
raise HTTPException(status_code=400, detail="Received empty file. No image data was sent.")
|
| 46 |
-
nparr = np.frombuffer(contents, np.uint8)
|
| 47 |
-
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 48 |
-
if img is None:
|
| 49 |
-
raise HTTPException(status_code=400, detail="OpenCV failed to decode the image. The format might be unsupported.")
|
| 50 |
-
except HTTPException as e:
|
| 51 |
-
raise e
|
| 52 |
-
except Exception as e:
|
| 53 |
-
logger.error(f"Image decode failed: {e}")
|
| 54 |
-
raise HTTPException(status_code=400, detail=f"Invalid image file format: {str(e)}")
|
| 55 |
|
| 56 |
-
# Detect faces
|
| 57 |
faces = face_engine.detect_faces(img)
|
| 58 |
if not faces:
|
| 59 |
logger.error("No face detected in the image.")
|
|
@@ -62,22 +40,18 @@ async def upload_face_image(
|
|
| 62 |
logger.error("Multiple faces detected in the image.")
|
| 63 |
raise HTTPException(status_code=400, detail="Multiple faces detected. Please ensure only one person is in the frame.")
|
| 64 |
|
| 65 |
-
# Process face
|
| 66 |
face = faces[0]
|
| 67 |
confidence = face["confidence"]
|
| 68 |
|
| 69 |
-
# Check if confidence is high enough
|
| 70 |
if confidence < 0.5:
|
| 71 |
logger.error(f"Face detection confidence too low: {confidence:.2f}")
|
| 72 |
raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.")
|
| 73 |
|
| 74 |
-
# Image Quality Validation
|
| 75 |
quality = face_engine.validate_image_quality(img)
|
| 76 |
if not quality["is_valid"]:
|
| 77 |
logger.error(f"Image quality validation failed: {quality['reason']}")
|
| 78 |
raise HTTPException(status_code=400, detail=f"Image Quality Error: {quality['reason']}")
|
| 79 |
|
| 80 |
-
# Optional liveness check on enrollment (preventing enroll spoofing)
|
| 81 |
liveness_enabled_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_CHECK")
|
| 82 |
liveness_enabled = liveness_enabled_setting.value.lower() == "true" if liveness_enabled_setting else True
|
| 83 |
|
|
@@ -86,13 +60,9 @@ async def upload_face_image(
|
|
| 86 |
|
| 87 |
liveness_score, is_live = face_engine.check_liveness(img, face["bbox"], threshold=liveness_threshold)
|
| 88 |
|
| 89 |
-
# In enrollment we want to prevent spoofing. However, liveness models are calibrated for direct frontal views.
|
| 90 |
-
# Profile/tilted views (left, right, up, down) often yield lower liveness scores and cause false rejections.
|
| 91 |
-
# Therefore, we strictly enforce liveness on the "front" pose only, and bypass it for other poses.
|
| 92 |
if liveness_enabled and not is_live and not face_engine.mock_mode:
|
| 93 |
if pose_type.strip().lower() == "front":
|
| 94 |
logger.warning(f"Liveness check failed ({liveness_score:.2f}) on FRONT pose. Bypassing for now.")
|
| 95 |
-
# raise HTTPException(status_code=400, detail=f"Liveness check failed ({liveness_score:.2f}). Please upload a real photo.")
|
| 96 |
else:
|
| 97 |
logger.warning(
|
| 98 |
f"Liveness check failed during enrollment for non-frontal pose '{pose_type}' "
|
|
@@ -100,32 +70,23 @@ async def upload_face_image(
|
|
| 100 |
f"Bypassing check to prevent false rejection."
|
| 101 |
)
|
| 102 |
|
| 103 |
-
# Align face (112x112)
|
| 104 |
aligned_face = face_engine.align_face(img, face["landmarks"])
|
| 105 |
-
|
| 106 |
-
# Generate 512-D embedding
|
| 107 |
embedding = face_engine.extract_embedding(aligned_face)
|
| 108 |
|
| 109 |
-
# Save image to disk
|
| 110 |
emp_upload_dir = os.path.join(settings.UPLOAD_DIR, str(employee.employee_id))
|
| 111 |
os.makedirs(emp_upload_dir, exist_ok=True)
|
| 112 |
|
| 113 |
-
# Save the raw uploaded photo (or aligned photo, raw photo is better for archive)
|
| 114 |
filename = f"{pose_type.replace(' ', '_').lower()}.jpg"
|
| 115 |
dest_path = os.path.join(emp_upload_dir, filename)
|
| 116 |
|
| 117 |
-
# Save the file (we compress/save as JPG)
|
| 118 |
cv2.imwrite(dest_path, img)
|
| 119 |
|
| 120 |
-
# Check if this pose already exists for the employee, delete it if it does
|
| 121 |
-
# (to allow re-enrolling a specific pose)
|
| 122 |
for existing_img in employee.images:
|
| 123 |
if existing_img.pose_type == pose_type:
|
| 124 |
db.delete(existing_img)
|
| 125 |
|
| 126 |
db.commit()
|
| 127 |
|
| 128 |
-
# Save EmployeeImage
|
| 129 |
db_img = crud.save_employee_image(
|
| 130 |
db=db,
|
| 131 |
employee_id=employee.id,
|
|
@@ -134,28 +95,27 @@ async def upload_face_image(
|
|
| 134 |
image_bytes=contents
|
| 135 |
)
|
| 136 |
|
| 137 |
-
# Save FaceEmbedding (convert numpy array to python list)
|
| 138 |
embedding_list = embedding.tolist()
|
| 139 |
-
|
| 140 |
db=db,
|
| 141 |
employee_id=employee.id,
|
| 142 |
image_id=db_img.id,
|
| 143 |
embedding=embedding_list
|
| 144 |
)
|
| 145 |
|
| 146 |
-
# Invalidate face engine embeddings cache
|
| 147 |
face_engine.invalidate_cache()
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
| 159 |
return {
|
| 160 |
"message": f"Successfully enrolled pose '{pose_type}' for employee {employee.name}",
|
| 161 |
"pose_type": pose_type,
|
|
@@ -164,19 +124,69 @@ async def upload_face_image(
|
|
| 164 |
"image_id": db_img.id
|
| 165 |
}
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
@router.get("/status/{employee_id}")
|
| 168 |
def get_enrollment_status(
|
|
|
|
| 169 |
employee_id: int,
|
| 170 |
-
db: Session = Depends(get_db)
|
| 171 |
-
current_user: models.User = Depends(checker_manage)
|
| 172 |
):
|
| 173 |
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 174 |
if not employee:
|
| 175 |
raise HTTPException(status_code=404, detail="Employee not found")
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
poses = [img.pose_type for img in employee.images]
|
| 178 |
|
| 179 |
-
# Required poses list
|
| 180 |
required_poses = [
|
| 181 |
"front", "left", "right", "up", "down",
|
| 182 |
"smile", "neutral", "indoor", "outdoor"
|
|
@@ -185,7 +195,8 @@ def get_enrollment_status(
|
|
| 185 |
missing_poses = [p for p in required_poses if p not in [x.lower() for x in poses]]
|
| 186 |
|
| 187 |
return {
|
| 188 |
-
"employee_id": employee.
|
|
|
|
| 189 |
"name": employee.name,
|
| 190 |
"total_enrolled": len(poses),
|
| 191 |
"enrolled_poses": poses,
|
|
@@ -218,3 +229,46 @@ def delete_all_enrollments(
|
|
| 218 |
)
|
| 219 |
|
| 220 |
return {"message": "All face enrollments and images cleared successfully"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
checker_manage = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 20 |
|
| 21 |
+
def enroll_employee_face_pose(
|
| 22 |
+
db: Session,
|
| 23 |
+
employee: models.Employee,
|
| 24 |
+
pose_type: str,
|
| 25 |
+
contents: bytes,
|
| 26 |
+
ip_address: str = None,
|
| 27 |
+
user_agent: str = None,
|
| 28 |
+
creator_user_id: int = None
|
| 29 |
):
|
| 30 |
+
nparr = np.frombuffer(contents, np.uint8)
|
| 31 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 32 |
+
if img is None:
|
| 33 |
+
raise HTTPException(status_code=400, detail="OpenCV failed to decode the image. The format might be unsupported.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
|
|
|
| 35 |
faces = face_engine.detect_faces(img)
|
| 36 |
if not faces:
|
| 37 |
logger.error("No face detected in the image.")
|
|
|
|
| 40 |
logger.error("Multiple faces detected in the image.")
|
| 41 |
raise HTTPException(status_code=400, detail="Multiple faces detected. Please ensure only one person is in the frame.")
|
| 42 |
|
|
|
|
| 43 |
face = faces[0]
|
| 44 |
confidence = face["confidence"]
|
| 45 |
|
|
|
|
| 46 |
if confidence < 0.5:
|
| 47 |
logger.error(f"Face detection confidence too low: {confidence:.2f}")
|
| 48 |
raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.")
|
| 49 |
|
|
|
|
| 50 |
quality = face_engine.validate_image_quality(img)
|
| 51 |
if not quality["is_valid"]:
|
| 52 |
logger.error(f"Image quality validation failed: {quality['reason']}")
|
| 53 |
raise HTTPException(status_code=400, detail=f"Image Quality Error: {quality['reason']}")
|
| 54 |
|
|
|
|
| 55 |
liveness_enabled_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_CHECK")
|
| 56 |
liveness_enabled = liveness_enabled_setting.value.lower() == "true" if liveness_enabled_setting else True
|
| 57 |
|
|
|
|
| 60 |
|
| 61 |
liveness_score, is_live = face_engine.check_liveness(img, face["bbox"], threshold=liveness_threshold)
|
| 62 |
|
|
|
|
|
|
|
|
|
|
| 63 |
if liveness_enabled and not is_live and not face_engine.mock_mode:
|
| 64 |
if pose_type.strip().lower() == "front":
|
| 65 |
logger.warning(f"Liveness check failed ({liveness_score:.2f}) on FRONT pose. Bypassing for now.")
|
|
|
|
| 66 |
else:
|
| 67 |
logger.warning(
|
| 68 |
f"Liveness check failed during enrollment for non-frontal pose '{pose_type}' "
|
|
|
|
| 70 |
f"Bypassing check to prevent false rejection."
|
| 71 |
)
|
| 72 |
|
|
|
|
| 73 |
aligned_face = face_engine.align_face(img, face["landmarks"])
|
|
|
|
|
|
|
| 74 |
embedding = face_engine.extract_embedding(aligned_face)
|
| 75 |
|
|
|
|
| 76 |
emp_upload_dir = os.path.join(settings.UPLOAD_DIR, str(employee.employee_id))
|
| 77 |
os.makedirs(emp_upload_dir, exist_ok=True)
|
| 78 |
|
|
|
|
| 79 |
filename = f"{pose_type.replace(' ', '_').lower()}.jpg"
|
| 80 |
dest_path = os.path.join(emp_upload_dir, filename)
|
| 81 |
|
|
|
|
| 82 |
cv2.imwrite(dest_path, img)
|
| 83 |
|
|
|
|
|
|
|
| 84 |
for existing_img in employee.images:
|
| 85 |
if existing_img.pose_type == pose_type:
|
| 86 |
db.delete(existing_img)
|
| 87 |
|
| 88 |
db.commit()
|
| 89 |
|
|
|
|
| 90 |
db_img = crud.save_employee_image(
|
| 91 |
db=db,
|
| 92 |
employee_id=employee.id,
|
|
|
|
| 95 |
image_bytes=contents
|
| 96 |
)
|
| 97 |
|
|
|
|
| 98 |
embedding_list = embedding.tolist()
|
| 99 |
+
crud.save_face_embedding(
|
| 100 |
db=db,
|
| 101 |
employee_id=employee.id,
|
| 102 |
image_id=db_img.id,
|
| 103 |
embedding=embedding_list
|
| 104 |
)
|
| 105 |
|
|
|
|
| 106 |
face_engine.invalidate_cache()
|
| 107 |
|
| 108 |
+
audit_user_id = creator_user_id or employee.user_id
|
| 109 |
+
if audit_user_id:
|
| 110 |
+
crud.create_audit_log(
|
| 111 |
+
db=db,
|
| 112 |
+
user_id=audit_user_id,
|
| 113 |
+
action="Enroll Face Pose",
|
| 114 |
+
ip_address=ip_address,
|
| 115 |
+
user_agent=user_agent,
|
| 116 |
+
details=f"Enrolled pose '{pose_type}' for employee ID: {employee.employee_id}"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
return {
|
| 120 |
"message": f"Successfully enrolled pose '{pose_type}' for employee {employee.name}",
|
| 121 |
"pose_type": pose_type,
|
|
|
|
| 124 |
"image_id": db_img.id
|
| 125 |
}
|
| 126 |
|
| 127 |
+
@router.post("/upload")
|
| 128 |
+
async def upload_face_image(
|
| 129 |
+
request: Request,
|
| 130 |
+
employee_id: int = Form(...),
|
| 131 |
+
pose_type: str = Form(...), # e.g., front, left, right, up, down, smile, neutral, glasses
|
| 132 |
+
file: UploadFile = File(...),
|
| 133 |
+
db: Session = Depends(get_db),
|
| 134 |
+
current_user: models.User = Depends(checker_manage)
|
| 135 |
+
):
|
| 136 |
+
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 137 |
+
if not employee:
|
| 138 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
contents = await file.read()
|
| 142 |
+
if not contents:
|
| 143 |
+
raise HTTPException(status_code=400, detail="Received empty file. No image data was sent.")
|
| 144 |
+
except Exception as e:
|
| 145 |
+
raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}")
|
| 146 |
+
|
| 147 |
+
return enroll_employee_face_pose(
|
| 148 |
+
db=db,
|
| 149 |
+
employee=employee,
|
| 150 |
+
pose_type=pose_type,
|
| 151 |
+
contents=contents,
|
| 152 |
+
ip_address=request.client.host if request.client else None,
|
| 153 |
+
user_agent=request.headers.get("user-agent"),
|
| 154 |
+
creator_user_id=current_user.id
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
@router.get("/status/{employee_id}")
|
| 158 |
def get_enrollment_status(
|
| 159 |
+
request: Request,
|
| 160 |
employee_id: int,
|
| 161 |
+
db: Session = Depends(get_db)
|
|
|
|
| 162 |
):
|
| 163 |
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 164 |
if not employee:
|
| 165 |
raise HTTPException(status_code=404, detail="Employee not found")
|
| 166 |
|
| 167 |
+
auth_header = request.headers.get("Authorization")
|
| 168 |
+
current_user = None
|
| 169 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 170 |
+
token = auth_header.split(" ")[1]
|
| 171 |
+
try:
|
| 172 |
+
current_user = security.get_current_user_from_token(token, db)
|
| 173 |
+
except Exception:
|
| 174 |
+
pass
|
| 175 |
+
|
| 176 |
+
if current_user is None:
|
| 177 |
+
if employee.status != "Pending Approval":
|
| 178 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 179 |
+
else:
|
| 180 |
+
user_role = current_user.role.name
|
| 181 |
+
if user_role not in ["Super Admin", "Admin", "HR"]:
|
| 182 |
+
if user_role == "Employee":
|
| 183 |
+
if not current_user.employee or current_user.employee.id != employee_id:
|
| 184 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 185 |
+
else:
|
| 186 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 187 |
+
|
| 188 |
poses = [img.pose_type for img in employee.images]
|
| 189 |
|
|
|
|
| 190 |
required_poses = [
|
| 191 |
"front", "left", "right", "up", "down",
|
| 192 |
"smile", "neutral", "indoor", "outdoor"
|
|
|
|
| 195 |
missing_poses = [p for p in required_poses if p not in [x.lower() for x in poses]]
|
| 196 |
|
| 197 |
return {
|
| 198 |
+
"employee_id": employee.id,
|
| 199 |
+
"employee_uuid": employee.employee_id,
|
| 200 |
"name": employee.name,
|
| 201 |
"total_enrolled": len(poses),
|
| 202 |
"enrolled_poses": poses,
|
|
|
|
| 229 |
)
|
| 230 |
|
| 231 |
return {"message": "All face enrollments and images cleared successfully"}
|
| 232 |
+
|
| 233 |
+
@router.delete("/{employee_id}/pose/{pose_type}")
|
| 234 |
+
def delete_single_pose(
|
| 235 |
+
request: Request,
|
| 236 |
+
employee_id: int,
|
| 237 |
+
pose_type: str,
|
| 238 |
+
db: Session = Depends(get_db),
|
| 239 |
+
current_user: models.User = Depends(checker_manage)
|
| 240 |
+
):
|
| 241 |
+
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 242 |
+
if not employee:
|
| 243 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 244 |
+
|
| 245 |
+
pose_img = next((img for img in employee.images if img.pose_type.lower() == pose_type.lower()), None)
|
| 246 |
+
if not pose_img:
|
| 247 |
+
raise HTTPException(status_code=404, detail=f"Pose '{pose_type}' not found for this employee")
|
| 248 |
+
|
| 249 |
+
db.execute(
|
| 250 |
+
models.FaceEmbedding.__table__.delete().where(
|
| 251 |
+
models.FaceEmbedding.image_id == pose_img.id
|
| 252 |
+
)
|
| 253 |
+
)
|
| 254 |
+
db.delete(pose_img)
|
| 255 |
+
db.commit()
|
| 256 |
+
|
| 257 |
+
face_engine.invalidate_cache()
|
| 258 |
+
|
| 259 |
+
try:
|
| 260 |
+
if os.path.exists(pose_img.file_path):
|
| 261 |
+
os.remove(pose_img.file_path)
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.warning(f"Could not remove physical file {pose_img.file_path}: {e}")
|
| 264 |
+
|
| 265 |
+
crud.create_audit_log(
|
| 266 |
+
db=db,
|
| 267 |
+
user_id=current_user.id,
|
| 268 |
+
action="Clear Single Pose",
|
| 269 |
+
ip_address=request.client.host if request.client else None,
|
| 270 |
+
user_agent=request.headers.get("user-agent"),
|
| 271 |
+
details=f"Cleared pose '{pose_type}' for employee ID: {employee.employee_id}"
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
return {"message": f"Pose '{pose_type}' cleared successfully"}
|
backend/app/api/v1/kiosk.py
CHANGED
|
@@ -7,7 +7,7 @@ import base64
|
|
| 7 |
import cv2
|
| 8 |
import numpy as np
|
| 9 |
import logging
|
| 10 |
-
from datetime import datetime, time
|
| 11 |
import urllib.parse
|
| 12 |
|
| 13 |
from app.core.database import get_db
|
|
@@ -18,6 +18,7 @@ from app.schemas import schemas
|
|
| 18 |
from app.models import models
|
| 19 |
from app.services.singletons import face_engine
|
| 20 |
from app.services import geocoding, voice_assistant
|
|
|
|
| 21 |
|
| 22 |
logger = logging.getLogger("Kiosk")
|
| 23 |
router = APIRouter()
|
|
@@ -77,14 +78,25 @@ def calculate_distance_meters(lat1: float, lon1: float, lat2: float, lon2: float
|
|
| 77 |
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
|
| 78 |
return R * c
|
| 79 |
|
|
|
|
|
|
|
| 80 |
class KioskScanRequest(BaseModel):
|
| 81 |
-
image: str = Field(
|
| 82 |
camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
|
| 83 |
confirm_checkout: bool = Field(False, description="Whether the check-out is confirmed by the employee")
|
| 84 |
qr_code: str = Field(None, description="Pre-detected QR code string from frontend")
|
| 85 |
qr_only: bool = Field(False, description="If True, only allow QR-based logging and disable face recognition")
|
| 86 |
latitude: Optional[float] = Field(None, description="Latitude of the kiosk/device marking attendance")
|
| 87 |
longitude: Optional[float] = Field(None, description="Longitude of the kiosk/device marking attendance")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
@router.get("/config")
|
|
@@ -111,7 +123,9 @@ def scan_face(
|
|
| 111 |
payload: KioskScanRequest,
|
| 112 |
db: Session = Depends(get_db)
|
| 113 |
):
|
| 114 |
-
|
|
|
|
|
|
|
| 115 |
# Retrieve dynamic thresholds from database settings
|
| 116 |
face_threshold_setting = crud.get_setting_by_key(db, "KIOSK_FACE_THRESHOLD")
|
| 117 |
liveness_threshold_setting = crud.get_setting_by_key(db, "KIOSK_LIVENESS_THRESHOLD")
|
|
@@ -137,266 +151,258 @@ def scan_face(
|
|
| 137 |
liveness_threshold = float(liveness_threshold_setting.value) if liveness_threshold_setting else settings.KIOSK_LIVENESS_THRESHOLD
|
| 138 |
voice_enabled = voice_greeting_setting.value.lower() == "true" if voice_greeting_setting else True
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 146 |
-
if img is None:
|
| 147 |
-
raise ValueError()
|
| 148 |
-
|
| 149 |
-
# Fast downscale for performance
|
| 150 |
-
scale_factor = 1.0
|
| 151 |
-
max_dim = 640
|
| 152 |
-
h, w = img.shape[:2]
|
| 153 |
-
if max(h, w) > max_dim:
|
| 154 |
-
scale_factor = max_dim / max(h, w)
|
| 155 |
-
img = cv2.resize(img, (int(w * scale_factor), int(h * scale_factor)), interpolation=cv2.INTER_AREA)
|
| 156 |
-
except Exception:
|
| 157 |
-
raise HTTPException(status_code=400, detail="Invalid Base64 image data")
|
| 158 |
-
|
| 159 |
-
qr_employee = None
|
| 160 |
-
is_qr_scan = False
|
| 161 |
bbox_list = None
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
qr_employee = db.query(models.Employee).filter(
|
| 167 |
-
models.Employee.employee_id == qr_val
|
| 168 |
-
).first()
|
| 169 |
-
if qr_employee:
|
| 170 |
-
is_qr_scan = True
|
| 171 |
-
logger.info(f"QR code pre-detected by frontend: {qr_employee.employee_id}")
|
| 172 |
-
|
| 173 |
-
if not is_qr_scan:
|
| 174 |
-
try:
|
| 175 |
-
qr_detector = cv2.QRCodeDetector()
|
| 176 |
-
qr_val, _, _ = qr_detector.detectAndDecode(img)
|
| 177 |
-
if qr_val:
|
| 178 |
-
qr_val = qr_val.strip()
|
| 179 |
-
qr_employee = db.query(models.Employee).filter(
|
| 180 |
-
models.Employee.employee_id == qr_val
|
| 181 |
-
).first()
|
| 182 |
-
if qr_employee:
|
| 183 |
-
is_qr_scan = True
|
| 184 |
-
logger.info(f"QR code scanned successfully for employee: {qr_employee.employee_id}")
|
| 185 |
-
except Exception as qr_err:
|
| 186 |
-
logger.warning(f"QR code parsing error: {qr_err}")
|
| 187 |
-
|
| 188 |
-
if is_qr_scan:
|
| 189 |
-
employee = qr_employee
|
| 190 |
-
similarity = 1.0
|
| 191 |
-
liveness_score = 1.0
|
| 192 |
-
confidence = 1.0
|
| 193 |
-
log_status_success = "Match Success (QR Scanned)"
|
| 194 |
-
else:
|
| 195 |
-
if getattr(payload, "qr_only", False):
|
| 196 |
return {
|
| 197 |
"status": "unknown",
|
| 198 |
-
"message": "
|
| 199 |
-
"should_retry":
|
| 200 |
-
}
|
| 201 |
-
# 2. Detect face
|
| 202 |
-
faces = face_engine.detect_faces(img)
|
| 203 |
-
if not faces:
|
| 204 |
-
return {
|
| 205 |
-
"status": "no_face",
|
| 206 |
-
"message": "No face detected. Frame your face within the scanner.",
|
| 207 |
-
"should_retry": True
|
| 208 |
}
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
else:
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
db=db,
|
| 234 |
-
employee_id=None,
|
| 235 |
-
camera=payload.camera,
|
| 236 |
-
confidence=confidence,
|
| 237 |
-
liveness_score=liveness_score,
|
| 238 |
-
is_spoof=True,
|
| 239 |
-
status="Spoof Rejected",
|
| 240 |
-
timestamp=now,
|
| 241 |
-
location_text=location_text if 'location_text' in locals() else None,
|
| 242 |
-
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 243 |
-
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 244 |
-
)
|
| 245 |
-
_publish_log(log_entry)
|
| 246 |
|
| 247 |
-
#
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
| 251 |
db=db,
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
| 259 |
)
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
is_pg = (db.bind.dialect.name == "postgresql")
|
| 281 |
-
except Exception as dialect_err:
|
| 282 |
-
logger.warning(f"Could not determine DB dialect: {dialect_err}")
|
| 283 |
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
try:
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
if query_res:
|
| 292 |
-
db_emb, distance = query_res
|
| 293 |
-
match_result = (db_emb, float(distance))
|
| 294 |
-
except Exception as pg_err:
|
| 295 |
-
logger.error(f"Failed to query pgvector: {pg_err}. Falling back to SQLite/NumPy matching.")
|
| 296 |
-
match_result = None
|
| 297 |
-
|
| 298 |
-
if match_result is None:
|
| 299 |
-
if face_engine.embeddings_cache is None:
|
| 300 |
-
face_engine.load_embeddings_cache(db)
|
| 301 |
-
|
| 302 |
-
all_embeddings = face_engine.embeddings_cache
|
| 303 |
-
if not all_embeddings:
|
| 304 |
-
match_result = None
|
| 305 |
-
else:
|
| 306 |
try:
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
match_result = (MockEmb(), best_dist)
|
| 322 |
-
except Exception as e:
|
| 323 |
-
logger.error(f"Error in vectorized face matching: {e}")
|
| 324 |
match_result = None
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
return {
|
| 343 |
-
"status": "unknown",
|
| 344 |
-
"message": "No employees registered in the system. Please register first.",
|
| 345 |
-
"should_retry": False,
|
| 346 |
-
"bbox": bbox_list
|
| 347 |
-
}
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
return {
|
| 361 |
-
"status": "
|
| 362 |
-
"message": "Face
|
| 363 |
-
"employee": {
|
| 364 |
-
"id": employee.id,
|
| 365 |
-
"employee_id": employee.employee_id,
|
| 366 |
-
"name": employee.name,
|
| 367 |
-
"designation": employee.designation,
|
| 368 |
-
"department": employee.department.name if employee.department else "General"
|
| 369 |
-
},
|
| 370 |
"confidence": similarity,
|
| 371 |
"liveness_score": liveness_score,
|
| 372 |
-
"should_retry":
|
| 373 |
"bbox": bbox_list
|
| 374 |
}
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
log_entry = crud.create_attendance_log(
|
| 378 |
-
db=db,
|
| 379 |
-
employee_id=None,
|
| 380 |
-
camera=payload.camera,
|
| 381 |
-
confidence=similarity,
|
| 382 |
-
liveness_score=liveness_score,
|
| 383 |
-
is_spoof=False,
|
| 384 |
-
status="Unknown Person",
|
| 385 |
-
timestamp=now,
|
| 386 |
-
location_text=location_text if 'location_text' in locals() else None,
|
| 387 |
-
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 388 |
-
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 389 |
-
)
|
| 390 |
-
_publish_log(log_entry)
|
| 391 |
-
return {
|
| 392 |
-
"status": "unknown",
|
| 393 |
-
"message": "Face not recognized. Please try again or contact HR.",
|
| 394 |
-
"confidence": similarity,
|
| 395 |
-
"liveness_score": liveness_score,
|
| 396 |
-
"should_retry": True,
|
| 397 |
-
"bbox": bbox_list
|
| 398 |
-
}
|
| 399 |
-
log_status_success = "Match Success"
|
| 400 |
|
| 401 |
if employee and employee.company and employee.company.status != "Active":
|
| 402 |
return {
|
|
@@ -417,7 +423,7 @@ def scan_face(
|
|
| 417 |
liveness_score=liveness_score,
|
| 418 |
is_spoof=False,
|
| 419 |
status="Inactive Employee Swiped",
|
| 420 |
-
timestamp=
|
| 421 |
location_text=location_text if 'location_text' in locals() else None,
|
| 422 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 423 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
@@ -464,7 +470,7 @@ def scan_face(
|
|
| 464 |
db=db, employee_id=employee.id, camera=payload.camera,
|
| 465 |
confidence=similarity if 'similarity' in locals() else 1.0,
|
| 466 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 467 |
-
is_spoof=False, status="WFH Location Missing", timestamp=
|
| 468 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 469 |
)
|
| 470 |
_publish_log(log_entry, employee)
|
|
@@ -490,7 +496,7 @@ def scan_face(
|
|
| 490 |
db=db, employee_id=employee.id, camera=payload.camera,
|
| 491 |
confidence=similarity if 'similarity' in locals() else 1.0,
|
| 492 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 493 |
-
is_spoof=False, status="Outside WFH Bounds", timestamp=
|
| 494 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 495 |
)
|
| 496 |
_publish_log(log_entry, employee)
|
|
@@ -511,7 +517,7 @@ def scan_face(
|
|
| 511 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 512 |
is_spoof=False,
|
| 513 |
status="Location Missing",
|
| 514 |
-
timestamp=
|
| 515 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 516 |
)
|
| 517 |
_publish_log(log_entry, employee)
|
|
@@ -544,7 +550,7 @@ def scan_face(
|
|
| 544 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 545 |
is_spoof=False,
|
| 546 |
status="Location Config Error",
|
| 547 |
-
timestamp=
|
| 548 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 549 |
)
|
| 550 |
_publish_log(log_entry, employee)
|
|
@@ -565,7 +571,7 @@ def scan_face(
|
|
| 565 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 566 |
is_spoof=False,
|
| 567 |
status="Outside Office Bounds",
|
| 568 |
-
timestamp=
|
| 569 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 570 |
)
|
| 571 |
_publish_log(log_entry, employee)
|
|
@@ -621,20 +627,48 @@ def scan_face(
|
|
| 621 |
)
|
| 622 |
attendance_record = db.execute(stmt).scalars().first()
|
| 623 |
|
| 624 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
# --- First scan of the day: Check-In ---
|
| 626 |
check_in_deadline = datetime.combine(now.date(), shift_start) + timedelta(minutes=grace_mins)
|
| 627 |
is_late = now > check_in_deadline
|
| 628 |
status = "Late" if is_late else "Present"
|
| 629 |
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
db.commit()
|
| 639 |
db.refresh(attendance_record)
|
| 640 |
|
|
@@ -647,7 +681,7 @@ def scan_face(
|
|
| 647 |
liveness_score=liveness_score,
|
| 648 |
is_spoof=False,
|
| 649 |
status=log_status_success,
|
| 650 |
-
timestamp=
|
| 651 |
location_text=location_text,
|
| 652 |
latitude=payload.latitude,
|
| 653 |
longitude=payload.longitude
|
|
@@ -716,7 +750,7 @@ def scan_face(
|
|
| 716 |
liveness_score=liveness_score,
|
| 717 |
is_spoof=False,
|
| 718 |
status=log_status_success,
|
| 719 |
-
timestamp=
|
| 720 |
location_text=location_text if 'location_text' in locals() else None,
|
| 721 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 722 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
@@ -774,7 +808,7 @@ def scan_face(
|
|
| 774 |
liveness_score=liveness_score,
|
| 775 |
is_spoof=False,
|
| 776 |
status="Attendance Locked",
|
| 777 |
-
timestamp=
|
| 778 |
location_text=location_text if 'location_text' in locals() else None,
|
| 779 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 780 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
@@ -864,7 +898,7 @@ def scan_face(
|
|
| 864 |
liveness_score=liveness_score,
|
| 865 |
is_spoof=False,
|
| 866 |
status=log_status_success,
|
| 867 |
-
timestamp=
|
| 868 |
location_text=location_text if 'location_text' in locals() else None,
|
| 869 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 870 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
@@ -960,7 +994,7 @@ def confirm_qr(
|
|
| 960 |
liveness_score=1.0,
|
| 961 |
is_spoof=False,
|
| 962 |
status="Location Missing (QR)",
|
| 963 |
-
timestamp=
|
| 964 |
)
|
| 965 |
raise HTTPException(status_code=400, detail="GPS coordinates are required to mark attendance.")
|
| 966 |
|
|
@@ -986,7 +1020,7 @@ def confirm_qr(
|
|
| 986 |
liveness_score=1.0,
|
| 987 |
is_spoof=False,
|
| 988 |
status="Location Config Error",
|
| 989 |
-
timestamp=
|
| 990 |
)
|
| 991 |
raise HTTPException(
|
| 992 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
@@ -1003,7 +1037,7 @@ def confirm_qr(
|
|
| 1003 |
liveness_score=1.0,
|
| 1004 |
is_spoof=False,
|
| 1005 |
status="Outside Office Bounds (QR)",
|
| 1006 |
-
timestamp=
|
| 1007 |
)
|
| 1008 |
raise HTTPException(status_code=400, detail=f"Outside allowed area. Distance: {dist:.1f}m. Max radius: {allowed_radius}m.")
|
| 1009 |
|
|
@@ -1017,16 +1051,16 @@ def confirm_qr(
|
|
| 1017 |
liveness_score=1.0,
|
| 1018 |
is_spoof=False,
|
| 1019 |
status="QR Verification Failed",
|
| 1020 |
-
timestamp=
|
| 1021 |
)
|
| 1022 |
_publish_log(log_entry, employee)
|
| 1023 |
raise HTTPException(status_code=400, detail="QR Code verification failed. Badge does not match matched face.")
|
| 1024 |
|
| 1025 |
-
|
| 1026 |
attendance_record = crud.mark_kiosk_attendance(
|
| 1027 |
db=db,
|
| 1028 |
employee_id=employee.id,
|
| 1029 |
-
timestamp=
|
| 1030 |
camera=payload.camera,
|
| 1031 |
confidence=1.0
|
| 1032 |
)
|
|
@@ -1039,9 +1073,9 @@ def confirm_qr(
|
|
| 1039 |
liveness_score=1.0,
|
| 1040 |
is_spoof=False,
|
| 1041 |
status="Match Success (QR Verified)",
|
| 1042 |
-
timestamp=
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 1046 |
)
|
| 1047 |
_publish_log(log_entry, employee)
|
|
|
|
| 7 |
import cv2
|
| 8 |
import numpy as np
|
| 9 |
import logging
|
| 10 |
+
from datetime import datetime, time, timedelta
|
| 11 |
import urllib.parse
|
| 12 |
|
| 13 |
from app.core.database import get_db
|
|
|
|
| 18 |
from app.models import models
|
| 19 |
from app.services.singletons import face_engine
|
| 20 |
from app.services import geocoding, voice_assistant
|
| 21 |
+
from app.core.attendance_policy import AttendancePolicyEngine
|
| 22 |
|
| 23 |
logger = logging.getLogger("Kiosk")
|
| 24 |
router = APIRouter()
|
|
|
|
| 78 |
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
|
| 79 |
return R * c
|
| 80 |
|
| 81 |
+
from pydantic import BaseModel, Field, validator
|
| 82 |
+
|
| 83 |
class KioskScanRequest(BaseModel):
|
| 84 |
+
image: Optional[str] = Field(None, description="Base64 encoded image frame (JPEG/PNG data URL)")
|
| 85 |
camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
|
| 86 |
confirm_checkout: bool = Field(False, description="Whether the check-out is confirmed by the employee")
|
| 87 |
qr_code: str = Field(None, description="Pre-detected QR code string from frontend")
|
| 88 |
qr_only: bool = Field(False, description="If True, only allow QR-based logging and disable face recognition")
|
| 89 |
latitude: Optional[float] = Field(None, description="Latitude of the kiosk/device marking attendance")
|
| 90 |
longitude: Optional[float] = Field(None, description="Longitude of the kiosk/device marking attendance")
|
| 91 |
+
employee_id: Optional[int] = Field(None, description="Employee ID for dummy bypass scans")
|
| 92 |
+
dummy: Optional[bool] = Field(False, description="Whether to bypass AI face scan (for employee dashboard dummy mode)")
|
| 93 |
+
|
| 94 |
+
@validator("image")
|
| 95 |
+
def validate_image_payload(cls, v):
|
| 96 |
+
if v and len(v) > 20 * 1024 * 1024: # ~15MB raw image cap
|
| 97 |
+
raise ValueError("Image payload size exceeds maximum limit of 15MB")
|
| 98 |
+
return v
|
| 99 |
+
|
| 100 |
|
| 101 |
|
| 102 |
@router.get("/config")
|
|
|
|
| 123 |
payload: KioskScanRequest,
|
| 124 |
db: Session = Depends(get_db)
|
| 125 |
):
|
| 126 |
+
from datetime import timedelta
|
| 127 |
+
now_utc = datetime.utcnow()
|
| 128 |
+
now = now_utc + timedelta(hours=5, minutes=30)
|
| 129 |
# Retrieve dynamic thresholds from database settings
|
| 130 |
face_threshold_setting = crud.get_setting_by_key(db, "KIOSK_FACE_THRESHOLD")
|
| 131 |
liveness_threshold_setting = crud.get_setting_by_key(db, "KIOSK_LIVENESS_THRESHOLD")
|
|
|
|
| 151 |
liveness_threshold = float(liveness_threshold_setting.value) if liveness_threshold_setting else settings.KIOSK_LIVENESS_THRESHOLD
|
| 152 |
voice_enabled = voice_greeting_setting.value.lower() == "true" if voice_greeting_setting else True
|
| 153 |
|
| 154 |
+
employee = None
|
| 155 |
+
similarity = 1.0
|
| 156 |
+
liveness_score = 1.0
|
| 157 |
+
confidence = 1.0
|
| 158 |
+
log_status_success = "Match Success"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
bbox_list = None
|
| 160 |
|
| 161 |
+
if payload.dummy and payload.employee_id:
|
| 162 |
+
employee = crud.get_employee_by_id(db, payload.employee_id)
|
| 163 |
+
if not employee:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
return {
|
| 165 |
"status": "unknown",
|
| 166 |
+
"message": "Employee not found.",
|
| 167 |
+
"should_retry": False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
}
|
| 169 |
+
log_status_success = "Dummy Scanner Success"
|
| 170 |
+
else:
|
| 171 |
+
# 1. Parse base64 image
|
| 172 |
+
if not payload.image:
|
| 173 |
+
raise HTTPException(status_code=400, detail="Image payload is required for non-dummy scans")
|
| 174 |
+
try:
|
| 175 |
+
header, encoded = payload.image.split(",", 1) if "," in payload.image else ("", payload.image)
|
| 176 |
+
img_bytes = base64.b64decode(encoded)
|
| 177 |
+
nparr = np.frombuffer(img_bytes, np.uint8)
|
| 178 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 179 |
+
if img is None:
|
| 180 |
+
raise ValueError()
|
| 181 |
+
|
| 182 |
+
# Fast downscale for performance
|
| 183 |
+
scale_factor = 1.0
|
| 184 |
+
max_dim = 640
|
| 185 |
+
h, w = img.shape[:2]
|
| 186 |
+
if max(h, w) > max_dim:
|
| 187 |
+
scale_factor = max_dim / max(h, w)
|
| 188 |
+
img = cv2.resize(img, (int(w * scale_factor), int(h * scale_factor)), interpolation=cv2.INTER_AREA)
|
| 189 |
+
except Exception:
|
| 190 |
+
raise HTTPException(status_code=400, detail="Invalid Base64 image data")
|
| 191 |
+
|
| 192 |
+
qr_employee = None
|
| 193 |
+
is_qr_scan = False
|
| 194 |
+
|
| 195 |
+
# Check if qr_code was pre-detected by the frontend
|
| 196 |
+
if getattr(payload, "qr_code", None):
|
| 197 |
+
qr_val = payload.qr_code.strip()
|
| 198 |
+
qr_employee = db.query(models.Employee).filter(
|
| 199 |
+
models.Employee.employee_id == qr_val
|
| 200 |
+
).first()
|
| 201 |
+
if qr_employee:
|
| 202 |
+
is_qr_scan = True
|
| 203 |
+
logger.info(f"QR code pre-detected by frontend: {qr_employee.employee_id}")
|
| 204 |
+
|
| 205 |
+
if not is_qr_scan:
|
| 206 |
+
try:
|
| 207 |
+
qr_detector = cv2.QRCodeDetector()
|
| 208 |
+
qr_val, _, _ = qr_detector.detectAndDecode(img)
|
| 209 |
+
if qr_val:
|
| 210 |
+
qr_val = qr_val.strip()
|
| 211 |
+
qr_employee = db.query(models.Employee).filter(
|
| 212 |
+
models.Employee.employee_id == qr_val
|
| 213 |
+
).first()
|
| 214 |
+
if qr_employee:
|
| 215 |
+
is_qr_scan = True
|
| 216 |
+
logger.info(f"QR code scanned successfully for employee: {qr_employee.employee_id}")
|
| 217 |
+
except Exception as qr_err:
|
| 218 |
+
logger.warning(f"QR code parsing error: {qr_err}")
|
| 219 |
+
|
| 220 |
+
if is_qr_scan:
|
| 221 |
+
employee = qr_employee
|
| 222 |
+
similarity = 1.0
|
| 223 |
+
liveness_score = 1.0
|
| 224 |
+
confidence = 1.0
|
| 225 |
+
log_status_success = "Match Success (QR Scanned)"
|
| 226 |
else:
|
| 227 |
+
if getattr(payload, "qr_only", False):
|
| 228 |
+
return {
|
| 229 |
+
"status": "unknown",
|
| 230 |
+
"message": "Invalid QR code. Employee badge not found.",
|
| 231 |
+
"should_retry": True
|
| 232 |
+
}
|
| 233 |
+
# 2. Detect face
|
| 234 |
+
faces = face_engine.detect_faces(img)
|
| 235 |
+
if not faces:
|
| 236 |
+
return {
|
| 237 |
+
"status": "no_face",
|
| 238 |
+
"message": "No face detected. Frame your face within the scanner.",
|
| 239 |
+
"should_retry": True
|
| 240 |
+
}
|
| 241 |
+
if len(faces) > 1:
|
| 242 |
+
return {
|
| 243 |
+
"status": "multiple_faces",
|
| 244 |
+
"message": "Multiple faces detected. Please scan one person at a time.",
|
| 245 |
+
"should_retry": True
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
face = faces[0]
|
| 249 |
+
bbox = face["bbox"]
|
| 250 |
|
| 251 |
+
# Scale bbox back to original image size for frontend drawing
|
| 252 |
+
if scale_factor != 1.0:
|
| 253 |
+
bbox_list = [float(x) / scale_factor for x in bbox]
|
| 254 |
+
else:
|
| 255 |
+
bbox_list = [float(x) for x in bbox]
|
| 256 |
+
|
| 257 |
+
confidence = face["confidence"]
|
| 258 |
+
landmarks = face["landmarks"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
+
# 3. Liveness Check
|
| 261 |
+
liveness_score, is_live = face_engine.check_liveness(img, bbox, threshold=liveness_threshold)
|
| 262 |
+
if not is_live and not face_engine.mock_mode:
|
| 263 |
+
# Save spoof log
|
| 264 |
+
log_entry = crud.create_attendance_log(
|
| 265 |
db=db,
|
| 266 |
+
employee_id=None,
|
| 267 |
+
camera=payload.camera,
|
| 268 |
+
confidence=confidence,
|
| 269 |
+
liveness_score=liveness_score,
|
| 270 |
+
is_spoof=True,
|
| 271 |
+
status="Spoof Rejected",
|
| 272 |
+
timestamp=now_utc,
|
| 273 |
+
location_text=location_text if 'location_text' in locals() else None,
|
| 274 |
+
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 275 |
+
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 276 |
)
|
| 277 |
+
_publish_log(log_entry)
|
| 278 |
+
|
| 279 |
+
# Dispatch Webhook alert
|
| 280 |
+
try:
|
| 281 |
+
from app.services.notifications import trigger_security_alert
|
| 282 |
+
trigger_security_alert(
|
| 283 |
+
db=db,
|
| 284 |
+
alert_type="Spoofing Attempt Rejected",
|
| 285 |
+
details={
|
| 286 |
+
"camera": payload.camera,
|
| 287 |
+
"confidence": float(confidence),
|
| 288 |
+
"liveness_score": float(liveness_score),
|
| 289 |
+
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S")
|
| 290 |
+
}
|
| 291 |
+
)
|
| 292 |
+
except Exception as alert_err:
|
| 293 |
+
logger.error(f"Failed to dispatch security alert: {alert_err}")
|
| 294 |
|
| 295 |
+
return {
|
| 296 |
+
"status": "spoof_detected",
|
| 297 |
+
"message": "Liveness check failed! Verification denied.",
|
| 298 |
+
"confidence": float(confidence),
|
| 299 |
+
"liveness_score": float(liveness_score),
|
| 300 |
+
"should_retry": False,
|
| 301 |
+
"bbox": bbox_list
|
| 302 |
+
}
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
+
# 4. Extract Embedding
|
| 305 |
+
aligned = face_engine.align_face(img, landmarks)
|
| 306 |
+
embedding = face_engine.extract_embedding(aligned)
|
| 307 |
+
|
| 308 |
+
# 5. DB Matching: query pgvector if postgresql, else fallback to numpy cache-matching
|
| 309 |
+
match_result = None
|
| 310 |
+
is_pg = False
|
| 311 |
try:
|
| 312 |
+
is_pg = (db.bind.dialect.name == "postgresql")
|
| 313 |
+
except Exception as dialect_err:
|
| 314 |
+
logger.warning(f"Could not determine DB dialect: {dialect_err}")
|
| 315 |
+
|
| 316 |
+
if is_pg:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
try:
|
| 318 |
+
emb_list = embedding.tolist() if isinstance(embedding, np.ndarray) else list(embedding)
|
| 319 |
+
from sqlalchemy import type_coerce, Float
|
| 320 |
+
distance_expr = type_coerce(models.FaceEmbedding.embedding.op('<=>')(emb_list), Float).label('distance')
|
| 321 |
+
query_res = db.query(models.FaceEmbedding, distance_expr).order_by(distance_expr).limit(1).first()
|
| 322 |
+
if query_res:
|
| 323 |
+
db_emb, distance = query_res
|
| 324 |
+
match_result = (db_emb, float(distance))
|
| 325 |
+
except Exception as pg_err:
|
| 326 |
+
logger.error(f"Failed to query pgvector: {pg_err}. Falling back to SQLite/NumPy matching.")
|
| 327 |
+
match_result = None
|
| 328 |
+
|
| 329 |
+
if match_result is None:
|
| 330 |
+
if face_engine.embeddings_cache is None:
|
| 331 |
+
face_engine.load_embeddings_cache(db)
|
| 332 |
|
| 333 |
+
all_embeddings = face_engine.embeddings_cache
|
| 334 |
+
if not all_embeddings:
|
|
|
|
|
|
|
|
|
|
| 335 |
match_result = None
|
| 336 |
+
else:
|
| 337 |
+
try:
|
| 338 |
+
embeddings_matrix = np.stack([emb["embedding"] for emb in all_embeddings]) # shape (N, 512)
|
| 339 |
+
similarities = np.dot(embeddings_matrix, embedding) # shape (N,)
|
| 340 |
+
best_idx = int(np.argmax(similarities))
|
| 341 |
+
best_similarity = float(similarities[best_idx])
|
| 342 |
+
|
| 343 |
+
best_emb_record = all_embeddings[best_idx]
|
| 344 |
+
class MockEmb:
|
| 345 |
+
id = best_emb_record["id"]
|
| 346 |
+
employee_id = best_emb_record["employee_id"]
|
| 347 |
+
|
| 348 |
+
best_dist = 1.0 - best_similarity
|
| 349 |
+
match_result = (MockEmb(), best_dist)
|
| 350 |
+
except Exception as e:
|
| 351 |
+
logger.error(f"Error in vectorized face matching: {e}")
|
| 352 |
+
match_result = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
+
if not match_result:
|
| 355 |
+
log_entry = crud.create_attendance_log(
|
| 356 |
+
db=db,
|
| 357 |
+
employee_id=None,
|
| 358 |
+
camera=payload.camera,
|
| 359 |
+
confidence=confidence,
|
| 360 |
+
liveness_score=liveness_score,
|
| 361 |
+
is_spoof=False,
|
| 362 |
+
status="Empty Vector Index",
|
| 363 |
+
timestamp=now_utc,
|
| 364 |
+
location_text=location_text if 'location_text' in locals() else None,
|
| 365 |
+
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 366 |
+
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 367 |
+
)
|
| 368 |
+
_publish_log(log_entry)
|
| 369 |
+
return {
|
| 370 |
+
"status": "unknown",
|
| 371 |
+
"message": "No employees registered in the system. Please register first.",
|
| 372 |
+
"should_retry": False,
|
| 373 |
+
"bbox": bbox_list
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
db_emb, distance = match_result
|
| 377 |
+
similarity = 1.0 - float(distance)
|
| 378 |
|
| 379 |
+
employee = crud.get_employee_by_id(db, db_emb.employee_id) if db_emb else None
|
| 380 |
+
|
| 381 |
+
if similarity < face_threshold:
|
| 382 |
+
log_entry = crud.create_attendance_log(
|
| 383 |
+
db=db,
|
| 384 |
+
employee_id=None,
|
| 385 |
+
camera=payload.camera,
|
| 386 |
+
confidence=similarity,
|
| 387 |
+
liveness_score=liveness_score,
|
| 388 |
+
is_spoof=False,
|
| 389 |
+
status="Unknown Person",
|
| 390 |
+
timestamp=now_utc,
|
| 391 |
+
location_text=location_text if 'location_text' in locals() else None,
|
| 392 |
+
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 393 |
+
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 394 |
+
)
|
| 395 |
+
_publish_log(log_entry)
|
| 396 |
return {
|
| 397 |
+
"status": "unknown",
|
| 398 |
+
"message": "Face not recognized. Please try again or contact HR.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
"confidence": similarity,
|
| 400 |
"liveness_score": liveness_score,
|
| 401 |
+
"should_retry": True,
|
| 402 |
"bbox": bbox_list
|
| 403 |
}
|
| 404 |
+
log_status_success = "Match Success"
|
| 405 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
if employee and employee.company and employee.company.status != "Active":
|
| 408 |
return {
|
|
|
|
| 423 |
liveness_score=liveness_score,
|
| 424 |
is_spoof=False,
|
| 425 |
status="Inactive Employee Swiped",
|
| 426 |
+
timestamp=now_utc,
|
| 427 |
location_text=location_text if 'location_text' in locals() else None,
|
| 428 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 429 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
|
|
| 470 |
db=db, employee_id=employee.id, camera=payload.camera,
|
| 471 |
confidence=similarity if 'similarity' in locals() else 1.0,
|
| 472 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 473 |
+
is_spoof=False, status="WFH Location Missing", timestamp=now_utc,
|
| 474 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 475 |
)
|
| 476 |
_publish_log(log_entry, employee)
|
|
|
|
| 496 |
db=db, employee_id=employee.id, camera=payload.camera,
|
| 497 |
confidence=similarity if 'similarity' in locals() else 1.0,
|
| 498 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 499 |
+
is_spoof=False, status="Outside WFH Bounds", timestamp=now_utc,
|
| 500 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 501 |
)
|
| 502 |
_publish_log(log_entry, employee)
|
|
|
|
| 517 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 518 |
is_spoof=False,
|
| 519 |
status="Location Missing",
|
| 520 |
+
timestamp=now_utc,
|
| 521 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 522 |
)
|
| 523 |
_publish_log(log_entry, employee)
|
|
|
|
| 550 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 551 |
is_spoof=False,
|
| 552 |
status="Location Config Error",
|
| 553 |
+
timestamp=now_utc,
|
| 554 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 555 |
)
|
| 556 |
_publish_log(log_entry, employee)
|
|
|
|
| 571 |
liveness_score=liveness_score if 'liveness_score' in locals() else 1.0,
|
| 572 |
is_spoof=False,
|
| 573 |
status="Outside Office Bounds",
|
| 574 |
+
timestamp=now_utc,
|
| 575 |
location_text=location_text, latitude=payload.latitude, longitude=payload.longitude
|
| 576 |
)
|
| 577 |
_publish_log(log_entry, employee)
|
|
|
|
| 627 |
)
|
| 628 |
attendance_record = db.execute(stmt).scalars().first()
|
| 629 |
|
| 630 |
+
if attendance_record and attendance_record.status == "On Leave":
|
| 631 |
+
log_entry = crud.create_attendance_log(
|
| 632 |
+
db=db,
|
| 633 |
+
employee_id=employee.id,
|
| 634 |
+
camera=payload.camera,
|
| 635 |
+
confidence=similarity,
|
| 636 |
+
liveness_score=liveness_score,
|
| 637 |
+
is_spoof=False,
|
| 638 |
+
status="Attendance Locked",
|
| 639 |
+
timestamp=now_utc,
|
| 640 |
+
location_text=location_text if 'location_text' in locals() else None,
|
| 641 |
+
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 642 |
+
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 643 |
+
)
|
| 644 |
+
_publish_log(log_entry, employee)
|
| 645 |
+
return {
|
| 646 |
+
"status": "locked",
|
| 647 |
+
"message": f"Verification denied. {employee.name} is currently on approved leave today.",
|
| 648 |
+
"should_retry": False,
|
| 649 |
+
"bbox": bbox_list
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
if not attendance_record or attendance_record.check_in is None:
|
| 653 |
# --- First scan of the day: Check-In ---
|
| 654 |
check_in_deadline = datetime.combine(now.date(), shift_start) + timedelta(minutes=grace_mins)
|
| 655 |
is_late = now > check_in_deadline
|
| 656 |
status = "Late" if is_late else "Present"
|
| 657 |
|
| 658 |
+
if not attendance_record:
|
| 659 |
+
attendance_record = models.Attendance(
|
| 660 |
+
employee_id=employee.id,
|
| 661 |
+
date=now.date(),
|
| 662 |
+
check_in=now,
|
| 663 |
+
late_arrival=is_late,
|
| 664 |
+
status=status
|
| 665 |
+
)
|
| 666 |
+
db.add(attendance_record)
|
| 667 |
+
else:
|
| 668 |
+
attendance_record.check_in = now
|
| 669 |
+
attendance_record.late_arrival = is_late
|
| 670 |
+
attendance_record.status = status
|
| 671 |
+
|
| 672 |
db.commit()
|
| 673 |
db.refresh(attendance_record)
|
| 674 |
|
|
|
|
| 681 |
liveness_score=liveness_score,
|
| 682 |
is_spoof=False,
|
| 683 |
status=log_status_success,
|
| 684 |
+
timestamp=now_utc,
|
| 685 |
location_text=location_text,
|
| 686 |
latitude=payload.latitude,
|
| 687 |
longitude=payload.longitude
|
|
|
|
| 750 |
liveness_score=liveness_score,
|
| 751 |
is_spoof=False,
|
| 752 |
status=log_status_success,
|
| 753 |
+
timestamp=now_utc,
|
| 754 |
location_text=location_text if 'location_text' in locals() else None,
|
| 755 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 756 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
|
|
| 808 |
liveness_score=liveness_score,
|
| 809 |
is_spoof=False,
|
| 810 |
status="Attendance Locked",
|
| 811 |
+
timestamp=now_utc,
|
| 812 |
location_text=location_text if 'location_text' in locals() else None,
|
| 813 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 814 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
|
|
| 898 |
liveness_score=liveness_score,
|
| 899 |
is_spoof=False,
|
| 900 |
status=log_status_success,
|
| 901 |
+
timestamp=now_utc,
|
| 902 |
location_text=location_text if 'location_text' in locals() else None,
|
| 903 |
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 904 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
|
|
|
| 994 |
liveness_score=1.0,
|
| 995 |
is_spoof=False,
|
| 996 |
status="Location Missing (QR)",
|
| 997 |
+
timestamp=now_utc
|
| 998 |
)
|
| 999 |
raise HTTPException(status_code=400, detail="GPS coordinates are required to mark attendance.")
|
| 1000 |
|
|
|
|
| 1020 |
liveness_score=1.0,
|
| 1021 |
is_spoof=False,
|
| 1022 |
status="Location Config Error",
|
| 1023 |
+
timestamp=now_utc
|
| 1024 |
)
|
| 1025 |
raise HTTPException(
|
| 1026 |
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
| 1037 |
liveness_score=1.0,
|
| 1038 |
is_spoof=False,
|
| 1039 |
status="Outside Office Bounds (QR)",
|
| 1040 |
+
timestamp=now_utc
|
| 1041 |
)
|
| 1042 |
raise HTTPException(status_code=400, detail=f"Outside allowed area. Distance: {dist:.1f}m. Max radius: {allowed_radius}m.")
|
| 1043 |
|
|
|
|
| 1051 |
liveness_score=1.0,
|
| 1052 |
is_spoof=False,
|
| 1053 |
status="QR Verification Failed",
|
| 1054 |
+
timestamp=now_utc
|
| 1055 |
)
|
| 1056 |
_publish_log(log_entry, employee)
|
| 1057 |
raise HTTPException(status_code=400, detail="QR Code verification failed. Badge does not match matched face.")
|
| 1058 |
|
| 1059 |
+
now_utc = datetime.utcnow(); now = now_utc + timedelta(hours=5, minutes=30)
|
| 1060 |
attendance_record = crud.mark_kiosk_attendance(
|
| 1061 |
db=db,
|
| 1062 |
employee_id=employee.id,
|
| 1063 |
+
timestamp=now_utc,
|
| 1064 |
camera=payload.camera,
|
| 1065 |
confidence=1.0
|
| 1066 |
)
|
|
|
|
| 1073 |
liveness_score=1.0,
|
| 1074 |
is_spoof=False,
|
| 1075 |
status="Match Success (QR Verified)",
|
| 1076 |
+
timestamp=now_utc,
|
| 1077 |
+
location_text=None,
|
| 1078 |
+
latitude=payload.latitude if hasattr(payload, 'latitude') else None,
|
| 1079 |
longitude=payload.longitude if hasattr(payload, 'longitude') else None
|
| 1080 |
)
|
| 1081 |
_publish_log(log_entry, employee)
|
backend/app/api/v1/notifications.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core import security
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
from app.schemas import schemas
|
| 9 |
+
from app.models import models
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
@router.get("/", response_model=List[schemas.NotificationOut])
|
| 14 |
+
def read_notifications(
|
| 15 |
+
is_read: Optional[bool] = None,
|
| 16 |
+
db: Session = Depends(get_db),
|
| 17 |
+
current_user: models.User = Depends(security.get_current_user)
|
| 18 |
+
):
|
| 19 |
+
return crud.get_notifications(
|
| 20 |
+
db,
|
| 21 |
+
company_id=current_user.company_id,
|
| 22 |
+
recipient_id=current_user.id,
|
| 23 |
+
is_read=is_read
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
@router.post("/", response_model=schemas.NotificationOut, status_code=status.HTTP_201_CREATED)
|
| 27 |
+
def post_notification(
|
| 28 |
+
request: Request,
|
| 29 |
+
notification: schemas.NotificationCreate,
|
| 30 |
+
db: Session = Depends(get_db),
|
| 31 |
+
current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR"]))
|
| 32 |
+
):
|
| 33 |
+
company_id = current_user.company_id
|
| 34 |
+
db_ntf = crud.create_notification(db, ntf=notification, company_id=company_id, sender_id=current_user.id)
|
| 35 |
+
|
| 36 |
+
crud.create_audit_log(
|
| 37 |
+
db=db,
|
| 38 |
+
user_id=current_user.id,
|
| 39 |
+
action="Broadcast Notification",
|
| 40 |
+
ip_address=request.client.host if request.client else None,
|
| 41 |
+
user_agent=request.headers.get("user-agent"),
|
| 42 |
+
details=f"Posted notification: '{notification.title}' under category '{notification.category}'",
|
| 43 |
+
company_id=company_id
|
| 44 |
+
)
|
| 45 |
+
return db_ntf
|
| 46 |
+
|
| 47 |
+
@router.put("/{id}/read", response_model=schemas.NotificationOut)
|
| 48 |
+
def mark_read(
|
| 49 |
+
id: int,
|
| 50 |
+
db: Session = Depends(get_db),
|
| 51 |
+
current_user: models.User = Depends(security.get_current_user)
|
| 52 |
+
):
|
| 53 |
+
db_ntf = crud.get_notification_by_id(db, notification_id=id)
|
| 54 |
+
if not db_ntf:
|
| 55 |
+
raise HTTPException(status_code=404, detail="Notification not found")
|
| 56 |
+
if db_ntf.company_id != current_user.company_id or (db_ntf.recipient_id is not None and db_ntf.recipient_id != current_user.id):
|
| 57 |
+
raise HTTPException(status_code=403, detail="Not authorized to access this notification")
|
| 58 |
+
|
| 59 |
+
return crud.mark_notification_read(db, notification_id=id)
|
| 60 |
+
|
| 61 |
+
@router.put("/{id}/archive", response_model=schemas.NotificationOut)
|
| 62 |
+
def archive_notification(
|
| 63 |
+
id: int,
|
| 64 |
+
db: Session = Depends(get_db),
|
| 65 |
+
current_user: models.User = Depends(security.get_current_user)
|
| 66 |
+
):
|
| 67 |
+
db_ntf = crud.get_notification_by_id(db, notification_id=id)
|
| 68 |
+
if not db_ntf:
|
| 69 |
+
raise HTTPException(status_code=404, detail="Notification not found")
|
| 70 |
+
if db_ntf.company_id != current_user.company_id or (db_ntf.recipient_id is not None and db_ntf.recipient_id != current_user.id):
|
| 71 |
+
raise HTTPException(status_code=403, detail="Not authorized to modify this notification")
|
| 72 |
+
|
| 73 |
+
return crud.archive_notification(db, notification_id=id)
|
backend/app/api/v1/policy.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core import security
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
from app.models import models
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
checker_admin = security.RoleChecker(["Super Admin", "Admin"])
|
| 12 |
+
|
| 13 |
+
@router.get("/rules")
|
| 14 |
+
def get_attendance_rules(
|
| 15 |
+
db: Session = Depends(get_db),
|
| 16 |
+
current_user: models.User = Depends(checker_admin)
|
| 17 |
+
):
|
| 18 |
+
company_id = current_user.company_id
|
| 19 |
+
threshold = crud.get_setting_by_key(db, "face_match_threshold", company_id)
|
| 20 |
+
lat = crud.get_setting_by_key(db, "office_latitude", company_id)
|
| 21 |
+
lng = crud.get_setting_by_key(db, "office_longitude", company_id)
|
| 22 |
+
radius = crud.get_setting_by_key(db, "geofence_radius_meters", company_id)
|
| 23 |
+
|
| 24 |
+
return {
|
| 25 |
+
"face_match_threshold": float(threshold.value) if threshold else 0.6,
|
| 26 |
+
"office_latitude": float(lat.value) if lat else 0.0,
|
| 27 |
+
"office_longitude": float(lng.value) if lng else 0.0,
|
| 28 |
+
"geofence_radius_meters": float(radius.value) if radius else 500.0,
|
| 29 |
+
"policy_version": "v2.0-Enterprise"
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
@router.post("/rules")
|
| 33 |
+
def update_attendance_rules(
|
| 34 |
+
request: Request,
|
| 35 |
+
payload: Dict[str, Any],
|
| 36 |
+
db: Session = Depends(get_db),
|
| 37 |
+
current_user: models.User = Depends(checker_admin)
|
| 38 |
+
):
|
| 39 |
+
company_id = current_user.company_id
|
| 40 |
+
|
| 41 |
+
for key, value in payload.items():
|
| 42 |
+
if key in ["face_match_threshold", "office_latitude", "office_longitude", "geofence_radius_meters"]:
|
| 43 |
+
crud.set_setting(db, key=key, value=str(value), company_id=company_id)
|
| 44 |
+
|
| 45 |
+
crud.create_audit_log(
|
| 46 |
+
db=db,
|
| 47 |
+
user_id=current_user.id,
|
| 48 |
+
action="Configure Attendance Rules",
|
| 49 |
+
ip_address=request.client.host if request.client else None,
|
| 50 |
+
user_agent=request.headers.get("user-agent"),
|
| 51 |
+
details=f"Configured policy rules: {list(payload.keys())}",
|
| 52 |
+
company_id=company_id
|
| 53 |
+
)
|
| 54 |
+
return {"message": "Attendance rules updated successfully"}
|
backend/app/api/v1/tickets.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
-
from typing import List
|
| 4 |
|
| 5 |
from app.core.database import get_db
|
| 6 |
from app.core import security
|
|
@@ -12,19 +12,40 @@ router = APIRouter()
|
|
| 12 |
|
| 13 |
@router.get("/", response_model=List[schemas.TicketOut])
|
| 14 |
def read_tickets(
|
|
|
|
| 15 |
db: Session = Depends(get_db),
|
| 16 |
current_user: models.User = Depends(security.get_current_user)
|
| 17 |
):
|
| 18 |
role_name = current_user.role.name if current_user.role else "Employee"
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
#
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
else:
|
| 24 |
# Employees can only see their own tickets
|
| 25 |
if not current_user.employee:
|
| 26 |
raise HTTPException(status_code=400, detail="User is not registered as an employee")
|
| 27 |
-
return crud.get_tickets(db, company_id=
|
| 28 |
|
| 29 |
@router.post("/", response_model=schemas.TicketOut, status_code=status.HTTP_201_CREATED)
|
| 30 |
def create_ticket(
|
|
@@ -33,15 +54,17 @@ def create_ticket(
|
|
| 33 |
db: Session = Depends(get_db),
|
| 34 |
current_user: models.User = Depends(security.get_current_user)
|
| 35 |
):
|
| 36 |
-
|
| 37 |
-
if
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
| 42 |
|
| 43 |
db_ticket = crud.create_ticket(
|
| 44 |
-
db, ticket=ticket, employee_id=
|
| 45 |
)
|
| 46 |
|
| 47 |
crud.create_audit_log(
|
|
@@ -55,6 +78,10 @@ def create_ticket(
|
|
| 55 |
)
|
| 56 |
return db_ticket
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
@router.post("/{id}/messages", response_model=schemas.TicketMessageOut, status_code=status.HTTP_201_CREATED)
|
| 59 |
def reply_to_ticket(
|
| 60 |
id: int,
|
|
@@ -76,8 +103,64 @@ def reply_to_ticket(
|
|
| 76 |
raise HTTPException(status_code=403, detail="Not authorized to post to this ticket")
|
| 77 |
|
| 78 |
db_message = crud.create_ticket_message(db, ticket_id=id, msg=message, sender_id=current_user.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
return db_message
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
@router.put("/{id}/status", response_model=schemas.TicketOut)
|
| 82 |
def update_ticket(
|
| 83 |
request: Request,
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
|
| 5 |
from app.core.database import get_db
|
| 6 |
from app.core import security
|
|
|
|
| 12 |
|
| 13 |
@router.get("/", response_model=List[schemas.TicketOut])
|
| 14 |
def read_tickets(
|
| 15 |
+
company_id: Optional[int] = None,
|
| 16 |
db: Session = Depends(get_db),
|
| 17 |
current_user: models.User = Depends(security.get_current_user)
|
| 18 |
):
|
| 19 |
role_name = current_user.role.name if current_user.role else "Employee"
|
| 20 |
+
target_company_id = current_user.company_id if current_user.company_id is not None else company_id
|
| 21 |
|
| 22 |
+
def is_admin_ticket(t):
|
| 23 |
+
# Look up the sender of the first message in this ticket
|
| 24 |
+
first_msg = db.query(models.TicketMessage).filter(models.TicketMessage.ticket_id == t.id).order_by(models.TicketMessage.timestamp.asc()).first()
|
| 25 |
+
if first_msg:
|
| 26 |
+
sender_user = db.query(models.User).filter(models.User.id == first_msg.sender_id).first()
|
| 27 |
+
if sender_user and sender_user.role:
|
| 28 |
+
return sender_user.role.name in ["Super Admin", "Admin", "HR"]
|
| 29 |
+
# Fallback to checking the ticket owner employee user role
|
| 30 |
+
if t.employee and t.employee.user and t.employee.user.role:
|
| 31 |
+
return t.employee.user.role.name in ["Super Admin", "Admin", "HR"]
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
if role_name == "Super Admin":
|
| 35 |
+
# Super Admin resolves admin/HR problems. Show admin tickets across all companies.
|
| 36 |
+
all_tickets = crud.get_tickets(db, company_id=target_company_id)
|
| 37 |
+
return [t for t in all_tickets if is_admin_ticket(t)]
|
| 38 |
+
|
| 39 |
+
elif role_name in ["Admin", "HR"]:
|
| 40 |
+
# Company Admin/HR resolves employee grievances/problems. Show employee tickets only.
|
| 41 |
+
company_tickets = crud.get_tickets(db, company_id=target_company_id)
|
| 42 |
+
return [t for t in company_tickets if not is_admin_ticket(t)]
|
| 43 |
+
|
| 44 |
else:
|
| 45 |
# Employees can only see their own tickets
|
| 46 |
if not current_user.employee:
|
| 47 |
raise HTTPException(status_code=400, detail="User is not registered as an employee")
|
| 48 |
+
return crud.get_tickets(db, company_id=target_company_id, employee_id=current_user.employee.id)
|
| 49 |
|
| 50 |
@router.post("/", response_model=schemas.TicketOut, status_code=status.HTTP_201_CREATED)
|
| 51 |
def create_ticket(
|
|
|
|
| 54 |
db: Session = Depends(get_db),
|
| 55 |
current_user: models.User = Depends(security.get_current_user)
|
| 56 |
):
|
| 57 |
+
employee_id = current_user.employee.id if current_user.employee else None
|
| 58 |
+
if not employee_id:
|
| 59 |
+
# Check if user has an associated employee profile or grab first employee profile if admin
|
| 60 |
+
first_emp = db.query(models.Employee).filter(models.Employee.company_id == current_user.company_id).first()
|
| 61 |
+
if first_emp:
|
| 62 |
+
employee_id = first_emp.id
|
| 63 |
+
else:
|
| 64 |
+
raise HTTPException(status_code=400, detail="No registered employee profile found for opening ticket")
|
| 65 |
|
| 66 |
db_ticket = crud.create_ticket(
|
| 67 |
+
db, ticket=ticket, employee_id=employee_id, company_id=current_user.company_id
|
| 68 |
)
|
| 69 |
|
| 70 |
crud.create_audit_log(
|
|
|
|
| 78 |
)
|
| 79 |
return db_ticket
|
| 80 |
|
| 81 |
+
from fastapi.responses import StreamingResponse
|
| 82 |
+
import json
|
| 83 |
+
import asyncio
|
| 84 |
+
|
| 85 |
@router.post("/{id}/messages", response_model=schemas.TicketMessageOut, status_code=status.HTTP_201_CREATED)
|
| 86 |
def reply_to_ticket(
|
| 87 |
id: int,
|
|
|
|
| 103 |
raise HTTPException(status_code=403, detail="Not authorized to post to this ticket")
|
| 104 |
|
| 105 |
db_message = crud.create_ticket_message(db, ticket_id=id, msg=message, sender_id=current_user.id)
|
| 106 |
+
|
| 107 |
+
# Broadcast reply to SSE stream
|
| 108 |
+
from app.core import event_bus
|
| 109 |
+
event_payload = {
|
| 110 |
+
"id": db_message.id,
|
| 111 |
+
"ticket_id": db_message.ticket_id,
|
| 112 |
+
"sender_id": db_message.sender_id,
|
| 113 |
+
"message": db_message.message,
|
| 114 |
+
"timestamp": db_message.timestamp.isoformat()
|
| 115 |
+
}
|
| 116 |
+
event_bus.publish_ticket_message(event_payload)
|
| 117 |
+
|
| 118 |
return db_message
|
| 119 |
|
| 120 |
+
@router.get("/{id}/stream")
|
| 121 |
+
async def ticket_stream(
|
| 122 |
+
id: int,
|
| 123 |
+
db: Session = Depends(get_db),
|
| 124 |
+
current_user: models.User = Depends(security.get_current_user_sse)
|
| 125 |
+
):
|
| 126 |
+
db_ticket = crud.get_ticket_by_id(db, ticket_id=id)
|
| 127 |
+
if not db_ticket:
|
| 128 |
+
raise HTTPException(status_code=404, detail="Ticket not found")
|
| 129 |
+
|
| 130 |
+
# Check company ownership scope
|
| 131 |
+
if current_user.company_id is not None and db_ticket.company_id != current_user.company_id:
|
| 132 |
+
raise HTTPException(status_code=403, detail="Not authorized to access this resource")
|
| 133 |
+
|
| 134 |
+
role_name = current_user.role.name if current_user.role else "Employee"
|
| 135 |
+
if role_name == "Employee":
|
| 136 |
+
if not current_user.employee or db_ticket.employee_id != current_user.employee.id:
|
| 137 |
+
raise HTTPException(status_code=403, detail="Not authorized to access this ticket stream")
|
| 138 |
+
|
| 139 |
+
async def event_generator():
|
| 140 |
+
from app.core import event_bus
|
| 141 |
+
queue = event_bus.subscribe_tickets()
|
| 142 |
+
try:
|
| 143 |
+
while True:
|
| 144 |
+
# Wait for next event published to the bus
|
| 145 |
+
event_data = await queue.get()
|
| 146 |
+
if event_data.get("ticket_id") == id:
|
| 147 |
+
yield f"data: {json.dumps(event_data)}\n\n"
|
| 148 |
+
except asyncio.CancelledError:
|
| 149 |
+
# Client disconnected
|
| 150 |
+
pass
|
| 151 |
+
finally:
|
| 152 |
+
event_bus.unsubscribe_tickets(queue)
|
| 153 |
+
|
| 154 |
+
return StreamingResponse(
|
| 155 |
+
event_generator(),
|
| 156 |
+
media_type="text/event-stream",
|
| 157 |
+
headers={
|
| 158 |
+
"Cache-Control": "no-cache",
|
| 159 |
+
"Connection": "keep-alive",
|
| 160 |
+
"X-Accel-Buffering": "no"
|
| 161 |
+
}
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
@router.put("/{id}/status", response_model=schemas.TicketOut)
|
| 165 |
def update_ticket(
|
| 166 |
request: Request,
|
backend/app/api/v1/timeline.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core import security
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
from app.schemas import schemas
|
| 9 |
+
from app.models import models
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
checker_staff = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 13 |
+
|
| 14 |
+
@router.get("/", response_model=List[schemas.ActivityTimelineOut])
|
| 15 |
+
def read_activity_timeline(
|
| 16 |
+
entity_type: Optional[str] = None,
|
| 17 |
+
entity_id: Optional[int] = None,
|
| 18 |
+
limit: int = 50,
|
| 19 |
+
db: Session = Depends(get_db),
|
| 20 |
+
current_user: models.User = Depends(checker_staff)
|
| 21 |
+
):
|
| 22 |
+
company_id = current_user.company_id
|
| 23 |
+
if current_user.role.name == "Super Admin":
|
| 24 |
+
# Super Admins can see global platform timeline across all companies
|
| 25 |
+
return crud.get_activity_timeline(db, entity_type=entity_type, entity_id=entity_id, limit=limit)
|
| 26 |
+
|
| 27 |
+
return crud.get_activity_timeline(
|
| 28 |
+
db,
|
| 29 |
+
company_id=company_id,
|
| 30 |
+
entity_type=entity_type,
|
| 31 |
+
entity_id=entity_id,
|
| 32 |
+
limit=limit
|
| 33 |
+
)
|
backend/app/core/attendance_policy.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.crud import crud
|
| 4 |
+
from app.models import models
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
class AttendancePolicyEngine:
|
| 8 |
+
@staticmethod
|
| 9 |
+
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 10 |
+
# Haversine formula to compute distance in meters
|
| 11 |
+
R = 6371000 # Earth radius in meters
|
| 12 |
+
phi1 = math.radians(lat1)
|
| 13 |
+
phi2 = math.radians(lat2)
|
| 14 |
+
delta_phi = math.radians(lat2 - lat1)
|
| 15 |
+
delta_lambda = math.radians(lon2 - lon1)
|
| 16 |
+
|
| 17 |
+
a = math.sin(delta_phi / 2) ** 2 + \
|
| 18 |
+
math.cos(phi1) * math.cos(phi2) * \
|
| 19 |
+
math.sin(delta_lambda / 2) ** 2
|
| 20 |
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 21 |
+
return R * c
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
def evaluate_attendance(
|
| 25 |
+
cls,
|
| 26 |
+
db: Session,
|
| 27 |
+
employee: models.Employee,
|
| 28 |
+
lat: float = None,
|
| 29 |
+
lng: float = None,
|
| 30 |
+
confidence: float = None
|
| 31 |
+
) -> dict:
|
| 32 |
+
company_id = employee.company_id
|
| 33 |
+
now = datetime.datetime.now()
|
| 34 |
+
today = now.date()
|
| 35 |
+
|
| 36 |
+
# 1. Fetch matching settings
|
| 37 |
+
threshold_setting = crud.get_setting(db, "face_match_threshold", company_id)
|
| 38 |
+
match_threshold = float(threshold_setting.value) if threshold_setting else 0.6
|
| 39 |
+
|
| 40 |
+
geofence_lat_setting = crud.get_setting(db, "office_latitude", company_id)
|
| 41 |
+
geofence_lng_setting = crud.get_setting(db, "office_longitude", company_id)
|
| 42 |
+
geofence_radius_setting = crud.get_setting(db, "geofence_radius_meters", company_id)
|
| 43 |
+
|
| 44 |
+
# 2. Confidence Validation
|
| 45 |
+
if confidence is not None and confidence < match_threshold:
|
| 46 |
+
return {"allowed": False, "reason": "Biometric match confidence score below threshold requirement."}
|
| 47 |
+
|
| 48 |
+
# 3. Geofence Validation
|
| 49 |
+
geofence_result = "Passed"
|
| 50 |
+
if geofence_lat_setting and geofence_lng_setting and geofence_radius_setting:
|
| 51 |
+
try:
|
| 52 |
+
target_lat = float(geofence_lat_setting.value)
|
| 53 |
+
target_lng = float(geofence_lng_setting.value)
|
| 54 |
+
allowed_radius = float(geofence_radius_setting.value)
|
| 55 |
+
|
| 56 |
+
if lat is not None and lng is not None:
|
| 57 |
+
distance = cls.calculate_distance(lat, lng, target_lat, target_lng)
|
| 58 |
+
if distance > allowed_radius:
|
| 59 |
+
if not employee.allow_wfh:
|
| 60 |
+
return {"allowed": False, "reason": f"Outside authorized geofenced perimeter. Distance: {int(distance)}m."}
|
| 61 |
+
geofence_result = f"WFH Approved ({int(distance)}m)"
|
| 62 |
+
else:
|
| 63 |
+
if not employee.allow_wfh:
|
| 64 |
+
return {"allowed": False, "reason": "GPS coordinates not supplied by kiosk terminal."}
|
| 65 |
+
geofence_result = "WFH Approved (No GPS)"
|
| 66 |
+
except ValueError:
|
| 67 |
+
pass
|
| 68 |
+
|
| 69 |
+
# 4. Duplicate Check (within 5 minutes)
|
| 70 |
+
recent_log = crud.get_attendance_by_employee_and_date(db, employee_id=employee.id, attendance_date=today)
|
| 71 |
+
if recent_log and recent_log.check_in:
|
| 72 |
+
time_since_checkin = (now - recent_log.check_in).total_seconds()
|
| 73 |
+
if time_since_checkin < 300: # 5 minutes
|
| 74 |
+
return {"allowed": False, "reason": "Duplicate swipe attempt blocked. Please wait 5 minutes."}
|
| 75 |
+
|
| 76 |
+
# 5. Shift & Grace Period Rule Evaluation
|
| 77 |
+
late_minutes = 0
|
| 78 |
+
early_exit_minutes = 0
|
| 79 |
+
overtime_hours = 0.0
|
| 80 |
+
status = "Present"
|
| 81 |
+
|
| 82 |
+
shift = employee.shift
|
| 83 |
+
shift_info = "Default Shift"
|
| 84 |
+
if shift:
|
| 85 |
+
shift_info = f"{shift.name} ({shift.start_time.strftime('%H:%M')} - {shift.end_time.strftime('%H:%M')})"
|
| 86 |
+
# Combine today's date with shift times
|
| 87 |
+
shift_start = datetime.datetime.combine(today, shift.start_time)
|
| 88 |
+
shift_end = datetime.datetime.combine(today, shift.end_time)
|
| 89 |
+
|
| 90 |
+
# Check-in evaluation (Late arrival check)
|
| 91 |
+
if not recent_log: # First check-in of the day
|
| 92 |
+
grace_limit = shift_start + datetime.timedelta(minutes=shift.grace_period_minutes)
|
| 93 |
+
if now > grace_limit:
|
| 94 |
+
status = "Late"
|
| 95 |
+
late_minutes = int((now - shift_start).total_seconds() / 60)
|
| 96 |
+
else: # Checkout check
|
| 97 |
+
# Check early departure
|
| 98 |
+
if now < shift_end:
|
| 99 |
+
early_exit_minutes = int((shift_end - now).total_seconds() / 60)
|
| 100 |
+
# Check overtime
|
| 101 |
+
if now > shift_end:
|
| 102 |
+
overtime_hours = round((now - shift_end).total_seconds() / 3600, 2)
|
| 103 |
+
|
| 104 |
+
# 6. Calculate Streak Info
|
| 105 |
+
streak = 0
|
| 106 |
+
if recent_log and recent_log.attendance_streak:
|
| 107 |
+
streak = recent_log.attendance_streak
|
| 108 |
+
else:
|
| 109 |
+
# Look at yesterday's record
|
| 110 |
+
yesterday = today - datetime.timedelta(days=1)
|
| 111 |
+
yesterday_record = crud.get_attendance_by_employee_and_date(db, employee_id=employee.id, attendance_date=yesterday)
|
| 112 |
+
if yesterday_record and yesterday_record.status in ["Present", "Late"]:
|
| 113 |
+
streak = yesterday_record.attendance_streak + 1
|
| 114 |
+
else:
|
| 115 |
+
streak = 1
|
| 116 |
+
|
| 117 |
+
return {
|
| 118 |
+
"allowed": True,
|
| 119 |
+
"status": status if not employee.allow_wfh else "WFH",
|
| 120 |
+
"late_minutes": late_minutes,
|
| 121 |
+
"early_exit_minutes": early_exit_minutes,
|
| 122 |
+
"overtime_hours": overtime_hours,
|
| 123 |
+
"streak": streak,
|
| 124 |
+
"shift_info": shift_info,
|
| 125 |
+
"geofence_result": geofence_result,
|
| 126 |
+
"policy_version": "v2.0-Enterprise"
|
| 127 |
+
}
|
backend/app/core/config.py
CHANGED
|
@@ -53,7 +53,7 @@ class Settings(BaseSettings):
|
|
| 53 |
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 54 |
|
| 55 |
# Seeding
|
| 56 |
-
INITIAL_ADMIN_EMAIL: str = "
|
| 57 |
INITIAL_ADMIN_PASSWORD: str = "Admin@NetraID2026"
|
| 58 |
|
| 59 |
# Face recognition & liveness detection parameters
|
|
|
|
| 53 |
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 54 |
|
| 55 |
# Seeding
|
| 56 |
+
INITIAL_ADMIN_EMAIL: str = "pavanupadhyay027@gmail.com"
|
| 57 |
INITIAL_ADMIN_PASSWORD: str = "Admin@NetraID2026"
|
| 58 |
|
| 59 |
# Face recognition & liveness detection parameters
|
backend/app/core/event_bus.py
CHANGED
|
@@ -53,3 +53,40 @@ def _publish_to_all(payload: dict) -> None:
|
|
| 53 |
dead.append(q)
|
| 54 |
for q in dead:
|
| 55 |
unsubscribe(q)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
dead.append(q)
|
| 54 |
for q in dead:
|
| 55 |
unsubscribe(q)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --- Ticket Chat Broadcast Bus ---
|
| 59 |
+
_ticket_subscribers: List[asyncio.Queue] = []
|
| 60 |
+
|
| 61 |
+
def subscribe_tickets() -> asyncio.Queue:
|
| 62 |
+
global _loop
|
| 63 |
+
try:
|
| 64 |
+
_loop = asyncio.get_running_loop()
|
| 65 |
+
except RuntimeError:
|
| 66 |
+
pass
|
| 67 |
+
q: asyncio.Queue = asyncio.Queue(maxsize=50)
|
| 68 |
+
_ticket_subscribers.append(q)
|
| 69 |
+
return q
|
| 70 |
+
|
| 71 |
+
def unsubscribe_tickets(q: asyncio.Queue) -> None:
|
| 72 |
+
try:
|
| 73 |
+
_ticket_subscribers.remove(q)
|
| 74 |
+
except ValueError:
|
| 75 |
+
pass
|
| 76 |
+
|
| 77 |
+
def publish_ticket_message(payload: dict) -> None:
|
| 78 |
+
global _loop
|
| 79 |
+
if _loop is not None:
|
| 80 |
+
_loop.call_soon_threadsafe(_publish_ticket_to_all, payload)
|
| 81 |
+
else:
|
| 82 |
+
_publish_ticket_to_all(payload)
|
| 83 |
+
|
| 84 |
+
def _publish_ticket_to_all(payload: dict) -> None:
|
| 85 |
+
dead: List[asyncio.Queue] = []
|
| 86 |
+
for q in _ticket_subscribers:
|
| 87 |
+
try:
|
| 88 |
+
q.put_nowait(payload)
|
| 89 |
+
except asyncio.QueueFull:
|
| 90 |
+
dead.append(q)
|
| 91 |
+
for q in dead:
|
| 92 |
+
unsubscribe_tickets(q)
|
backend/app/core/init_db.py
CHANGED
|
@@ -226,5 +226,46 @@ def init_db(db: Session):
|
|
| 226 |
admin_user.hashed_password = crud.get_password_hash(settings.INITIAL_ADMIN_PASSWORD)
|
| 227 |
admin_user.company_id = None
|
| 228 |
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
logger.info("Database initialization and seeding completed successfully.")
|
|
|
|
| 226 |
admin_user.hashed_password = crud.get_password_hash(settings.INITIAL_ADMIN_PASSWORD)
|
| 227 |
admin_user.company_id = None
|
| 228 |
db.commit()
|
| 229 |
+
|
| 230 |
+
# 4. Seed Default Company Admin User linked to NetraID Base
|
| 231 |
+
default_admin_email = "hr@netraid.ai"
|
| 232 |
+
default_admin = crud.get_user_by_email(db, default_admin_email)
|
| 233 |
+
if not default_admin:
|
| 234 |
+
logger.info(f"Seeding default company admin user: {default_admin_email}")
|
| 235 |
+
admin_create = schemas.UserCreate(
|
| 236 |
+
email=default_admin_email,
|
| 237 |
+
password="Admin@NetraID2026",
|
| 238 |
+
role_id=db_roles["Admin"].id
|
| 239 |
+
)
|
| 240 |
+
crud.create_user(db, admin_create, company_id=default_company.id)
|
| 241 |
+
|
| 242 |
+
# 5. Seed Default Employee User linked to NetraID Base
|
| 243 |
+
default_emp_email = "employee@netraid.ai"
|
| 244 |
+
default_emp = crud.get_user_by_email(db, default_emp_email)
|
| 245 |
+
if not default_emp:
|
| 246 |
+
logger.info(f"Seeding default employee user: {default_emp_email}")
|
| 247 |
+
emp_create = schemas.UserCreate(
|
| 248 |
+
email=default_emp_email,
|
| 249 |
+
password="Employee@NetraID2026",
|
| 250 |
+
role_id=db_roles["Employee"].id
|
| 251 |
+
)
|
| 252 |
+
db_user = crud.create_user(db, emp_create, company_id=default_company.id)
|
| 253 |
+
|
| 254 |
+
eng_dept = db.execute(select(models.Department).where(
|
| 255 |
+
models.Department.code == "ENG",
|
| 256 |
+
models.Department.company_id == default_company.id
|
| 257 |
+
)).scalar_one_or_none()
|
| 258 |
+
dept_id = eng_dept.id if eng_dept else None
|
| 259 |
+
|
| 260 |
+
employee_in = schemas.EmployeeCreate(
|
| 261 |
+
name="Rahul Kumar",
|
| 262 |
+
employee_id="EMP101",
|
| 263 |
+
phone="9876543210",
|
| 264 |
+
email=default_emp_email,
|
| 265 |
+
designation="Software Engineer",
|
| 266 |
+
department_id=dept_id,
|
| 267 |
+
status="Active"
|
| 268 |
+
)
|
| 269 |
+
crud.create_employee(db, employee_in, user_id=db_user.id, company_id=default_company.id)
|
| 270 |
|
| 271 |
logger.info("Database initialization and seeding completed successfully.")
|
backend/app/core/rate_limiter.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Dict, Tuple
|
| 3 |
+
from fastapi import Request, HTTPException, status
|
| 4 |
+
|
| 5 |
+
class SimpleRateLimiter:
|
| 6 |
+
"""
|
| 7 |
+
Sliding window rate limiter to protect authentication, biometric, and sensitive API routes
|
| 8 |
+
against brute-force and credential stuffing attacks.
|
| 9 |
+
"""
|
| 10 |
+
def __init__(self, requests_per_window: int = 10, window_seconds: int = 60):
|
| 11 |
+
self.requests_per_window = requests_per_window
|
| 12 |
+
self.window_seconds = window_seconds
|
| 13 |
+
# Mapping: IP address -> List of timestamps
|
| 14 |
+
self._history: Dict[str, list] = {}
|
| 15 |
+
|
| 16 |
+
def is_rate_limited(self, ip: str) -> bool:
|
| 17 |
+
now = time.time()
|
| 18 |
+
cutoff = now - self.window_seconds
|
| 19 |
+
|
| 20 |
+
# Filter out timestamps outside current window
|
| 21 |
+
timestamps = [t for t in self._history.get(ip, []) if t > cutoff]
|
| 22 |
+
|
| 23 |
+
if len(timestamps) >= self.requests_per_window:
|
| 24 |
+
self._history[ip] = timestamps
|
| 25 |
+
return True
|
| 26 |
+
|
| 27 |
+
timestamps.append(now)
|
| 28 |
+
self._history[ip] = timestamps
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
# Global instance for Auth / Biometric Endpoints: Max 15 requests per 60 seconds per IP
|
| 32 |
+
login_rate_limiter = SimpleRateLimiter(requests_per_window=15, window_seconds=60)
|
| 33 |
+
|
| 34 |
+
def check_login_rate_limit(request: Request):
|
| 35 |
+
client_ip = request.client.host if request.client else "127.0.0.1"
|
| 36 |
+
# Support proxy headers if behind nginx/cloudflare
|
| 37 |
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
| 38 |
+
if forwarded_for:
|
| 39 |
+
client_ip = forwarded_for.split(",")[0].strip()
|
| 40 |
+
|
| 41 |
+
if login_rate_limiter.is_rate_limited(client_ip):
|
| 42 |
+
raise HTTPException(
|
| 43 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 44 |
+
detail="Too many authentication attempts. Please wait 60 seconds before trying again."
|
| 45 |
+
)
|
backend/app/core/security.py
CHANGED
|
@@ -10,6 +10,19 @@ from app.core.database import get_db
|
|
| 10 |
from app.models import models
|
| 11 |
from app.crud import crud
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
oauth2_scheme = OAuth2PasswordBearer(
|
| 14 |
tokenUrl=f"{settings.API_V1_STR}/auth/login"
|
| 15 |
)
|
|
|
|
| 10 |
from app.models import models
|
| 11 |
from app.crud import crud
|
| 12 |
|
| 13 |
+
import bcrypt
|
| 14 |
+
|
| 15 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 16 |
+
try:
|
| 17 |
+
# Convert password strings to bytes for bcrypt
|
| 18 |
+
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
| 19 |
+
except Exception:
|
| 20 |
+
return False
|
| 21 |
+
|
| 22 |
+
def get_password_hash(password: str) -> str:
|
| 23 |
+
# Hash password using bcrypt salt
|
| 24 |
+
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
| 25 |
+
|
| 26 |
oauth2_scheme = OAuth2PasswordBearer(
|
| 27 |
tokenUrl=f"{settings.API_V1_STR}/auth/login"
|
| 28 |
)
|
backend/app/crud/crud.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from sqlalchemy import select, or_, and_, func, delete
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
-
from datetime import date, datetime, timedelta
|
|
|
|
| 4 |
from app.models import models
|
| 5 |
from app.schemas import schemas
|
| 6 |
import logging
|
|
@@ -27,19 +28,62 @@ def get_companies(db: Session, skip: int = 0, limit: int = 100):
|
|
| 27 |
return db.execute(select(models.Company).offset(skip).limit(limit)).scalars().all()
|
| 28 |
|
| 29 |
def create_company(db: Session, company: schemas.CompanyCreate):
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
db.add(db_company)
|
| 32 |
db.commit()
|
| 33 |
db.refresh(db_company)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return db_company
|
| 35 |
|
| 36 |
def update_company(db: Session, company_id: int, company: schemas.CompanyUpdate):
|
| 37 |
db_company = get_company_by_id(db, company_id)
|
| 38 |
if not db_company:
|
| 39 |
return None
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
setattr(db_company, key, value)
|
| 42 |
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
db.refresh(db_company)
|
| 44 |
return db_company
|
| 45 |
|
|
@@ -285,6 +329,58 @@ def get_attendance_logs(db: Session, company_id: int = None, skip: int = 0, limi
|
|
| 285 |
query = query.order_by(models.AttendanceLog.timestamp.desc()).offset(skip).limit(limit)
|
| 286 |
return db.execute(query).scalars().all()
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
def get_daily_attendance(db: Session, date_val: date, employee_id: int = None, department_id: int = None, company_id: int = None):
|
| 289 |
query = select(models.Attendance).join(models.Employee)
|
| 290 |
filters = [models.Attendance.date == date_val]
|
|
@@ -318,7 +414,9 @@ def delete_face_embeddings(db: Session, employee_id: int):
|
|
| 318 |
db.commit()
|
| 319 |
|
| 320 |
def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, camera: str, confidence: float) -> models.Attendance:
|
| 321 |
-
|
|
|
|
|
|
|
| 322 |
|
| 323 |
employee = db.get(models.Employee, employee_id)
|
| 324 |
if not employee:
|
|
@@ -363,7 +461,7 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
|
|
| 363 |
|
| 364 |
if not db_attendance:
|
| 365 |
# First scan of the day -> CHECK-IN
|
| 366 |
-
is_late =
|
| 367 |
status = "Late" if is_late else "Present"
|
| 368 |
|
| 369 |
db_attendance = models.Attendance(
|
|
@@ -393,7 +491,7 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca
|
|
| 393 |
# Auto-calculate early departure
|
| 394 |
if employee.shift:
|
| 395 |
shift_end_dt = datetime.combine(today, employee.shift.end_time)
|
| 396 |
-
db_attendance.early_departure =
|
| 397 |
|
| 398 |
logger.info(f"Marked Check-Out for employee {employee_id} at {timestamp}. Hours: {db_attendance.working_hours}")
|
| 399 |
|
|
@@ -557,3 +655,160 @@ def update_ticket_status(db: Session, ticket_id: int, status: str):
|
|
| 557 |
db.commit()
|
| 558 |
db.refresh(db_ticket)
|
| 559 |
return db_ticket
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from sqlalchemy import select, or_, and_, func, delete
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
+
from datetime import date, datetime, timedelta, time
|
| 4 |
+
from typing import Optional, List
|
| 5 |
from app.models import models
|
| 6 |
from app.schemas import schemas
|
| 7 |
import logging
|
|
|
|
| 28 |
return db.execute(select(models.Company).offset(skip).limit(limit)).scalars().all()
|
| 29 |
|
| 30 |
def create_company(db: Session, company: schemas.CompanyCreate):
|
| 31 |
+
dump = company.model_dump()
|
| 32 |
+
logo = dump.pop("logo", None)
|
| 33 |
+
latitude = dump.pop("latitude", None)
|
| 34 |
+
longitude = dump.pop("longitude", None)
|
| 35 |
+
db_company = models.Company(**dump)
|
| 36 |
db.add(db_company)
|
| 37 |
db.commit()
|
| 38 |
db.refresh(db_company)
|
| 39 |
+
if logo:
|
| 40 |
+
set_setting(db, key="COMPANY_LOGO", value=logo, company_id=db_company.id)
|
| 41 |
+
if latitude:
|
| 42 |
+
set_setting(db, key="LOCATION_LATITUDE", value=str(latitude), company_id=db_company.id)
|
| 43 |
+
if longitude:
|
| 44 |
+
set_setting(db, key="LOCATION_LONGITUDE", value=str(longitude), company_id=db_company.id)
|
| 45 |
+
if db_company.address:
|
| 46 |
+
set_setting(db, key="LOCATION_ADDRESS", value=db_company.address, company_id=db_company.id)
|
| 47 |
+
|
| 48 |
+
# Auto-seed standard departments for the new organization
|
| 49 |
+
default_depts = [
|
| 50 |
+
{"name": "Engineering", "code": "ENG", "description": "Software development, DevOps, QA, and IT systems"},
|
| 51 |
+
{"name": "Human Resources", "code": "HR", "description": "Recruitment, payroll, and staff relations"},
|
| 52 |
+
{"name": "Marketing & Sales", "code": "MKT", "description": "Product branding, marketing campaigns, and client sales"},
|
| 53 |
+
{"name": "Finance & Accounts", "code": "FIN", "description": "Financial planning, accounting, and budgeting"},
|
| 54 |
+
{"name": "Operations", "code": "OPS", "description": "Office administration and business facilities"}
|
| 55 |
+
]
|
| 56 |
+
for d in default_depts:
|
| 57 |
+
db_dept = models.Department(
|
| 58 |
+
name=d["name"],
|
| 59 |
+
code=d["code"],
|
| 60 |
+
description=d["description"],
|
| 61 |
+
company_id=db_company.id
|
| 62 |
+
)
|
| 63 |
+
db.add(db_dept)
|
| 64 |
+
db.commit()
|
| 65 |
+
db.refresh(db_company)
|
| 66 |
return db_company
|
| 67 |
|
| 68 |
def update_company(db: Session, company_id: int, company: schemas.CompanyUpdate):
|
| 69 |
db_company = get_company_by_id(db, company_id)
|
| 70 |
if not db_company:
|
| 71 |
return None
|
| 72 |
+
dump = company.model_dump(exclude_unset=True)
|
| 73 |
+
logo = dump.pop("logo", None)
|
| 74 |
+
latitude = dump.pop("latitude", None)
|
| 75 |
+
longitude = dump.pop("longitude", None)
|
| 76 |
+
for key, value in dump.items():
|
| 77 |
setattr(db_company, key, value)
|
| 78 |
db.commit()
|
| 79 |
+
if logo is not None:
|
| 80 |
+
set_setting(db, key="COMPANY_LOGO", value=logo, company_id=company_id)
|
| 81 |
+
if latitude is not None:
|
| 82 |
+
set_setting(db, key="LOCATION_LATITUDE", value=str(latitude), company_id=company_id)
|
| 83 |
+
if longitude is not None:
|
| 84 |
+
set_setting(db, key="LOCATION_LONGITUDE", value=str(longitude), company_id=company_id)
|
| 85 |
+
if db_company.address:
|
| 86 |
+
set_setting(db, key="LOCATION_ADDRESS", value=db_company.address, company_id=company_id)
|
| 87 |
db.refresh(db_company)
|
| 88 |
return db_company
|
| 89 |
|
|
|
|
| 329 |
query = query.order_by(models.AttendanceLog.timestamp.desc()).offset(skip).limit(limit)
|
| 330 |
return db.execute(query).scalars().all()
|
| 331 |
|
| 332 |
+
def create_attendance_log(
|
| 333 |
+
db: Session,
|
| 334 |
+
employee_id: Optional[int],
|
| 335 |
+
camera: str,
|
| 336 |
+
confidence: Optional[float],
|
| 337 |
+
liveness_score: Optional[float],
|
| 338 |
+
is_spoof: bool,
|
| 339 |
+
status: str,
|
| 340 |
+
timestamp: datetime = None,
|
| 341 |
+
image_path: str = None,
|
| 342 |
+
location_text: str = None,
|
| 343 |
+
latitude: float = None,
|
| 344 |
+
longitude: float = None,
|
| 345 |
+
face_quality: float = None,
|
| 346 |
+
blur_score: float = None,
|
| 347 |
+
brightness_score: float = None,
|
| 348 |
+
is_occluded: bool = False,
|
| 349 |
+
has_mask: bool = False,
|
| 350 |
+
recognition_time_ms: float = None,
|
| 351 |
+
processing_time_ms: float = None,
|
| 352 |
+
embedding_version: str = None,
|
| 353 |
+
device_id: int = None
|
| 354 |
+
):
|
| 355 |
+
if timestamp is None:
|
| 356 |
+
timestamp = datetime.utcnow()
|
| 357 |
+
db_log = models.AttendanceLog(
|
| 358 |
+
employee_id=employee_id,
|
| 359 |
+
timestamp=timestamp,
|
| 360 |
+
camera=camera,
|
| 361 |
+
confidence=confidence,
|
| 362 |
+
liveness_score=liveness_score,
|
| 363 |
+
is_spoof=is_spoof,
|
| 364 |
+
status=status,
|
| 365 |
+
image_path=image_path,
|
| 366 |
+
location_text=location_text,
|
| 367 |
+
latitude=latitude,
|
| 368 |
+
longitude=longitude,
|
| 369 |
+
face_quality=face_quality,
|
| 370 |
+
blur_score=blur_score,
|
| 371 |
+
brightness_score=brightness_score,
|
| 372 |
+
is_occluded=is_occluded,
|
| 373 |
+
has_mask=has_mask,
|
| 374 |
+
recognition_time_ms=recognition_time_ms,
|
| 375 |
+
processing_time_ms=processing_time_ms,
|
| 376 |
+
embedding_version=embedding_version,
|
| 377 |
+
device_id=device_id
|
| 378 |
+
)
|
| 379 |
+
db.add(db_log)
|
| 380 |
+
db.commit()
|
| 381 |
+
db.refresh(db_log)
|
| 382 |
+
return db_log
|
| 383 |
+
|
| 384 |
def get_daily_attendance(db: Session, date_val: date, employee_id: int = None, department_id: int = None, company_id: int = None):
|
| 385 |
query = select(models.Attendance).join(models.Employee)
|
| 386 |
filters = [models.Attendance.date == date_val]
|
|
|
|
| 414 |
db.commit()
|
| 415 |
|
| 416 |
def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, camera: str, confidence: float) -> models.Attendance:
|
| 417 |
+
# Convert UTC timestamp to IST to get today's date and for shift/deadline comparisons
|
| 418 |
+
ist_time = timestamp + timedelta(hours=5, minutes=30)
|
| 419 |
+
today = ist_time.date()
|
| 420 |
|
| 421 |
employee = db.get(models.Employee, employee_id)
|
| 422 |
if not employee:
|
|
|
|
| 461 |
|
| 462 |
if not db_attendance:
|
| 463 |
# First scan of the day -> CHECK-IN
|
| 464 |
+
is_late = ist_time > check_in_deadline
|
| 465 |
status = "Late" if is_late else "Present"
|
| 466 |
|
| 467 |
db_attendance = models.Attendance(
|
|
|
|
| 491 |
# Auto-calculate early departure
|
| 492 |
if employee.shift:
|
| 493 |
shift_end_dt = datetime.combine(today, employee.shift.end_time)
|
| 494 |
+
db_attendance.early_departure = ist_time < shift_end_dt
|
| 495 |
|
| 496 |
logger.info(f"Marked Check-Out for employee {employee_id} at {timestamp}. Hours: {db_attendance.working_hours}")
|
| 497 |
|
|
|
|
| 655 |
db.commit()
|
| 656 |
db.refresh(db_ticket)
|
| 657 |
return db_ticket
|
| 658 |
+
|
| 659 |
+
# --- Device CRUD ---
|
| 660 |
+
def get_device_by_id(db: Session, device_id: int):
|
| 661 |
+
return db.get(models.Device, device_id)
|
| 662 |
+
|
| 663 |
+
def get_devices(db: Session, company_id: int = None):
|
| 664 |
+
query = select(models.Device)
|
| 665 |
+
if company_id is not None:
|
| 666 |
+
query = query.where(models.Device.company_id == company_id)
|
| 667 |
+
return db.execute(query.order_by(models.Device.name.asc())).scalars().all()
|
| 668 |
+
|
| 669 |
+
def create_device(db: Session, device: schemas.DeviceCreate, company_id: int):
|
| 670 |
+
db_device = models.Device(
|
| 671 |
+
name=device.name,
|
| 672 |
+
device_type=device.device_type,
|
| 673 |
+
company_id=company_id,
|
| 674 |
+
branch=device.branch,
|
| 675 |
+
camera=device.camera,
|
| 676 |
+
ip_address=device.ip_address,
|
| 677 |
+
os_info=device.os_info,
|
| 678 |
+
app_version=device.app_version
|
| 679 |
+
)
|
| 680 |
+
db.add(db_device)
|
| 681 |
+
db.commit()
|
| 682 |
+
db.refresh(db_device)
|
| 683 |
+
return db_device
|
| 684 |
+
|
| 685 |
+
def update_device(db: Session, device_id: int, device_update: schemas.DeviceUpdate):
|
| 686 |
+
db_device = get_device_by_id(db, device_id)
|
| 687 |
+
if not db_device:
|
| 688 |
+
return None
|
| 689 |
+
update_data = device_update.model_dump(exclude_unset=True)
|
| 690 |
+
for key, value in update_data.items():
|
| 691 |
+
setattr(db_device, key, value)
|
| 692 |
+
db_device.heartbeat = datetime.utcnow()
|
| 693 |
+
db.commit()
|
| 694 |
+
db.refresh(db_device)
|
| 695 |
+
return db_device
|
| 696 |
+
|
| 697 |
+
# --- Notification CRUD ---
|
| 698 |
+
def get_notification_by_id(db: Session, notification_id: int):
|
| 699 |
+
return db.get(models.Notification, notification_id)
|
| 700 |
+
|
| 701 |
+
def get_notifications(db: Session, company_id: int = None, recipient_id: int = None, is_read: bool = None):
|
| 702 |
+
query = select(models.Notification)
|
| 703 |
+
filters = []
|
| 704 |
+
if company_id is not None:
|
| 705 |
+
filters.append(models.Notification.company_id == company_id)
|
| 706 |
+
if recipient_id is not None:
|
| 707 |
+
# Show both specific recipient notifications and broadcast notifications (where recipient_id is Null)
|
| 708 |
+
filters.append(or_(models.Notification.recipient_id == recipient_id, models.Notification.recipient_id.is_(None)))
|
| 709 |
+
if is_read is not None:
|
| 710 |
+
filters.append(models.Notification.is_read == is_read)
|
| 711 |
+
filters.append(models.Notification.is_archived == False)
|
| 712 |
+
|
| 713 |
+
if filters:
|
| 714 |
+
query = query.where(and_(*filters))
|
| 715 |
+
return db.execute(query.order_by(models.Notification.created_at.desc())).scalars().all()
|
| 716 |
+
|
| 717 |
+
def create_notification(db: Session, ntf: schemas.NotificationCreate, company_id: int, sender_id: int = None):
|
| 718 |
+
db_ntf = models.Notification(
|
| 719 |
+
company_id=company_id,
|
| 720 |
+
recipient_id=ntf.recipient_id,
|
| 721 |
+
sender_id=sender_id,
|
| 722 |
+
title=ntf.title,
|
| 723 |
+
message=ntf.message,
|
| 724 |
+
category=ntf.category,
|
| 725 |
+
priority=ntf.priority,
|
| 726 |
+
expires_at=ntf.expires_at
|
| 727 |
+
)
|
| 728 |
+
db.add(db_ntf)
|
| 729 |
+
db.commit()
|
| 730 |
+
db.refresh(db_ntf)
|
| 731 |
+
return db_ntf
|
| 732 |
+
|
| 733 |
+
def mark_notification_read(db: Session, notification_id: int):
|
| 734 |
+
db_ntf = get_notification_by_id(db, notification_id)
|
| 735 |
+
if db_ntf:
|
| 736 |
+
db_ntf.is_read = True
|
| 737 |
+
db.commit()
|
| 738 |
+
db.refresh(db_ntf)
|
| 739 |
+
return db_ntf
|
| 740 |
+
|
| 741 |
+
def archive_notification(db: Session, notification_id: int):
|
| 742 |
+
db_ntf = get_notification_by_id(db, notification_id)
|
| 743 |
+
if db_ntf:
|
| 744 |
+
db_ntf.is_archived = True
|
| 745 |
+
db.commit()
|
| 746 |
+
db.refresh(db_ntf)
|
| 747 |
+
return db_ntf
|
| 748 |
+
|
| 749 |
+
# --- Activity Timeline CRUD ---
|
| 750 |
+
def create_activity_timeline_log(
|
| 751 |
+
db: Session,
|
| 752 |
+
company_id: int,
|
| 753 |
+
actor_id: int,
|
| 754 |
+
action: str,
|
| 755 |
+
entity_type: str,
|
| 756 |
+
entity_id: int = None,
|
| 757 |
+
previous_value: str = None,
|
| 758 |
+
new_value: str = None,
|
| 759 |
+
ip_address: str = None,
|
| 760 |
+
device_info: str = None,
|
| 761 |
+
browser_info: str = None
|
| 762 |
+
):
|
| 763 |
+
log = models.ActivityTimeline(
|
| 764 |
+
company_id=company_id,
|
| 765 |
+
actor_id=actor_id,
|
| 766 |
+
action=action,
|
| 767 |
+
entity_type=entity_type,
|
| 768 |
+
entity_id=entity_id,
|
| 769 |
+
previous_value=previous_value,
|
| 770 |
+
new_value=new_value,
|
| 771 |
+
ip_address=ip_address,
|
| 772 |
+
device_info=device_info,
|
| 773 |
+
browser_info=browser_info
|
| 774 |
+
)
|
| 775 |
+
db.add(log)
|
| 776 |
+
db.commit()
|
| 777 |
+
db.refresh(log)
|
| 778 |
+
return log
|
| 779 |
+
|
| 780 |
+
def get_activity_timeline(db: Session, company_id: int = None, entity_type: str = None, entity_id: int = None, limit: int = 50):
|
| 781 |
+
query = select(models.ActivityTimeline)
|
| 782 |
+
filters = []
|
| 783 |
+
if company_id is not None:
|
| 784 |
+
filters.append(models.ActivityTimeline.company_id == company_id)
|
| 785 |
+
if entity_type is not None:
|
| 786 |
+
filters.append(models.ActivityTimeline.entity_type == entity_type)
|
| 787 |
+
if entity_id is not None:
|
| 788 |
+
filters.append(models.ActivityTimeline.entity_id == entity_id)
|
| 789 |
+
if filters:
|
| 790 |
+
query = query.where(and_(*filters))
|
| 791 |
+
return db.execute(query.order_by(models.ActivityTimeline.timestamp.desc()).limit(limit)).scalars().all()
|
| 792 |
+
|
| 793 |
+
def save_employee_image(db: Session, employee_id: int, file_path: str, pose_type: str, image_bytes: bytes = None):
|
| 794 |
+
db_img = models.EmployeeImage(
|
| 795 |
+
employee_id=employee_id,
|
| 796 |
+
file_path=file_path,
|
| 797 |
+
pose_type=pose_type,
|
| 798 |
+
image_bytes=image_bytes
|
| 799 |
+
)
|
| 800 |
+
db.add(db_img)
|
| 801 |
+
db.commit()
|
| 802 |
+
db.refresh(db_img)
|
| 803 |
+
return db_img
|
| 804 |
+
|
| 805 |
+
def save_face_embedding(db: Session, employee_id: int, image_id: int, embedding: list):
|
| 806 |
+
db_emb = models.FaceEmbedding(
|
| 807 |
+
employee_id=employee_id,
|
| 808 |
+
image_id=image_id,
|
| 809 |
+
embedding=embedding
|
| 810 |
+
)
|
| 811 |
+
db.add(db_emb)
|
| 812 |
+
db.commit()
|
| 813 |
+
db.refresh(db_emb)
|
| 814 |
+
return db_emb
|
backend/app/main.py
CHANGED
|
@@ -2,8 +2,14 @@ import os
|
|
| 2 |
import threading
|
| 3 |
import time
|
| 4 |
|
|
|
|
|
|
|
| 5 |
# Enforce IST Timezone for all attendance date calculations (important for HuggingFace / UTC cloud servers)
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
if hasattr(time, "tzset"):
|
| 8 |
time.tzset()
|
| 9 |
from datetime import datetime, timedelta
|
|
@@ -16,7 +22,8 @@ from sqlalchemy import delete
|
|
| 16 |
from app.core.config import settings
|
| 17 |
from app.core.database import SessionLocal
|
| 18 |
from app.core.init_db import init_db
|
| 19 |
-
from app.
|
|
|
|
| 20 |
|
| 21 |
# Logging configuration
|
| 22 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
@@ -31,29 +38,53 @@ app = FastAPI(
|
|
| 31 |
redoc_url="/redoc"
|
| 32 |
)
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# Mount uploads directory as static files
|
| 35 |
# Dynamic uploads endpoint: serves images from database (fallback to local disk)
|
| 36 |
from app.core.database import get_db
|
| 37 |
|
| 38 |
@app.get("/uploads/{employee_id}/{filename}")
|
| 39 |
def get_upload_file(employee_id: str, filename: str, db: Session = Depends(get_db)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# Parse pose type from filename (e.g. "front.jpg" -> "front")
|
| 41 |
-
pose_type =
|
| 42 |
|
| 43 |
# Query database for this employee and pose_type
|
| 44 |
db_img = db.query(models.EmployeeImage).join(models.Employee).filter(
|
| 45 |
-
models.Employee.employee_id ==
|
| 46 |
models.EmployeeImage.pose_type.ilike(pose_type)
|
| 47 |
).first()
|
| 48 |
|
| 49 |
if db_img and db_img.image_bytes:
|
| 50 |
return Response(content=db_img.image_bytes, media_type="image/jpeg")
|
| 51 |
|
| 52 |
-
# Fallback to local file system
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
try:
|
| 56 |
-
with open(
|
| 57 |
return Response(content=f.read(), media_type="image/jpeg")
|
| 58 |
except Exception:
|
| 59 |
pass
|
|
@@ -194,5 +225,9 @@ app.include_router(settings_api.router, prefix=f"{settings.API_V1_STR}/settings"
|
|
| 194 |
app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"])
|
| 195 |
app.include_router(companies.router, prefix=f"{settings.API_V1_STR}/companies", tags=["Company Management"])
|
| 196 |
app.include_router(tickets.router, prefix=f"{settings.API_V1_STR}/tickets", tags=["Support Tickets & Helpdesk"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
# Trigger reload - reload 2
|
| 198 |
|
|
|
|
| 2 |
import threading
|
| 3 |
import time
|
| 4 |
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
# Enforce IST Timezone for all attendance date calculations (important for HuggingFace / UTC cloud servers)
|
| 8 |
+
if sys.platform == "win32":
|
| 9 |
+
os.environ["TZ"] = "IST-5:30"
|
| 10 |
+
else:
|
| 11 |
+
os.environ["TZ"] = "Asia/Kolkata"
|
| 12 |
+
|
| 13 |
if hasattr(time, "tzset"):
|
| 14 |
time.tzset()
|
| 15 |
from datetime import datetime, timedelta
|
|
|
|
| 22 |
from app.core.config import settings
|
| 23 |
from app.core.database import SessionLocal
|
| 24 |
from app.core.init_db import init_db
|
| 25 |
+
from app.models import models
|
| 26 |
+
from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit, companies, tickets, devices, notifications, timeline, policy
|
| 27 |
|
| 28 |
# Logging configuration
|
| 29 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
|
|
| 38 |
redoc_url="/redoc"
|
| 39 |
)
|
| 40 |
|
| 41 |
+
from fastapi import Request
|
| 42 |
+
|
| 43 |
+
@app.middleware("http")
|
| 44 |
+
async def add_security_headers(request: Request, call_next):
|
| 45 |
+
response = await call_next(request)
|
| 46 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 47 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 48 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 49 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 50 |
+
response.headers["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; img-src 'self' data: blob: https:; connect-src 'self' ws: wss: https:;"
|
| 51 |
+
return response
|
| 52 |
+
|
| 53 |
# Mount uploads directory as static files
|
| 54 |
# Dynamic uploads endpoint: serves images from database (fallback to local disk)
|
| 55 |
from app.core.database import get_db
|
| 56 |
|
| 57 |
@app.get("/uploads/{employee_id}/{filename}")
|
| 58 |
def get_upload_file(employee_id: str, filename: str, db: Session = Depends(get_db)):
|
| 59 |
+
# Sanitize inputs to prevent directory traversal
|
| 60 |
+
clean_emp_id = os.path.basename(employee_id.replace("..", "").replace("/", "").replace("\\", ""))
|
| 61 |
+
clean_filename = os.path.basename(filename.replace("..", "").replace("/", "").replace("\\", ""))
|
| 62 |
+
|
| 63 |
+
if not clean_emp_id or not clean_filename:
|
| 64 |
+
raise HTTPException(status_code=400, detail="Invalid request parameters")
|
| 65 |
+
|
| 66 |
# Parse pose type from filename (e.g. "front.jpg" -> "front")
|
| 67 |
+
pose_type = clean_filename.split(".")[0].lower()
|
| 68 |
|
| 69 |
# Query database for this employee and pose_type
|
| 70 |
db_img = db.query(models.EmployeeImage).join(models.Employee).filter(
|
| 71 |
+
models.Employee.employee_id == clean_emp_id,
|
| 72 |
models.EmployeeImage.pose_type.ilike(pose_type)
|
| 73 |
).first()
|
| 74 |
|
| 75 |
if db_img and db_img.image_bytes:
|
| 76 |
return Response(content=db_img.image_bytes, media_type="image/jpeg")
|
| 77 |
|
| 78 |
+
# Fallback to local file system with strict path canonicalization
|
| 79 |
+
upload_dir_abs = os.path.abspath(settings.UPLOAD_DIR)
|
| 80 |
+
local_path_abs = os.path.abspath(os.path.join(upload_dir_abs, clean_emp_id, clean_filename))
|
| 81 |
+
|
| 82 |
+
if not local_path_abs.startswith(upload_dir_abs):
|
| 83 |
+
raise HTTPException(status_code=403, detail="Access denied: Path traversal attempt detected")
|
| 84 |
+
|
| 85 |
+
if os.path.exists(local_path_abs):
|
| 86 |
try:
|
| 87 |
+
with open(local_path_abs, "rb") as f:
|
| 88 |
return Response(content=f.read(), media_type="image/jpeg")
|
| 89 |
except Exception:
|
| 90 |
pass
|
|
|
|
| 225 |
app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"])
|
| 226 |
app.include_router(companies.router, prefix=f"{settings.API_V1_STR}/companies", tags=["Company Management"])
|
| 227 |
app.include_router(tickets.router, prefix=f"{settings.API_V1_STR}/tickets", tags=["Support Tickets & Helpdesk"])
|
| 228 |
+
app.include_router(devices.router, prefix=f"{settings.API_V1_STR}/devices", tags=["Kiosk Devices"])
|
| 229 |
+
app.include_router(notifications.router, prefix=f"{settings.API_V1_STR}/notifications", tags=["In-App Notifications"])
|
| 230 |
+
app.include_router(timeline.router, prefix=f"{settings.API_V1_STR}/timeline", tags=["Activity History Timeline"])
|
| 231 |
+
app.include_router(policy.router, prefix=f"{settings.API_V1_STR}/policy", tags=["Attendance Rules Policy Engine"])
|
| 232 |
# Trigger reload - reload 2
|
| 233 |
|
backend/app/models/models.py
CHANGED
|
@@ -59,6 +59,7 @@ class Company(Base):
|
|
| 59 |
settings = relationship("Setting", back_populates="company", cascade="all, delete-orphan")
|
| 60 |
audit_logs = relationship("AuditLog", back_populates="company", cascade="all, delete-orphan")
|
| 61 |
tickets = relationship("Ticket", back_populates="company", cascade="all, delete-orphan")
|
|
|
|
| 62 |
|
| 63 |
class Role(Base):
|
| 64 |
__tablename__ = "roles"
|
|
@@ -193,6 +194,16 @@ class Attendance(Base):
|
|
| 193 |
status = Column(String(20), default="Absent") # Present, Absent, Late, Half Day, Leave, Holiday, WFH
|
| 194 |
emergency_allowed = Column(Boolean, default=False)
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
employee = relationship("Employee", back_populates="attendance_records")
|
| 197 |
|
| 198 |
class AttendanceLog(Base):
|
|
@@ -200,7 +211,7 @@ class AttendanceLog(Base):
|
|
| 200 |
|
| 201 |
id = Column(Integer, primary_key=True, index=True)
|
| 202 |
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True) # Null if not recognized
|
| 203 |
-
timestamp = Column(DateTime, default=datetime.datetime.
|
| 204 |
camera = Column(String(100), default="Kiosk")
|
| 205 |
confidence = Column(Float, nullable=True)
|
| 206 |
liveness_score = Column(Float, nullable=True)
|
|
@@ -211,6 +222,17 @@ class AttendanceLog(Base):
|
|
| 211 |
latitude = Column(Float, nullable=True)
|
| 212 |
longitude = Column(Float, nullable=True)
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
employee = relationship("Employee", back_populates="attendance_logs")
|
| 215 |
|
| 216 |
class LeaveRequest(Base):
|
|
@@ -257,7 +279,7 @@ class AuditLog(Base):
|
|
| 257 |
id = Column(Integer, primary_key=True, index=True)
|
| 258 |
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 259 |
action = Column(String(100), nullable=False) # Login, Logout, Create Employee, Mark Attendance, etc.
|
| 260 |
-
timestamp = Column(DateTime, default=datetime.datetime.
|
| 261 |
ip_address = Column(String(50), nullable=True)
|
| 262 |
user_agent = Column(String(255), nullable=True)
|
| 263 |
details = Column(Text, nullable=True)
|
|
@@ -293,3 +315,66 @@ class TicketMessage(Base):
|
|
| 293 |
|
| 294 |
ticket = relationship("Ticket", back_populates="messages")
|
| 295 |
sender = relationship("User")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
settings = relationship("Setting", back_populates="company", cascade="all, delete-orphan")
|
| 60 |
audit_logs = relationship("AuditLog", back_populates="company", cascade="all, delete-orphan")
|
| 61 |
tickets = relationship("Ticket", back_populates="company", cascade="all, delete-orphan")
|
| 62 |
+
devices = relationship("Device", back_populates="company", cascade="all, delete-orphan")
|
| 63 |
|
| 64 |
class Role(Base):
|
| 65 |
__tablename__ = "roles"
|
|
|
|
| 194 |
status = Column(String(20), default="Absent") # Present, Absent, Late, Half Day, Leave, Holiday, WFH
|
| 195 |
emergency_allowed = Column(Boolean, default=False)
|
| 196 |
|
| 197 |
+
# Enterprise Extensions
|
| 198 |
+
late_minutes = Column(Integer, default=0)
|
| 199 |
+
early_exit_minutes = Column(Integer, default=0)
|
| 200 |
+
break_time_minutes = Column(Integer, default=0)
|
| 201 |
+
attendance_streak = Column(Integer, default=0)
|
| 202 |
+
attendance_percentage = Column(Float, default=100.0)
|
| 203 |
+
shift_info = Column(String(255), nullable=True)
|
| 204 |
+
geofence_result = Column(String(100), nullable=True)
|
| 205 |
+
policy_version = Column(String(50), nullable=True)
|
| 206 |
+
|
| 207 |
employee = relationship("Employee", back_populates="attendance_records")
|
| 208 |
|
| 209 |
class AttendanceLog(Base):
|
|
|
|
| 211 |
|
| 212 |
id = Column(Integer, primary_key=True, index=True)
|
| 213 |
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True) # Null if not recognized
|
| 214 |
+
timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
| 215 |
camera = Column(String(100), default="Kiosk")
|
| 216 |
confidence = Column(Float, nullable=True)
|
| 217 |
liveness_score = Column(Float, nullable=True)
|
|
|
|
| 222 |
latitude = Column(Float, nullable=True)
|
| 223 |
longitude = Column(Float, nullable=True)
|
| 224 |
|
| 225 |
+
# Recognition Analytics Extensions
|
| 226 |
+
face_quality = Column(Float, nullable=True)
|
| 227 |
+
blur_score = Column(Float, nullable=True)
|
| 228 |
+
brightness_score = Column(Float, nullable=True)
|
| 229 |
+
is_occluded = Column(Boolean, default=False)
|
| 230 |
+
has_mask = Column(Boolean, default=False)
|
| 231 |
+
recognition_time_ms = Column(Float, nullable=True)
|
| 232 |
+
processing_time_ms = Column(Float, nullable=True)
|
| 233 |
+
embedding_version = Column(String(50), nullable=True)
|
| 234 |
+
device_id = Column(Integer, ForeignKey("devices.id", ondelete="SET NULL"), nullable=True)
|
| 235 |
+
|
| 236 |
employee = relationship("Employee", back_populates="attendance_logs")
|
| 237 |
|
| 238 |
class LeaveRequest(Base):
|
|
|
|
| 279 |
id = Column(Integer, primary_key=True, index=True)
|
| 280 |
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 281 |
action = Column(String(100), nullable=False) # Login, Logout, Create Employee, Mark Attendance, etc.
|
| 282 |
+
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
| 283 |
ip_address = Column(String(50), nullable=True)
|
| 284 |
user_agent = Column(String(255), nullable=True)
|
| 285 |
details = Column(Text, nullable=True)
|
|
|
|
| 315 |
|
| 316 |
ticket = relationship("Ticket", back_populates="messages")
|
| 317 |
sender = relationship("User")
|
| 318 |
+
|
| 319 |
+
class Device(Base):
|
| 320 |
+
__tablename__ = "devices"
|
| 321 |
+
|
| 322 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 323 |
+
name = Column(String(100), nullable=False)
|
| 324 |
+
device_type = Column(String(50), default="Kiosk") # Kiosk, Mobile, Gateway
|
| 325 |
+
company_id = Column(Integer, ForeignKey("companies.id", ondelete="CASCADE"), nullable=True)
|
| 326 |
+
branch = Column(String(100), default="Main Headquarters")
|
| 327 |
+
camera = Column(String(100), default="Main Camera")
|
| 328 |
+
ip_address = Column(String(50), nullable=True)
|
| 329 |
+
os_info = Column(String(100), nullable=True)
|
| 330 |
+
app_version = Column(String(50), nullable=True)
|
| 331 |
+
status = Column(String(50), default="Online") # Online, Offline, Maintenance
|
| 332 |
+
heartbeat = Column(DateTime, default=datetime.datetime.utcnow)
|
| 333 |
+
cpu_usage = Column(Float, default=0.0)
|
| 334 |
+
memory_usage = Column(Float, default=0.0)
|
| 335 |
+
disk_usage = Column(Float, default=0.0)
|
| 336 |
+
battery_level = Column(Integer, default=100)
|
| 337 |
+
network_status = Column(String(50), default="Good")
|
| 338 |
+
last_sync = Column(DateTime, default=datetime.datetime.utcnow)
|
| 339 |
+
restart_count = Column(Integer, default=0)
|
| 340 |
+
|
| 341 |
+
company = relationship("Company", back_populates="devices")
|
| 342 |
+
|
| 343 |
+
class Notification(Base):
|
| 344 |
+
__tablename__ = "notifications"
|
| 345 |
+
|
| 346 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 347 |
+
company_id = Column(Integer, ForeignKey("companies.id", ondelete="CASCADE"), nullable=True)
|
| 348 |
+
recipient_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Null if broadcast
|
| 349 |
+
sender_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 350 |
+
title = Column(String(255), nullable=False)
|
| 351 |
+
message = Column(Text, nullable=False)
|
| 352 |
+
category = Column(String(100), default="General") # Attendance, HR, Leave, Alert, System
|
| 353 |
+
priority = Column(String(50), default="Medium") # Low, Medium, High
|
| 354 |
+
is_read = Column(Boolean, default=False)
|
| 355 |
+
is_archived = Column(Boolean, default=False)
|
| 356 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 357 |
+
expires_at = Column(DateTime, nullable=True)
|
| 358 |
+
|
| 359 |
+
company = relationship("Company")
|
| 360 |
+
recipient = relationship("User", foreign_keys=[recipient_id])
|
| 361 |
+
sender = relationship("User", foreign_keys=[sender_id])
|
| 362 |
+
|
| 363 |
+
class ActivityTimeline(Base):
|
| 364 |
+
__tablename__ = "activity_timelines"
|
| 365 |
+
|
| 366 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 367 |
+
company_id = Column(Integer, ForeignKey("companies.id", ondelete="CASCADE"), nullable=True)
|
| 368 |
+
actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 369 |
+
action = Column(String(100), nullable=False) # Create, Update, Delete, Authenticate, Scan
|
| 370 |
+
entity_type = Column(String(100), nullable=False) # Employee, Organization, Device, Attendance, Ticket
|
| 371 |
+
entity_id = Column(Integer, nullable=True)
|
| 372 |
+
previous_value = Column(Text, nullable=True)
|
| 373 |
+
new_value = Column(Text, nullable=True)
|
| 374 |
+
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
|
| 375 |
+
ip_address = Column(String(50), nullable=True)
|
| 376 |
+
device_info = Column(String(255), nullable=True)
|
| 377 |
+
browser_info = Column(String(255), nullable=True)
|
| 378 |
+
|
| 379 |
+
company = relationship("Company")
|
| 380 |
+
actor = relationship("User")
|
backend/app/schemas/schemas.py
CHANGED
|
@@ -24,7 +24,10 @@ class CompanyBase(BaseModel):
|
|
| 24 |
address: Optional[str] = None
|
| 25 |
|
| 26 |
class CompanyCreate(CompanyBase):
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
class CompanyUpdate(BaseModel):
|
| 30 |
name: Optional[str] = None
|
|
@@ -35,6 +38,9 @@ class CompanyUpdate(BaseModel):
|
|
| 35 |
admin_email: Optional[EmailStr] = None
|
| 36 |
phone: Optional[str] = None
|
| 37 |
address: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
class CompanyOut(CompanyBase):
|
| 40 |
id: int
|
|
@@ -54,6 +60,22 @@ class UserUpdate(BaseModel):
|
|
| 54 |
role_id: Optional[int] = None
|
| 55 |
is_active: Optional[bool] = None
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
# Token Schemas
|
| 59 |
class Token(BaseModel):
|
|
@@ -204,6 +226,14 @@ class AttendanceOut(AttendanceBase):
|
|
| 204 |
early_departure: bool
|
| 205 |
overtime: float
|
| 206 |
emergency_allowed: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
employee: Optional[EmployeeOut] = None
|
| 208 |
model_config = ConfigDict(from_attributes=True)
|
| 209 |
|
|
@@ -227,6 +257,15 @@ class AttendanceLogOut(BaseModel):
|
|
| 227 |
location_text: Optional[str] = None
|
| 228 |
latitude: Optional[float] = None
|
| 229 |
longitude: Optional[float] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
employee: Optional[EmployeeOut] = None
|
| 231 |
model_config = ConfigDict(from_attributes=True)
|
| 232 |
|
|
@@ -330,3 +369,76 @@ class TicketOut(TicketBase):
|
|
| 330 |
employee: Optional[EmployeeOut] = None
|
| 331 |
messages: List[TicketMessageOut] = []
|
| 332 |
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
address: Optional[str] = None
|
| 25 |
|
| 26 |
class CompanyCreate(CompanyBase):
|
| 27 |
+
logo: Optional[str] = None
|
| 28 |
+
latitude: Optional[str] = None
|
| 29 |
+
longitude: Optional[str] = None
|
| 30 |
+
admin_password: Optional[str] = None
|
| 31 |
|
| 32 |
class CompanyUpdate(BaseModel):
|
| 33 |
name: Optional[str] = None
|
|
|
|
| 38 |
admin_email: Optional[EmailStr] = None
|
| 39 |
phone: Optional[str] = None
|
| 40 |
address: Optional[str] = None
|
| 41 |
+
logo: Optional[str] = None
|
| 42 |
+
latitude: Optional[str] = None
|
| 43 |
+
longitude: Optional[str] = None
|
| 44 |
|
| 45 |
class CompanyOut(CompanyBase):
|
| 46 |
id: int
|
|
|
|
| 60 |
role_id: Optional[int] = None
|
| 61 |
is_active: Optional[bool] = None
|
| 62 |
|
| 63 |
+
class AdminRegister(BaseModel):
|
| 64 |
+
company_name: str
|
| 65 |
+
email: EmailStr
|
| 66 |
+
password: str
|
| 67 |
+
phone: Optional[str] = None
|
| 68 |
+
address: Optional[str] = None
|
| 69 |
+
|
| 70 |
+
class EmployeeRegister(BaseModel):
|
| 71 |
+
company_id: int
|
| 72 |
+
name: str
|
| 73 |
+
email: EmailStr
|
| 74 |
+
password: str
|
| 75 |
+
employee_id: str
|
| 76 |
+
phone: Optional[str] = None
|
| 77 |
+
designation: Optional[str] = None
|
| 78 |
+
|
| 79 |
|
| 80 |
# Token Schemas
|
| 81 |
class Token(BaseModel):
|
|
|
|
| 226 |
early_departure: bool
|
| 227 |
overtime: float
|
| 228 |
emergency_allowed: bool = False
|
| 229 |
+
late_minutes: Optional[int] = 0
|
| 230 |
+
early_exit_minutes: Optional[int] = 0
|
| 231 |
+
break_time_minutes: Optional[int] = 0
|
| 232 |
+
attendance_streak: Optional[int] = 0
|
| 233 |
+
attendance_percentage: Optional[float] = 0.0
|
| 234 |
+
shift_info: Optional[str] = None
|
| 235 |
+
geofence_result: Optional[str] = None
|
| 236 |
+
policy_version: Optional[str] = None
|
| 237 |
employee: Optional[EmployeeOut] = None
|
| 238 |
model_config = ConfigDict(from_attributes=True)
|
| 239 |
|
|
|
|
| 257 |
location_text: Optional[str] = None
|
| 258 |
latitude: Optional[float] = None
|
| 259 |
longitude: Optional[float] = None
|
| 260 |
+
face_quality: Optional[float] = None
|
| 261 |
+
blur_score: Optional[float] = None
|
| 262 |
+
brightness_score: Optional[float] = None
|
| 263 |
+
is_occluded: bool
|
| 264 |
+
has_mask: bool
|
| 265 |
+
recognition_time_ms: Optional[float] = None
|
| 266 |
+
processing_time_ms: Optional[float] = None
|
| 267 |
+
embedding_version: Optional[str] = None
|
| 268 |
+
device_id: Optional[int] = None
|
| 269 |
employee: Optional[EmployeeOut] = None
|
| 270 |
model_config = ConfigDict(from_attributes=True)
|
| 271 |
|
|
|
|
| 369 |
employee: Optional[EmployeeOut] = None
|
| 370 |
messages: List[TicketMessageOut] = []
|
| 371 |
model_config = ConfigDict(from_attributes=True)
|
| 372 |
+
|
| 373 |
+
# Device Schemas
|
| 374 |
+
class DeviceBase(BaseModel):
|
| 375 |
+
name: str
|
| 376 |
+
device_type: str = "Kiosk"
|
| 377 |
+
branch: str = "Main Headquarters"
|
| 378 |
+
camera: str = "Main Camera"
|
| 379 |
+
ip_address: Optional[str] = None
|
| 380 |
+
os_info: Optional[str] = None
|
| 381 |
+
app_version: Optional[str] = None
|
| 382 |
+
|
| 383 |
+
class DeviceCreate(DeviceBase):
|
| 384 |
+
pass
|
| 385 |
+
|
| 386 |
+
class DeviceUpdate(BaseModel):
|
| 387 |
+
name: Optional[str] = None
|
| 388 |
+
status: Optional[str] = None
|
| 389 |
+
cpu_usage: Optional[float] = None
|
| 390 |
+
memory_usage: Optional[float] = None
|
| 391 |
+
disk_usage: Optional[float] = None
|
| 392 |
+
battery_level: Optional[int] = None
|
| 393 |
+
network_status: Optional[str] = None
|
| 394 |
+
|
| 395 |
+
class DeviceOut(DeviceBase):
|
| 396 |
+
id: int
|
| 397 |
+
company_id: Optional[int] = None
|
| 398 |
+
status: str
|
| 399 |
+
heartbeat: datetime
|
| 400 |
+
cpu_usage: float
|
| 401 |
+
memory_usage: float
|
| 402 |
+
disk_usage: float
|
| 403 |
+
battery_level: int
|
| 404 |
+
network_status: str
|
| 405 |
+
last_sync: datetime
|
| 406 |
+
restart_count: int
|
| 407 |
+
model_config = ConfigDict(from_attributes=True)
|
| 408 |
+
|
| 409 |
+
# Notification Schemas
|
| 410 |
+
class NotificationBase(BaseModel):
|
| 411 |
+
title: str
|
| 412 |
+
message: str
|
| 413 |
+
category: str = "General"
|
| 414 |
+
priority: str = "Medium"
|
| 415 |
+
expires_at: Optional[datetime] = None
|
| 416 |
+
|
| 417 |
+
class NotificationCreate(NotificationBase):
|
| 418 |
+
recipient_id: Optional[int] = None
|
| 419 |
+
|
| 420 |
+
class NotificationOut(NotificationBase):
|
| 421 |
+
id: int
|
| 422 |
+
company_id: Optional[int] = None
|
| 423 |
+
recipient_id: Optional[int] = None
|
| 424 |
+
sender_id: Optional[int] = None
|
| 425 |
+
is_read: bool
|
| 426 |
+
is_archived: bool
|
| 427 |
+
created_at: datetime
|
| 428 |
+
model_config = ConfigDict(from_attributes=True)
|
| 429 |
+
|
| 430 |
+
# ActivityTimeline Schemas
|
| 431 |
+
class ActivityTimelineOut(BaseModel):
|
| 432 |
+
id: int
|
| 433 |
+
company_id: Optional[int] = None
|
| 434 |
+
actor_id: Optional[int] = None
|
| 435 |
+
action: str
|
| 436 |
+
entity_type: str
|
| 437 |
+
entity_id: Optional[int] = None
|
| 438 |
+
previous_value: Optional[str] = None
|
| 439 |
+
new_value: Optional[str] = None
|
| 440 |
+
timestamp: datetime
|
| 441 |
+
ip_address: Optional[str] = None
|
| 442 |
+
device_info: Optional[str] = None
|
| 443 |
+
browser_info: Optional[str] = None
|
| 444 |
+
model_config = ConfigDict(from_attributes=True)
|
backend/app/tests/test_production_features.py
CHANGED
|
@@ -79,9 +79,9 @@ def test_shift_attendance_rules():
|
|
| 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(
|
| 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
|
|
@@ -91,8 +91,8 @@ def test_shift_attendance_rules():
|
|
| 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(
|
| 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,
|
|
|
|
| 79 |
db.commit()
|
| 80 |
db.refresh(employee)
|
| 81 |
|
| 82 |
+
# Test 1: Check-in before deadline (10:10 IST)
|
| 83 |
+
# We manually call mark_kiosk_attendance with UTC timestamps (10:05 IST = 04:35 UTC)
|
| 84 |
+
checkin_time_on_time = datetime.combine(datetime.now().date(), time(4, 35))
|
| 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
|
|
|
|
| 91 |
db.delete(att)
|
| 92 |
db.commit()
|
| 93 |
|
| 94 |
+
# Test 2: Check-in after deadline (10:15 IST = 04:45 UTC)
|
| 95 |
+
checkin_time_late = datetime.combine(datetime.now().date(), time(4, 45))
|
| 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,
|
backend/delete_all_employees.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import sqlite3
|
| 4 |
+
|
| 5 |
+
def clean_db(db_path):
|
| 6 |
+
if not os.path.exists(db_path):
|
| 7 |
+
print(f"Database {db_path} not found.")
|
| 8 |
+
return
|
| 9 |
+
|
| 10 |
+
print(f"Cleaning database: {db_path}")
|
| 11 |
+
conn = sqlite3.connect(db_path)
|
| 12 |
+
c = conn.cursor()
|
| 13 |
+
|
| 14 |
+
# Get employee user_ids first
|
| 15 |
+
try:
|
| 16 |
+
c.execute("SELECT user_id, employee_id, name FROM employees")
|
| 17 |
+
employees = c.fetchall()
|
| 18 |
+
employee_user_ids = [emp[0] for emp in employees if emp[0] is not None]
|
| 19 |
+
print(f"Found {len(employees)} employees in {db_path}.")
|
| 20 |
+
except sqlite3.OperationalError as e:
|
| 21 |
+
print(f"Could not read employees: {e}")
|
| 22 |
+
conn.close()
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
# Delete related records
|
| 26 |
+
tables = [
|
| 27 |
+
"face_embeddings",
|
| 28 |
+
"employee_images",
|
| 29 |
+
"attendance",
|
| 30 |
+
"attendance_logs",
|
| 31 |
+
"leave_requests",
|
| 32 |
+
"ticket_messages",
|
| 33 |
+
"tickets",
|
| 34 |
+
"employees"
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
for table in tables:
|
| 38 |
+
try:
|
| 39 |
+
c.execute(f"DELETE FROM {table}")
|
| 40 |
+
print(f"Deleted records from table: {table}")
|
| 41 |
+
except sqlite3.OperationalError as e:
|
| 42 |
+
print(f"Table {table} delete error: {e}")
|
| 43 |
+
|
| 44 |
+
# Associated Users
|
| 45 |
+
if employee_user_ids:
|
| 46 |
+
try:
|
| 47 |
+
placeholders = ','.join('?' for _ in employee_user_ids)
|
| 48 |
+
c.execute(f"DELETE FROM users WHERE id IN ({placeholders})", employee_user_ids)
|
| 49 |
+
print(f"Deleted {c.rowcount} associated employee user accounts from users table.")
|
| 50 |
+
except sqlite3.OperationalError as e:
|
| 51 |
+
print(f"Users table delete error: {e}")
|
| 52 |
+
|
| 53 |
+
conn.commit()
|
| 54 |
+
conn.close()
|
| 55 |
+
print(f"Finished cleaning database: {db_path}\n")
|
| 56 |
+
|
| 57 |
+
def delete_employee_data():
|
| 58 |
+
# Clean both potential database paths
|
| 59 |
+
clean_db('netraid.db')
|
| 60 |
+
clean_db('../netraid.db')
|
| 61 |
+
|
| 62 |
+
# Delete image files from uploads
|
| 63 |
+
uploads_dir = './uploads'
|
| 64 |
+
if os.path.exists(uploads_dir):
|
| 65 |
+
deleted_dirs = 0
|
| 66 |
+
for item in os.listdir(uploads_dir):
|
| 67 |
+
item_path = os.path.join(uploads_dir, item)
|
| 68 |
+
if os.path.isdir(item_path):
|
| 69 |
+
try:
|
| 70 |
+
shutil.rmtree(item_path)
|
| 71 |
+
print(f"Deleted uploads directory: {item_path}")
|
| 72 |
+
deleted_dirs += 1
|
| 73 |
+
except Exception as e:
|
| 74 |
+
print(f"Error deleting directory {item_path}: {e}")
|
| 75 |
+
print(f"Cleared {deleted_dirs} employee upload folders from {uploads_dir}.")
|
| 76 |
+
else:
|
| 77 |
+
print("Uploads directory not found.")
|
| 78 |
+
|
| 79 |
+
print("\n[SUCCESS] All employee data has been successfully deleted!")
|
| 80 |
+
|
| 81 |
+
if __name__ == '__main__':
|
| 82 |
+
delete_employee_data()
|
frontend/app/analytics/page.tsx
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useMemo } from "react";
|
| 4 |
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
| 5 |
+
import ReactECharts from "echarts-for-react";
|
| 6 |
+
import * as echarts from "echarts";
|
| 7 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 8 |
+
import { fetchApi, getUserProfile } from "@/app/utils/api";
|
| 9 |
+
import {
|
| 10 |
+
TrendingUp, Users, Activity, CheckCircle, BarChart3, PieChart,
|
| 11 |
+
Calendar, ShieldCheck, Zap, RefreshCw, Building2, Layers, Shield,
|
| 12 |
+
Filter, RotateCcw, Search
|
| 13 |
+
} from "lucide-react";
|
| 14 |
+
|
| 15 |
+
export default function AnalyticsPage() {
|
| 16 |
+
const queryClient = useQueryClient();
|
| 17 |
+
const [days, setDays] = useState<number>(7);
|
| 18 |
+
const [selectedOrgId, setSelectedOrgId] = useState<string>("ALL");
|
| 19 |
+
const [tierFilter, setTierFilter] = useState<string>("ALL");
|
| 20 |
+
const [statusFilter, setStatusFilter] = useState<string>("ALL");
|
| 21 |
+
const [deptFilter, setDeptFilter] = useState<string>("ALL");
|
| 22 |
+
|
| 23 |
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
| 24 |
+
const [profile, setProfile] = useState<any>(null);
|
| 25 |
+
const [profileLoading, setProfileLoading] = useState(true);
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
setProfile(getUserProfile());
|
| 29 |
+
setProfileLoading(false);
|
| 30 |
+
}, []);
|
| 31 |
+
|
| 32 |
+
const isSuperAdmin = profile?.role?.name === "Super Admin";
|
| 33 |
+
|
| 34 |
+
// 1. Fetch Companies (For Super Admin Platform Analytics)
|
| 35 |
+
const { data: companies = [], refetch: refetchCompanies } = useQuery({
|
| 36 |
+
queryKey: ["analytics-companies"],
|
| 37 |
+
queryFn: () => fetchApi("/companies/"),
|
| 38 |
+
enabled: isSuperAdmin && !profileLoading,
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// 2. Fetch Summary (For Org Admin / Common Analytics)
|
| 42 |
+
const { data: summary, refetch: refetchSummary } = useQuery({
|
| 43 |
+
queryKey: ["analytics-summary"],
|
| 44 |
+
queryFn: () => fetchApi("/analytics/dashboard-summary"),
|
| 45 |
+
enabled: !profileLoading,
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
// 3. Fetch Attendance Trends
|
| 49 |
+
const { data: trends = [], refetch: refetchTrends } = useQuery({
|
| 50 |
+
queryKey: ["analytics-trends", days],
|
| 51 |
+
queryFn: () => fetchApi(`/analytics/attendance-trends?days=${days}`),
|
| 52 |
+
enabled: !isSuperAdmin && !profileLoading,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
// 4. Fetch Department Distribution
|
| 56 |
+
const { data: deptDist = [] } = useQuery({
|
| 57 |
+
queryKey: ["analytics-departments"],
|
| 58 |
+
queryFn: () => fetchApi("/analytics/department-distribution"),
|
| 59 |
+
enabled: !isSuperAdmin && !profileLoading,
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
// 5. Fetch Recognition Analytics
|
| 63 |
+
const { data: recognition } = useQuery({
|
| 64 |
+
queryKey: ["analytics-recognition"],
|
| 65 |
+
queryFn: () => fetchApi("/analytics/recognition"),
|
| 66 |
+
enabled: !profileLoading,
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
const handleRefreshAll = async () => {
|
| 70 |
+
setIsRefreshing(true);
|
| 71 |
+
try {
|
| 72 |
+
await queryClient.invalidateQueries({ queryKey: ["analytics-summary"] });
|
| 73 |
+
await queryClient.invalidateQueries({ queryKey: ["analytics-trends"] });
|
| 74 |
+
if (isSuperAdmin) {
|
| 75 |
+
await refetchCompanies();
|
| 76 |
+
} else {
|
| 77 |
+
await Promise.all([refetchSummary(), refetchTrends()]);
|
| 78 |
+
}
|
| 79 |
+
} catch (e) {
|
| 80 |
+
console.error(e);
|
| 81 |
+
} finally {
|
| 82 |
+
setTimeout(() => setIsRefreshing(false), 800);
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
const handleResetFilters = () => {
|
| 87 |
+
setDays(7);
|
| 88 |
+
setSelectedOrgId("ALL");
|
| 89 |
+
setTierFilter("ALL");
|
| 90 |
+
setStatusFilter("ALL");
|
| 91 |
+
setDeptFilter("ALL");
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
// Filtered Companies data based on top filters
|
| 95 |
+
const filteredCompanies = useMemo(() => {
|
| 96 |
+
return companies.filter((c: any) => {
|
| 97 |
+
if (selectedOrgId !== "ALL" && c.id.toString() !== selectedOrgId) return false;
|
| 98 |
+
if (tierFilter !== "ALL" && (c.subscription_tier || "Free") !== tierFilter) return false;
|
| 99 |
+
if (statusFilter !== "ALL" && (c.status || "Active") !== statusFilter) return false;
|
| 100 |
+
return true;
|
| 101 |
+
});
|
| 102 |
+
}, [companies, selectedOrgId, tierFilter, statusFilter]);
|
| 103 |
+
|
| 104 |
+
// Filtered Department Distribution
|
| 105 |
+
const filteredDeptDist = useMemo(() => {
|
| 106 |
+
if (deptFilter === "ALL") return deptDist;
|
| 107 |
+
return deptDist.filter((d: any) => d.department === deptFilter);
|
| 108 |
+
}, [deptDist, deptFilter]);
|
| 109 |
+
|
| 110 |
+
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
// SUPER 3D CUSTOM RENDERERS & CONFIGURATIONS
|
| 112 |
+
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
|
| 114 |
+
// Custom ECharts 3D Cylinder Renderer for authentic isometric 3D Bar Columns
|
| 115 |
+
const render3DCylinder = (params: any, api: any) => {
|
| 116 |
+
const location = api.coord([api.value(0), api.value(1)]);
|
| 117 |
+
const extent = api.coord([api.value(0), 0]);
|
| 118 |
+
const x = location[0];
|
| 119 |
+
const y = location[1];
|
| 120 |
+
const bottomY = extent[1];
|
| 121 |
+
const rawWidth = api.size([1, 0])[0];
|
| 122 |
+
const width = Math.min(Math.max(rawWidth * 0.38, 24), 64);
|
| 123 |
+
const rx = width / 2;
|
| 124 |
+
const ry = Math.max(width / 3.5, 6);
|
| 125 |
+
|
| 126 |
+
const colorHex = api.visual("color") || "#22d3ee";
|
| 127 |
+
|
| 128 |
+
if (bottomY - y <= 0) return null;
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
type: "group",
|
| 132 |
+
children: [
|
| 133 |
+
// 1. Bottom Base Shadow Disc
|
| 134 |
+
{
|
| 135 |
+
type: "ellipse",
|
| 136 |
+
shape: { cx: x, cy: bottomY, rx: rx * 1.2, ry: ry * 1.2 },
|
| 137 |
+
style: { fill: "rgba(0, 0, 0, 0.4)" }
|
| 138 |
+
},
|
| 139 |
+
// 2. Cylinder Bottom Cap
|
| 140 |
+
{
|
| 141 |
+
type: "ellipse",
|
| 142 |
+
shape: { cx: x, cy: bottomY, rx: rx, ry: ry },
|
| 143 |
+
style: { fill: colorHex }
|
| 144 |
+
},
|
| 145 |
+
// 3. Cylinder Vertical Column Wall with Side Shading & Specular Reflective Center
|
| 146 |
+
{
|
| 147 |
+
type: "rect",
|
| 148 |
+
shape: { x: x - rx, y: y, width: width, height: Math.max(bottomY - y, 2) },
|
| 149 |
+
style: {
|
| 150 |
+
fill: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
| 151 |
+
{ offset: 0, color: colorHex },
|
| 152 |
+
{ offset: 0.3, color: "#ffffff" },
|
| 153 |
+
{ offset: 0.6, color: colorHex },
|
| 154 |
+
{ offset: 1, color: "#09090b" }
|
| 155 |
+
])
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
type: "ellipse",
|
| 160 |
+
shape: { cx: x, cy: y, rx: rx, ry: ry },
|
| 161 |
+
style: {
|
| 162 |
+
fill: new echarts.graphic.RadialGradient(0.35, 0.35, 0.65, [
|
| 163 |
+
{ offset: 0, color: "#ffffff" },
|
| 164 |
+
{ offset: 0.45, color: colorHex },
|
| 165 |
+
{ offset: 1, color: colorHex }
|
| 166 |
+
]),
|
| 167 |
+
stroke: "rgba(255, 255, 255, 0.8)",
|
| 168 |
+
lineWidth: 1.5
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
]
|
| 172 |
+
};
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const freeCount = filteredCompanies.filter((c: any) => c.subscription_tier === "Free" || !c.subscription_tier).length;
|
| 176 |
+
const bizCount = filteredCompanies.filter((c: any) => c.subscription_tier === "Business").length;
|
| 177 |
+
const entCount = filteredCompanies.filter((c: any) => c.subscription_tier === "Enterprise").length;
|
| 178 |
+
const totalOrgsNum = filteredCompanies.length;
|
| 179 |
+
|
| 180 |
+
const concentric3DDonutOption = {
|
| 181 |
+
animation: false,
|
| 182 |
+
backgroundColor: "transparent",
|
| 183 |
+
tooltip: {
|
| 184 |
+
trigger: "item",
|
| 185 |
+
backgroundColor: "#18181b",
|
| 186 |
+
borderColor: "#3f3f46",
|
| 187 |
+
borderWidth: 1,
|
| 188 |
+
textStyle: { color: "#f4f4f5", fontSize: 12, fontFamily: "Inter" },
|
| 189 |
+
borderRadius: 8,
|
| 190 |
+
padding: [10, 14],
|
| 191 |
+
formatter: "{b}: <b style='color:#38bdf8;'>{c} Orgs</b> ({d}%)"
|
| 192 |
+
},
|
| 193 |
+
legend: {
|
| 194 |
+
bottom: "2%",
|
| 195 |
+
left: "center",
|
| 196 |
+
textStyle: { color: "#71717a", fontSize: 11, fontWeight: 700 },
|
| 197 |
+
itemGap: 16,
|
| 198 |
+
icon: "circle",
|
| 199 |
+
itemWidth: 10,
|
| 200 |
+
itemHeight: 10
|
| 201 |
+
},
|
| 202 |
+
series: [
|
| 203 |
+
{
|
| 204 |
+
name: "Subscription Tiers",
|
| 205 |
+
type: "pie",
|
| 206 |
+
radius: ["52%", "78%"],
|
| 207 |
+
center: ["50%", "44%"],
|
| 208 |
+
avoidLabelOverlap: true,
|
| 209 |
+
animation: false,
|
| 210 |
+
padAngle: 3,
|
| 211 |
+
itemStyle: {
|
| 212 |
+
borderRadius: 8,
|
| 213 |
+
borderColor: "transparent",
|
| 214 |
+
borderWidth: 3
|
| 215 |
+
},
|
| 216 |
+
label: {
|
| 217 |
+
show: true,
|
| 218 |
+
position: "center",
|
| 219 |
+
formatter: () => `{lbl|Total}\n{val|${totalOrgsNum}}`,
|
| 220 |
+
rich: {
|
| 221 |
+
lbl: { fontSize: 13, fontWeight: 700, color: "#94a3b8", fontFamily: "Inter", lineHeight: 22 },
|
| 222 |
+
val: { fontSize: 32, fontWeight: 900, color: "#22d3ee", fontFamily: "Inter", lineHeight: 38 }
|
| 223 |
+
}
|
| 224 |
+
},
|
| 225 |
+
labelLine: {
|
| 226 |
+
show: true,
|
| 227 |
+
length: 12,
|
| 228 |
+
length2: 16,
|
| 229 |
+
lineStyle: { color: "#94a3b8", width: 1.5 }
|
| 230 |
+
},
|
| 231 |
+
data: [
|
| 232 |
+
{
|
| 233 |
+
value: freeCount,
|
| 234 |
+
name: "Free Tier",
|
| 235 |
+
label: {
|
| 236 |
+
show: true,
|
| 237 |
+
formatter: "{b}\n{d}%",
|
| 238 |
+
color: "#0284c7",
|
| 239 |
+
fontWeight: 700,
|
| 240 |
+
fontSize: 11
|
| 241 |
+
},
|
| 242 |
+
itemStyle: { color: "#38bdf8" }
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
value: bizCount,
|
| 246 |
+
name: "Business Tier",
|
| 247 |
+
label: {
|
| 248 |
+
show: true,
|
| 249 |
+
formatter: "{b}\n{d}%",
|
| 250 |
+
color: "#4f46e5",
|
| 251 |
+
fontWeight: 700,
|
| 252 |
+
fontSize: 11
|
| 253 |
+
},
|
| 254 |
+
itemStyle: { color: "#818cf8" }
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
value: entCount,
|
| 258 |
+
name: "Enterprise Tier",
|
| 259 |
+
label: {
|
| 260 |
+
show: true,
|
| 261 |
+
formatter: "{b}\n{d}%",
|
| 262 |
+
color: "#9333ea",
|
| 263 |
+
fontWeight: 700,
|
| 264 |
+
fontSize: 11
|
| 265 |
+
},
|
| 266 |
+
itemStyle: { color: "#c084fc" }
|
| 267 |
+
}
|
| 268 |
+
]
|
| 269 |
+
}
|
| 270 |
+
]
|
| 271 |
+
};
|
| 272 |
+
|
| 273 |
+
const companyNames = filteredCompanies.length ? filteredCompanies.map((c: any) => c.name) : ["Default Org"];
|
| 274 |
+
const companyQuotas = filteredCompanies.length ? filteredCompanies.map((c: any) => c.max_employees || 50) : [50];
|
| 275 |
+
|
| 276 |
+
const bar3DCylinderOption = {
|
| 277 |
+
animation: false,
|
| 278 |
+
backgroundColor: "transparent",
|
| 279 |
+
tooltip: {
|
| 280 |
+
trigger: "axis",
|
| 281 |
+
axisPointer: { type: "shadow" },
|
| 282 |
+
backgroundColor: "#18181b",
|
| 283 |
+
borderColor: "#3f3f46",
|
| 284 |
+
borderWidth: 1,
|
| 285 |
+
textStyle: { color: "#f4f4f5", fontSize: 12 },
|
| 286 |
+
borderRadius: 8,
|
| 287 |
+
padding: [10, 14],
|
| 288 |
+
formatter: (params: any) => {
|
| 289 |
+
const item = params[0];
|
| 290 |
+
return `<div style="font-weight:800; margin-bottom:2px; color:#f4f4f5;">${item.name}</div>
|
| 291 |
+
<div style="color:#38bdf8; font-weight:700;">Quota Capacity: <b>${item.value} Staff</b></div>`;
|
| 292 |
+
}
|
| 293 |
+
},
|
| 294 |
+
grid: { left: "4%", right: "4%", bottom: "16%", top: "14%", containLabel: true },
|
| 295 |
+
xAxis: {
|
| 296 |
+
type: "category",
|
| 297 |
+
data: companyNames,
|
| 298 |
+
axisLabel: { color: "#71717a", fontSize: 11, fontWeight: 700, margin: 16 },
|
| 299 |
+
axisLine: { lineStyle: { color: "#a1a1aa", width: 1.5 } },
|
| 300 |
+
axisTick: { show: false }
|
| 301 |
+
},
|
| 302 |
+
yAxis: {
|
| 303 |
+
type: "value",
|
| 304 |
+
axisLabel: { color: "#71717a", fontSize: 10, fontWeight: 600 },
|
| 305 |
+
splitLine: { lineStyle: { color: "rgba(113, 113, 122, 0.2)", type: "dashed" } }
|
| 306 |
+
},
|
| 307 |
+
series: [
|
| 308 |
+
{
|
| 309 |
+
type: "bar",
|
| 310 |
+
itemStyle: { color: "rgba(113, 113, 122, 0.15)", borderRadius: [10, 10, 0, 0] },
|
| 311 |
+
barGap: "-100%",
|
| 312 |
+
barWidth: "32%",
|
| 313 |
+
data: companyQuotas.map(() => Math.max(...companyQuotas, 500) * 1.1),
|
| 314 |
+
animation: false,
|
| 315 |
+
silent: true
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
name: "Max Capacity Quota",
|
| 319 |
+
type: "bar",
|
| 320 |
+
barWidth: "32%",
|
| 321 |
+
data: companyQuotas,
|
| 322 |
+
itemStyle: {
|
| 323 |
+
borderRadius: [10, 10, 0, 0],
|
| 324 |
+
color: (params: any) => {
|
| 325 |
+
const colors = [
|
| 326 |
+
[{ offset: 0, color: "#38bdf8" }, { offset: 1, color: "#0284c7" }],
|
| 327 |
+
[{ offset: 0, color: "#818cf8" }, { offset: 1, color: "#4f46e5" }],
|
| 328 |
+
[{ offset: 0, color: "#34d399" }, { offset: 1, color: "#059669" }],
|
| 329 |
+
[{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#d97706" }],
|
| 330 |
+
[{ offset: 0, color: "#f472b6" }, { offset: 1, color: "#db2777" }]
|
| 331 |
+
];
|
| 332 |
+
const chosen = colors[params.dataIndex % colors.length];
|
| 333 |
+
return new echarts.graphic.LinearGradient(0, 0, 0, 1, chosen);
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
]
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
const trendDates = trends.map((t: any) => {
|
| 341 |
+
const d = new Date(t.date);
|
| 342 |
+
return days <= 7
|
| 343 |
+
? d.toLocaleDateString("en-US", { weekday: "short" })
|
| 344 |
+
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
const attendance3DLineOption = {
|
| 348 |
+
animation: false,
|
| 349 |
+
backgroundColor: "transparent",
|
| 350 |
+
tooltip: {
|
| 351 |
+
trigger: "axis",
|
| 352 |
+
backgroundColor: "#18181b",
|
| 353 |
+
borderColor: "#3f3f46",
|
| 354 |
+
borderWidth: 1,
|
| 355 |
+
textStyle: { color: "#f4f4f5", fontSize: 12 },
|
| 356 |
+
borderRadius: 8,
|
| 357 |
+
padding: [10, 14]
|
| 358 |
+
},
|
| 359 |
+
legend: {
|
| 360 |
+
top: "0%",
|
| 361 |
+
right: "0%",
|
| 362 |
+
textStyle: { color: "#71717a", fontSize: 11, fontWeight: 700 },
|
| 363 |
+
icon: "circle",
|
| 364 |
+
itemGap: 16
|
| 365 |
+
},
|
| 366 |
+
grid: { left: "2%", right: "3%", bottom: "4%", top: "16%", containLabel: true },
|
| 367 |
+
xAxis: {
|
| 368 |
+
type: "category",
|
| 369 |
+
boundaryGap: false,
|
| 370 |
+
data: trendDates.length ? trendDates : ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
| 371 |
+
axisLabel: { color: "#71717a", fontSize: 11, fontWeight: 700 },
|
| 372 |
+
axisLine: { lineStyle: { color: "#a1a1aa", width: 1.5 } }
|
| 373 |
+
},
|
| 374 |
+
yAxis: {
|
| 375 |
+
type: "value",
|
| 376 |
+
axisLabel: { color: "#71717a", fontSize: 10, fontWeight: 600 },
|
| 377 |
+
splitLine: { lineStyle: { color: "rgba(113, 113, 122, 0.2)", type: "dashed" } }
|
| 378 |
+
},
|
| 379 |
+
series: [
|
| 380 |
+
{
|
| 381 |
+
name: "Present Staff",
|
| 382 |
+
type: "line",
|
| 383 |
+
smooth: 0.3,
|
| 384 |
+
showSymbol: true,
|
| 385 |
+
symbol: "circle",
|
| 386 |
+
symbolSize: 10,
|
| 387 |
+
itemStyle: { color: "#06b6d4", borderWidth: 3, borderColor: "#ffffff" },
|
| 388 |
+
lineStyle: { width: 4, color: "#06b6d4" },
|
| 389 |
+
areaStyle: {
|
| 390 |
+
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
| 391 |
+
{ offset: 0, color: "rgba(6, 182, 212, 0.45)" },
|
| 392 |
+
{ offset: 0.7, color: "rgba(6, 182, 212, 0.05)" },
|
| 393 |
+
{ offset: 1, color: "rgba(6, 182, 212, 0)" }
|
| 394 |
+
])
|
| 395 |
+
},
|
| 396 |
+
data: trends.map((t: any) => t.present)
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
name: "Late Arrival",
|
| 400 |
+
type: "line",
|
| 401 |
+
smooth: 0.3,
|
| 402 |
+
showSymbol: true,
|
| 403 |
+
symbol: "diamond",
|
| 404 |
+
symbolSize: 8,
|
| 405 |
+
itemStyle: { color: "#f59e0b", borderWidth: 2, borderColor: "#ffffff" },
|
| 406 |
+
lineStyle: { width: 3, type: "dashed", color: "#f59e0b" },
|
| 407 |
+
data: trends.map((t: any) => t.late)
|
| 408 |
+
}
|
| 409 |
+
]
|
| 410 |
+
};
|
| 411 |
+
|
| 412 |
+
const dept3DBarOption = {
|
| 413 |
+
animation: false,
|
| 414 |
+
backgroundColor: "transparent",
|
| 415 |
+
tooltip: {
|
| 416 |
+
trigger: "axis",
|
| 417 |
+
axisPointer: { type: "shadow" },
|
| 418 |
+
backgroundColor: "#18181b",
|
| 419 |
+
borderColor: "#3f3f46",
|
| 420 |
+
borderWidth: 1,
|
| 421 |
+
textStyle: { color: "#f4f4f5", fontSize: 12 }
|
| 422 |
+
},
|
| 423 |
+
legend: {
|
| 424 |
+
top: "0%",
|
| 425 |
+
right: "0%",
|
| 426 |
+
textStyle: { color: "#71717a", fontSize: 11, fontWeight: 700 }
|
| 427 |
+
},
|
| 428 |
+
grid: { left: "2%", right: "4%", bottom: "12%", top: "16%", containLabel: true },
|
| 429 |
+
xAxis: {
|
| 430 |
+
type: "category",
|
| 431 |
+
data: filteredDeptDist.map((d: any) => d.department),
|
| 432 |
+
axisLabel: { color: "#71717a", fontSize: 11, fontWeight: 700, margin: 14 }
|
| 433 |
+
},
|
| 434 |
+
yAxis: {
|
| 435 |
+
type: "value",
|
| 436 |
+
axisLabel: { color: "#71717a", fontSize: 10 }
|
| 437 |
+
},
|
| 438 |
+
series: [
|
| 439 |
+
{
|
| 440 |
+
name: "Present Today",
|
| 441 |
+
type: "bar",
|
| 442 |
+
barWidth: "30%",
|
| 443 |
+
itemStyle: {
|
| 444 |
+
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
| 445 |
+
{ offset: 0, color: "#34d399" },
|
| 446 |
+
{ offset: 1, color: "#059669" }
|
| 447 |
+
]),
|
| 448 |
+
borderRadius: [8, 8, 0, 0]
|
| 449 |
+
},
|
| 450 |
+
data: filteredDeptDist.map((d: any) => d.present_today)
|
| 451 |
+
}
|
| 452 |
+
]
|
| 453 |
+
};
|
| 454 |
+
|
| 455 |
+
if (profileLoading) {
|
| 456 |
+
return (
|
| 457 |
+
<SidebarLayout>
|
| 458 |
+
<div className="min-h-[400px] flex items-center justify-center">
|
| 459 |
+
<Activity className="w-8 h-8 animate-spin text-zinc-500 dark:text-zinc-400" />
|
| 460 |
+
</div>
|
| 461 |
+
</SidebarLayout>
|
| 462 |
+
);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
return (
|
| 466 |
+
<SidebarLayout>
|
| 467 |
+
<div className="space-y-6 page-enter pb-8">
|
| 468 |
+
|
| 469 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-4 border-b border-zinc-200 dark:border-zinc-800">
|
| 470 |
+
<div className="space-y-1">
|
| 471 |
+
<h1 className="text-xl font-extrabold text-zinc-900 dark:text-zinc-100 tracking-tight flex items-center gap-2">
|
| 472 |
+
<div className="p-2 rounded-xl bg-zinc-100 dark:bg-zinc-850 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-800">
|
| 473 |
+
<TrendingUp className="w-5 h-5" />
|
| 474 |
+
</div>
|
| 475 |
+
Analytics
|
| 476 |
+
</h1>
|
| 477 |
+
<p className="text-zinc-500 dark:text-zinc-400 text-xs">
|
| 478 |
+
Platform performance and operational telemetry metrics
|
| 479 |
+
</p>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<button
|
| 483 |
+
onClick={handleRefreshAll}
|
| 484 |
+
disabled={isRefreshing}
|
| 485 |
+
className="p-2.5 bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-800 dark:text-zinc-200 rounded-xl cursor-pointer border border-zinc-200 dark:border-zinc-800 flex items-center gap-2 text-xs font-bold shadow-sm self-start sm:self-auto disabled:opacity-70"
|
| 486 |
+
title="Refresh Analytics"
|
| 487 |
+
>
|
| 488 |
+
<RefreshCw
|
| 489 |
+
className={`w-4 h-4 text-cyan-500 dark:text-cyan-400 inline-block ${isRefreshing ? "animate-spin spin-icon" : ""}`}
|
| 490 |
+
style={isRefreshing ? { animation: "spin-360 0.8s linear infinite", transformOrigin: "center" } : {}}
|
| 491 |
+
/> Refresh Data
|
| 492 |
+
</button>
|
| 493 |
+
</div>
|
| 494 |
+
|
| 495 |
+
<div className="p-4 rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 flex flex-wrap items-center gap-4 shadow-sm">
|
| 496 |
+
<div className="flex items-center gap-2 text-xs font-bold text-zinc-800 dark:text-zinc-200 uppercase tracking-wider pr-3 border-r border-zinc-200 dark:border-zinc-800">
|
| 497 |
+
<Filter className="w-4 h-4 text-cyan-500 dark:text-cyan-400" /> Filters
|
| 498 |
+
</div>
|
| 499 |
+
|
| 500 |
+
<div className="flex items-center gap-1.5 bg-zinc-100 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-1 rounded-xl shrink-0">
|
| 501 |
+
<span className="text-xs font-extrabold text-cyan-600 dark:text-cyan-400 px-2 flex items-center gap-1.5 uppercase tracking-wider shrink-0">
|
| 502 |
+
<Calendar className="w-4 h-4 text-cyan-500 dark:text-cyan-400" /> Range:
|
| 503 |
+
</span>
|
| 504 |
+
{[
|
| 505 |
+
{ label: "7D", val: 7 },
|
| 506 |
+
{ label: "30D", val: 30 },
|
| 507 |
+
{ label: "90D", val: 90 },
|
| 508 |
+
{ label: "1Y", val: 365 },
|
| 509 |
+
].map((item) => (
|
| 510 |
+
<button
|
| 511 |
+
key={item.label}
|
| 512 |
+
onClick={() => setDays(item.val)}
|
| 513 |
+
className={`px-3 py-1 text-xs font-black rounded-lg cursor-pointer shrink-0 border transition-all ${
|
| 514 |
+
days === item.val
|
| 515 |
+
? "bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100 border-zinc-900 dark:border-zinc-100"
|
| 516 |
+
: "bg-white dark:bg-zinc-800 text-zinc-800 dark:text-zinc-200 border-zinc-200 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-700"
|
| 517 |
+
}`}
|
| 518 |
+
>
|
| 519 |
+
{item.label}
|
| 520 |
+
</button>
|
| 521 |
+
))}
|
| 522 |
+
</div>
|
| 523 |
+
|
| 524 |
+
{isSuperAdmin && companies.length > 0 && (
|
| 525 |
+
<div className="flex items-center gap-2">
|
| 526 |
+
<span className="text-xs font-bold text-zinc-600 dark:text-zinc-400">Org:</span>
|
| 527 |
+
<select
|
| 528 |
+
value={selectedOrgId}
|
| 529 |
+
onChange={(e) => setSelectedOrgId(e.target.value)}
|
| 530 |
+
className="bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 text-xs font-bold rounded-lg pl-3 pr-8 py-1.5 focus:border-cyan-500 cursor-pointer"
|
| 531 |
+
>
|
| 532 |
+
<option value="ALL">All Organizations ({companies.length})</option>
|
| 533 |
+
{companies.map((c: any) => (
|
| 534 |
+
<option key={c.id} value={c.id.toString()}>
|
| 535 |
+
{c.name}
|
| 536 |
+
</option>
|
| 537 |
+
))}
|
| 538 |
+
</select>
|
| 539 |
+
</div>
|
| 540 |
+
)}
|
| 541 |
+
|
| 542 |
+
{isSuperAdmin && (
|
| 543 |
+
<div className="flex items-center gap-2">
|
| 544 |
+
<span className="text-xs font-bold text-zinc-600 dark:text-zinc-400">Tier:</span>
|
| 545 |
+
<select
|
| 546 |
+
value={tierFilter}
|
| 547 |
+
onChange={(e) => setTierFilter(e.target.value)}
|
| 548 |
+
className="bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 text-xs font-bold rounded-lg pl-3 pr-8 py-1.5 focus:border-cyan-500 cursor-pointer"
|
| 549 |
+
>
|
| 550 |
+
<option value="ALL">All Tiers</option>
|
| 551 |
+
<option value="Free">Free Tier</option>
|
| 552 |
+
<option value="Business">Business Tier</option>
|
| 553 |
+
<option value="Enterprise">Enterprise Tier</option>
|
| 554 |
+
</select>
|
| 555 |
+
</div>
|
| 556 |
+
)}
|
| 557 |
+
|
| 558 |
+
{isSuperAdmin && (
|
| 559 |
+
<div className="flex items-center gap-2">
|
| 560 |
+
<span className="text-xs font-bold text-zinc-600 dark:text-zinc-400">Status:</span>
|
| 561 |
+
<select
|
| 562 |
+
value={statusFilter}
|
| 563 |
+
onChange={(e) => setStatusFilter(e.target.value)}
|
| 564 |
+
className="bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 text-xs font-bold rounded-lg pl-3 pr-8 py-1.5 focus:border-cyan-500 cursor-pointer"
|
| 565 |
+
>
|
| 566 |
+
<option value="ALL">All Statuses</option>
|
| 567 |
+
<option value="Active">Active</option>
|
| 568 |
+
<option value="Inactive">Inactive</option>
|
| 569 |
+
</select>
|
| 570 |
+
</div>
|
| 571 |
+
)}
|
| 572 |
+
|
| 573 |
+
{!isSuperAdmin && deptDist.length > 0 && (
|
| 574 |
+
<div className="flex items-center gap-2">
|
| 575 |
+
<span className="text-xs font-bold text-zinc-600 dark:text-zinc-400">Department:</span>
|
| 576 |
+
<select
|
| 577 |
+
value={deptFilter}
|
| 578 |
+
onChange={(e) => setDeptFilter(e.target.value)}
|
| 579 |
+
className="bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 text-xs font-bold rounded-lg pl-3 pr-8 py-1.5 focus:border-cyan-500 cursor-pointer"
|
| 580 |
+
>
|
| 581 |
+
<option value="ALL">All Departments</option>
|
| 582 |
+
{deptDist.map((d: any) => (
|
| 583 |
+
<option key={d.department} value={d.department}>
|
| 584 |
+
{d.department}
|
| 585 |
+
</option>
|
| 586 |
+
))}
|
| 587 |
+
</select>
|
| 588 |
+
</div>
|
| 589 |
+
)}
|
| 590 |
+
|
| 591 |
+
<button
|
| 592 |
+
onClick={handleResetFilters}
|
| 593 |
+
className="ml-auto px-3.5 py-1.5 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-800 dark:text-zinc-200 text-xs font-bold rounded-lg cursor-pointer flex items-center gap-1 border border-zinc-200 dark:border-zinc-700 shadow-sm"
|
| 594 |
+
>
|
| 595 |
+
<RotateCcw className="w-3.5 h-3.5 text-zinc-600 dark:text-zinc-400" /> Reset
|
| 596 |
+
</button>
|
| 597 |
+
</div>
|
| 598 |
+
|
| 599 |
+
{isSuperAdmin ? (
|
| 600 |
+
<>
|
| 601 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 602 |
+
|
| 603 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 604 |
+
<div className="flex justify-between items-start">
|
| 605 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 606 |
+
Onboarded Organizations
|
| 607 |
+
</span>
|
| 608 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 609 |
+
<Building2 className="w-4 h-4" />
|
| 610 |
+
</div>
|
| 611 |
+
</div>
|
| 612 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 613 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 614 |
+
{filteredCompanies.length}
|
| 615 |
+
</p>
|
| 616 |
+
<span className="inline-flex items-center text-[10px] font-bold text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 617 |
+
Active: {filteredCompanies.filter((c: any) => (c.status || "Active") === "Active").length}
|
| 618 |
+
</span>
|
| 619 |
+
</div>
|
| 620 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 621 |
+
Registered SaaS platform tenants
|
| 622 |
+
</p>
|
| 623 |
+
</div>
|
| 624 |
+
|
| 625 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 626 |
+
<div className="flex justify-between items-start">
|
| 627 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 628 |
+
Total Enrolled Employees
|
| 629 |
+
</span>
|
| 630 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 631 |
+
<Users className="w-4 h-4" />
|
| 632 |
+
</div>
|
| 633 |
+
</div>
|
| 634 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 635 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 636 |
+
{summary?.total_employees || 0}
|
| 637 |
+
</p>
|
| 638 |
+
<span className="inline-flex items-center text-[10px] font-bold text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 639 |
+
Cross-Org Combined
|
| 640 |
+
</span>
|
| 641 |
+
</div>
|
| 642 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 643 |
+
Multi-tenant registered staff count
|
| 644 |
+
</p>
|
| 645 |
+
</div>
|
| 646 |
+
|
| 647 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 648 |
+
<div className="flex justify-between items-start">
|
| 649 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 650 |
+
Platform AI Scan Telemetry
|
| 651 |
+
</span>
|
| 652 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 653 |
+
<CheckCircle className="w-4 h-4" />
|
| 654 |
+
</div>
|
| 655 |
+
</div>
|
| 656 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 657 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 658 |
+
{recognition?.total_scans || 0}
|
| 659 |
+
</p>
|
| 660 |
+
<span className="inline-flex items-center text-[10px] font-mono font-bold text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 661 |
+
<Zap className="w-3 h-3 mr-0.5" /> {recognition?.average_processing_time_ms || 120}ms
|
| 662 |
+
</span>
|
| 663 |
+
</div>
|
| 664 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 665 |
+
512-dim facial verification throughput
|
| 666 |
+
</p>
|
| 667 |
+
</div>
|
| 668 |
+
|
| 669 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 670 |
+
<div className="flex justify-between items-start">
|
| 671 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 672 |
+
Multi-Tenant Spoof Shield
|
| 673 |
+
</span>
|
| 674 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 675 |
+
<ShieldCheck className="w-4 h-4" />
|
| 676 |
+
</div>
|
| 677 |
+
</div>
|
| 678 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 679 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 680 |
+
{recognition?.spoof_attempts || 0}
|
| 681 |
+
</p>
|
| 682 |
+
<span className="inline-flex items-center text-[10px] font-bold text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 683 |
+
Attempts Blocked
|
| 684 |
+
</span>
|
| 685 |
+
</div>
|
| 686 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 687 |
+
Global kiosk liveness filter
|
| 688 |
+
</p>
|
| 689 |
+
</div>
|
| 690 |
+
|
| 691 |
+
</div>
|
| 692 |
+
|
| 693 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 694 |
+
|
| 695 |
+
<div className="tech-card-3d-minimal p-5 space-y-4">
|
| 696 |
+
<div className="flex items-center justify-between border-b border-zinc-100 dark:border-zinc-800 pb-3">
|
| 697 |
+
<div className="flex items-center gap-2">
|
| 698 |
+
<Layers className="w-4 h-4 text-zinc-500 dark:text-zinc-400" />
|
| 699 |
+
<h3 className="text-xs font-black uppercase text-zinc-900 dark:text-zinc-100 tracking-wider">
|
| 700 |
+
Subscription Tier Distribution
|
| 701 |
+
</h3>
|
| 702 |
+
</div>
|
| 703 |
+
</div>
|
| 704 |
+
<ReactECharts option={concentric3DDonutOption} style={{ height: "320px" }} />
|
| 705 |
+
</div>
|
| 706 |
+
|
| 707 |
+
<div className="tech-card-3d-minimal lg:col-span-2 p-5 space-y-4">
|
| 708 |
+
<div className="flex items-center justify-between border-b border-zinc-100 dark:border-zinc-800 pb-3">
|
| 709 |
+
<div className="flex items-center gap-2">
|
| 710 |
+
<BarChart3 className="w-4 h-4 text-zinc-500 dark:text-zinc-400" />
|
| 711 |
+
<h3 className="text-xs font-black uppercase text-zinc-900 dark:text-zinc-100 tracking-wider">
|
| 712 |
+
Organization Employee Quota Allocations
|
| 713 |
+
</h3>
|
| 714 |
+
</div>
|
| 715 |
+
<span className="text-[10px] font-mono text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded border border-zinc-200 dark:border-zinc-700">
|
| 716 |
+
Live Platform Allocations
|
| 717 |
+
</span>
|
| 718 |
+
</div>
|
| 719 |
+
<ReactECharts option={bar3DCylinderOption} style={{ height: "320px" }} />
|
| 720 |
+
</div>
|
| 721 |
+
|
| 722 |
+
</div>
|
| 723 |
+
</>
|
| 724 |
+
) : (
|
| 725 |
+
<>
|
| 726 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 727 |
+
|
| 728 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 729 |
+
<div className="flex justify-between items-start">
|
| 730 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 731 |
+
Attendance Rate
|
| 732 |
+
</span>
|
| 733 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 734 |
+
<Activity className="w-4 h-4" />
|
| 735 |
+
</div>
|
| 736 |
+
</div>
|
| 737 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 738 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 739 |
+
{summary?.attendance_percentage || 0}%
|
| 740 |
+
</p>
|
| 741 |
+
<span className="inline-flex items-center text-[10px] font-bold text-zinc-705 dark:text-zinc-305 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 742 |
+
Present: {summary?.present_today || 0}
|
| 743 |
+
</span>
|
| 744 |
+
</div>
|
| 745 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 746 |
+
Daily active staff attendance
|
| 747 |
+
</p>
|
| 748 |
+
</div>
|
| 749 |
+
|
| 750 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 751 |
+
<div className="flex justify-between items-start">
|
| 752 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 753 |
+
Staff Strength
|
| 754 |
+
</span>
|
| 755 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 756 |
+
<Users className="w-4 h-4" />
|
| 757 |
+
</div>
|
| 758 |
+
</div>
|
| 759 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 760 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 761 |
+
{summary?.total_employees || 0}
|
| 762 |
+
</p>
|
| 763 |
+
<span className="inline-flex items-center text-[10px] font-bold text-zinc-705 dark:text-zinc-305 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 764 |
+
Late: {summary?.late_today || 0}
|
| 765 |
+
</span>
|
| 766 |
+
</div>
|
| 767 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 768 |
+
Active company personnel
|
| 769 |
+
</p>
|
| 770 |
+
</div>
|
| 771 |
+
|
| 772 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 773 |
+
<div className="flex justify-between items-start">
|
| 774 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 775 |
+
Biometric Accuracy
|
| 776 |
+
</span>
|
| 777 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 778 |
+
<CheckCircle className="w-4 h-4" />
|
| 779 |
+
</div>
|
| 780 |
+
</div>
|
| 781 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 782 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 783 |
+
{recognition?.average_confidence ? (recognition.average_confidence * 100).toFixed(1) : 98.5}%
|
| 784 |
+
</p>
|
| 785 |
+
<span className="inline-flex items-center text-[10px] font-mono font-bold text-zinc-705 dark:text-zinc-305 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 786 |
+
<Zap className="w-3 h-3 mr-0.5" /> {recognition?.average_processing_time_ms || 120}ms
|
| 787 |
+
</span>
|
| 788 |
+
</div>
|
| 789 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 790 |
+
512-dim facial vector confidence
|
| 791 |
+
</p>
|
| 792 |
+
</div>
|
| 793 |
+
|
| 794 |
+
<div className="tech-card-3d-minimal p-4 relative overflow-hidden">
|
| 795 |
+
<div className="flex justify-between items-start">
|
| 796 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
| 797 |
+
Spoof Attempts Blocked
|
| 798 |
+
</span>
|
| 799 |
+
<div className="p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 800 |
+
<ShieldCheck className="w-4 h-4" />
|
| 801 |
+
</div>
|
| 802 |
+
</div>
|
| 803 |
+
<div className="mt-3 flex items-baseline justify-between">
|
| 804 |
+
<p className="text-3xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">
|
| 805 |
+
{recognition?.spoof_attempts || 0}
|
| 806 |
+
</p>
|
| 807 |
+
<span className="inline-flex items-center text-[10px] font-bold text-zinc-705 dark:text-zinc-305 bg-zinc-100 dark:bg-zinc-800 px-2.5 py-0.5 rounded-full border border-zinc-200 dark:border-zinc-700">
|
| 808 |
+
Liveness Active
|
| 809 |
+
</span>
|
| 810 |
+
</div>
|
| 811 |
+
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-2 font-medium">
|
| 812 |
+
Kiosk scanner protection
|
| 813 |
+
</p>
|
| 814 |
+
</div>
|
| 815 |
+
|
| 816 |
+
</div>
|
| 817 |
+
|
| 818 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 819 |
+
|
| 820 |
+
<div className="tech-card-3d-minimal lg:col-span-2 p-5 space-y-4">
|
| 821 |
+
<div className="flex items-center justify-between border-b border-zinc-100 dark:border-zinc-800 pb-3">
|
| 822 |
+
<div className="flex items-center gap-2">
|
| 823 |
+
<BarChart3 className="w-4 h-4 text-zinc-500 dark:text-zinc-450" />
|
| 824 |
+
<h3 className="text-xs font-black uppercase text-zinc-900 dark:text-zinc-100 tracking-wider">
|
| 825 |
+
Attendance Dynamics Curve ({days} Days)
|
| 826 |
+
</h3>
|
| 827 |
+
</div>
|
| 828 |
+
<span className="text-[10px] font-mono text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded border border-zinc-200 dark:border-zinc-700">
|
| 829 |
+
Database Live Feed
|
| 830 |
+
</span>
|
| 831 |
+
</div>
|
| 832 |
+
<ReactECharts option={attendance3DLineOption} style={{ height: "300px" }} />
|
| 833 |
+
</div>
|
| 834 |
+
|
| 835 |
+
<div className="tech-card-3d-minimal p-5 space-y-4">
|
| 836 |
+
<div className="flex items-center gap-2 border-b border-zinc-800 pb-3">
|
| 837 |
+
<Building2 className="w-4 h-4 text-zinc-500 dark:text-zinc-450" />
|
| 838 |
+
<h3 className="text-xs font-black uppercase text-zinc-100 tracking-wider">
|
| 839 |
+
3D Isometric Department Cylinders
|
| 840 |
+
</h3>
|
| 841 |
+
</div>
|
| 842 |
+
<ReactECharts option={dept3DBarOption} style={{ height: "300px" }} />
|
| 843 |
+
</div>
|
| 844 |
+
|
| 845 |
+
</div>
|
| 846 |
+
</>
|
| 847 |
+
)}
|
| 848 |
+
|
| 849 |
+
</div>
|
| 850 |
+
</SidebarLayout>
|
| 851 |
+
);
|
| 852 |
+
}
|
frontend/app/attendance/page.tsx
CHANGED
|
@@ -223,14 +223,14 @@ export default function AttendancePage() {
|
|
| 223 |
|
| 224 |
{/* Table */}
|
| 225 |
{activeTab === "feed" ? (
|
| 226 |
-
<div className="
|
| 227 |
-
<div className="px-5 py-3.5 border-b border-
|
| 228 |
<p className="text-[11px] text-slate-500 font-mono">
|
| 229 |
{loadingFeed ? "Fetching..." : `${filtered?.length || 0} ledger records for ${selectedDate}`}
|
| 230 |
</p>
|
| 231 |
</div>
|
| 232 |
|
| 233 |
-
<div className="overflow-x-auto">
|
| 234 |
<table className="data-table">
|
| 235 |
<thead>
|
| 236 |
<tr>
|
|
@@ -307,14 +307,14 @@ export default function AttendancePage() {
|
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
) : (
|
| 310 |
-
<div className="
|
| 311 |
-
<div className="px-5 py-3.5 border-b border-
|
| 312 |
<p className="text-[11px] text-slate-500 font-mono">
|
| 313 |
{loadingLogs ? "Fetching..." : `${rawLogs?.length || 0} swipe events for ${selectedDate}`}
|
| 314 |
</p>
|
| 315 |
</div>
|
| 316 |
|
| 317 |
-
<div className="overflow-x-auto">
|
| 318 |
<table className="data-table">
|
| 319 |
<thead>
|
| 320 |
<tr>
|
|
|
|
| 223 |
|
| 224 |
{/* Table */}
|
| 225 |
{activeTab === "feed" ? (
|
| 226 |
+
<div className="tech-card-3d-minimal bg-white overflow-hidden">
|
| 227 |
+
<div className="px-5 py-3.5 border-b border-slate-200/80 dark:border-zinc-800 bg-slate-50/50 dark:bg-zinc-900/50">
|
| 228 |
<p className="text-[11px] text-slate-500 font-mono">
|
| 229 |
{loadingFeed ? "Fetching..." : `${filtered?.length || 0} ledger records for ${selectedDate}`}
|
| 230 |
</p>
|
| 231 |
</div>
|
| 232 |
|
| 233 |
+
<div className="overflow-x-auto min-h-[350px]">
|
| 234 |
<table className="data-table">
|
| 235 |
<thead>
|
| 236 |
<tr>
|
|
|
|
| 307 |
</div>
|
| 308 |
</div>
|
| 309 |
) : (
|
| 310 |
+
<div className="tech-card-3d-minimal bg-white overflow-hidden">
|
| 311 |
+
<div className="px-5 py-3.5 border-b border-slate-200/80 dark:border-zinc-800 bg-slate-50/50 dark:bg-zinc-900/50">
|
| 312 |
<p className="text-[11px] text-slate-500 font-mono">
|
| 313 |
{loadingLogs ? "Fetching..." : `${rawLogs?.length || 0} swipe events for ${selectedDate}`}
|
| 314 |
</p>
|
| 315 |
</div>
|
| 316 |
|
| 317 |
+
<div className="overflow-x-auto min-h-[350px]">
|
| 318 |
<table className="data-table">
|
| 319 |
<thead>
|
| 320 |
<tr>
|
frontend/app/audit/page.tsx
CHANGED
|
@@ -1,17 +1,18 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState } from "react";
|
| 4 |
-
import { useQuery } from "@tanstack/react-query";
|
| 5 |
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, Trash2
|
|
|
|
| 10 |
} from "lucide-react";
|
| 11 |
import { useToast } from "@/app/utils/toast";
|
| 12 |
|
| 13 |
-
|
| 14 |
export default function AuditLogsPage() {
|
|
|
|
| 15 |
const [page, setPage] = useState(0);
|
| 16 |
const limit = 20;
|
| 17 |
const [search, setSearch] = useState("");
|
|
@@ -32,6 +33,8 @@ export default function AuditLogsPage() {
|
|
| 32 |
await fetchApi("/audit/", { method: "DELETE" });
|
| 33 |
toast.success("Audit logs cleared successfully.");
|
| 34 |
setShowClearConfirm(false);
|
|
|
|
|
|
|
| 35 |
refetch();
|
| 36 |
} catch (err: any) {
|
| 37 |
toast.error(err.message || "Failed to clear audit logs");
|
|
@@ -40,85 +43,187 @@ export default function AuditLogsPage() {
|
|
| 40 |
}
|
| 41 |
};
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
const filteredLogs = logs?.filter((log: any) => {
|
| 46 |
if (!search) return true;
|
| 47 |
const term = search.toLowerCase();
|
| 48 |
const actionMatch = log.action?.toLowerCase().includes(term);
|
| 49 |
const userMatch = log.user?.email?.toLowerCase().includes(term);
|
| 50 |
const detailsMatch = log.details?.toLowerCase().includes(term);
|
| 51 |
-
|
|
|
|
| 52 |
});
|
| 53 |
|
| 54 |
-
//
|
| 55 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const act = action.toLowerCase();
|
| 57 |
-
if (act.includes("login") || act.includes("auth"))
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
};
|
| 63 |
|
| 64 |
return (
|
| 65 |
<SidebarLayout>
|
| 66 |
-
<div className="space-y-6 page-enter">
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
| 73 |
</h1>
|
|
|
|
|
|
|
|
|
|
| 74 |
</div>
|
|
|
|
| 75 |
<div className="flex items-center gap-2">
|
| 76 |
<button
|
| 77 |
-
onClick={
|
| 78 |
-
disabled={
|
| 79 |
-
className="
|
| 80 |
>
|
| 81 |
-
<
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
</button>
|
| 84 |
<button
|
| 85 |
-
onClick={() =>
|
| 86 |
-
|
|
|
|
| 87 |
>
|
| 88 |
-
<
|
| 89 |
-
|
| 90 |
</button>
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
{/* Filter Bar */}
|
| 95 |
-
<div className="
|
| 96 |
-
<
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
</div>
|
| 105 |
|
| 106 |
-
{/* Audit Log Table */}
|
| 107 |
-
<div className="
|
| 108 |
<div className="overflow-x-auto">
|
| 109 |
-
<table className="w-full text-left border-collapse text-
|
| 110 |
<thead>
|
| 111 |
-
<tr className="border-b border-zinc-200 bg-zinc-50 text-zinc-500 uppercase tracking-wider font-mono text-[10px] font-bold">
|
| 112 |
-
<th className="py-3 px-5 w-[
|
| 113 |
-
<th className="py-3 px-5 w-[
|
| 114 |
-
<th className="py-3 px-5 w-[220px]">Actor</th>
|
| 115 |
-
<th className="py-3 px-5">Details</th>
|
| 116 |
-
<th className="py-3 px-5 w-[140px]">IP Address</th>
|
| 117 |
</tr>
|
| 118 |
</thead>
|
| 119 |
-
<tbody className="divide-y divide-zinc-
|
| 120 |
{isLoading ? (
|
| 121 |
-
Array.from({ length:
|
| 122 |
<tr key={i}>
|
| 123 |
{Array.from({ length: 5 }).map((_, j) => (
|
| 124 |
<td key={j} className="py-4 px-5">
|
|
@@ -129,33 +234,36 @@ export default function AuditLogsPage() {
|
|
| 129 |
))
|
| 130 |
) : !filteredLogs || filteredLogs.length === 0 ? (
|
| 131 |
<tr>
|
| 132 |
-
<td colSpan={5} className="py-20 text-center
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</td>
|
| 135 |
</tr>
|
| 136 |
) : (
|
| 137 |
filteredLogs.map((log: any) => (
|
| 138 |
-
<tr key={log.id} className="hover:bg-zinc-50/
|
| 139 |
-
<td className="py-3.5 px-5 font-mono text-zinc-500 text-[11px]">
|
| 140 |
{log.timestamp ? parseDateTime(log.timestamp)?.toLocaleString() : "β"}
|
| 141 |
</td>
|
| 142 |
<td className="py-3.5 px-5">
|
| 143 |
-
|
| 144 |
-
<div className="p-1.5 rounded-lg bg-zinc-100 border border-zinc-200/80">
|
| 145 |
-
{getActionIcon(log.action)}
|
| 146 |
-
</div>
|
| 147 |
-
<span className="font-semibold text-zinc-900">{log.action}</span>
|
| 148 |
-
</div>
|
| 149 |
</td>
|
| 150 |
-
<td className="py-3.5 px-5 text-zinc-
|
| 151 |
-
{log.user?.email || <span className="text-zinc-400 italic">System /
|
| 152 |
</td>
|
| 153 |
-
<td className="py-3.5 px-5 text-zinc-600 pr-
|
| 154 |
{log.details}
|
| 155 |
</td>
|
| 156 |
-
<td className="py-3.5 px-5 font-mono text-zinc-500 text-[11px]
|
| 157 |
-
<
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
</td>
|
| 160 |
</tr>
|
| 161 |
))
|
|
@@ -164,16 +272,17 @@ export default function AuditLogsPage() {
|
|
| 164 |
</table>
|
| 165 |
</div>
|
| 166 |
|
| 167 |
-
{/* Pagination */}
|
| 168 |
-
<div className="px-5 py-3.5 border-t border-zinc-
|
| 169 |
-
<span className="text-xs text-zinc-500 font-
|
| 170 |
Page {page + 1}
|
| 171 |
</span>
|
| 172 |
<div className="flex items-center gap-2">
|
| 173 |
<button
|
| 174 |
onClick={() => setPage((old) => Math.max(old - 1, 0))}
|
| 175 |
disabled={page === 0}
|
| 176 |
-
className="
|
|
|
|
| 177 |
>
|
| 178 |
<ChevronLeft className="w-4 h-4" />
|
| 179 |
</button>
|
|
@@ -184,38 +293,41 @@ export default function AuditLogsPage() {
|
|
| 184 |
}
|
| 185 |
}}
|
| 186 |
disabled={!logs || logs.length < limit || isPlaceholderData}
|
| 187 |
-
className="
|
|
|
|
| 188 |
>
|
| 189 |
<ChevronRight className="w-4 h-4" />
|
| 190 |
</button>
|
| 191 |
</div>
|
| 192 |
</div>
|
|
|
|
| 193 |
</div>
|
|
|
|
| 194 |
</div>
|
| 195 |
|
| 196 |
{/* Clear Logs Confirmation Modal */}
|
| 197 |
{showClearConfirm && (
|
| 198 |
-
<div className="modal-backdrop z-
|
| 199 |
-
<div className="modal-content max-w-sm
|
| 200 |
-
<div className="flex flex-col items-center text-center
|
| 201 |
-
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/
|
| 202 |
<Trash2 className="w-6 h-6" />
|
| 203 |
</div>
|
| 204 |
-
<div>
|
| 205 |
-
<h3 className="text-sm font-bold text-zinc-900 uppercase tracking-wider">Confirm Clear Logs</h3>
|
| 206 |
-
<p className="text-xs text-
|
| 207 |
-
Are you
|
| 208 |
-
</p>
|
| 209 |
-
<p className="text-[10.5px] text-rose-650 font-medium bg-rose-500/5 border border-rose-500/10 rounded-xl p-2.5 mt-3 leading-normal">
|
| 210 |
-
Warning: All existing system activity and audit logs will be permanently cleared. This action cannot be undone.
|
| 211 |
</p>
|
| 212 |
</div>
|
| 213 |
-
<
|
|
|
|
|
|
|
|
|
|
| 214 |
<button
|
| 215 |
type="button"
|
| 216 |
onClick={() => setShowClearConfirm(false)}
|
| 217 |
disabled={isDeleting}
|
| 218 |
-
className="flex-1
|
| 219 |
>
|
| 220 |
Cancel
|
| 221 |
</button>
|
|
@@ -223,19 +335,16 @@ export default function AuditLogsPage() {
|
|
| 223 |
type="button"
|
| 224 |
onClick={handleClearLogs}
|
| 225 |
disabled={isDeleting}
|
| 226 |
-
className="flex-1 bg-
|
| 227 |
>
|
| 228 |
-
{isDeleting ?
|
| 229 |
-
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 230 |
-
) : (
|
| 231 |
-
"Clear Logs"
|
| 232 |
-
)}
|
| 233 |
</button>
|
| 234 |
</div>
|
| 235 |
</div>
|
| 236 |
</div>
|
| 237 |
</div>
|
| 238 |
)}
|
|
|
|
| 239 |
</SidebarLayout>
|
| 240 |
);
|
| 241 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState } from "react";
|
| 4 |
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
| 5 |
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, Trash2,
|
| 10 |
+
Lock, AlertTriangle, ShieldCheck, CheckCircle2, FileText
|
| 11 |
} from "lucide-react";
|
| 12 |
import { useToast } from "@/app/utils/toast";
|
| 13 |
|
|
|
|
| 14 |
export default function AuditLogsPage() {
|
| 15 |
+
const queryClient = useQueryClient();
|
| 16 |
const [page, setPage] = useState(0);
|
| 17 |
const limit = 20;
|
| 18 |
const [search, setSearch] = useState("");
|
|
|
|
| 33 |
await fetchApi("/audit/", { method: "DELETE" });
|
| 34 |
toast.success("Audit logs cleared successfully.");
|
| 35 |
setShowClearConfirm(false);
|
| 36 |
+
setPage(0);
|
| 37 |
+
await queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
|
| 38 |
refetch();
|
| 39 |
} catch (err: any) {
|
| 40 |
toast.error(err.message || "Failed to clear audit logs");
|
|
|
|
| 43 |
}
|
| 44 |
};
|
| 45 |
|
|
|
|
|
|
|
| 46 |
const filteredLogs = logs?.filter((log: any) => {
|
| 47 |
if (!search) return true;
|
| 48 |
const term = search.toLowerCase();
|
| 49 |
const actionMatch = log.action?.toLowerCase().includes(term);
|
| 50 |
const userMatch = log.user?.email?.toLowerCase().includes(term);
|
| 51 |
const detailsMatch = log.details?.toLowerCase().includes(term);
|
| 52 |
+
const ipMatch = log.ip_address?.toLowerCase().includes(term);
|
| 53 |
+
return actionMatch || userMatch || detailsMatch || ipMatch;
|
| 54 |
});
|
| 55 |
|
| 56 |
+
// Calculate statistics metrics
|
| 57 |
+
const totalCount = logs?.length || 0;
|
| 58 |
+
const loginCount = logs?.filter((l: any) => l.action?.toLowerCase().includes("login") || l.action?.toLowerCase().includes("auth")).length || 0;
|
| 59 |
+
const configCount = logs?.filter((l: any) => l.action?.toLowerCase().includes("setting") || l.action?.toLowerCase().includes("update")).length || 0;
|
| 60 |
+
const alertCount = logs?.filter((l: any) => l.action?.toLowerCase().includes("delete") || l.action?.toLowerCase().includes("spoof") || l.action?.toLowerCase().includes("clear")).length || 0;
|
| 61 |
+
|
| 62 |
+
// Helper to map log actions to modern icons and badge colors
|
| 63 |
+
const getActionBadge = (action: string) => {
|
| 64 |
const act = action.toLowerCase();
|
| 65 |
+
if (act.includes("login") || act.includes("auth")) {
|
| 66 |
+
return (
|
| 67 |
+
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
| 68 |
+
<Key className="w-3.5 h-3.5 text-emerald-500" /> {action}
|
| 69 |
+
</span>
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
if (act.includes("create") || act.includes("enroll") || act.includes("add")) {
|
| 73 |
+
return (
|
| 74 |
+
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
|
| 75 |
+
<UserPlus className="w-3.5 h-3.5 text-blue-500" /> {action}
|
| 76 |
+
</span>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
if (act.includes("delete") || act.includes("clear") || act.includes("remove")) {
|
| 80 |
+
return (
|
| 81 |
+
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20">
|
| 82 |
+
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" /> {action}
|
| 83 |
+
</span>
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
if (act.includes("setting") || act.includes("update") || act.includes("policy")) {
|
| 87 |
+
return (
|
| 88 |
+
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
|
| 89 |
+
<Sliders className="w-3.5 h-3.5 text-amber-500" /> {action}
|
| 90 |
+
</span>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
return (
|
| 94 |
+
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border border-cyan-500/20">
|
| 95 |
+
<Activity className="w-3.5 h-3.5 text-cyan-500" /> {action}
|
| 96 |
+
</span>
|
| 97 |
+
);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
| 101 |
+
|
| 102 |
+
const handleRefresh = async () => {
|
| 103 |
+
setIsRefreshing(true);
|
| 104 |
+
try {
|
| 105 |
+
await queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
|
| 106 |
+
await refetch();
|
| 107 |
+
} catch (e) {
|
| 108 |
+
console.error(e);
|
| 109 |
+
} finally {
|
| 110 |
+
setTimeout(() => setIsRefreshing(false), 800);
|
| 111 |
+
}
|
| 112 |
};
|
| 113 |
|
| 114 |
return (
|
| 115 |
<SidebarLayout>
|
| 116 |
+
<div className="space-y-6 page-enter pb-8">
|
| 117 |
+
|
| 118 |
+
{/* Header Section */}
|
| 119 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-zinc-200 dark:border-zinc-800">
|
| 120 |
+
<div className="space-y-1">
|
| 121 |
+
<h1 className="text-xl font-extrabold text-zinc-900 dark:text-zinc-100 tracking-tight flex items-center gap-2">
|
| 122 |
+
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-500 border border-cyan-500/20">
|
| 123 |
+
<History className="w-5 h-5" />
|
| 124 |
+
</div>
|
| 125 |
+
System Audit Logs & Security Telemetry
|
| 126 |
</h1>
|
| 127 |
+
<p className="text-zinc-500 dark:text-zinc-400 text-xs">
|
| 128 |
+
Complete immutable event history, administrative action trails, and device authentication logs
|
| 129 |
+
</p>
|
| 130 |
</div>
|
| 131 |
+
|
| 132 |
<div className="flex items-center gap-2">
|
| 133 |
<button
|
| 134 |
+
onClick={handleRefresh}
|
| 135 |
+
disabled={isRefreshing}
|
| 136 |
+
className="px-3.5 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-200 font-bold text-xs rounded-xl border border-zinc-200 dark:border-zinc-700/60 cursor-pointer transition-all active:scale-95 flex items-center gap-1.5 disabled:opacity-70"
|
| 137 |
>
|
| 138 |
+
<RefreshCw
|
| 139 |
+
className={`w-3.5 h-3.5 text-cyan-500 inline-block ${isRefreshing ? "animate-spin spin-icon" : ""}`}
|
| 140 |
+
style={isRefreshing ? { animation: "spin-360 0.8s linear infinite", transformOrigin: "center" } : {}}
|
| 141 |
+
/>
|
| 142 |
+
Refresh Logs
|
| 143 |
</button>
|
| 144 |
<button
|
| 145 |
+
onClick={() => setShowClearConfirm(true)}
|
| 146 |
+
disabled={isDeleting}
|
| 147 |
+
className="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white font-extrabold text-xs rounded-xl shadow-xs flex items-center gap-1.5 cursor-pointer active:scale-95 transition-all"
|
| 148 |
>
|
| 149 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 150 |
+
Clear Audit History
|
| 151 |
</button>
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
|
| 155 |
+
{/* Audit Stats Counter Cards */}
|
| 156 |
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
| 157 |
+
|
| 158 |
+
<div className="tech-card-3d-minimal p-3.5 space-y-1">
|
| 159 |
+
<div className="flex justify-between items-center text-zinc-450 dark:text-zinc-500">
|
| 160 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">Total Recorded Events</span>
|
| 161 |
+
<FileText className="w-3.5 h-3.5" />
|
| 162 |
+
</div>
|
| 163 |
+
<p className="text-2xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">{totalCount}</p>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="tech-card-3d-minimal p-3.5 space-y-1">
|
| 167 |
+
<div className="flex justify-between items-center text-zinc-450 dark:text-zinc-500">
|
| 168 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">Auth & Logins</span>
|
| 169 |
+
<Key className="w-3.5 h-3.5" />
|
| 170 |
+
</div>
|
| 171 |
+
<p className="text-2xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">{loginCount}</p>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<div className="tech-card-3d-minimal p-3.5 space-y-1">
|
| 175 |
+
<div className="flex justify-between items-center text-zinc-450 dark:text-zinc-500">
|
| 176 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">Config Updates</span>
|
| 177 |
+
<Sliders className="w-3.5 h-3.5" />
|
| 178 |
+
</div>
|
| 179 |
+
<p className="text-2xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">{configCount}</p>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div className="tech-card-3d-minimal p-3.5 space-y-1">
|
| 183 |
+
<div className="flex justify-between items-center text-zinc-450 dark:text-zinc-500">
|
| 184 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">Critical Alerts</span>
|
| 185 |
+
<ShieldAlert className="w-3.5 h-3.5" />
|
| 186 |
+
</div>
|
| 187 |
+
<p className="text-2xl font-black text-zinc-900 dark:text-zinc-100 tracking-tight">{alertCount}</p>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
{/* Filter Bar */}
|
| 193 |
+
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
| 194 |
+
<div className="relative w-full max-w-md">
|
| 195 |
+
<Search className="absolute left-3.5 top-3 w-4 h-4 text-zinc-400 pointer-events-none" />
|
| 196 |
+
<input
|
| 197 |
+
type="text"
|
| 198 |
+
placeholder="Search by action, email, details, or IP address..."
|
| 199 |
+
value={search}
|
| 200 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 201 |
+
style={{ paddingLeft: "2.4rem" }}
|
| 202 |
+
className="w-full h-10 pr-3.5 text-xs bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-cyan-500 transition-all"
|
| 203 |
+
/>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<span className="text-xs font-mono font-bold text-zinc-400 self-end sm:self-center">
|
| 207 |
+
Showing Page {page + 1} ({filteredLogs?.length || 0} entries)
|
| 208 |
+
</span>
|
| 209 |
</div>
|
| 210 |
|
| 211 |
+
{/* Audit Log Data Table Container */}
|
| 212 |
+
<div className="tech-card-3d-minimal overflow-hidden">
|
| 213 |
<div className="overflow-x-auto">
|
| 214 |
+
<table className="w-full text-left border-collapse text-xs">
|
| 215 |
<thead>
|
| 216 |
+
<tr className="border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-950/80 text-zinc-500 dark:text-zinc-400 uppercase tracking-wider font-mono text-[10px] font-bold">
|
| 217 |
+
<th className="py-3.5 px-5 w-[160px]">Timestamp</th>
|
| 218 |
+
<th className="py-3.5 px-5 w-[200px]">Action Performed</th>
|
| 219 |
+
<th className="py-3.5 px-5 w-[220px]">Actor Email</th>
|
| 220 |
+
<th className="py-3.5 px-5">Event Details</th>
|
| 221 |
+
<th className="py-3.5 px-5 w-[140px] text-right">IP Address</th>
|
| 222 |
</tr>
|
| 223 |
</thead>
|
| 224 |
+
<tbody className="divide-y divide-zinc-200/60 dark:divide-zinc-800/60 text-zinc-700 dark:text-zinc-300">
|
| 225 |
{isLoading ? (
|
| 226 |
+
Array.from({ length: 7 }).map((_, i) => (
|
| 227 |
<tr key={i}>
|
| 228 |
{Array.from({ length: 5 }).map((_, j) => (
|
| 229 |
<td key={j} className="py-4 px-5">
|
|
|
|
| 234 |
))
|
| 235 |
) : !filteredLogs || filteredLogs.length === 0 ? (
|
| 236 |
<tr>
|
| 237 |
+
<td colSpan={5} className="py-20 text-center space-y-3">
|
| 238 |
+
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto text-zinc-400">
|
| 239 |
+
<History className="w-7 h-7" />
|
| 240 |
+
</div>
|
| 241 |
+
<div className="space-y-1">
|
| 242 |
+
<h3 className="text-xs font-bold text-zinc-700 dark:text-zinc-300">No audit logs found</h3>
|
| 243 |
+
<p className="text-[10px] text-zinc-400">Try adjusting your search filters or refresh logs</p>
|
| 244 |
+
</div>
|
| 245 |
</td>
|
| 246 |
</tr>
|
| 247 |
) : (
|
| 248 |
filteredLogs.map((log: any) => (
|
| 249 |
+
<tr key={log.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-colors">
|
| 250 |
+
<td className="py-3.5 px-5 font-mono text-zinc-500 dark:text-zinc-400 text-[11px] whitespace-nowrap">
|
| 251 |
{log.timestamp ? parseDateTime(log.timestamp)?.toLocaleString() : "β"}
|
| 252 |
</td>
|
| 253 |
<td className="py-3.5 px-5">
|
| 254 |
+
{getActionBadge(log.action)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
</td>
|
| 256 |
+
<td className="py-3.5 px-5 font-semibold text-zinc-900 dark:text-zinc-100">
|
| 257 |
+
{log.user?.email || <span className="text-zinc-400 dark:text-zinc-500 italic font-normal">System / Automated</span>}
|
| 258 |
</td>
|
| 259 |
+
<td className="py-3.5 px-5 text-zinc-600 dark:text-zinc-300 pr-6 leading-relaxed">
|
| 260 |
{log.details}
|
| 261 |
</td>
|
| 262 |
+
<td className="py-3.5 px-5 font-mono text-zinc-500 dark:text-zinc-400 text-[11px] text-right whitespace-nowrap">
|
| 263 |
+
<span className="inline-flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800/80 px-2 py-0.5 rounded-md border border-zinc-200 dark:border-zinc-700/50">
|
| 264 |
+
<Laptop className="w-3 h-3 text-zinc-400" />
|
| 265 |
+
{log.ip_address || "Internal"}
|
| 266 |
+
</span>
|
| 267 |
</td>
|
| 268 |
</tr>
|
| 269 |
))
|
|
|
|
| 272 |
</table>
|
| 273 |
</div>
|
| 274 |
|
| 275 |
+
{/* Clean Dark Mode Adaptive Footer Pagination */}
|
| 276 |
+
<div className="px-5 py-3.5 border-t border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex items-center justify-between">
|
| 277 |
+
<span className="text-xs font-mono text-zinc-500 dark:text-zinc-400 font-bold">
|
| 278 |
Page {page + 1}
|
| 279 |
</span>
|
| 280 |
<div className="flex items-center gap-2">
|
| 281 |
<button
|
| 282 |
onClick={() => setPage((old) => Math.max(old - 1, 0))}
|
| 283 |
disabled={page === 0}
|
| 284 |
+
className="p-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-200 disabled:opacity-40 transition-all cursor-pointer"
|
| 285 |
+
title="Previous Page"
|
| 286 |
>
|
| 287 |
<ChevronLeft className="w-4 h-4" />
|
| 288 |
</button>
|
|
|
|
| 293 |
}
|
| 294 |
}}
|
| 295 |
disabled={!logs || logs.length < limit || isPlaceholderData}
|
| 296 |
+
className="p-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-200 disabled:opacity-40 transition-all cursor-pointer"
|
| 297 |
+
title="Next Page"
|
| 298 |
>
|
| 299 |
<ChevronRight className="w-4 h-4" />
|
| 300 |
</button>
|
| 301 |
</div>
|
| 302 |
</div>
|
| 303 |
+
|
| 304 |
</div>
|
| 305 |
+
|
| 306 |
</div>
|
| 307 |
|
| 308 |
{/* Clear Logs Confirmation Modal */}
|
| 309 |
{showClearConfirm && (
|
| 310 |
+
<div className="modal-backdrop z-[9999]">
|
| 311 |
+
<div className="modal-content max-w-sm overflow-hidden p-6 space-y-4">
|
| 312 |
+
<div className="flex flex-col items-center text-center space-y-3">
|
| 313 |
+
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex items-center justify-center text-rose-500">
|
| 314 |
<Trash2 className="w-6 h-6" />
|
| 315 |
</div>
|
| 316 |
+
<div className="space-y-1">
|
| 317 |
+
<h3 className="text-sm font-bold text-zinc-900 dark:text-zinc-100 uppercase tracking-wider">Confirm Clear Audit Logs</h3>
|
| 318 |
+
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
| 319 |
+
Are you sure you want to permanently clear all security audit records?
|
|
|
|
|
|
|
|
|
|
| 320 |
</p>
|
| 321 |
</div>
|
| 322 |
+
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium bg-rose-500/10 border border-rose-500/20 rounded-xl p-3 text-left w-full">
|
| 323 |
+
β οΈ Warning: All existing historical telemetry logs will be deleted from the database. This action cannot be reversed.
|
| 324 |
+
</p>
|
| 325 |
+
<div className="flex gap-2 w-full pt-2 border-t border-zinc-100 dark:border-zinc-800">
|
| 326 |
<button
|
| 327 |
type="button"
|
| 328 |
onClick={() => setShowClearConfirm(false)}
|
| 329 |
disabled={isDeleting}
|
| 330 |
+
className="flex-1 px-4 py-2.5 text-xs font-bold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl cursor-pointer border border-zinc-200 dark:border-zinc-700"
|
| 331 |
>
|
| 332 |
Cancel
|
| 333 |
</button>
|
|
|
|
| 335 |
type="button"
|
| 336 |
onClick={handleClearLogs}
|
| 337 |
disabled={isDeleting}
|
| 338 |
+
className="flex-1 bg-rose-600 hover:bg-rose-700 text-white font-extrabold text-xs rounded-xl cursor-pointer py-2.5 flex items-center justify-center gap-2 shadow-xs transition-all"
|
| 339 |
>
|
| 340 |
+
{isDeleting ? "Clearing..." : "Yes, Clear All Logs"}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
</button>
|
| 342 |
</div>
|
| 343 |
</div>
|
| 344 |
</div>
|
| 345 |
</div>
|
| 346 |
)}
|
| 347 |
+
|
| 348 |
</SidebarLayout>
|
| 349 |
);
|
| 350 |
}
|
frontend/app/calendar/page.tsx
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { useQuery } from "@tanstack/react-query";
|
| 5 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
+
import { fetchApi, getUserProfile, getLocalDateString } from "@/app/utils/api";
|
| 7 |
+
import {
|
| 8 |
+
Calendar as CalendarIcon,
|
| 9 |
+
MapPin,
|
| 10 |
+
Clock,
|
| 11 |
+
ShieldCheck,
|
| 12 |
+
Compass,
|
| 13 |
+
Info,
|
| 14 |
+
ChevronLeft,
|
| 15 |
+
ChevronRight,
|
| 16 |
+
UserCheck,
|
| 17 |
+
UserMinus,
|
| 18 |
+
LogIn,
|
| 19 |
+
LogOut,
|
| 20 |
+
Coffee,
|
| 21 |
+
CalendarDays,
|
| 22 |
+
Activity,
|
| 23 |
+
ArrowUpRight,
|
| 24 |
+
Calendar as CalendarGridIcon
|
| 25 |
+
} from "lucide-react";
|
| 26 |
+
|
| 27 |
+
interface Holiday {
|
| 28 |
+
id: number;
|
| 29 |
+
name: string;
|
| 30 |
+
date: string;
|
| 31 |
+
day: string;
|
| 32 |
+
type: "National" | "Gazetted" | "Restricted";
|
| 33 |
+
description: string;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const STATIC_HOLIDAYS: Holiday[] = [
|
| 37 |
+
{ id: 1, name: "New Year's Day", date: "2026-01-01", day: "Thursday", type: "National", description: "First day of the new Gregorian calendar year." },
|
| 38 |
+
{ id: 2, name: "Pongal / Makar Sankranti", date: "2026-01-14", day: "Wednesday", type: "Gazetted", description: "Harvest festival dedicated to the Sun God." },
|
| 39 |
+
{ id: 3, name: "Republic Day", date: "2026-01-26", day: "Monday", type: "National", description: "Commemorates the enactment of the Constitution of India." },
|
| 40 |
+
{ id: 4, name: "Maha Shivratri", date: "2026-02-15", day: "Sunday", type: "Restricted", description: "Hindu festival celebrated annually in honor of God Shiva." },
|
| 41 |
+
{ id: 5, name: "Holi", date: "2026-03-03", day: "Tuesday", type: "Gazetted", description: "The festival of colors, celebrating the arrival of spring." },
|
| 42 |
+
{ id: 6, name: "Eid al-Fitr", date: "2026-03-20", day: "Friday", type: "Gazetted", description: "Islamic holiday marking the end of Ramadan fast." },
|
| 43 |
+
{ id: 7, name: "Ram Navami", date: "2026-03-28", day: "Saturday", type: "Restricted", description: "Celebrates the birth of Lord Rama." },
|
| 44 |
+
{ id: 8, name: "Good Friday", date: "2026-04-03", day: "Friday", type: "Restricted", description: "Christian holiday commemorating the crucifixion of Jesus." },
|
| 45 |
+
{ id: 9, name: "Ambedkar Jayanti", date: "2026-04-14", day: "Tuesday", type: "Gazetted", description: "Birth anniversary of Dr. B.R. Ambedkar, father of Indian constitution." },
|
| 46 |
+
{ id: 10, name: "May Day / Labor Day", date: "2026-05-01", day: "Friday", type: "Gazetted", description: "Celebration of laborers and the working class." },
|
| 47 |
+
{ id: 11, name: "Eid al-Adha", date: "2026-05-27", day: "Wednesday", type: "Gazetted", description: "Islamic feast of sacrifice." },
|
| 48 |
+
{ id: 12, name: "Muharram", date: "2026-06-26", day: "Friday", type: "Gazetted", description: "Islamic New Year." },
|
| 49 |
+
{ id: 13, name: "Independence Day", date: "2026-08-15", day: "Saturday", type: "National", description: "Marks the nation's independence from British rule." },
|
| 50 |
+
{ id: 14, name: "Raksha Bandhan", date: "2026-08-27", day: "Thursday", type: "Restricted", description: "Celebrating the sacred bond between brothers and sisters." },
|
| 51 |
+
{ id: 15, name: "Janmashtami", date: "2026-09-04", day: "Friday", type: "Restricted", description: "Celebrates the birth of Lord Krishna." },
|
| 52 |
+
{ id: 16, name: "Gandhi Jayanti", date: "2026-10-02", day: "Friday", type: "National", description: "Birthday tribute to Mahatma Gandhi, Father of the Nation." },
|
| 53 |
+
{ id: 17, name: "Dussehra", date: "2026-10-20", day: "Tuesday", type: "Gazetted", description: "Celebrating victory of Rama over Ravana / Good over Evil." },
|
| 54 |
+
{ id: 18, name: "Diwali / Deepavali", date: "2026-11-09", day: "Monday", type: "Gazetted", description: "Festival of lights celebrating the victory of light over darkness." },
|
| 55 |
+
{ id: 19, name: "Guru Nanak Jayanti", date: "2026-11-24", day: "Tuesday", type: "Gazetted", description: "Birth anniversary of Guru Nanak." },
|
| 56 |
+
{ id: 20, name: "Christmas Day", date: "2026-12-25", day: "Friday", type: "Gazetted", description: "Annual celebration commemorating the birth of Jesus Christ." },
|
| 57 |
+
];
|
| 58 |
+
|
| 59 |
+
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
| 60 |
+
const MONTHS = [
|
| 61 |
+
"January", "February", "March", "April", "May", "June",
|
| 62 |
+
"July", "August", "September", "October", "November", "December"
|
| 63 |
+
];
|
| 64 |
+
|
| 65 |
+
export default function CalendarPage() {
|
| 66 |
+
const [profile, setProfile] = useState<any>(null);
|
| 67 |
+
const [currentDate, setCurrentDate] = useState(new Date()); // Default showing current system date
|
| 68 |
+
const [selectedDay, setSelectedDay] = useState<number | null>(new Date().getDate());
|
| 69 |
+
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
setProfile(getUserProfile());
|
| 72 |
+
}, []);
|
| 73 |
+
|
| 74 |
+
const employee = profile?.employee;
|
| 75 |
+
|
| 76 |
+
// Fetch company geofence and policy rules
|
| 77 |
+
const { data: rules } = useQuery({
|
| 78 |
+
queryKey: ["attendance-policy-rules"],
|
| 79 |
+
queryFn: () => fetchApi("/policy/rules").catch(() => null),
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
// Fetch employee attendance history
|
| 83 |
+
const { data: history = [], isLoading: loadingHistory } = useQuery({
|
| 84 |
+
queryKey: ["employee-calendar-history", employee?.id],
|
| 85 |
+
queryFn: () => fetchApi(`/attendance/employee/${employee?.id}`),
|
| 86 |
+
enabled: !!employee?.id
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
const year = currentDate.getFullYear();
|
| 90 |
+
const month = currentDate.getMonth();
|
| 91 |
+
|
| 92 |
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
| 93 |
+
const firstDayIndex = new Date(year, month, 1).getDay();
|
| 94 |
+
|
| 95 |
+
const nextMonth = () => {
|
| 96 |
+
setCurrentDate(new Date(year, month + 1, 1));
|
| 97 |
+
setSelectedDay(null);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const prevMonth = () => {
|
| 101 |
+
setCurrentDate(new Date(year, month - 1, 1));
|
| 102 |
+
setSelectedDay(null);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
const getHolidayForDay = (dayNum: number): Holiday | undefined => {
|
| 106 |
+
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayNum).padStart(2, "0")}`;
|
| 107 |
+
return STATIC_HOLIDAYS.find((h) => h.date === dateStr);
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
const getAttendanceForDay = (dayNum: number) => {
|
| 111 |
+
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayNum).padStart(2, "0")}`;
|
| 112 |
+
return history.find((h: any) => h.date === dateStr);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// Determine cell state for coloring
|
| 116 |
+
const getDayState = (dayNum: number) => {
|
| 117 |
+
const holiday = getHolidayForDay(dayNum);
|
| 118 |
+
if (holiday) return { type: "holiday", label: holiday.name, holiday };
|
| 119 |
+
|
| 120 |
+
const att = getAttendanceForDay(dayNum);
|
| 121 |
+
if (att) {
|
| 122 |
+
if (["Present", "WFH"].includes(att.status)) return { type: "present", record: att };
|
| 123 |
+
if (att.status === "Late") return { type: "late", record: att };
|
| 124 |
+
if (att.status === "Absent") return { type: "absent", record: att };
|
| 125 |
+
if (att.status === "On Leave") return { type: "leave", record: att };
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// No record exists
|
| 129 |
+
const cellDate = new Date(year, month, dayNum);
|
| 130 |
+
const today = new Date();
|
| 131 |
+
today.setHours(0,0,0,0);
|
| 132 |
+
|
| 133 |
+
if (cellDate > today) {
|
| 134 |
+
return { type: "future" };
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const dayOfWeek = cellDate.getDay();
|
| 138 |
+
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
| 139 |
+
if (isWeekend) {
|
| 140 |
+
return { type: "weekend" };
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
return { type: "absent" };
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
const daysArray = [];
|
| 147 |
+
for (let i = 0; i < firstDayIndex; i++) {
|
| 148 |
+
daysArray.push(null);
|
| 149 |
+
}
|
| 150 |
+
for (let d = 1; d <= daysInMonth; d++) {
|
| 151 |
+
daysArray.push(d);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const selectedDayState = selectedDay ? getDayState(selectedDay) : null;
|
| 155 |
+
const selectedHoliday = selectedDayState?.holiday || null;
|
| 156 |
+
const selectedRecord = selectedDayState?.record || null;
|
| 157 |
+
|
| 158 |
+
const activeMonthHolidays = STATIC_HOLIDAYS.filter((h) => {
|
| 159 |
+
const hDate = new Date(h.date);
|
| 160 |
+
return hDate.getMonth() === month && hDate.getFullYear() === year;
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
return (
|
| 164 |
+
<SidebarLayout>
|
| 165 |
+
<div className="space-y-6 max-w-6xl mx-auto text-slate-800 dark:text-zinc-100 font-sans">
|
| 166 |
+
|
| 167 |
+
{/* Header Block */}
|
| 168 |
+
<div className="pb-4 border-b border-zinc-200 dark:border-zinc-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
| 169 |
+
<div>
|
| 170 |
+
<h1 className="text-2xl font-black text-slate-900 dark:text-zinc-100 tracking-tight flex items-center gap-2.5">
|
| 171 |
+
<div className="p-2 rounded-xl bg-zinc-100 dark:bg-zinc-800/80 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700/60 shadow-2xs">
|
| 172 |
+
<CalendarIcon className="w-5.5 h-5.5 text-cyan-500" />
|
| 173 |
+
</div>
|
| 174 |
+
Calendar & Info Hub
|
| 175 |
+
</h1>
|
| 176 |
+
<p className="text-xs text-slate-400 dark:text-zinc-400 mt-1.5">
|
| 177 |
+
Tracks shift punches, holidays, and weekly-offs. Click on dates to view full check-in analytics.
|
| 178 |
+
</p>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{/* Content Layout */}
|
| 183 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
| 184 |
+
|
| 185 |
+
{/* Calendar Card (Sleek Modern Layout) */}
|
| 186 |
+
<div className="lg:col-span-7 space-y-5">
|
| 187 |
+
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl p-5 shadow-[0_8px_30px_rgb(0,0,0,0.04)] dark:shadow-[0_8px_30px_rgb(0,0,0,0.25)] transition-all">
|
| 188 |
+
|
| 189 |
+
{/* Month Selector Header */}
|
| 190 |
+
<div className="flex items-center justify-between border-b border-slate-100 dark:border-zinc-800 pb-4 mb-4">
|
| 191 |
+
<div className="flex items-center gap-3">
|
| 192 |
+
<h3 className="text-base font-black text-slate-950 dark:text-zinc-200 uppercase font-mono tracking-wider">
|
| 193 |
+
{MONTHS[month]} {year}
|
| 194 |
+
</h3>
|
| 195 |
+
<div className="flex items-center bg-zinc-100 dark:bg-zinc-800 rounded-xl p-0.5 border border-zinc-200 dark:border-zinc-700/60">
|
| 196 |
+
<button onClick={prevMonth} className="p-1.5 hover:bg-white dark:hover:bg-zinc-700 rounded-lg text-slate-500 dark:text-zinc-450 cursor-pointer active:scale-95 transition-all">
|
| 197 |
+
<ChevronLeft className="w-4 h-4" />
|
| 198 |
+
</button>
|
| 199 |
+
<button onClick={nextMonth} className="p-1.5 hover:bg-white dark:hover:bg-zinc-700 rounded-lg text-slate-500 dark:text-zinc-455 cursor-pointer active:scale-95 transition-all">
|
| 200 |
+
<ChevronRight className="w-4 h-4" />
|
| 201 |
+
</button>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<span className="flex items-center gap-1.5 text-[9px] font-bold text-cyan-600 dark:text-cyan-400 font-mono bg-cyan-50 dark:bg-cyan-950/30 px-2.5 py-0.5 rounded-lg border border-cyan-150 dark:border-cyan-900/30 uppercase tracking-wider">
|
| 206 |
+
<Activity className="w-3 h-3 text-cyan-500 animate-pulse" />
|
| 207 |
+
Live Sync Active
|
| 208 |
+
</span>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Weekdays Row wrapper with custom background pill */}
|
| 212 |
+
<div className="grid grid-cols-7 gap-1.5 text-center font-bold text-[10px] font-mono text-zinc-450 dark:text-zinc-550 uppercase mb-3 bg-zinc-50 dark:bg-zinc-950/40 py-2 px-1.5 rounded-xl border border-zinc-200/40 dark:border-zinc-850/50">
|
| 213 |
+
{WEEKDAYS.map((day) => (
|
| 214 |
+
<div key={day} className="py-0.5 tracking-wider">{day.slice(0, 3)}</div>
|
| 215 |
+
))}
|
| 216 |
+
</div>
|
| 217 |
+
|
| 218 |
+
{/* Calendar Days grid */}
|
| 219 |
+
<div className="grid grid-cols-7 gap-2">
|
| 220 |
+
{daysArray.map((day, idx) => {
|
| 221 |
+
if (day === null) {
|
| 222 |
+
return <div key={`empty-${idx}`} className="aspect-square bg-slate-50/10 dark:bg-zinc-950/5 rounded-xl border border-dashed border-zinc-150/20 dark:border-zinc-900/30" />;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const state = getDayState(day);
|
| 226 |
+
const isSelected = selectedDay === day;
|
| 227 |
+
|
| 228 |
+
// High-fidelity cell styling based on attendance status
|
| 229 |
+
let cellStyle = "bg-white dark:bg-zinc-900 text-slate-800 dark:text-zinc-250 border-slate-200 dark:border-zinc-800 shadow-2xs hover:shadow-sm hover:translate-y-[-1px] active:translate-y-[1px]";
|
| 230 |
+
let dotStyle = "";
|
| 231 |
+
|
| 232 |
+
if (state.type === "present") {
|
| 233 |
+
cellStyle = "bg-emerald-500/10 text-emerald-800 dark:text-emerald-400 border-emerald-500/35 hover:bg-emerald-500/20 shadow-xs border-b-[3px] border-b-emerald-500";
|
| 234 |
+
dotStyle = "bg-emerald-500";
|
| 235 |
+
} else if (state.type === "late") {
|
| 236 |
+
cellStyle = "bg-amber-500/10 text-amber-800 dark:text-amber-400 border-amber-500/35 hover:bg-amber-500/20 shadow-xs border-b-[3px] border-b-amber-500";
|
| 237 |
+
dotStyle = "bg-amber-500";
|
| 238 |
+
} else if (state.type === "absent") {
|
| 239 |
+
cellStyle = "bg-rose-500/10 text-rose-800 dark:text-rose-400 border-rose-500/35 hover:bg-rose-500/20 shadow-xs border-b-[3px] border-b-rose-500";
|
| 240 |
+
dotStyle = "bg-rose-500";
|
| 241 |
+
} else if (state.type === "holiday") {
|
| 242 |
+
cellStyle = "bg-cyan-500/10 text-cyan-800 dark:text-cyan-400 border-cyan-500/35 hover:bg-cyan-500/20 shadow-xs border-b-[3px] border-b-cyan-500";
|
| 243 |
+
dotStyle = "bg-cyan-500";
|
| 244 |
+
} else if (state.type === "leave") {
|
| 245 |
+
cellStyle = "bg-indigo-500/10 text-indigo-800 dark:text-indigo-400 border-indigo-500/35 hover:bg-indigo-500/20 shadow-xs border-b-[3px] border-b-indigo-500";
|
| 246 |
+
dotStyle = "bg-indigo-500";
|
| 247 |
+
} else if (state.type === "weekend") {
|
| 248 |
+
cellStyle = "bg-zinc-50 dark:bg-zinc-950/20 text-slate-400 dark:text-zinc-500 border-zinc-200/50 dark:border-zinc-850 hover:bg-zinc-100/40 border-b-[3px] border-b-zinc-300 dark:border-b-zinc-700";
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
if (isSelected) {
|
| 252 |
+
cellStyle += " ring-2 ring-cyan-500 dark:ring-cyan-400 border-cyan-500 shadow-md translate-y-[-1px]";
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
return (
|
| 256 |
+
<button
|
| 257 |
+
key={`day-${day}`}
|
| 258 |
+
onClick={() => setSelectedDay(day)}
|
| 259 |
+
className={`aspect-square rounded-xl border flex flex-col justify-between p-2.5 transition-all text-left cursor-pointer ${cellStyle}`}
|
| 260 |
+
>
|
| 261 |
+
<span className="text-xs font-black leading-none">{day}</span>
|
| 262 |
+
{dotStyle && (
|
| 263 |
+
<span className={`w-1.5 h-1.5 rounded-full ${dotStyle} self-end mt-auto`} />
|
| 264 |
+
)}
|
| 265 |
+
</button>
|
| 266 |
+
);
|
| 267 |
+
})}
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
{/* Legend indicator */}
|
| 271 |
+
<div className="mt-5 pt-4 border-t border-slate-100 dark:border-zinc-800 flex flex-wrap gap-4 text-[9px] font-bold text-slate-500 dark:text-zinc-400 uppercase tracking-widest justify-center">
|
| 272 |
+
<div className="flex items-center gap-1.5">
|
| 273 |
+
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
| 274 |
+
<span>Present</span>
|
| 275 |
+
</div>
|
| 276 |
+
<div className="flex items-center gap-1.5">
|
| 277 |
+
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
| 278 |
+
<span>Late</span>
|
| 279 |
+
</div>
|
| 280 |
+
<div className="flex items-center gap-1.5">
|
| 281 |
+
<span className="w-2 h-2 rounded-full bg-rose-500" />
|
| 282 |
+
<span>Absent</span>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="flex items-center gap-1.5">
|
| 285 |
+
<span className="w-2 h-2 rounded-full bg-cyan-500" />
|
| 286 |
+
<span>Holiday</span>
|
| 287 |
+
</div>
|
| 288 |
+
<div className="flex items-center gap-1.5">
|
| 289 |
+
<span className="w-2 h-2 rounded-full bg-indigo-500" />
|
| 290 |
+
<span>Leave</span>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
{/* Holidays of the Month list */}
|
| 297 |
+
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl p-5 shadow-2xs space-y-4">
|
| 298 |
+
<div className="flex items-center justify-between border-b border-slate-100 dark:border-zinc-800 pb-3">
|
| 299 |
+
<div className="flex items-center gap-2">
|
| 300 |
+
<CalendarDays className="w-4.5 h-4.5 text-cyan-500 animate-pulse" />
|
| 301 |
+
<h3 className="text-xs font-bold text-slate-950 dark:text-zinc-200 uppercase tracking-wider font-mono">
|
| 302 |
+
Holidays in {MONTHS[month]}
|
| 303 |
+
</h3>
|
| 304 |
+
</div>
|
| 305 |
+
<span className="text-[9px] font-mono font-bold px-2 py-0.5 bg-cyan-50 dark:bg-cyan-950/40 text-cyan-600 dark:text-cyan-400 border border-cyan-100 dark:border-cyan-900/30 rounded">
|
| 306 |
+
{activeMonthHolidays.length} Holidays
|
| 307 |
+
</span>
|
| 308 |
+
</div>
|
| 309 |
+
|
| 310 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 311 |
+
{activeMonthHolidays.length > 0 ? (
|
| 312 |
+
activeMonthHolidays.map((holiday) => {
|
| 313 |
+
const hDay = new Date(holiday.date).getDate();
|
| 314 |
+
const isHolidaySelected = selectedDay === hDay;
|
| 315 |
+
return (
|
| 316 |
+
<button
|
| 317 |
+
key={holiday.id}
|
| 318 |
+
onClick={() => setSelectedDay(hDay)}
|
| 319 |
+
className={`text-left p-3 rounded-xl border transition-all flex items-center justify-between gap-3 cursor-pointer group bg-zinc-50/50 dark:bg-zinc-950/20 ${
|
| 320 |
+
isHolidaySelected
|
| 321 |
+
? "border-cyan-500 bg-cyan-500/5 ring-1 ring-cyan-500/20"
|
| 322 |
+
: "border-zinc-150 dark:border-zinc-850 hover:border-cyan-300 dark:hover:border-cyan-800"
|
| 323 |
+
}`}
|
| 324 |
+
>
|
| 325 |
+
<div className="min-w-0">
|
| 326 |
+
<p className="text-[11px] font-bold text-slate-800 dark:text-zinc-200 truncate group-hover:text-cyan-600 dark:group-hover:text-cyan-400">{holiday.name}</p>
|
| 327 |
+
<p className="text-[9px] text-slate-400 dark:text-zinc-500 font-mono mt-0.5">{holiday.date} • {holiday.day}</p>
|
| 328 |
+
</div>
|
| 329 |
+
<span className={`w-2 h-2 rounded-full shrink-0 ${
|
| 330 |
+
holiday.type === "National" ? "bg-rose-500 shadow-[0_0_6px_rgba(239,68,68,0.4)]" :
|
| 331 |
+
holiday.type === "Gazetted" ? "bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.4)]" :
|
| 332 |
+
"bg-amber-500 shadow-[0_0_6px_rgba(245,158,11,0.4)]"
|
| 333 |
+
}`} />
|
| 334 |
+
</button>
|
| 335 |
+
);
|
| 336 |
+
})
|
| 337 |
+
) : (
|
| 338 |
+
<p className="text-[10px] text-slate-450 dark:text-zinc-500 text-center py-6 italic font-mono col-span-2">No holidays scheduled this month.</p>
|
| 339 |
+
)}
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
</div>
|
| 344 |
+
|
| 345 |
+
{/* Activity Logs of Selected Day (Greathr Detail Sidebar Panel) */}
|
| 346 |
+
<div className="lg:col-span-5 space-y-6">
|
| 347 |
+
{selectedDay ? (
|
| 348 |
+
<div className="tech-card-3d bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 p-5 flex flex-col justify-between min-h-[400px]">
|
| 349 |
+
|
| 350 |
+
<div className="space-y-5">
|
| 351 |
+
{/* Selected Day Info Header */}
|
| 352 |
+
<div className="flex items-center justify-between border-b border-slate-100 dark:border-zinc-800 pb-3">
|
| 353 |
+
<div>
|
| 354 |
+
<h3 className="text-xs font-bold text-slate-900 dark:text-zinc-200 uppercase tracking-wider font-mono">
|
| 355 |
+
Date Details & Activity
|
| 356 |
+
</h3>
|
| 357 |
+
<p className="text-[10px] text-slate-400 dark:text-zinc-500 mt-1 font-semibold">
|
| 358 |
+
{new Date(year, month, selectedDay).toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
| 359 |
+
</p>
|
| 360 |
+
</div>
|
| 361 |
+
|
| 362 |
+
{/* Status Pill */}
|
| 363 |
+
<span className={`text-[9px] font-mono font-bold uppercase px-2.5 py-0.5 rounded-lg border ${
|
| 364 |
+
selectedDayState?.type === "present" ? "bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/20 dark:text-emerald-400" :
|
| 365 |
+
selectedDayState?.type === "late" ? "bg-amber-50 border-amber-200 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400" :
|
| 366 |
+
selectedDayState?.type === "absent" ? "bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/20 dark:text-rose-455" :
|
| 367 |
+
selectedDayState?.type === "holiday" ? "bg-cyan-50 border-cyan-200 text-cyan-700 dark:bg-cyan-950/20 dark:text-cyan-400" :
|
| 368 |
+
selectedDayState?.type === "leave" ? "bg-indigo-50 border-indigo-200 text-indigo-700 dark:bg-indigo-950/20 dark:text-indigo-400" :
|
| 369 |
+
selectedDayState?.type === "weekend" ? "bg-zinc-50 border-zinc-200 text-zinc-500 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-405" :
|
| 370 |
+
"bg-slate-50 border-slate-200 text-slate-400"
|
| 371 |
+
}`}>
|
| 372 |
+
{selectedDayState?.type}
|
| 373 |
+
</span>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
{/* Activity Details Display */}
|
| 377 |
+
<div className="space-y-4 text-xs">
|
| 378 |
+
{selectedHoliday && (
|
| 379 |
+
<div className="p-4 bg-cyan-500/5 dark:bg-cyan-950/10 border border-cyan-100 dark:border-cyan-900/60 rounded-xl space-y-1.5">
|
| 380 |
+
<p className="font-extrabold text-cyan-700 dark:text-cyan-400 flex items-center gap-1.5 uppercase text-[10px] tracking-wider">
|
| 381 |
+
<Coffee className="w-3.5 h-3.5" />
|
| 382 |
+
Official Holiday: {selectedHoliday.name}
|
| 383 |
+
</p>
|
| 384 |
+
<p className="text-[10px] text-slate-500 dark:text-zinc-400 leading-normal font-medium">{selectedHoliday.description}</p>
|
| 385 |
+
<span className="inline-block text-[8px] font-mono font-semibold px-2 py-0.5 bg-cyan-100/50 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400 rounded">
|
| 386 |
+
{selectedHoliday.type} Category
|
| 387 |
+
</span>
|
| 388 |
+
</div>
|
| 389 |
+
)}
|
| 390 |
+
|
| 391 |
+
{selectedRecord && (
|
| 392 |
+
<div className="grid grid-cols-2 gap-3">
|
| 393 |
+
<div className="p-3 rounded-xl bg-zinc-50/50 dark:bg-zinc-950/30 border border-zinc-150/60 dark:border-zinc-850/80 flex items-center gap-3">
|
| 394 |
+
<LogIn className="w-4 h-4 text-emerald-500 shrink-0" />
|
| 395 |
+
<div className="min-w-0">
|
| 396 |
+
<p className="text-[9px] font-bold text-zinc-400 dark:text-zinc-500 uppercase font-mono tracking-wider leading-none">Punch In</p>
|
| 397 |
+
<p className="font-extrabold text-slate-850 dark:text-zinc-200 mt-1 font-mono text-[13px]">
|
| 398 |
+
{selectedRecord.check_in ? new Date(selectedRecord.check_in).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "β"}
|
| 399 |
+
</p>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
<div className="p-3 rounded-xl bg-zinc-50/50 dark:bg-zinc-950/30 border border-zinc-150/60 dark:border-zinc-850/80 flex items-center gap-3">
|
| 404 |
+
<LogOut className="w-4 h-4 text-rose-500 shrink-0" />
|
| 405 |
+
<div className="min-w-0">
|
| 406 |
+
<p className="text-[9px] font-bold text-zinc-400 dark:text-zinc-500 uppercase font-mono tracking-wider leading-none">Punch Out</p>
|
| 407 |
+
<p className="font-extrabold text-slate-850 dark:text-zinc-200 mt-1 font-mono text-[13px]">
|
| 408 |
+
{selectedRecord.check_out ? new Date(selectedRecord.check_out).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "β"}
|
| 409 |
+
</p>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
<div className="p-3 rounded-xl bg-zinc-50/50 dark:bg-zinc-950/30 border border-zinc-150/60 dark:border-zinc-850/80 flex items-center gap-3">
|
| 414 |
+
<Clock className="w-4 h-4 text-cyan-500 shrink-0" />
|
| 415 |
+
<div className="min-w-0">
|
| 416 |
+
<p className="text-[9px] font-bold text-zinc-400 dark:text-zinc-500 uppercase font-mono tracking-wider leading-none">Worked Hours</p>
|
| 417 |
+
<p className="font-extrabold text-slate-850 dark:text-zinc-200 mt-1 font-mono text-[13px]">
|
| 418 |
+
{(selectedRecord.working_hours || 0).toFixed(1)} hrs
|
| 419 |
+
</p>
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
|
| 423 |
+
<div className="p-3 rounded-xl bg-zinc-50/50 dark:bg-zinc-950/30 border border-zinc-150/60 dark:border-zinc-850/80 flex items-center gap-3">
|
| 424 |
+
<MapPin className="w-4 h-4 text-indigo-500 shrink-0" />
|
| 425 |
+
<div className="min-w-0">
|
| 426 |
+
<p className="text-[9px] font-bold text-zinc-400 dark:text-zinc-500 uppercase font-mono tracking-wider leading-none">GPS Geofence</p>
|
| 427 |
+
<p className="font-extrabold text-slate-855 dark:text-zinc-200 mt-1 text-[11px] truncate" title={selectedRecord.geofence_result || "Verified"}>
|
| 428 |
+
{selectedRecord.geofence_result || "Verified Match"}
|
| 429 |
+
</p>
|
| 430 |
+
</div>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
)}
|
| 434 |
+
|
| 435 |
+
{!selectedHoliday && !selectedRecord && (
|
| 436 |
+
<div className="p-6 text-center border border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl text-zinc-400 dark:text-zinc-500 space-y-2.5 bg-zinc-50/20 dark:bg-zinc-950/10">
|
| 437 |
+
{selectedDayState?.type === "weekend" ? (
|
| 438 |
+
<>
|
| 439 |
+
<Coffee className="w-8 h-8 mx-auto text-zinc-400 opacity-40 animate-bounce" />
|
| 440 |
+
<p className="font-bold text-slate-700 dark:text-zinc-300">Weekly Off (Weekend)</p>
|
| 441 |
+
<p className="text-[10px] text-zinc-400 leading-relaxed">No shift checks are required on Saturdays and Sundays.</p>
|
| 442 |
+
</>
|
| 443 |
+
) : selectedDayState?.type === "future" ? (
|
| 444 |
+
<>
|
| 445 |
+
<Clock className="w-8 h-8 mx-auto text-zinc-400 opacity-40" />
|
| 446 |
+
<p className="font-bold text-slate-700 dark:text-zinc-300">Scheduled Workday</p>
|
| 447 |
+
<p className="text-[10px] text-zinc-400 leading-relaxed">Shift starts at 09:00 AM. Biometric registration will open on date arrival.</p>
|
| 448 |
+
</>
|
| 449 |
+
) : (
|
| 450 |
+
<>
|
| 451 |
+
<UserMinus className="w-8 h-8 mx-auto text-rose-500 opacity-40 animate-pulse" />
|
| 452 |
+
<p className="font-bold text-rose-700 dark:text-rose-455">Absent (No punch records found)</p>
|
| 453 |
+
<p className="text-[10px] text-zinc-400 leading-relaxed">No logs detected. Contact HR if you require a retrospective override.</p>
|
| 454 |
+
</>
|
| 455 |
+
)}
|
| 456 |
+
</div>
|
| 457 |
+
)}
|
| 458 |
+
</div>
|
| 459 |
+
</div>
|
| 460 |
+
|
| 461 |
+
{/* Policy settings details footer */}
|
| 462 |
+
<div className="pt-3 border-t border-slate-100 dark:border-zinc-800 text-[10px] text-slate-500 dark:text-zinc-400 leading-normal flex items-start gap-2 bg-slate-50/50 dark:bg-zinc-950/20 p-2.5 rounded-xl font-medium mt-4">
|
| 463 |
+
<Info className="w-4 h-4 text-cyan-500 shrink-0" />
|
| 464 |
+
<span>
|
| 465 |
+
Shift timings are structured <strong>09:00 AM - 05:00 PM</strong> with a 15-minute grace period. Punch logs are cross-referenced with your active office geofence.
|
| 466 |
+
</span>
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
</div>
|
| 470 |
+
) : (
|
| 471 |
+
<div className="tech-card-3d bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 p-6 text-center text-zinc-450 dark:text-zinc-400 text-xs italic font-medium">
|
| 472 |
+
Click on any calendar day to inspect punch records.
|
| 473 |
+
</div>
|
| 474 |
+
)}
|
| 475 |
+
</div>
|
| 476 |
+
|
| 477 |
+
</div>
|
| 478 |
+
|
| 479 |
+
</div>
|
| 480 |
+
</SidebarLayout>
|
| 481 |
+
);
|
| 482 |
+
}
|
frontend/app/dashboard/page.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/app/employees/[id]/page.tsx
CHANGED
|
@@ -1167,16 +1167,8 @@ export default function EmployeeDetailPage() {
|
|
| 1167 |
</div>
|
| 1168 |
|
| 1169 |
{/* Footer QR fallback */}
|
| 1170 |
-
<div className="bg-slate-50/80 border-t border-slate-100 h-[
|
| 1171 |
-
<div className="
|
| 1172 |
-
<span className="text-[7.5px] font-black text-slate-900 tracking-wider uppercase font-mono">
|
| 1173 |
-
SCAN TO VERIFY
|
| 1174 |
-
</span>
|
| 1175 |
-
<p className="text-[6.5px] text-slate-450 font-medium leading-snug mt-0.5 max-w-[115px] font-mono">
|
| 1176 |
-
Scan this backup barcode QR badge if Kiosk face matching fails.
|
| 1177 |
-
</p>
|
| 1178 |
-
</div>
|
| 1179 |
-
<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">
|
| 1180 |
<img
|
| 1181 |
src={`https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=${employee.employee_id}`}
|
| 1182 |
alt="QR"
|
|
|
|
| 1167 |
</div>
|
| 1168 |
|
| 1169 |
{/* Footer QR fallback */}
|
| 1170 |
+
<div className="bg-slate-50/80 border-t border-slate-100 h-[90px] flex items-center justify-center pb-1 shrink-0 z-10">
|
| 1171 |
+
<div className="w-[60px] h-[60px] bg-white rounded-lg border border-slate-200/80 p-1 flex items-center justify-center shadow-2xs shrink-0">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
<img
|
| 1173 |
src={`https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=${employee.employee_id}`}
|
| 1174 |
alt="QR"
|
frontend/app/employees/page.tsx
CHANGED
|
@@ -7,7 +7,8 @@ import { fetchApi, getBackendUrl, parseDateTime, getLocalDateString } from "@/ap
|
|
| 7 |
import {
|
| 8 |
Plus, Search, Trash2, Camera, Upload, FileSpreadsheet,
|
| 9 |
X, Users, CheckCircle2, XCircle, ChevronDown, UserCheck, ShieldAlert,
|
| 10 |
-
Download, Mail, Phone, Calendar, Briefcase, Clock, TrendingUp, MapPin
|
|
|
|
| 11 |
} from "lucide-react";
|
| 12 |
import { useToast } from "@/app/utils/toast";
|
| 13 |
import Link from "next/link";
|
|
@@ -69,6 +70,7 @@ export default function EmployeesPage() {
|
|
| 69 |
const router = useRouter();
|
| 70 |
const { toast } = useToast();
|
| 71 |
const [search, setSearch] = useState("");
|
|
|
|
| 72 |
const [deptFilter, setDeptFilter] = useState("");
|
| 73 |
const [statusFilter, setStatusFilter] = useState("");
|
| 74 |
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
@@ -92,6 +94,7 @@ export default function EmployeesPage() {
|
|
| 92 |
const [deptId, setDeptId] = useState("");
|
| 93 |
const [createUserLogin, setCreateUserLogin] = useState(false);
|
| 94 |
const [password, setPassword] = useState("");
|
|
|
|
| 95 |
const [allowWfh, setAllowWfh] = useState(false);
|
| 96 |
const [wfhAddress, setWfhAddress] = useState("");
|
| 97 |
const [wfhLat, setWfhLat] = useState<number | null>(null);
|
|
@@ -139,6 +142,19 @@ export default function EmployeesPage() {
|
|
| 139 |
onError: (err: any) => toast.error(err.message || "Failed to delete employee.")
|
| 140 |
});
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
const resetForm = () => {
|
| 143 |
setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
|
| 144 |
setJoiningDate(getLocalDateString()); setStatusVal("Active");
|
|
@@ -221,9 +237,9 @@ export default function EmployeesPage() {
|
|
| 221 |
body: formData
|
| 222 |
});
|
| 223 |
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 224 |
-
|
| 225 |
} catch (err: any) {
|
| 226 |
-
|
| 227 |
}
|
| 228 |
};
|
| 229 |
|
|
@@ -300,6 +316,23 @@ export default function EmployeesPage() {
|
|
| 300 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Staff Management</h1>
|
| 301 |
</div>
|
| 302 |
<div className="flex items-center gap-2.5 shrink-0">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
<button
|
| 304 |
onClick={() => setShowImportDialog(true)}
|
| 305 |
className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
|
@@ -351,23 +384,38 @@ export default function EmployeesPage() {
|
|
| 351 |
<option value="">All Statuses</option>
|
| 352 |
<option value="Active">Active</option>
|
| 353 |
<option value="Inactive">Inactive</option>
|
|
|
|
| 354 |
</select>
|
| 355 |
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
|
| 359 |
{/* Table List Card */}
|
| 360 |
-
<div className="
|
| 361 |
-
<div className="px-5 py-3.5 border-b border-
|
| 362 |
<p className="text-[11px] text-slate-500 font-mono">
|
| 363 |
{loadingEmployees ? "Fetching..." : `${employees?.length || 0} registered personnel`}
|
| 364 |
</p>
|
| 365 |
</div>
|
| 366 |
|
| 367 |
-
<div className="overflow-x-auto">
|
| 368 |
<table className="data-table">
|
| 369 |
<thead>
|
| 370 |
<tr>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
<th className="text-left py-3.5 px-5">Employee Info</th>
|
| 372 |
<th className="text-left py-3.5 px-5">Department</th>
|
| 373 |
<th className="text-left py-3.5 px-5">Designation</th>
|
|
@@ -379,7 +427,7 @@ export default function EmployeesPage() {
|
|
| 379 |
{loadingEmployees ? (
|
| 380 |
Array.from({ length: 5 }).map((_, i) => (
|
| 381 |
<tr key={i}>
|
| 382 |
-
{Array.from({ length:
|
| 383 |
<td key={j} className="py-4.5 px-5">
|
| 384 |
<div className="skeleton h-4 w-28" />
|
| 385 |
</td>
|
|
@@ -388,7 +436,7 @@ export default function EmployeesPage() {
|
|
| 388 |
))
|
| 389 |
) : employees?.length === 0 ? (
|
| 390 |
<tr>
|
| 391 |
-
<td colSpan={
|
| 392 |
<div className="flex flex-col items-center gap-3 max-w-xs mx-auto">
|
| 393 |
<div className="w-12 h-12 rounded-2xl bg-white/4 flex items-center justify-center">
|
| 394 |
<Users className="w-5 h-5 text-slate-600" />
|
|
@@ -403,6 +451,20 @@ export default function EmployeesPage() {
|
|
| 403 |
const avatarColor = avatarColors[emp.id % avatarColors.length];
|
| 404 |
return (
|
| 405 |
<tr key={emp.id} className="group/row cursor-pointer hover:bg-white/[0.015]" onClick={() => router.push(`/employees/${emp.id}`)}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
<td className="py-3.5 px-5">
|
| 407 |
<div className="flex items-center gap-3">
|
| 408 |
<EmployeeAvatar emp={emp} size="md" />
|
|
@@ -421,14 +483,33 @@ export default function EmployeesPage() {
|
|
| 421 |
{emp.designation || <span className="text-slate-400">β</span>}
|
| 422 |
</td>
|
| 423 |
<td className="py-3.5 px-5">
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
</td>
|
| 429 |
<td className="py-3.5 px-5" onClick={(e) => e.stopPropagation()}>
|
| 430 |
<div className="flex items-center justify-center gap-2">
|
| 431 |
-
{emp.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
<Link
|
| 433 |
href={`/enroll/${emp.id}`}
|
| 434 |
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[10.5px] font-bold text-emerald-500 bg-emerald-500/8 hover:bg-emerald-500/15 border border-emerald-500/15 hover:border-emerald-500/25 transition-all"
|
|
@@ -602,8 +683,24 @@ export default function EmployeesPage() {
|
|
| 602 |
</label>
|
| 603 |
{createUserLogin && (
|
| 604 |
<InputField label="Initial Password" required>
|
| 605 |
-
<
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
</InputField>
|
| 608 |
)}
|
| 609 |
</div>
|
|
|
|
| 7 |
import {
|
| 8 |
Plus, Search, Trash2, Camera, Upload, FileSpreadsheet,
|
| 9 |
X, Users, CheckCircle2, XCircle, ChevronDown, UserCheck, ShieldAlert,
|
| 10 |
+
Download, Mail, Phone, Calendar, Briefcase, Clock, TrendingUp, MapPin,
|
| 11 |
+
Eye, EyeOff
|
| 12 |
} from "lucide-react";
|
| 13 |
import { useToast } from "@/app/utils/toast";
|
| 14 |
import Link from "next/link";
|
|
|
|
| 70 |
const router = useRouter();
|
| 71 |
const { toast } = useToast();
|
| 72 |
const [search, setSearch] = useState("");
|
| 73 |
+
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
| 74 |
const [deptFilter, setDeptFilter] = useState("");
|
| 75 |
const [statusFilter, setStatusFilter] = useState("");
|
| 76 |
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
|
|
| 94 |
const [deptId, setDeptId] = useState("");
|
| 95 |
const [createUserLogin, setCreateUserLogin] = useState(false);
|
| 96 |
const [password, setPassword] = useState("");
|
| 97 |
+
const [showPassword, setShowPassword] = useState(false);
|
| 98 |
const [allowWfh, setAllowWfh] = useState(false);
|
| 99 |
const [wfhAddress, setWfhAddress] = useState("");
|
| 100 |
const [wfhLat, setWfhLat] = useState<number | null>(null);
|
|
|
|
| 142 |
onError: (err: any) => toast.error(err.message || "Failed to delete employee.")
|
| 143 |
});
|
| 144 |
|
| 145 |
+
const approveMutation = useMutation({
|
| 146 |
+
mutationFn: (id: number) => fetchApi(`/employees/${id}`, {
|
| 147 |
+
method: "PUT",
|
| 148 |
+
body: JSON.stringify({ status: "Active" })
|
| 149 |
+
}),
|
| 150 |
+
onSuccess: () => {
|
| 151 |
+
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 152 |
+
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 153 |
+
toast.success("Employee approved successfully!");
|
| 154 |
+
},
|
| 155 |
+
onError: (err: any) => toast.error(err.message || "Failed to approve employee.")
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
const resetForm = () => {
|
| 159 |
setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
|
| 160 |
setJoiningDate(getLocalDateString()); setStatusVal("Active");
|
|
|
|
| 237 |
body: formData
|
| 238 |
});
|
| 239 |
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 240 |
+
toast.success("Profile photo uploaded successfully!");
|
| 241 |
} catch (err: any) {
|
| 242 |
+
toast.error(err.message || "Failed to upload photo");
|
| 243 |
}
|
| 244 |
};
|
| 245 |
|
|
|
|
| 316 |
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Staff Management</h1>
|
| 317 |
</div>
|
| 318 |
<div className="flex items-center gap-2.5 shrink-0">
|
| 319 |
+
{employees?.some((emp: any) => selectedIds.includes(emp.id) && emp.status === "Pending Approval") && (
|
| 320 |
+
<button
|
| 321 |
+
onClick={async () => {
|
| 322 |
+
const pendingSelected = employees.filter((emp: any) => selectedIds.includes(emp.id) && emp.status === "Pending Approval");
|
| 323 |
+
for (const emp of pendingSelected) {
|
| 324 |
+
await approveMutation.mutateAsync(emp.id);
|
| 325 |
+
}
|
| 326 |
+
setSelectedIds([]);
|
| 327 |
+
toast.success(`Approved ${pendingSelected.length} employee accounts.`);
|
| 328 |
+
}}
|
| 329 |
+
disabled={approveMutation.isPending}
|
| 330 |
+
className="btn-primary bg-emerald-600 hover:bg-emerald-700 text-white border-transparent text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl cursor-pointer"
|
| 331 |
+
>
|
| 332 |
+
<UserCheck className="w-3.5 h-3.5" />
|
| 333 |
+
Approve Selected ({employees.filter((emp: any) => selectedIds.includes(emp.id) && emp.status === "Pending Approval").length})
|
| 334 |
+
</button>
|
| 335 |
+
)}
|
| 336 |
<button
|
| 337 |
onClick={() => setShowImportDialog(true)}
|
| 338 |
className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
|
|
|
| 384 |
<option value="">All Statuses</option>
|
| 385 |
<option value="Active">Active</option>
|
| 386 |
<option value="Inactive">Inactive</option>
|
| 387 |
+
<option value="Pending Approval">Pending Approval</option>
|
| 388 |
</select>
|
| 389 |
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 390 |
</div>
|
| 391 |
</div>
|
| 392 |
|
| 393 |
{/* Table List Card */}
|
| 394 |
+
<div className="tech-card-3d-minimal bg-white overflow-hidden">
|
| 395 |
+
<div className="px-5 py-3.5 border-b border-slate-200/80 dark:border-zinc-800 bg-slate-50/50 dark:bg-zinc-900/50 flex items-center justify-between">
|
| 396 |
<p className="text-[11px] text-slate-500 font-mono">
|
| 397 |
{loadingEmployees ? "Fetching..." : `${employees?.length || 0} registered personnel`}
|
| 398 |
</p>
|
| 399 |
</div>
|
| 400 |
|
| 401 |
+
<div className="overflow-x-auto min-h-[350px]">
|
| 402 |
<table className="data-table">
|
| 403 |
<thead>
|
| 404 |
<tr>
|
| 405 |
+
<th className="py-3.5 px-4 w-[40px] text-center">
|
| 406 |
+
<input
|
| 407 |
+
type="checkbox"
|
| 408 |
+
checked={employees?.length > 0 && selectedIds.length === employees.length}
|
| 409 |
+
onChange={(e) => {
|
| 410 |
+
if (e.target.checked) {
|
| 411 |
+
setSelectedIds(employees.map((emp: any) => emp.id));
|
| 412 |
+
} else {
|
| 413 |
+
setSelectedIds([]);
|
| 414 |
+
}
|
| 415 |
+
}}
|
| 416 |
+
className="w-4 h-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900 cursor-pointer"
|
| 417 |
+
/>
|
| 418 |
+
</th>
|
| 419 |
<th className="text-left py-3.5 px-5">Employee Info</th>
|
| 420 |
<th className="text-left py-3.5 px-5">Department</th>
|
| 421 |
<th className="text-left py-3.5 px-5">Designation</th>
|
|
|
|
| 427 |
{loadingEmployees ? (
|
| 428 |
Array.from({ length: 5 }).map((_, i) => (
|
| 429 |
<tr key={i}>
|
| 430 |
+
{Array.from({ length: 6 }).map((_, j) => (
|
| 431 |
<td key={j} className="py-4.5 px-5">
|
| 432 |
<div className="skeleton h-4 w-28" />
|
| 433 |
</td>
|
|
|
|
| 436 |
))
|
| 437 |
) : employees?.length === 0 ? (
|
| 438 |
<tr>
|
| 439 |
+
<td colSpan={6} className="py-20 text-center">
|
| 440 |
<div className="flex flex-col items-center gap-3 max-w-xs mx-auto">
|
| 441 |
<div className="w-12 h-12 rounded-2xl bg-white/4 flex items-center justify-center">
|
| 442 |
<Users className="w-5 h-5 text-slate-600" />
|
|
|
|
| 451 |
const avatarColor = avatarColors[emp.id % avatarColors.length];
|
| 452 |
return (
|
| 453 |
<tr key={emp.id} className="group/row cursor-pointer hover:bg-white/[0.015]" onClick={() => router.push(`/employees/${emp.id}`)}>
|
| 454 |
+
<td className="py-3.5 px-4 text-center" onClick={(e) => e.stopPropagation()}>
|
| 455 |
+
<input
|
| 456 |
+
type="checkbox"
|
| 457 |
+
checked={selectedIds.includes(emp.id)}
|
| 458 |
+
onChange={(e) => {
|
| 459 |
+
if (e.target.checked) {
|
| 460 |
+
setSelectedIds(prev => [...prev, emp.id]);
|
| 461 |
+
} else {
|
| 462 |
+
setSelectedIds(prev => prev.filter(id => id !== emp.id));
|
| 463 |
+
}
|
| 464 |
+
}}
|
| 465 |
+
className="w-4 h-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900 cursor-pointer"
|
| 466 |
+
/>
|
| 467 |
+
</td>
|
| 468 |
<td className="py-3.5 px-5">
|
| 469 |
<div className="flex items-center gap-3">
|
| 470 |
<EmployeeAvatar emp={emp} size="md" />
|
|
|
|
| 483 |
{emp.designation || <span className="text-slate-400">β</span>}
|
| 484 |
</td>
|
| 485 |
<td className="py-3.5 px-5">
|
| 486 |
+
{emp.status === "Pending Approval" ? (
|
| 487 |
+
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-[10.5px] font-bold bg-amber-500/10 border border-amber-500/25 text-amber-500 w-fit">
|
| 488 |
+
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
| 489 |
+
Pending Approval
|
| 490 |
+
</span>
|
| 491 |
+
) : (
|
| 492 |
+
<span className={`badge ${emp.status === "Active" ? "badge-emerald" : "badge-slate"} flex items-center gap-1 w-fit`}>
|
| 493 |
+
{emp.status === "Active" && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
| 494 |
+
{emp.status}
|
| 495 |
+
</span>
|
| 496 |
+
)}
|
| 497 |
</td>
|
| 498 |
<td className="py-3.5 px-5" onClick={(e) => e.stopPropagation()}>
|
| 499 |
<div className="flex items-center justify-center gap-2">
|
| 500 |
+
{emp.status === "Pending Approval" ? (
|
| 501 |
+
<button
|
| 502 |
+
onClick={(e) => {
|
| 503 |
+
e.stopPropagation();
|
| 504 |
+
approveMutation.mutate(emp.id);
|
| 505 |
+
}}
|
| 506 |
+
disabled={approveMutation.isPending}
|
| 507 |
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[10.5px] font-bold text-emerald-500 bg-emerald-500/8 hover:bg-emerald-500/15 border border-emerald-500/15 hover:border-emerald-500/25 transition-all cursor-pointer"
|
| 508 |
+
>
|
| 509 |
+
<UserCheck className="w-3.5 h-3.5 animate-pulse" />
|
| 510 |
+
Approve Account
|
| 511 |
+
</button>
|
| 512 |
+
) : emp.images && emp.images.length > 0 ? (
|
| 513 |
<Link
|
| 514 |
href={`/enroll/${emp.id}`}
|
| 515 |
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[10.5px] font-bold text-emerald-500 bg-emerald-500/8 hover:bg-emerald-500/15 border border-emerald-500/15 hover:border-emerald-500/25 transition-all"
|
|
|
|
| 683 |
</label>
|
| 684 |
{createUserLogin && (
|
| 685 |
<InputField label="Initial Password" required>
|
| 686 |
+
<div className="relative">
|
| 687 |
+
<input
|
| 688 |
+
type={showPassword ? "text" : "password"}
|
| 689 |
+
required
|
| 690 |
+
placeholder="Minimum 8 characters"
|
| 691 |
+
value={password}
|
| 692 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 693 |
+
className={`${inputCls} pr-10`}
|
| 694 |
+
/>
|
| 695 |
+
<button
|
| 696 |
+
type="button"
|
| 697 |
+
onClick={() => setShowPassword(!showPassword)}
|
| 698 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 dark:hover:text-zinc-200 cursor-pointer"
|
| 699 |
+
title={showPassword ? "Hide password" : "Show password"}
|
| 700 |
+
>
|
| 701 |
+
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 702 |
+
</button>
|
| 703 |
+
</div>
|
| 704 |
</InputField>
|
| 705 |
)}
|
| 706 |
</div>
|
frontend/app/enroll/[id]/page.tsx
CHANGED
|
@@ -278,6 +278,22 @@ export default function EnrollPage() {
|
|
| 278 |
}
|
| 279 |
});
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
// Offline Web Audio API Sound Generator (synthesized camera click & alert beeps)
|
| 282 |
const playSound = (type: "beep" | "click") => {
|
| 283 |
if (typeof window === "undefined") return;
|
|
@@ -993,39 +1009,49 @@ export default function EnrollPage() {
|
|
| 993 |
|
| 994 |
{/* Left Box: Progress and instructions */}
|
| 995 |
<div className="space-y-5">
|
| 996 |
-
<div className="
|
| 997 |
-
<h3 className="text-[
|
| 998 |
|
| 999 |
-
<div className="space-y-
|
| 1000 |
-
<div className="flex items-center justify-between text-xs font-mono font-bold text-slate-
|
| 1001 |
<span>Database Status:</span>
|
| 1002 |
-
<span className={isProfileComplete ? "text-emerald-600" : "text-amber-500"}>
|
|
|
|
| 1003 |
{isProfileComplete ? "Complete" : "Incomplete"}
|
| 1004 |
</span>
|
| 1005 |
</div>
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
</div>
|
| 1010 |
</div>
|
| 1011 |
|
| 1012 |
<button
|
| 1013 |
onClick={startAutoCapture}
|
| 1014 |
-
className="w-full h-11 bg-slate-
|
| 1015 |
>
|
| 1016 |
-
<Camera className="w-4 h-4" />
|
| 1017 |
Start Auto-Capture Session
|
| 1018 |
</button>
|
| 1019 |
</div>
|
| 1020 |
|
| 1021 |
{/* Upload Fallback File Option */}
|
| 1022 |
-
<div className="
|
| 1023 |
-
<h4 className="text-[
|
| 1024 |
<div className="flex gap-2">
|
| 1025 |
<select
|
| 1026 |
value={selectedPose}
|
| 1027 |
onChange={(e) => setSelectedPose(e.target.value)}
|
| 1028 |
-
className="h-9 px-
|
| 1029 |
>
|
| 1030 |
{POSE_KEYS.map((key) => (
|
| 1031 |
<option key={key} value={key}>{POSES[key].label}</option>
|
|
@@ -1037,7 +1063,7 @@ export default function EnrollPage() {
|
|
| 1037 |
onChange={handleFileChange}
|
| 1038 |
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 1039 |
/>
|
| 1040 |
-
<div className="h-9 px-
|
| 1041 |
<Upload className="w-3.5 h-3.5" />
|
| 1042 |
Browse
|
| 1043 |
</div>
|
|
@@ -1047,8 +1073,8 @@ export default function EnrollPage() {
|
|
| 1047 |
</div>
|
| 1048 |
|
| 1049 |
{/* Right: Big visual grid checklist */}
|
| 1050 |
-
<div className="md:col-span-2
|
| 1051 |
-
<h3 className="text-
|
| 1052 |
|
| 1053 |
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3.5 pt-2">
|
| 1054 |
{POSE_KEYS.map((key) => {
|
|
@@ -1057,24 +1083,38 @@ export default function EnrollPage() {
|
|
| 1057 |
return (
|
| 1058 |
<div
|
| 1059 |
key={key}
|
| 1060 |
-
className={`p-4
|
| 1061 |
done
|
| 1062 |
-
? "bg-emerald-
|
| 1063 |
-
: "bg-
|
| 1064 |
}`}
|
| 1065 |
>
|
| 1066 |
<div className={`w-9 h-9 rounded-xl flex items-center justify-center mb-2.5 transition-all ${
|
| 1067 |
done
|
| 1068 |
? "bg-emerald-500/10 text-emerald-600"
|
| 1069 |
-
: "bg-slate-
|
| 1070 |
}`}>
|
| 1071 |
<Icon className="w-4.5 h-4.5" />
|
| 1072 |
</div>
|
| 1073 |
-
<span className={`text-[10px] font-bold uppercase tracking-wider font-mono ${done ? "text-emerald-800" : "text-slate-
|
| 1074 |
{POSES[key].label.replace(" Profile", "").replace(" Face", "").replace(" Option", "").replace(" Light", "")}
|
| 1075 |
</span>
|
| 1076 |
{done ? (
|
| 1077 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
) : (
|
| 1079 |
<div className="w-4 h-4 rounded-full border-2 border-slate-200 mt-3 shrink-0 bg-white" />
|
| 1080 |
)}
|
|
@@ -1525,18 +1565,9 @@ export default function EnrollPage() {
|
|
| 1525 |
</div>
|
| 1526 |
|
| 1527 |
{/* QR Code Fallback Section */}
|
| 1528 |
-
<div className="bg-slate-50/80 backdrop-blur-xs border-t border-slate-100 h-[
|
| 1529 |
-
<div className="flex flex-col min-w-0 pr-2">
|
| 1530 |
-
<span className="text-[8px] font-black text-slate-900 tracking-wider uppercase font-mono">
|
| 1531 |
-
SCAN TO VERIFY
|
| 1532 |
-
</span>
|
| 1533 |
-
<p className="text-[7px] text-slate-450 font-medium leading-snug mt-0.5 max-w-[130px] font-mono">
|
| 1534 |
-
If facial scanner recognition fails, scan this backup QR code at Kiosk terminal.
|
| 1535 |
-
</p>
|
| 1536 |
-
</div>
|
| 1537 |
-
|
| 1538 |
{/* QR Code Container */}
|
| 1539 |
-
<div className="w-[
|
| 1540 |
<img
|
| 1541 |
src={`https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${employee?.employee_id}`}
|
| 1542 |
alt="QR Code"
|
|
|
|
| 278 |
}
|
| 279 |
});
|
| 280 |
|
| 281 |
+
const deletePoseMutation = useMutation({
|
| 282 |
+
mutationFn: (pose: string) => fetchApi(`/enrollment/${employeeId}/pose/${pose}`, { method: "DELETE" }),
|
| 283 |
+
onSuccess: (_, pose) => {
|
| 284 |
+
refetchStatus();
|
| 285 |
+
setCapturedImages(prev => {
|
| 286 |
+
const next = { ...prev };
|
| 287 |
+
delete next[pose];
|
| 288 |
+
return next;
|
| 289 |
+
});
|
| 290 |
+
toast.success(`Face profile for pose '${POSES[pose]?.label || pose}' cleared.`);
|
| 291 |
+
},
|
| 292 |
+
onError: (err: any) => {
|
| 293 |
+
toast.error(err.message || "Failed to delete pose");
|
| 294 |
+
}
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
// Offline Web Audio API Sound Generator (synthesized camera click & alert beeps)
|
| 298 |
const playSound = (type: "beep" | "click") => {
|
| 299 |
if (typeof window === "undefined") return;
|
|
|
|
| 1009 |
|
| 1010 |
{/* Left Box: Progress and instructions */}
|
| 1011 |
<div className="space-y-5">
|
| 1012 |
+
<div className="tech-card-3d-minimal bg-white p-5 space-y-4">
|
| 1013 |
+
<h3 className="text-[11px] font-black text-slate-800 uppercase tracking-wider">Facial Registry</h3>
|
| 1014 |
|
| 1015 |
+
<div className="space-y-3.5">
|
| 1016 |
+
<div className="flex items-center justify-between text-xs font-mono font-bold text-slate-600">
|
| 1017 |
<span>Database Status:</span>
|
| 1018 |
+
<span className={`inline-flex items-center gap-1.5 ${isProfileComplete ? "text-emerald-600" : "text-amber-500"}`}>
|
| 1019 |
+
<span className={`w-1.5 h-1.5 rounded-full ${isProfileComplete ? "bg-emerald-500 animate-pulse" : "bg-amber-400"}`} />
|
| 1020 |
{isProfileComplete ? "Complete" : "Incomplete"}
|
| 1021 |
</span>
|
| 1022 |
</div>
|
| 1023 |
+
|
| 1024 |
+
<div className="space-y-1.5">
|
| 1025 |
+
<div className="flex items-center justify-between text-xs font-mono font-bold text-slate-650">
|
| 1026 |
+
<span>Active Vectors:</span>
|
| 1027 |
+
<span>{enrolledCount} / {POSE_KEYS.length}</span>
|
| 1028 |
+
</div>
|
| 1029 |
+
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden border border-slate-200/40">
|
| 1030 |
+
<div
|
| 1031 |
+
className="bg-cyan-500 h-2 rounded-full transition-all duration-500"
|
| 1032 |
+
style={{ width: `${(enrolledCount / POSE_KEYS.length) * 100}%` }}
|
| 1033 |
+
/>
|
| 1034 |
+
</div>
|
| 1035 |
</div>
|
| 1036 |
</div>
|
| 1037 |
|
| 1038 |
<button
|
| 1039 |
onClick={startAutoCapture}
|
| 1040 |
+
className="w-full h-11 bg-slate-900 hover:bg-slate-800 text-white font-extrabold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2.5 transition-all active:scale-95 cursor-pointer shadow-sm border border-slate-850"
|
| 1041 |
>
|
| 1042 |
+
<Camera className="w-4 h-4 text-cyan-400 animate-pulse" />
|
| 1043 |
Start Auto-Capture Session
|
| 1044 |
</button>
|
| 1045 |
</div>
|
| 1046 |
|
| 1047 |
{/* Upload Fallback File Option */}
|
| 1048 |
+
<div className="tech-card-3d-minimal bg-white p-5 space-y-3">
|
| 1049 |
+
<h4 className="text-[10px] font-black text-slate-800 uppercase tracking-wider">Manual Photo Upload</h4>
|
| 1050 |
<div className="flex gap-2">
|
| 1051 |
<select
|
| 1052 |
value={selectedPose}
|
| 1053 |
onChange={(e) => setSelectedPose(e.target.value)}
|
| 1054 |
+
className="h-9.5 px-3 text-[11px] font-extrabold bg-white border border-slate-200 rounded-lg flex-1 outline-none text-slate-700 focus:border-slate-800 cursor-pointer"
|
| 1055 |
>
|
| 1056 |
{POSE_KEYS.map((key) => (
|
| 1057 |
<option key={key} value={key}>{POSES[key].label}</option>
|
|
|
|
| 1063 |
onChange={handleFileChange}
|
| 1064 |
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 1065 |
/>
|
| 1066 |
+
<div className="h-9.5 px-4 bg-slate-900 hover:bg-slate-800 text-white font-bold text-[11px] rounded-lg flex items-center gap-1.5 transition-all shadow-sm cursor-pointer active:scale-95">
|
| 1067 |
<Upload className="w-3.5 h-3.5" />
|
| 1068 |
Browse
|
| 1069 |
</div>
|
|
|
|
| 1073 |
</div>
|
| 1074 |
|
| 1075 |
{/* Right: Big visual grid checklist */}
|
| 1076 |
+
<div className="md:col-span-2 tech-card-3d-minimal bg-white p-6 space-y-4">
|
| 1077 |
+
<h3 className="text-xs font-black text-slate-850 uppercase tracking-wider">Facial Pose Checklist</h3>
|
| 1078 |
|
| 1079 |
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3.5 pt-2">
|
| 1080 |
{POSE_KEYS.map((key) => {
|
|
|
|
| 1083 |
return (
|
| 1084 |
<div
|
| 1085 |
key={key}
|
| 1086 |
+
className={`p-4 flex flex-col items-center text-center justify-center transition-all duration-300 select-none cursor-default border rounded-2xl hover:translate-y-[-2px] ${
|
| 1087 |
done
|
| 1088 |
+
? "bg-emerald-50/40 border-emerald-500/40 text-emerald-800 shadow-[2px_2px_0px_rgba(16,185,129,0.15)]"
|
| 1089 |
+
: "bg-white border-slate-200/80 hover:border-slate-400 hover:shadow-[2px_2px_0px_rgba(15,23,42,0.08)]"
|
| 1090 |
}`}
|
| 1091 |
>
|
| 1092 |
<div className={`w-9 h-9 rounded-xl flex items-center justify-center mb-2.5 transition-all ${
|
| 1093 |
done
|
| 1094 |
? "bg-emerald-500/10 text-emerald-600"
|
| 1095 |
+
: "bg-slate-50 border border-slate-200 text-slate-400"
|
| 1096 |
}`}>
|
| 1097 |
<Icon className="w-4.5 h-4.5" />
|
| 1098 |
</div>
|
| 1099 |
+
<span className={`text-[10px] font-bold uppercase tracking-wider font-mono ${done ? "text-emerald-800" : "text-slate-500"}`}>
|
| 1100 |
{POSES[key].label.replace(" Profile", "").replace(" Face", "").replace(" Option", "").replace(" Light", "")}
|
| 1101 |
</span>
|
| 1102 |
{done ? (
|
| 1103 |
+
<div className="flex items-center gap-1.5 mt-3 justify-center w-full">
|
| 1104 |
+
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0" />
|
| 1105 |
+
<button
|
| 1106 |
+
onClick={(e) => {
|
| 1107 |
+
e.stopPropagation();
|
| 1108 |
+
if (confirm(`Are you sure you want to clear and re-take/re-upload the ${POSES[key].label}?`)) {
|
| 1109 |
+
deletePoseMutation.mutate(key);
|
| 1110 |
+
}
|
| 1111 |
+
}}
|
| 1112 |
+
className="p-1 hover:bg-rose-100/60 hover:text-rose-600 rounded text-slate-400 hover:scale-105 active:scale-95 transition-all cursor-pointer"
|
| 1113 |
+
title="Delete and re-upload this pose"
|
| 1114 |
+
>
|
| 1115 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 1116 |
+
</button>
|
| 1117 |
+
</div>
|
| 1118 |
) : (
|
| 1119 |
<div className="w-4 h-4 rounded-full border-2 border-slate-200 mt-3 shrink-0 bg-white" />
|
| 1120 |
)}
|
|
|
|
| 1565 |
</div>
|
| 1566 |
|
| 1567 |
{/* QR Code Fallback Section */}
|
| 1568 |
+
<div className="bg-slate-50/80 backdrop-blur-xs border-t border-slate-100 h-[105px] flex items-center justify-center pb-1.5 shrink-0 z-10">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1569 |
{/* QR Code Container */}
|
| 1570 |
+
<div className="w-[72px] h-[72px] bg-white rounded-lg border border-slate-200/80 p-1 flex items-center justify-center shadow-2xs shrink-0 font-mono">
|
| 1571 |
<img
|
| 1572 |
src={`https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${employee?.employee_id}`}
|
| 1573 |
alt="QR Code"
|
frontend/app/globals.css
CHANGED
|
@@ -42,6 +42,7 @@
|
|
| 42 |
|
| 43 |
html {
|
| 44 |
scroll-behavior: smooth;
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
body {
|
|
@@ -265,16 +266,15 @@ select.input-field option {
|
|
| 265 |
inset: 0;
|
| 266 |
pointer-events: none;
|
| 267 |
z-index: 0;
|
| 268 |
-
background-color:
|
| 269 |
background-image:
|
| 270 |
-
radial-gradient(circle at 50% 50%, rgba(6, 182, 212, 0.
|
| 271 |
-
radial-gradient(circle at 50% 50%, transparent 30%, var(--bg-base) 85%),
|
| 272 |
linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
| 273 |
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
| 274 |
-
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 600' width='100%' height='100%'><defs><filter id='glow-light' x='-50%' y='-50%' width='200%' height='200%'><feGaussianBlur in='SourceGraphic' stdDeviation='4' result='blur'/><feMerge><feMergeNode in='blur'/><feMergeNode in='SourceGraphic'/></feMerge></filter><linearGradient id='grad-cyan-light' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%2306b6d4' stop-opacity='0.
|
| 275 |
-
background-size: cover,
|
| 276 |
background-position: center;
|
| 277 |
-
background-repeat: no-repeat,
|
| 278 |
}
|
| 279 |
|
| 280 |
.mesh-bg {
|
|
@@ -295,6 +295,21 @@ select.input-field option {
|
|
| 295 |
border: 1px solid rgba(39, 39, 42, 0.4) !important;
|
| 296 |
}
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
/* βββ Kiosk Scanner Laser βββ */
|
| 299 |
@keyframes scan-line {
|
| 300 |
0% { top: 0%; opacity: 1; }
|
|
@@ -334,6 +349,21 @@ select.input-field option {
|
|
| 334 |
animation: fadeIn 0.25s ease both;
|
| 335 |
}
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
/* βββ Pulse Ring βββ */
|
| 338 |
@keyframes pulse-ring {
|
| 339 |
0% { transform: scale(1); opacity: 0.5; }
|
|
@@ -364,7 +394,7 @@ select.input-field option {
|
|
| 364 |
|
| 365 |
/* βββ Page Transition βββ */
|
| 366 |
.page-enter {
|
| 367 |
-
animation:
|
| 368 |
}
|
| 369 |
|
| 370 |
/* βββ Sidebar Active Indicator βββ */
|
|
@@ -418,14 +448,15 @@ select.input-field option {
|
|
| 418 |
.modal-backdrop {
|
| 419 |
position: fixed;
|
| 420 |
inset: 0;
|
| 421 |
-
background: rgba(0, 0, 0, 0.
|
| 422 |
backdrop-filter: blur(4px);
|
| 423 |
-webkit-backdrop-filter: blur(4px);
|
| 424 |
-
z-index:
|
| 425 |
display: flex;
|
| 426 |
-
align-items:
|
| 427 |
justify-content: center;
|
| 428 |
-
padding: 16px;
|
|
|
|
| 429 |
animation: fadeIn 0.2s ease;
|
| 430 |
}
|
| 431 |
|
|
@@ -435,10 +466,10 @@ select.input-field option {
|
|
| 435 |
border-radius: var(--radius-2xl);
|
| 436 |
padding: 28px;
|
| 437 |
width: 100%;
|
| 438 |
-
|
| 439 |
-
|
| 440 |
animation: fadeInUp 0.25s cubic-bezier(0.4,0,0.2,1) both;
|
| 441 |
-
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.
|
| 442 |
}
|
| 443 |
|
| 444 |
/* βββ Stat Card Accent Lines βββ */
|
|
@@ -627,7 +658,7 @@ select.input-field option {
|
|
| 627 |
.dark .text-zinc-800,
|
| 628 |
.dark .text-zinc-755,
|
| 629 |
.dark .text-zinc-750 {
|
| 630 |
-
color: #f4f4f5 !important; /* Zinc 100 */
|
| 631 |
}
|
| 632 |
|
| 633 |
.dark .text-slate-555,
|
|
@@ -640,7 +671,7 @@ select.input-field option {
|
|
| 640 |
.dark .text-zinc-500,
|
| 641 |
.dark .text-zinc-450,
|
| 642 |
.dark .text-zinc-400 {
|
| 643 |
-
color: #
|
| 644 |
}
|
| 645 |
|
| 646 |
.dark .text-slate-650,
|
|
@@ -650,18 +681,18 @@ select.input-field option {
|
|
| 650 |
.dark .text-zinc-605,
|
| 651 |
.dark .text-zinc-600,
|
| 652 |
.dark .text-zinc-700 {
|
| 653 |
-
color: #
|
| 654 |
}
|
| 655 |
|
| 656 |
.dark .bg-zinc-50,
|
| 657 |
.dark .bg-zinc-55,
|
| 658 |
.dark .bg-slate-50,
|
| 659 |
.dark .bg-slate-100 {
|
| 660 |
-
background-color: #
|
| 661 |
}
|
| 662 |
|
| 663 |
.dark .bg-zinc-100 {
|
| 664 |
-
background-color: #
|
| 665 |
}
|
| 666 |
|
| 667 |
.dark .border-zinc-200 {
|
|
@@ -724,13 +755,68 @@ select.input-field option {
|
|
| 724 |
border-color: #ffffff !important;
|
| 725 |
}
|
| 726 |
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
}
|
| 731 |
|
| 732 |
.dark .bg-zinc-50\/30 {
|
| 733 |
-
background-color:
|
| 734 |
}
|
| 735 |
|
| 736 |
.dark .border-zinc-150 {
|
|
@@ -873,6 +959,113 @@ select.input-field option {
|
|
| 873 |
-webkit-backdrop-filter: blur(16px);
|
| 874 |
}
|
| 875 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
|
| 877 |
|
| 878 |
|
|
|
|
| 42 |
|
| 43 |
html {
|
| 44 |
scroll-behavior: smooth;
|
| 45 |
+
scrollbar-gutter: stable;
|
| 46 |
}
|
| 47 |
|
| 48 |
body {
|
|
|
|
| 266 |
inset: 0;
|
| 267 |
pointer-events: none;
|
| 268 |
z-index: 0;
|
| 269 |
+
background-color: transparent;
|
| 270 |
background-image:
|
| 271 |
+
radial-gradient(circle at 50% 50%, rgba(6, 182, 212, 0.15) 0%, rgba(59, 130, 246, 0.08) 45%, transparent 75%),
|
|
|
|
| 272 |
linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
| 273 |
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
| 274 |
+
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 600' width='100%' height='100%'><defs><filter id='glow-light' x='-50%' y='-50%' width='200%' height='200%'><feGaussianBlur in='SourceGraphic' stdDeviation='4' result='blur'/><feMerge><feMergeNode in='blur'/><feMergeNode in='SourceGraphic'/></feMerge></filter><linearGradient id='grad-cyan-light' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%2306b6d4' stop-opacity='0.25'/><stop offset='100%' stop-color='%2306b6d4' stop-opacity='0'/></linearGradient><linearGradient id='grad-indigo-light' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%236366f1' stop-opacity='0.2'/><stop offset='100%' stop-color='%236366f1' stop-opacity='0'/></linearGradient></defs><style>.wave-1{animation:flow-1 38s linear infinite, bob-1 12s ease-in-out infinite alternate}.wave-2{animation:flow-2 28s linear infinite, bob-2 8s ease-in-out infinite alternate}.wave-3{animation:flow-3 22s linear infinite, bob-3 10s ease-in-out infinite alternate}.wave-4{animation:flow-4 16s linear infinite, bob-4 6s ease-in-out infinite alternate}@keyframes flow-1{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-2{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes flow-3{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-4{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes bob-1{0%{transform:translateY(-10px)}100%{transform:translateY(10px)}}@keyframes bob-2{0%{transform:translateY(8px)}100%{transform:translateY(-8px)}}@keyframes bob-3{0%{transform:translateY(-6px)}100%{transform:translateY(6px)}}@keyframes bob-4{0%{transform:translateY(5px)}100%{transform:translateY(-5px)}}</style><path class='wave-1' fill='url(%23grad-cyan-light)' d='M-1440,320 C-1080,240 -720,400 -360,320 C0,240 360,400 720,320 C1080,240 1440,400 1800,320 C2160,240 2520,400 2880,320 L2880,600 L-1440,600 Z'/><path class='wave-2' fill='url(%23grad-indigo-light)' d='M-1440,340 C-1080,440 -720,240 -360,340 C0,440 360,240 720,340 C1080,440 1440,240 1800,340 C2160,440 2520,240 2880,340 L2880,600 L-1440,600 Z'/><path class='wave-3' stroke='%2306b6d4' stroke-width='2.25' fill='none' opacity='0.85' filter='url(%23glow-light)' d='M-1440,310 C-1080,250 -720,370 -360,310 C0,250 360,370 720,310 C1080,250 1440,370 1800,310 C2160,250 2520,370 2880,310'/><path class='wave-4' stroke='%236366f1' stroke-width='2.0' fill='none' opacity='0.75' d='M-1440,330 C-1080,380 -720,280 -360,330 C0,380 360,280 720,330 C1080,380 1440,280 1800,330 C2160,380 2520,280 2880,330'/><path class='wave-3' stroke='%233b82f6' stroke-width='1.75' fill='none' opacity='0.8' d='M-1440,300 C-1080,220 -720,380 -360,300 C0,220 360,380 720,300 C1080,220 1440,380 1800,300 C2160,220 2520,380 2880,300'/></svg>");
|
| 275 |
+
background-size: cover, 80px 80px, 80px 80px, cover;
|
| 276 |
background-position: center;
|
| 277 |
+
background-repeat: no-repeat, repeat, repeat, no-repeat;
|
| 278 |
}
|
| 279 |
|
| 280 |
.mesh-bg {
|
|
|
|
| 295 |
border: 1px solid rgba(39, 39, 42, 0.4) !important;
|
| 296 |
}
|
| 297 |
|
| 298 |
+
/* Solid 3D Flat card structure without glassmorph or transitions */
|
| 299 |
+
.tech-card-3d {
|
| 300 |
+
background: var(--bg-surface);
|
| 301 |
+
border: 1.5px solid var(--text-primary);
|
| 302 |
+
box-shadow: 4px 4px 0px 0px var(--text-primary);
|
| 303 |
+
border-radius: var(--radius-xl);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.tech-card-3d-minimal {
|
| 307 |
+
background: var(--bg-surface);
|
| 308 |
+
border: 1px solid var(--text-primary);
|
| 309 |
+
box-shadow: 2px 2px 0px 0px var(--text-primary);
|
| 310 |
+
border-radius: var(--radius-lg);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
/* βββ Kiosk Scanner Laser βββ */
|
| 314 |
@keyframes scan-line {
|
| 315 |
0% { top: 0%; opacity: 1; }
|
|
|
|
| 349 |
animation: fadeIn 0.25s ease both;
|
| 350 |
}
|
| 351 |
|
| 352 |
+
/* βββ Spin Rotation for Refresh Buttons βββ */
|
| 353 |
+
@keyframes spin-360 {
|
| 354 |
+
from {
|
| 355 |
+
transform: rotate(0deg);
|
| 356 |
+
}
|
| 357 |
+
to {
|
| 358 |
+
transform: rotate(360deg);
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.spin-icon {
|
| 363 |
+
animation: spin-360 0.8s linear infinite !important;
|
| 364 |
+
transform-origin: center !important;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
/* βββ Pulse Ring βββ */
|
| 368 |
@keyframes pulse-ring {
|
| 369 |
0% { transform: scale(1); opacity: 0.5; }
|
|
|
|
| 394 |
|
| 395 |
/* βββ Page Transition βββ */
|
| 396 |
.page-enter {
|
| 397 |
+
animation: fadeIn 0.25s cubic-bezier(0.4,0,0.2,1) both;
|
| 398 |
}
|
| 399 |
|
| 400 |
/* βββ Sidebar Active Indicator βββ */
|
|
|
|
| 448 |
.modal-backdrop {
|
| 449 |
position: fixed;
|
| 450 |
inset: 0;
|
| 451 |
+
background: rgba(0, 0, 0, 0.4);
|
| 452 |
backdrop-filter: blur(4px);
|
| 453 |
-webkit-backdrop-filter: blur(4px);
|
| 454 |
+
z-index: 999;
|
| 455 |
display: flex;
|
| 456 |
+
align-items: flex-start;
|
| 457 |
justify-content: center;
|
| 458 |
+
padding: 40px 16px;
|
| 459 |
+
overflow-y: auto;
|
| 460 |
animation: fadeIn 0.2s ease;
|
| 461 |
}
|
| 462 |
|
|
|
|
| 466 |
border-radius: var(--radius-2xl);
|
| 467 |
padding: 28px;
|
| 468 |
width: 100%;
|
| 469 |
+
margin: auto;
|
| 470 |
+
position: relative;
|
| 471 |
animation: fadeInUp 0.25s cubic-bezier(0.4,0,0.2,1) both;
|
| 472 |
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
| 473 |
}
|
| 474 |
|
| 475 |
/* βββ Stat Card Accent Lines βββ */
|
|
|
|
| 658 |
.dark .text-zinc-800,
|
| 659 |
.dark .text-zinc-755,
|
| 660 |
.dark .text-zinc-750 {
|
| 661 |
+
color: #f4f4f5 !important; /* Crisp Zinc 100 */
|
| 662 |
}
|
| 663 |
|
| 664 |
.dark .text-slate-555,
|
|
|
|
| 671 |
.dark .text-zinc-500,
|
| 672 |
.dark .text-zinc-450,
|
| 673 |
.dark .text-zinc-400 {
|
| 674 |
+
color: #d4d4d8 !important; /* High contrast Zinc 300 */
|
| 675 |
}
|
| 676 |
|
| 677 |
.dark .text-slate-650,
|
|
|
|
| 681 |
.dark .text-zinc-605,
|
| 682 |
.dark .text-zinc-600,
|
| 683 |
.dark .text-zinc-700 {
|
| 684 |
+
color: #e4e4e7 !important; /* High contrast Zinc 200 */
|
| 685 |
}
|
| 686 |
|
| 687 |
.dark .bg-zinc-50,
|
| 688 |
.dark .bg-zinc-55,
|
| 689 |
.dark .bg-slate-50,
|
| 690 |
.dark .bg-slate-100 {
|
| 691 |
+
background-color: #18181b !important;
|
| 692 |
}
|
| 693 |
|
| 694 |
.dark .bg-zinc-100 {
|
| 695 |
+
background-color: #27272a !important;
|
| 696 |
}
|
| 697 |
|
| 698 |
.dark .border-zinc-200 {
|
|
|
|
| 755 |
border-color: #ffffff !important;
|
| 756 |
}
|
| 757 |
|
| 758 |
+
/* βββ Global Custom Select & Options Styling βββ */
|
| 759 |
+
select {
|
| 760 |
+
appearance: none;
|
| 761 |
+
-webkit-appearance: none;
|
| 762 |
+
-moz-appearance: none;
|
| 763 |
+
background-color: #ffffff;
|
| 764 |
+
color: #0f172a;
|
| 765 |
+
border: 1px solid #e2e8f0;
|
| 766 |
+
border-radius: var(--radius-md);
|
| 767 |
+
padding: 6px 32px 6px 12px;
|
| 768 |
+
font-size: 12px;
|
| 769 |
+
font-weight: 500;
|
| 770 |
+
cursor: pointer;
|
| 771 |
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
|
| 772 |
+
background-repeat: no-repeat;
|
| 773 |
+
background-position: right 10px center;
|
| 774 |
+
background-size: 14px;
|
| 775 |
+
transition: all 0.15s ease;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
select:focus {
|
| 779 |
+
outline: none;
|
| 780 |
+
border-color: #06b6d4 !important;
|
| 781 |
+
box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.15);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
select option {
|
| 785 |
+
background-color: #ffffff !important;
|
| 786 |
+
color: #0f172a !important;
|
| 787 |
+
padding: 10px 14px !important;
|
| 788 |
+
font-size: 12px;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
/* Dark mode select & options */
|
| 792 |
+
.dark select {
|
| 793 |
+
background-color: #18181b !important;
|
| 794 |
+
color: #f4f4f5 !important;
|
| 795 |
+
border-color: #27272a !important;
|
| 796 |
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.dark select:focus {
|
| 800 |
+
border-color: #22d3ee !important;
|
| 801 |
+
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.2) !important;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
.dark select option {
|
| 805 |
+
background-color: #18181b !important;
|
| 806 |
+
color: #f4f4f5 !important;
|
| 807 |
+
padding: 10px 14px !important;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
.dark select option:hover,
|
| 811 |
+
.dark select option:focus,
|
| 812 |
+
.dark select option:active,
|
| 813 |
+
.dark select option:checked {
|
| 814 |
+
background-color: #27272a !important;
|
| 815 |
+
color: #22d3ee !important;
|
| 816 |
}
|
| 817 |
|
| 818 |
.dark .bg-zinc-50\/30 {
|
| 819 |
+
background-color: transparent !important;
|
| 820 |
}
|
| 821 |
|
| 822 |
.dark .border-zinc-150 {
|
|
|
|
| 959 |
-webkit-backdrop-filter: blur(16px);
|
| 960 |
}
|
| 961 |
|
| 962 |
+
/* Login Page Input Autofill Overrides */
|
| 963 |
+
.login-input:-webkit-autofill,
|
| 964 |
+
.login-input:-webkit-autofill:hover,
|
| 965 |
+
.login-input:-webkit-autofill:focus,
|
| 966 |
+
.login-input:-webkit-autofill:active {
|
| 967 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 968 |
+
-webkit-box-shadow: 0 0 0px 1000px #090d16 inset !important;
|
| 969 |
+
box-shadow: 0 0 0px 1000px #090d16 inset !important;
|
| 970 |
+
transition: background-color 5000s ease-in-out 0s;
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
.login-input::-webkit-contacts-auto-fill-button,
|
| 974 |
+
.login-input::-webkit-credentials-auto-fill-button {
|
| 975 |
+
visibility: hidden;
|
| 976 |
+
display: none !important;
|
| 977 |
+
pointer-events: none;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
/* βββ Super Smooth Page Transitions & Animations βββ */
|
| 981 |
+
@keyframes smoothPageEnter {
|
| 982 |
+
0% {
|
| 983 |
+
opacity: 0;
|
| 984 |
+
transform: translate3d(0, 6px, 0);
|
| 985 |
+
}
|
| 986 |
+
100% {
|
| 987 |
+
opacity: 1;
|
| 988 |
+
transform: translate3d(0, 0, 0);
|
| 989 |
+
}
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
.page-enter,
|
| 993 |
+
.animate-fadeInUp {
|
| 994 |
+
animation: smoothPageEnter 0.3s cubic-bezier(0.25, 1, 0.5, 1) forwards;
|
| 995 |
+
will-change: transform, opacity;
|
| 996 |
+
backface-visibility: hidden;
|
| 997 |
+
-webkit-backface-visibility: hidden;
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
/* Butter smooth transition for sidebar collapse and nav links */
|
| 1001 |
+
aside,
|
| 1002 |
+
aside *,
|
| 1003 |
+
nav,
|
| 1004 |
+
nav * {
|
| 1005 |
+
transition-property: width, max-width, min-width, transform, opacity, background-color, border-color, color, box-shadow, translate, scale !important;
|
| 1006 |
+
transition-duration: 350ms !important;
|
| 1007 |
+
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1) !important;
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
/* Force light mode colors for printable ID card preview in dark mode */
|
| 1011 |
+
.dark #printable-id-card-wrap {
|
| 1012 |
+
background-color: #ffffff !important;
|
| 1013 |
+
border-color: #e2e8f0 !important;
|
| 1014 |
+
color: #0f172a !important;
|
| 1015 |
+
}
|
| 1016 |
+
.dark #printable-id-card-wrap .bg-slate-50\/80 {
|
| 1017 |
+
background-color: rgba(248, 250, 252, 0.8) !important;
|
| 1018 |
+
}
|
| 1019 |
+
.dark #printable-id-card-wrap .border-slate-100 {
|
| 1020 |
+
border-color: #f1f5f9 !important;
|
| 1021 |
+
}
|
| 1022 |
+
.dark #printable-id-card-wrap .text-slate-900 {
|
| 1023 |
+
color: #0f172a !important;
|
| 1024 |
+
}
|
| 1025 |
+
.dark #printable-id-card-wrap .text-slate-800 {
|
| 1026 |
+
color: #1e293b !important;
|
| 1027 |
+
}
|
| 1028 |
+
.dark #printable-id-card-wrap .text-slate-450 {
|
| 1029 |
+
color: #64748b !important;
|
| 1030 |
+
}
|
| 1031 |
+
.dark #printable-id-card-wrap .text-slate-400 {
|
| 1032 |
+
color: #94a3b8 !important;
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
/* βββ Premium 3D Solid Tech Card Effect (No Glassmorphism) βββ */
|
| 1036 |
+
.tech-card-3d {
|
| 1037 |
+
background: var(--bg-surface);
|
| 1038 |
+
border: 1px solid var(--border-medium);
|
| 1039 |
+
border-bottom: 4px solid var(--border-strong);
|
| 1040 |
+
border-radius: var(--radius-lg);
|
| 1041 |
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
| 1042 |
+
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
| 1043 |
+
transform-style: preserve-3d;
|
| 1044 |
+
transform: translate3d(0, 0, 0);
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
.dark .tech-card-3d {
|
| 1048 |
+
background: #18181b; /* solid dark gray zinc-900 */
|
| 1049 |
+
border: 1px solid #27272a; /* zinc-800 */
|
| 1050 |
+
border-bottom: 4px solid #3f3f46; /* zinc-700 */
|
| 1051 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
| 1052 |
+
}
|
| 1053 |
+
|
| 1054 |
+
.tech-card-3d:hover {
|
| 1055 |
+
transform: none;
|
| 1056 |
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
.dark .tech-card-3d:hover {
|
| 1060 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
.tech-card-3d:active {
|
| 1064 |
+
transform: none;
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
|
| 1069 |
|
| 1070 |
|
| 1071 |
|
frontend/app/holidays/page.tsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { useQuery } from "@tanstack/react-query";
|
| 5 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
+
import { fetchApi } from "@/app/utils/api";
|
| 7 |
+
import {
|
| 8 |
+
Calendar,
|
| 9 |
+
MapPin,
|
| 10 |
+
Clock,
|
| 11 |
+
ShieldCheck,
|
| 12 |
+
Award,
|
| 13 |
+
Compass,
|
| 14 |
+
Zap,
|
| 15 |
+
ChevronRight,
|
| 16 |
+
Info
|
| 17 |
+
} from "lucide-react";
|
| 18 |
+
|
| 19 |
+
interface Holiday {
|
| 20 |
+
id: number;
|
| 21 |
+
name: string;
|
| 22 |
+
date: string;
|
| 23 |
+
day: string;
|
| 24 |
+
type: "National" | "Gazetted" | "Restricted";
|
| 25 |
+
description: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const STATIC_HOLIDAYS: Holiday[] = [
|
| 29 |
+
{ id: 1, name: "New Year's Day", date: "2026-01-01", day: "Thursday", type: "National", description: "First day of the new Gregorian calendar year." },
|
| 30 |
+
{ id: 2, name: "Republic Day", date: "2026-01-26", day: "Monday", type: "National", description: "Commemorates the enactment of the Constitution of India." },
|
| 31 |
+
{ id: 3, name: "Good Friday", date: "2026-04-03", day: "Friday", type: "Restricted", description: "Christian holiday commemorating the crucifixion of Jesus." },
|
| 32 |
+
{ id: 4, name: "May Day / Labor Day", date: "2026-05-01", day: "Friday", type: "Gazetted", description: "Celebration of laborers and the working class." },
|
| 33 |
+
{ id: 5, name: "Independence Day", date: "2026-08-15", day: "Saturday", type: "National", description: "Marks the nation's independence from British rule." },
|
| 34 |
+
{ id: 6, name: "Gandhi Jayanti", date: "2026-10-02", day: "Friday", type: "National", description: "Birthday tribute to Mahatma Gandhi, Father of the Nation." },
|
| 35 |
+
{ id: 7, name: "Diwali / Deepavali", date: "2026-11-09", day: "Monday", type: "Gazetted", description: "Festival of lights celebrating the victory of light over darkness." },
|
| 36 |
+
{ id: 8, name: "Christmas Day", date: "2026-12-25", day: "Friday", type: "Gazetted", description: "Annual celebration commemorating the birth of Jesus Christ." },
|
| 37 |
+
];
|
| 38 |
+
|
| 39 |
+
export default function HolidaysPage() {
|
| 40 |
+
const [activeTab, setActiveTab] = useState<"upcoming" | "past">("upcoming");
|
| 41 |
+
|
| 42 |
+
// Fetch company geofence and policy rules
|
| 43 |
+
const { data: rules, isLoading: loadingRules } = useQuery({
|
| 44 |
+
queryKey: ["attendance-policy-rules"],
|
| 45 |
+
queryFn: () => fetchApi("/policy/rules").catch(() => null),
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
const today = new Date();
|
| 49 |
+
today.setHours(0, 0, 0, 0);
|
| 50 |
+
|
| 51 |
+
const filteredHolidays = STATIC_HOLIDAYS.filter((h) => {
|
| 52 |
+
const hDate = new Date(h.date);
|
| 53 |
+
if (activeTab === "upcoming") {
|
| 54 |
+
return hDate >= today;
|
| 55 |
+
} else {
|
| 56 |
+
return hDate < today;
|
| 57 |
+
}
|
| 58 |
+
}).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<SidebarLayout>
|
| 62 |
+
<div className="space-y-6 page-enter max-w-5xl mx-auto text-slate-800 dark:text-zinc-100 font-sans">
|
| 63 |
+
|
| 64 |
+
{/* Header Block */}
|
| 65 |
+
<div className="pb-5 border-b border-zinc-100 dark:border-zinc-800 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
| 66 |
+
<div>
|
| 67 |
+
<h1 className="text-xl font-bold text-slate-900 dark:text-zinc-100 tracking-tight flex items-center gap-2">
|
| 68 |
+
<Calendar className="w-5.5 h-5.5 text-cyan-500" />
|
| 69 |
+
Holidays & Policy Hub
|
| 70 |
+
</h1>
|
| 71 |
+
<p className="text-xs text-slate-400 dark:text-zinc-400 mt-1">
|
| 72 |
+
Official annual company holidays list and active attendance policies.
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* Content Layout */}
|
| 78 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
| 79 |
+
|
| 80 |
+
{/* Holidays Listing */}
|
| 81 |
+
<div className="lg:col-span-7 space-y-4">
|
| 82 |
+
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-2xl p-5 shadow-xs">
|
| 83 |
+
<div className="flex items-center justify-between border-b border-slate-100 dark:border-zinc-800/80 pb-4 mb-4">
|
| 84 |
+
<h3 className="text-xs font-bold text-slate-955 dark:text-zinc-200 uppercase tracking-wider font-mono">
|
| 85 |
+
Company Holidays Calendar
|
| 86 |
+
</h3>
|
| 87 |
+
|
| 88 |
+
{/* Tabs */}
|
| 89 |
+
<div className="flex bg-zinc-100 dark:bg-zinc-800/50 p-0.5 rounded-lg text-[10px] font-bold">
|
| 90 |
+
<button
|
| 91 |
+
onClick={() => setActiveTab("upcoming")}
|
| 92 |
+
className={`px-3 py-1 rounded-md transition-all uppercase cursor-pointer ${
|
| 93 |
+
activeTab === "upcoming"
|
| 94 |
+
? "bg-white dark:bg-zinc-750 text-slate-955 dark:text-white shadow-2xs"
|
| 95 |
+
: "text-slate-500 dark:text-zinc-400 hover:text-slate-800"
|
| 96 |
+
}`}
|
| 97 |
+
>
|
| 98 |
+
Upcoming
|
| 99 |
+
</button>
|
| 100 |
+
<button
|
| 101 |
+
onClick={() => setActiveTab("past")}
|
| 102 |
+
className={`px-3 py-1 rounded-md transition-all uppercase cursor-pointer ${
|
| 103 |
+
activeTab === "past"
|
| 104 |
+
? "bg-white dark:bg-zinc-750 text-slate-955 dark:text-white shadow-2xs"
|
| 105 |
+
: "text-slate-500 dark:text-zinc-400 hover:text-slate-800"
|
| 106 |
+
}`}
|
| 107 |
+
>
|
| 108 |
+
Past
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Holiday Cards */}
|
| 114 |
+
<div className="space-y-3.5 max-h-[500px] overflow-y-auto pr-1">
|
| 115 |
+
{filteredHolidays.length > 0 ? (
|
| 116 |
+
filteredHolidays.map((holiday) => {
|
| 117 |
+
const isUpcoming = new Date(holiday.date) >= today;
|
| 118 |
+
return (
|
| 119 |
+
<div
|
| 120 |
+
key={holiday.id}
|
| 121 |
+
className={`p-3.5 rounded-xl border transition-all duration-250 flex items-start gap-4 ${
|
| 122 |
+
isUpcoming
|
| 123 |
+
? "bg-white dark:bg-zinc-900/50 border-slate-200 dark:border-zinc-800/80 hover:border-cyan-300 dark:hover:border-cyan-800 shadow-3xs"
|
| 124 |
+
: "bg-zinc-50/50 dark:bg-zinc-950/20 border-zinc-100 dark:border-zinc-900 text-slate-400 dark:text-zinc-500"
|
| 125 |
+
}`}
|
| 126 |
+
>
|
| 127 |
+
{/* Calendar Icon Badge */}
|
| 128 |
+
<div className={`w-10 h-10 rounded-xl flex flex-col items-center justify-center shrink-0 border font-bold text-center ${
|
| 129 |
+
isUpcoming
|
| 130 |
+
? "bg-cyan-50 dark:bg-cyan-950/30 border-cyan-100 dark:border-cyan-900 text-cyan-600 dark:text-cyan-400"
|
| 131 |
+
: "bg-zinc-100 dark:bg-zinc-850 border-zinc-200 dark:border-zinc-800 text-zinc-400 dark:text-zinc-500"
|
| 132 |
+
}`}>
|
| 133 |
+
<span className="text-[9px] uppercase tracking-wider font-extrabold -mb-0.5 leading-none">
|
| 134 |
+
{new Date(holiday.date).toLocaleDateString([], { month: "short" })}
|
| 135 |
+
</span>
|
| 136 |
+
<span className="text-sm font-black tracking-tight leading-none mt-0.5">
|
| 137 |
+
{new Date(holiday.date).getDate()}
|
| 138 |
+
</span>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{/* Text */}
|
| 142 |
+
<div className="flex-1 min-w-0">
|
| 143 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 144 |
+
<h4 className={`text-xs font-bold truncate ${isUpcoming ? "text-slate-900 dark:text-zinc-100" : "text-slate-450 dark:text-zinc-500"}`}>
|
| 145 |
+
{holiday.name}
|
| 146 |
+
</h4>
|
| 147 |
+
<span className={`inline-block text-[8px] font-mono font-bold px-1.5 py-0.25 rounded border uppercase ${
|
| 148 |
+
holiday.type === "National"
|
| 149 |
+
? "bg-red-50 dark:bg-red-950/30 border-red-150 dark:border-red-900 text-red-600 dark:text-red-400"
|
| 150 |
+
: holiday.type === "Gazetted"
|
| 151 |
+
? "bg-emerald-50 dark:bg-emerald-950/30 border-emerald-150 dark:border-emerald-900 text-emerald-600 dark:text-emerald-400"
|
| 152 |
+
: "bg-amber-50 dark:bg-amber-950/30 border-amber-150 dark:border-amber-900 text-amber-600 dark:text-amber-400"
|
| 153 |
+
}`}>
|
| 154 |
+
{holiday.type}
|
| 155 |
+
</span>
|
| 156 |
+
</div>
|
| 157 |
+
<p className="text-[10px] text-slate-450 dark:text-zinc-400 mt-1.5 leading-relaxed">
|
| 158 |
+
{holiday.description}
|
| 159 |
+
</p>
|
| 160 |
+
<p className="text-[9px] text-slate-400 font-mono mt-1 flex items-center gap-1.5">
|
| 161 |
+
<span>{holiday.day}</span>
|
| 162 |
+
</p>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
);
|
| 166 |
+
})
|
| 167 |
+
) : (
|
| 168 |
+
<div className="text-center py-12 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl text-zinc-400 text-xs">
|
| 169 |
+
No holidays to display for this period.
|
| 170 |
+
</div>
|
| 171 |
+
)}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* Policy Information Side cards */}
|
| 177 |
+
<div className="lg:col-span-5 space-y-6">
|
| 178 |
+
|
| 179 |
+
{/* Geofence & Location */}
|
| 180 |
+
<div className="bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800/80 rounded-2xl p-5 shadow-xs space-y-4">
|
| 181 |
+
<div className="flex items-center gap-2 border-b border-slate-100 dark:border-zinc-800 pb-3">
|
| 182 |
+
<MapPin className="w-4 h-4 text-emerald-500" />
|
| 183 |
+
<h3 className="text-xs font-bold text-slate-955 dark:text-zinc-200 uppercase tracking-wider font-mono">
|
| 184 |
+
Attendance Rules & Location
|
| 185 |
+
</h3>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{loadingRules ? (
|
| 189 |
+
<div className="space-y-3">
|
| 190 |
+
<div className="skeleton h-8 w-full" />
|
| 191 |
+
<div className="skeleton h-8 w-full" />
|
| 192 |
+
</div>
|
| 193 |
+
) : (
|
| 194 |
+
<div className="space-y-4 text-xs">
|
| 195 |
+
<div className="flex items-start justify-between p-2.5 rounded-xl bg-zinc-50/50 dark:bg-zinc-955/30 border border-zinc-100 dark:border-zinc-850">
|
| 196 |
+
<div className="space-y-0.5">
|
| 197 |
+
<p className="text-[9px] font-bold text-zinc-400 uppercase tracking-wider">Geofence Bounds</p>
|
| 198 |
+
<p className="font-semibold text-slate-800 dark:text-zinc-200">
|
| 199 |
+
{rules?.geofence_radius_meters || "500"} Meters Radius
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
<Compass className="w-4 h-4 text-zinc-400" />
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<div className="flex items-start justify-between p-2.5 rounded-xl bg-zinc-50/50 dark:bg-zinc-955/30 border border-zinc-100 dark:border-zinc-850">
|
| 206 |
+
<div className="space-y-0.5">
|
| 207 |
+
<p className="text-[9px] font-bold text-zinc-400 uppercase tracking-wider">Face Recognition Quality</p>
|
| 208 |
+
<p className="font-semibold text-slate-800 dark:text-zinc-200">
|
| 209 |
+
{rules?.face_match_threshold ? `${Math.round(rules.face_match_threshold * 100)}%` : "60%"} Accuracy Threshold
|
| 210 |
+
</p>
|
| 211 |
+
</div>
|
| 212 |
+
<ShieldCheck className="w-4 h-4 text-zinc-400" />
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<div className="flex items-start justify-between p-2.5 rounded-xl bg-zinc-50/50 dark:bg-zinc-955/30 border border-zinc-100 dark:border-zinc-850">
|
| 216 |
+
<div className="space-y-0.5">
|
| 217 |
+
<p className="text-[9px] font-bold text-zinc-400 uppercase tracking-wider">Coordinates Lock</p>
|
| 218 |
+
<p className="font-semibold text-slate-800 dark:text-zinc-200 font-mono text-[10.5px]">
|
| 219 |
+
Lat: {rules?.office_latitude?.toFixed(4) || "β"} , Lng: {rules?.office_longitude?.toFixed(4) || "β"}
|
| 220 |
+
</p>
|
| 221 |
+
</div>
|
| 222 |
+
<MapPin className="w-4 h-4 text-zinc-400" />
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
{/* Quick Shift Timing Card */}
|
| 229 |
+
<div className="bg-gradient-to-br from-zinc-900 to-slate-950 text-white rounded-2xl p-5 shadow-md space-y-4">
|
| 230 |
+
<div className="flex items-center gap-2 border-b border-zinc-800/80 pb-3">
|
| 231 |
+
<Clock className="w-4 h-4 text-cyan-400" />
|
| 232 |
+
<h3 className="text-xs font-bold uppercase tracking-wider font-mono text-zinc-200">
|
| 233 |
+
Standard Shift Policy
|
| 234 |
+
</h3>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div className="space-y-3.5">
|
| 238 |
+
<div className="flex justify-between items-center text-xs">
|
| 239 |
+
<span className="text-zinc-400">Shift Timings</span>
|
| 240 |
+
<span className="font-bold font-mono">09:00 AM - 05:00 PM</span>
|
| 241 |
+
</div>
|
| 242 |
+
<div className="flex justify-between items-center text-xs">
|
| 243 |
+
<span className="text-zinc-400">Grace Period</span>
|
| 244 |
+
<span className="font-bold font-mono">15 Minutes Allowed</span>
|
| 245 |
+
</div>
|
| 246 |
+
<div className="flex justify-between items-center text-xs">
|
| 247 |
+
<span className="text-zinc-400">Policy Version</span>
|
| 248 |
+
<span className="font-bold font-mono text-[10px] bg-cyan-950 border border-cyan-800/60 px-2 py-0.5 rounded text-cyan-400 uppercase">
|
| 249 |
+
{rules?.policy_version || "v2.0-Enterprise"}
|
| 250 |
+
</span>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<div className="pt-2.5 border-t border-zinc-800/80 flex items-start gap-2.5 text-[10.5px] text-zinc-450 leading-normal">
|
| 254 |
+
<Info className="w-4 h-4 text-cyan-400 shrink-0" />
|
| 255 |
+
<span>
|
| 256 |
+
Checks outside coordinates lock or after grace periods are logged as anomalies requiring HR approvals.
|
| 257 |
+
</span>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
</div>
|
| 267 |
+
</SidebarLayout>
|
| 268 |
+
);
|
| 269 |
+
}
|
frontend/app/kiosk/page.tsx
CHANGED
|
@@ -642,10 +642,10 @@ export default function KioskPage() {
|
|
| 642 |
const currentZoom = track.getSettings().zoom || 1;
|
| 643 |
if (faceWidth < 120 && currentZoom < (caps.zoom.max || 3)) {
|
| 644 |
// Too far - zoom in
|
| 645 |
-
track.applyConstraints({ advanced: [{ zoom: Math.min(currentZoom + 0.3, caps.zoom.max || 3) }] });
|
| 646 |
} else if (faceWidth > 250 && currentZoom > (caps.zoom.min || 1)) {
|
| 647 |
// Too close - zoom out
|
| 648 |
-
track.applyConstraints({ advanced: [{ zoom: Math.max(currentZoom - 0.3, caps.zoom.min || 1) }] });
|
| 649 |
}
|
| 650 |
}
|
| 651 |
}
|
|
|
|
| 642 |
const currentZoom = track.getSettings().zoom || 1;
|
| 643 |
if (faceWidth < 120 && currentZoom < (caps.zoom.max || 3)) {
|
| 644 |
// Too far - zoom in
|
| 645 |
+
track.applyConstraints({ advanced: [{ zoom: Math.min(currentZoom + 0.3, caps.zoom.max || 3) }] } as any);
|
| 646 |
} else if (faceWidth > 250 && currentZoom > (caps.zoom.min || 1)) {
|
| 647 |
// Too close - zoom out
|
| 648 |
+
track.applyConstraints({ advanced: [{ zoom: Math.max(currentZoom - 0.3, caps.zoom.min || 1) }] } as any);
|
| 649 |
}
|
| 650 |
}
|
| 651 |
}
|
frontend/app/leaves/page.tsx
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { createPortal } from "react-dom";
|
| 5 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 6 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 7 |
+
import { fetchApi, getBackendUrl, getUserProfile } from "@/app/utils/api";
|
| 8 |
+
import {
|
| 9 |
+
Calendar, Check, X, Users, AlertCircle, FileText, CheckCircle, Clock,
|
| 10 |
+
ArrowRight, Search, Filter, Info, ShieldAlert, ArrowLeftRight, Download, Eye, FileDown
|
| 11 |
+
} from "lucide-react";
|
| 12 |
+
import { useToast } from "@/app/utils/toast";
|
| 13 |
+
|
| 14 |
+
const avatarColors = [
|
| 15 |
+
"from-blue-50 to-indigo-150 text-blue-600 border-blue-200 dark:from-blue-950/20 dark:to-indigo-950/20 dark:text-blue-400 dark:border-blue-900/40",
|
| 16 |
+
"from-emerald-50 to-teal-150 text-emerald-600 border-emerald-200 dark:from-emerald-950/20 dark:to-teal-950/20 dark:text-emerald-400 dark:border-emerald-900/40",
|
| 17 |
+
"from-rose-50 to-orange-150 text-rose-600 border-rose-200 dark:from-rose-950/20 dark:to-orange-950/20 dark:text-rose-400 dark:border-rose-900/40",
|
| 18 |
+
"from-purple-50 to-pink-150 text-purple-600 border-purple-200 dark:from-purple-950/20 dark:to-pink-950/20 dark:text-purple-400 dark:border-purple-900/40",
|
| 19 |
+
"from-cyan-50 to-blue-150 text-cyan-600 border-cyan-200 dark:from-cyan-950/20 dark:to-blue-950/20 dark:text-cyan-400 dark:border-cyan-900/40",
|
| 20 |
+
];
|
| 21 |
+
function formatDateDMY(dateInput: string | Date | null | undefined): string {
|
| 22 |
+
if (!dateInput) return "";
|
| 23 |
+
const d = new Date(dateInput);
|
| 24 |
+
if (isNaN(d.getTime())) return "";
|
| 25 |
+
const day = String(d.getDate()).padStart(2, "0");
|
| 26 |
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
| 27 |
+
const year = d.getFullYear();
|
| 28 |
+
return `${day}/${month}/${year}`;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function LeavesManagementPage() {
|
| 32 |
+
const queryClient = useQueryClient();
|
| 33 |
+
const { toast } = useToast();
|
| 34 |
+
const [profile, setProfile] = useState<any>(null);
|
| 35 |
+
const [filter, setFilter] = useState<"All" | "Pending" | "Approved" | "Rejected">("Pending");
|
| 36 |
+
const [searchQuery, setSearchQuery] = useState("");
|
| 37 |
+
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
| 38 |
+
const [selectedLeave, setSelectedLeave] = useState<any>(null);
|
| 39 |
+
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
setProfile(getUserProfile());
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
// Fetch all leaves of the company
|
| 45 |
+
const { data: leaves = [], isLoading, refetch } = useQuery({
|
| 46 |
+
queryKey: ["company-leaves"],
|
| 47 |
+
queryFn: () => fetchApi("/employees/leaves"),
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
// Mutation to update leave status
|
| 51 |
+
const updateLeaveStatusMutation = useMutation({
|
| 52 |
+
mutationFn: ({ id, status }: { id: number; status: "Approved" | "Rejected" }) => {
|
| 53 |
+
return fetchApi(`/employees/leaves/${id}`, {
|
| 54 |
+
method: "PUT",
|
| 55 |
+
body: JSON.stringify({ status })
|
| 56 |
+
});
|
| 57 |
+
},
|
| 58 |
+
onSuccess: (data) => {
|
| 59 |
+
toast({
|
| 60 |
+
title: "Status Updated",
|
| 61 |
+
description: `Leave request has been successfully ${data.status.toLowerCase()}.`,
|
| 62 |
+
type: "success"
|
| 63 |
+
});
|
| 64 |
+
queryClient.invalidateQueries({ queryKey: ["company-leaves"] });
|
| 65 |
+
// Update selected leave state if open
|
| 66 |
+
setSelectedLeave((prev: any) => prev?.id === data.id ? { ...prev, status: data.status } : prev);
|
| 67 |
+
},
|
| 68 |
+
onError: (err: any) => {
|
| 69 |
+
toast({
|
| 70 |
+
title: "Action Failed",
|
| 71 |
+
description: err.message || "Failed to update leave status.",
|
| 72 |
+
type: "error"
|
| 73 |
+
});
|
| 74 |
+
},
|
| 75 |
+
onSettled: () => {
|
| 76 |
+
setUpdatingId(null);
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
const handleAction = (id: number, status: "Approved" | "Rejected") => {
|
| 81 |
+
setUpdatingId(id);
|
| 82 |
+
updateLeaveStatusMutation.mutate({ id, status });
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const getDaysBetween = (startStr: string, endStr: string) => {
|
| 86 |
+
const start = new Date(startStr);
|
| 87 |
+
const end = new Date(endStr);
|
| 88 |
+
const diffTime = Math.abs(end.getTime() - start.getTime());
|
| 89 |
+
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
// Helper to parse Emergency Contact & Documents from reason text block
|
| 93 |
+
const parseReasonText = (reasonStr: string = "") => {
|
| 94 |
+
const contactMatch = reasonStr.match(/\(Emergency Contact:\s*([^\)]+)\)/);
|
| 95 |
+
const certMatch = reasonStr.match(/\(Attached Certificate:\s*([^\)]+)\)/);
|
| 96 |
+
const halfDayMatch = reasonStr.match(/\(Half-Day:\s*([^\)]+)\)/);
|
| 97 |
+
|
| 98 |
+
const cleanReason = reasonStr.split(" (")[0];
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
cleanReason: cleanReason || reasonStr,
|
| 102 |
+
contact: contactMatch ? contactMatch[1] : null,
|
| 103 |
+
certificate: certMatch ? certMatch[1] : null,
|
| 104 |
+
halfDay: halfDayMatch ? halfDayMatch[1] : null
|
| 105 |
+
};
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
// Handle Mock File Download
|
| 109 |
+
const handleDownloadDoc = (fileName: string) => {
|
| 110 |
+
const isImage = fileName.toLowerCase().endsWith(".png") ||
|
| 111 |
+
fileName.toLowerCase().endsWith(".jpg") ||
|
| 112 |
+
fileName.toLowerCase().endsWith(".jpeg");
|
| 113 |
+
|
| 114 |
+
if (isImage) {
|
| 115 |
+
const canvas = document.createElement("canvas");
|
| 116 |
+
canvas.width = 600;
|
| 117 |
+
canvas.height = 450;
|
| 118 |
+
const ctx = canvas.getContext("2d");
|
| 119 |
+
if (ctx) {
|
| 120 |
+
// Draw elegant medical certificate border and template
|
| 121 |
+
ctx.fillStyle = "#ffffff";
|
| 122 |
+
ctx.fillRect(0, 0, 600, 450);
|
| 123 |
+
|
| 124 |
+
ctx.strokeStyle = "#be123c"; // Crimson border
|
| 125 |
+
ctx.lineWidth = 12;
|
| 126 |
+
ctx.strokeRect(10, 10, 580, 430);
|
| 127 |
+
|
| 128 |
+
ctx.strokeStyle = "#e2e8f0";
|
| 129 |
+
ctx.lineWidth = 2;
|
| 130 |
+
ctx.strokeRect(22, 22, 556, 406);
|
| 131 |
+
|
| 132 |
+
// Header Title
|
| 133 |
+
ctx.fillStyle = "#1e293b";
|
| 134 |
+
ctx.font = "bold 20px sans-serif";
|
| 135 |
+
ctx.fillText("MEDICAL CERTIFICATE & TIME-OFF REQUEST", 80, 75);
|
| 136 |
+
|
| 137 |
+
// Subtitle
|
| 138 |
+
ctx.fillStyle = "#64748b";
|
| 139 |
+
ctx.font = "11px sans-serif";
|
| 140 |
+
ctx.fillText("NETRAID BIOMETRIC ATTENDANCE PORTAL • LEAVE DESK", 160, 105);
|
| 141 |
+
|
| 142 |
+
// Line divider
|
| 143 |
+
ctx.strokeStyle = "#cbd5e1";
|
| 144 |
+
ctx.lineWidth = 1.5;
|
| 145 |
+
ctx.beginPath();
|
| 146 |
+
ctx.moveTo(40, 125);
|
| 147 |
+
ctx.lineTo(560, 125);
|
| 148 |
+
ctx.stroke();
|
| 149 |
+
|
| 150 |
+
// Certificate Details
|
| 151 |
+
ctx.fillStyle = "#334155";
|
| 152 |
+
ctx.font = "bold 13px sans-serif";
|
| 153 |
+
ctx.fillText("DOCUMENT VERIFICATION LOG", 50, 165);
|
| 154 |
+
|
| 155 |
+
ctx.font = "12px sans-serif";
|
| 156 |
+
ctx.fillText(`File Attachment Name: ${fileName}`, 50, 200);
|
| 157 |
+
ctx.fillText("Verification Status: APPROVED", 50, 230);
|
| 158 |
+
ctx.fillText("Audited & Authenticated: TRUE", 50, 260);
|
| 159 |
+
|
| 160 |
+
ctx.fillStyle = "#475569";
|
| 161 |
+
ctx.fillText("This document serves as verification that the medical certificate image", 50, 310);
|
| 162 |
+
ctx.fillText("uploaded by the employee has been parsed and logged successfully.", 50, 330);
|
| 163 |
+
|
| 164 |
+
// Official Stamp / Signature Block
|
| 165 |
+
ctx.strokeStyle = "#10b981"; // Emerald green
|
| 166 |
+
ctx.lineWidth = 3;
|
| 167 |
+
ctx.strokeRect(400, 330, 130, 60);
|
| 168 |
+
|
| 169 |
+
ctx.fillStyle = "#10b981";
|
| 170 |
+
ctx.font = "bold 13px sans-serif";
|
| 171 |
+
ctx.fillText("NETRAID STAMP", 415, 355);
|
| 172 |
+
ctx.fillText("VERIFIED", 435, 378);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
canvas.toBlob((blob) => {
|
| 176 |
+
if (blob) {
|
| 177 |
+
const url = URL.createObjectURL(blob);
|
| 178 |
+
const link = document.createElement("a");
|
| 179 |
+
link.href = url;
|
| 180 |
+
link.download = fileName;
|
| 181 |
+
document.body.appendChild(link);
|
| 182 |
+
link.click();
|
| 183 |
+
document.body.removeChild(link);
|
| 184 |
+
URL.revokeObjectURL(url);
|
| 185 |
+
}
|
| 186 |
+
}, "image/png");
|
| 187 |
+
} else {
|
| 188 |
+
const blob = new Blob([`NetraID Biometric Attendance System\nMock Medical Certificate Document: ${fileName}\nLeaf Application Verification Audit Log.`], { type: "text/plain" });
|
| 189 |
+
const url = URL.createObjectURL(blob);
|
| 190 |
+
const link = document.createElement("a");
|
| 191 |
+
link.href = url;
|
| 192 |
+
link.download = fileName.endsWith(".txt") ? fileName : `${fileName}.txt`;
|
| 193 |
+
document.body.appendChild(link);
|
| 194 |
+
link.click();
|
| 195 |
+
document.body.removeChild(link);
|
| 196 |
+
URL.revokeObjectURL(url);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
toast({
|
| 200 |
+
title: "File Downloaded",
|
| 201 |
+
description: `${fileName} downloaded successfully.`,
|
| 202 |
+
type: "success"
|
| 203 |
+
});
|
| 204 |
+
};
|
| 205 |
+
|
| 206 |
+
// Filter & Search Logic
|
| 207 |
+
const filteredLeaves = leaves.filter((l: any) => {
|
| 208 |
+
const matchesFilter = filter === "All" ? true : l.status === filter;
|
| 209 |
+
|
| 210 |
+
const empName = l.employee?.name || "";
|
| 211 |
+
const empEmail = l.employee?.email || "";
|
| 212 |
+
const leaveType = l.leave_type || "";
|
| 213 |
+
const reason = l.reason || "";
|
| 214 |
+
const matchesSearch =
|
| 215 |
+
empName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 216 |
+
empEmail.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 217 |
+
leaveType.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
| 218 |
+
reason.toLowerCase().includes(searchQuery.toLowerCase());
|
| 219 |
+
|
| 220 |
+
return matchesFilter && matchesSearch;
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
// Calculate Metrics
|
| 224 |
+
const pendingCount = leaves.filter((l: any) => l.status === "Pending").length;
|
| 225 |
+
const approvedCount = leaves.filter((l: any) => l.status === "Approved").length;
|
| 226 |
+
const rejectedCount = leaves.filter((l: any) => l.status === "Rejected").length;
|
| 227 |
+
|
| 228 |
+
return (
|
| 229 |
+
<SidebarLayout>
|
| 230 |
+
<div className="space-y-6 max-w-6xl mx-auto text-slate-800 dark:text-zinc-100 font-sans">
|
| 231 |
+
|
| 232 |
+
{/* Header Block */}
|
| 233 |
+
<div className="pb-4 border-b border-zinc-200 dark:border-zinc-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
| 234 |
+
<div>
|
| 235 |
+
<h1 className="text-xl font-bold text-slate-900 dark:text-zinc-100 tracking-tight flex items-center gap-2.5">
|
| 236 |
+
<div className="p-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700/60 shadow-2xs">
|
| 237 |
+
<Calendar className="w-5 h-5 text-cyan-500" />
|
| 238 |
+
</div>
|
| 239 |
+
Time-Off Approval Center
|
| 240 |
+
</h1>
|
| 241 |
+
<p className="text-xs text-slate-400 dark:text-zinc-400 mt-1.5">
|
| 242 |
+
Review and manage employee leave applications. Approvals automatically sync with attendance logs.
|
| 243 |
+
</p>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
{/* Stats Section */}
|
| 248 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 249 |
+
<div className="tech-card-3d p-4 flex items-center justify-between bg-white dark:bg-zinc-900">
|
| 250 |
+
<div>
|
| 251 |
+
<p className="text-[10px] font-bold text-slate-455 dark:text-zinc-500 uppercase tracking-wider font-mono">Total Requests</p>
|
| 252 |
+
<p className="text-2xl font-black text-slate-900 dark:text-zinc-100 mt-1">{leaves.length}</p>
|
| 253 |
+
</div>
|
| 254 |
+
<div className="p-3 bg-zinc-50 dark:bg-zinc-950 border border-zinc-150 dark:border-zinc-800 rounded-xl text-zinc-400">
|
| 255 |
+
<FileText className="w-5 h-5" />
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
|
| 259 |
+
<div className="tech-card-3d p-4 flex items-center justify-between bg-white dark:bg-zinc-900">
|
| 260 |
+
<div>
|
| 261 |
+
<p className="text-[10px] font-bold text-amber-600 dark:text-amber-500 uppercase tracking-wider font-mono">Pending Action</p>
|
| 262 |
+
<p className="text-2xl font-black text-amber-600 dark:text-amber-500 mt-1">{pendingCount}</p>
|
| 263 |
+
</div>
|
| 264 |
+
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200/40 rounded-xl text-amber-500">
|
| 265 |
+
<Clock className="w-5 h-5" />
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
+
<div className="tech-card-3d p-4 flex items-center justify-between bg-white dark:bg-zinc-900">
|
| 270 |
+
<div>
|
| 271 |
+
<p className="text-[10px] font-bold text-emerald-600 dark:text-emerald-500 uppercase tracking-wider font-mono">Approved Leaves</p>
|
| 272 |
+
<p className="text-2xl font-black text-emerald-600 dark:text-emerald-500 mt-1">{approvedCount}</p>
|
| 273 |
+
</div>
|
| 274 |
+
<div className="p-3 bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-250/30 rounded-xl text-emerald-500">
|
| 275 |
+
<CheckCircle className="w-5 h-5" />
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<div className="tech-card-3d p-4 flex items-center justify-between bg-white dark:bg-zinc-900">
|
| 280 |
+
<div>
|
| 281 |
+
<p className="text-[10px] font-bold text-rose-600 dark:text-rose-500 uppercase tracking-wider font-mono">Rejected Requests</p>
|
| 282 |
+
<p className="text-2xl font-black text-rose-600 dark:text-rose-500 mt-1">{rejectedCount}</p>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="p-3 bg-rose-50 dark:bg-rose-950/20 border border-rose-250/30 rounded-xl text-rose-500">
|
| 285 |
+
<ShieldAlert className="w-5 h-5" />
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
|
| 290 |
+
{/* Filter Controls Row */}
|
| 291 |
+
<div className="tech-card-3d-minimal flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 bg-white dark:bg-zinc-900 shadow-none">
|
| 292 |
+
{/* Search bar */}
|
| 293 |
+
<div className="relative w-full sm:max-w-xs">
|
| 294 |
+
<Search className="w-3.5 h-3.5 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
| 295 |
+
<input
|
| 296 |
+
type="text"
|
| 297 |
+
placeholder="Search employee, leave type..."
|
| 298 |
+
value={searchQuery}
|
| 299 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 300 |
+
className="w-full pl-9 pr-4 py-1.5 text-xs bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-850 rounded-xl focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/20 text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 outline-none transition-all"
|
| 301 |
+
/>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
{/* Tabs */}
|
| 305 |
+
<div className="flex bg-zinc-100 dark:bg-zinc-950 p-1 rounded-xl border border-zinc-200/60 dark:border-zinc-850">
|
| 306 |
+
{(["Pending", "Approved", "Rejected", "All"] as const).map((tab) => {
|
| 307 |
+
const count = tab === "Pending" ? pendingCount : tab === "Approved" ? approvedCount : tab === "Rejected" ? rejectedCount : leaves.length;
|
| 308 |
+
return (
|
| 309 |
+
<button
|
| 310 |
+
key={tab}
|
| 311 |
+
onClick={() => setFilter(tab)}
|
| 312 |
+
className={`px-3 py-1.5 text-xs font-bold uppercase rounded-lg transition-all cursor-pointer flex items-center gap-1.5 ${
|
| 313 |
+
filter === tab
|
| 314 |
+
? "bg-white dark:bg-zinc-900 text-slate-900 dark:text-zinc-100 shadow-3xs"
|
| 315 |
+
: "text-slate-455 hover:text-slate-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
| 316 |
+
}`}
|
| 317 |
+
>
|
| 318 |
+
<span>{tab}</span>
|
| 319 |
+
<span className={`text-[9px] px-1.5 py-0.25 font-mono rounded ${
|
| 320 |
+
filter === tab
|
| 321 |
+
? "bg-zinc-100 dark:bg-zinc-950 text-zinc-650 dark:text-zinc-400"
|
| 322 |
+
: "bg-zinc-200 dark:bg-zinc-900 text-zinc-500"
|
| 323 |
+
}`}>
|
| 324 |
+
{count}
|
| 325 |
+
</span>
|
| 326 |
+
</button>
|
| 327 |
+
);
|
| 328 |
+
})}
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
{/* Requests Table / Cards */}
|
| 333 |
+
<div className="space-y-4">
|
| 334 |
+
{isLoading ? (
|
| 335 |
+
Array.from({ length: 3 }).map((_, i) => (
|
| 336 |
+
<div key={i} className="p-4 bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-2xl space-y-3">
|
| 337 |
+
<div className="flex items-center gap-3">
|
| 338 |
+
<div className="skeleton w-10 h-10 rounded-xl" />
|
| 339 |
+
<div className="space-y-1.5 flex-1">
|
| 340 |
+
<div className="skeleton h-3 w-1/4" />
|
| 341 |
+
<div className="skeleton h-2 w-1/3" />
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<div className="skeleton h-2 w-2/3" />
|
| 345 |
+
</div>
|
| 346 |
+
))
|
| 347 |
+
) : filteredLeaves.length > 0 ? (
|
| 348 |
+
filteredLeaves.map((leave: any) => {
|
| 349 |
+
const days = getDaysBetween(leave.start_date, leave.end_date);
|
| 350 |
+
const avatarColor = avatarColors[leave.employee?.id % avatarColors.length];
|
| 351 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
| 352 |
+
const parsed = parseReasonText(leave.reason);
|
| 353 |
+
|
| 354 |
+
return (
|
| 355 |
+
<div
|
| 356 |
+
key={leave.id}
|
| 357 |
+
onClick={() => setSelectedLeave(leave)}
|
| 358 |
+
className="tech-card-3d-minimal bg-white dark:bg-zinc-900 p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 group cursor-pointer hover:-translate-y-0.5 active:translate-y-0 transition-all shadow-none"
|
| 359 |
+
>
|
| 360 |
+
<div className="flex items-start gap-3.5 min-w-0">
|
| 361 |
+
{/* Avatar */}
|
| 362 |
+
<div className="shrink-0">
|
| 363 |
+
{leave.employee?.images?.some((img: any) => img.pose_type.toLowerCase() === "front") ? (
|
| 364 |
+
<img
|
| 365 |
+
src={`${baseUrl}/uploads/${leave.employee.employee_id}/front.jpg`}
|
| 366 |
+
alt={leave.employee.name}
|
| 367 |
+
className="w-10 h-10 rounded-xl object-cover border border-zinc-200 dark:border-zinc-800 shadow-3xs"
|
| 368 |
+
/>
|
| 369 |
+
) : (
|
| 370 |
+
<div className={`w-10 h-10 bg-gradient-to-br ${avatarColor} flex items-center justify-center border font-bold text-xs rounded-xl shadow-3xs`}>
|
| 371 |
+
{(leave.employee?.name || "?").charAt(0).toUpperCase()}
|
| 372 |
+
</div>
|
| 373 |
+
)}
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
<div className="space-y-1 min-w-0">
|
| 377 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 378 |
+
<span className="text-xs font-black text-slate-900 dark:text-zinc-100">{leave.employee?.name}</span>
|
| 379 |
+
<span className="text-[10px] text-slate-455 font-mono">({leave.employee?.designation || "Staff"})</span>
|
| 380 |
+
<span className={`text-[8.5px] font-mono font-bold uppercase px-2 py-0.25 rounded-md border ${
|
| 381 |
+
leave.leave_type === "Sick" ? "bg-rose-500/10 border-rose-500/25 text-rose-600 dark:text-rose-400" :
|
| 382 |
+
leave.leave_type === "Casual" ? "bg-amber-500/10 border-amber-500/25 text-amber-600 dark:text-amber-400" :
|
| 383 |
+
leave.leave_type === "Annual" ? "bg-emerald-500/10 border-emerald-500/25 text-emerald-600 dark:text-emerald-400" :
|
| 384 |
+
"bg-zinc-500/10 border-zinc-500/25 text-zinc-650 dark:text-zinc-400"
|
| 385 |
+
}`}>
|
| 386 |
+
{leave.leave_type}
|
| 387 |
+
</span>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<div className="flex items-center gap-1.5 text-[10.5px] text-slate-455 dark:text-zinc-400">
|
| 391 |
+
<span className="font-semibold text-slate-800 dark:text-zinc-250">
|
| 392 |
+
{formatDateDMY(leave.start_date)}
|
| 393 |
+
</span>
|
| 394 |
+
<ArrowRight className="w-3 h-3 text-zinc-400" />
|
| 395 |
+
<span className="font-semibold text-slate-800 dark:text-zinc-250">
|
| 396 |
+
{formatDateDMY(leave.end_date)}
|
| 397 |
+
</span>
|
| 398 |
+
<span className="text-[9px] font-bold text-cyan-600 dark:text-cyan-400 font-mono bg-cyan-50 dark:bg-cyan-950/20 px-1.5 py-0.25 rounded border border-cyan-150 dark:border-cyan-900/30">
|
| 399 |
+
{days} Day{days !== 1 ? "s" : ""}
|
| 400 |
+
</span>
|
| 401 |
+
{parsed.certificate && (
|
| 402 |
+
<span className="text-[8.5px] text-rose-500 bg-rose-500/10 px-1.5 py-0.25 rounded-md border border-rose-500/25 font-bold uppercase tracking-wider flex items-center gap-0.5 shrink-0">
|
| 403 |
+
π Cert
|
| 404 |
+
</span>
|
| 405 |
+
)}
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
{parsed.cleanReason && (
|
| 409 |
+
<p className="text-[10.5px] text-slate-400 dark:text-zinc-400 mt-1.5 leading-normal max-w-2xl bg-zinc-50 dark:bg-zinc-950/30 border border-zinc-200/30 dark:border-zinc-850 p-2 rounded-xl italic">
|
| 410 |
+
" {parsed.cleanReason} "
|
| 411 |
+
</p>
|
| 412 |
+
)}
|
| 413 |
+
</div>
|
| 414 |
+
</div>
|
| 415 |
+
|
| 416 |
+
{/* Actions Block */}
|
| 417 |
+
<div className="shrink-0 flex items-center gap-2 self-end md:self-auto" onClick={(e) => e.stopPropagation()}>
|
| 418 |
+
{leave.status === "Pending" ? (
|
| 419 |
+
<>
|
| 420 |
+
<button
|
| 421 |
+
onClick={() => handleAction(leave.id, "Rejected")}
|
| 422 |
+
disabled={updatingId === leave.id}
|
| 423 |
+
className="p-1.5 border border-rose-200 dark:border-rose-900/50 hover:bg-rose-50 dark:hover:bg-rose-950/20 text-rose-600 rounded-xl cursor-pointer transition-all active:scale-95 disabled:opacity-50"
|
| 424 |
+
title="Reject Request"
|
| 425 |
+
>
|
| 426 |
+
{updatingId === leave.id ? <span className="w-4 h-4 block animate-spin">β³</span> : <X className="w-4 h-4" />}
|
| 427 |
+
</button>
|
| 428 |
+
<button
|
| 429 |
+
onClick={() => handleAction(leave.id, "Approved")}
|
| 430 |
+
disabled={updatingId === leave.id}
|
| 431 |
+
className="px-3 py-1.5 bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-bold rounded-xl flex items-center gap-1 cursor-pointer transition-all active:scale-95 disabled:opacity-50 shadow-xs shadow-emerald-500/10"
|
| 432 |
+
title="Approve Request"
|
| 433 |
+
>
|
| 434 |
+
{updatingId === leave.id ? <span className="w-3.5 h-3.5 block animate-spin">β³</span> : <Check className="w-3.5 h-3.5" />}
|
| 435 |
+
<span>Approve</span>
|
| 436 |
+
</button>
|
| 437 |
+
</>
|
| 438 |
+
) : (
|
| 439 |
+
<span className={`text-[9.5px] font-mono font-bold uppercase px-3 py-1 rounded-xl border ${
|
| 440 |
+
leave.status === "Approved"
|
| 441 |
+
? "bg-emerald-50 border-emerald-250 text-emerald-700 dark:bg-emerald-950/20 dark:text-emerald-455 dark:border-emerald-900/60"
|
| 442 |
+
: "bg-rose-50 border-rose-250 text-rose-700 dark:bg-rose-950/20 dark:text-rose-455 dark:border-rose-900/60"
|
| 443 |
+
}`}>
|
| 444 |
+
{leave.status}
|
| 445 |
+
</span>
|
| 446 |
+
)}
|
| 447 |
+
</div>
|
| 448 |
+
</div>
|
| 449 |
+
);
|
| 450 |
+
})
|
| 451 |
+
) : (
|
| 452 |
+
<div className="text-center py-12 bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-2xl text-zinc-400 text-xs italic font-medium">
|
| 453 |
+
No leave requests found under the "{filter}" filter.
|
| 454 |
+
</div>
|
| 455 |
+
)}
|
| 456 |
+
</div>
|
| 457 |
+
|
| 458 |
+
</div>
|
| 459 |
+
|
| 460 |
+
{/* Details & Attachment Modal (Portaled) */}
|
| 461 |
+
{selectedLeave && typeof document !== "undefined" && createPortal(
|
| 462 |
+
<div className="modal-backdrop z-[999] bg-black/40 backdrop-blur-xs fixed inset-0 flex items-center justify-center p-4">
|
| 463 |
+
<div className="modal-content max-w-xl bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 text-slate-900 dark:text-zinc-100 shadow-2xl p-6 rounded-2xl w-full max-h-[90vh] overflow-y-auto">
|
| 464 |
+
|
| 465 |
+
{/* Modal Header */}
|
| 466 |
+
<div className="flex items-center justify-between mb-5 pb-3 border-b border-slate-100 dark:border-zinc-800">
|
| 467 |
+
<div>
|
| 468 |
+
<h3 className="text-sm font-bold text-slate-900 dark:text-zinc-100 font-mono uppercase tracking-wider">
|
| 469 |
+
Leave Details Check
|
| 470 |
+
</h3>
|
| 471 |
+
<p className="text-[10px] text-slate-400 mt-0.5">Verify details and attachments submitted by employee</p>
|
| 472 |
+
</div>
|
| 473 |
+
<button
|
| 474 |
+
onClick={() => setSelectedLeave(null)}
|
| 475 |
+
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 text-slate-400 hover:text-slate-600 transition-all cursor-pointer"
|
| 476 |
+
>
|
| 477 |
+
<X className="w-4 h-4" />
|
| 478 |
+
</button>
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
{/* Modal Body */}
|
| 482 |
+
{(() => {
|
| 483 |
+
const leave = selectedLeave;
|
| 484 |
+
const days = getDaysBetween(leave.start_date, leave.end_date);
|
| 485 |
+
const avatarColor = avatarColors[leave.employee?.id % avatarColors.length];
|
| 486 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
| 487 |
+
const parsed = parseReasonText(leave.reason);
|
| 488 |
+
|
| 489 |
+
return (
|
| 490 |
+
<div className="space-y-5 text-xs">
|
| 491 |
+
{/* Employee Meta Row */}
|
| 492 |
+
<div className="flex items-center gap-3.5 p-3.5 bg-zinc-50 dark:bg-zinc-950 rounded-xl border border-zinc-200/50 dark:border-zinc-850">
|
| 493 |
+
{leave.employee?.images?.some((img: any) => img.pose_type.toLowerCase() === "front") ? (
|
| 494 |
+
<img
|
| 495 |
+
src={`${baseUrl}/uploads/${leave.employee.employee_id}/front.jpg`}
|
| 496 |
+
alt={leave.employee.name}
|
| 497 |
+
className="w-12 h-12 rounded-2xl object-cover border border-zinc-200 dark:border-zinc-800 shadow-sm"
|
| 498 |
+
/>
|
| 499 |
+
) : (
|
| 500 |
+
<div className={`w-12 h-12 bg-gradient-to-br ${avatarColor} flex items-center justify-center border font-bold text-sm rounded-2xl shadow-sm`}>
|
| 501 |
+
{(leave.employee?.name || "?").charAt(0).toUpperCase()}
|
| 502 |
+
</div>
|
| 503 |
+
)}
|
| 504 |
+
<div className="min-w-0 flex-1">
|
| 505 |
+
<p className="text-[13px] font-black text-slate-900 dark:text-zinc-100 leading-none">{leave.employee?.name}</p>
|
| 506 |
+
<p className="text-[10px] text-slate-450 mt-1">{leave.employee?.email}</p>
|
| 507 |
+
<p className="text-[9px] text-cyan-600 dark:text-cyan-400 mt-1 font-mono tracking-wider font-bold">
|
| 508 |
+
{leave.employee?.designation} • {leave.employee?.department?.name || "Staff"}
|
| 509 |
+
</p>
|
| 510 |
+
</div>
|
| 511 |
+
|
| 512 |
+
<span className={`text-[9px] font-mono px-2.5 py-0.75 rounded-lg border font-extrabold uppercase ${
|
| 513 |
+
leave.status === "Approved" ? "bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/20 dark:text-emerald-400" :
|
| 514 |
+
leave.status === "Rejected" ? "bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400" :
|
| 515 |
+
"bg-amber-50 border-amber-200 text-amber-700 dark:bg-amber-950/20 dark:text-amber-400"
|
| 516 |
+
}`}>
|
| 517 |
+
{leave.status}
|
| 518 |
+
</span>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
{/* Leave parameters grid */}
|
| 522 |
+
<div className="grid grid-cols-2 gap-4">
|
| 523 |
+
<div className="p-3 bg-zinc-50 dark:bg-zinc-950/50 border border-zinc-150 dark:border-zinc-850 rounded-xl">
|
| 524 |
+
<p className="text-[8.5px] font-bold text-zinc-400 uppercase font-mono tracking-wider mb-1">Leave Category</p>
|
| 525 |
+
<p className="font-extrabold text-[12px] text-zinc-800 dark:text-zinc-200 uppercase tracking-wide">
|
| 526 |
+
{leave.leave_type} Leave
|
| 527 |
+
</p>
|
| 528 |
+
</div>
|
| 529 |
+
|
| 530 |
+
<div className="p-3 bg-zinc-50 dark:bg-zinc-950/50 border border-zinc-150 dark:border-zinc-850 rounded-xl">
|
| 531 |
+
<p className="text-[8.5px] font-bold text-zinc-400 uppercase font-mono tracking-wider mb-1">Duration & Days</p>
|
| 532 |
+
<p className="font-extrabold text-[12px] text-zinc-800 dark:text-zinc-200">
|
| 533 |
+
{days} Day{days !== 1 ? "s" : ""}
|
| 534 |
+
</p>
|
| 535 |
+
</div>
|
| 536 |
+
</div>
|
| 537 |
+
|
| 538 |
+
{/* Dates Row */}
|
| 539 |
+
<div className="p-3 bg-zinc-50 dark:bg-zinc-950/50 border border-zinc-150 dark:border-zinc-850 rounded-xl space-y-1">
|
| 540 |
+
<p className="text-[8.5px] font-bold text-zinc-400 uppercase font-mono tracking-wider">Leave Timeline</p>
|
| 541 |
+
<div className="flex items-center gap-2 text-[11px] font-bold text-zinc-800 dark:text-zinc-200 mt-1">
|
| 542 |
+
<span>{new Date(leave.start_date).toLocaleDateString([], { weekday: "short", month: "short", day: "numeric", year: "numeric" })}</span>
|
| 543 |
+
<ArrowRight className="w-3.5 h-3.5 text-zinc-400" />
|
| 544 |
+
<span>{new Date(leave.end_date).toLocaleDateString([], { weekday: "short", month: "short", day: "numeric", year: "numeric" })}</span>
|
| 545 |
+
</div>
|
| 546 |
+
</div>
|
| 547 |
+
|
| 548 |
+
{/* Contact Number */}
|
| 549 |
+
{parsed.contact && (
|
| 550 |
+
<div className="p-3 bg-zinc-50 dark:bg-zinc-950/50 border border-zinc-150 dark:border-zinc-850 rounded-xl">
|
| 551 |
+
<p className="text-[8.5px] font-bold text-zinc-400 uppercase font-mono tracking-wider mb-1">Emergency Contact Number</p>
|
| 552 |
+
<p className="font-mono text-[12px] text-slate-800 dark:text-zinc-200 font-extrabold tracking-wide">{parsed.contact}</p>
|
| 553 |
+
</div>
|
| 554 |
+
)}
|
| 555 |
+
|
| 556 |
+
{/* Reason Text */}
|
| 557 |
+
{parsed.cleanReason && (
|
| 558 |
+
<div className="p-3 bg-zinc-50 dark:bg-zinc-950/50 border border-zinc-150 dark:border-zinc-850 rounded-xl">
|
| 559 |
+
<p className="text-[8.5px] font-bold text-zinc-400 uppercase font-mono tracking-wider mb-1">Reason / Description</p>
|
| 560 |
+
<p className="text-[11px] text-slate-600 dark:text-zinc-350 leading-relaxed font-medium">"{parsed.cleanReason}"</p>
|
| 561 |
+
</div>
|
| 562 |
+
)}
|
| 563 |
+
|
| 564 |
+
{/* Document Attachment Section (Sick Leave specific) */}
|
| 565 |
+
{parsed.certificate && (
|
| 566 |
+
<div className="p-4 border border-dashed border-rose-250 dark:border-rose-900 bg-rose-50/10 dark:bg-rose-950/10 rounded-xl flex items-center justify-between gap-4">
|
| 567 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 568 |
+
<div className="p-2 bg-rose-100 dark:bg-rose-950/30 rounded-lg text-rose-500 border border-rose-200/50">
|
| 569 |
+
<FileText className="w-5 h-5" />
|
| 570 |
+
</div>
|
| 571 |
+
<div className="min-w-0">
|
| 572 |
+
<p className="text-[11px] font-black text-rose-800 dark:text-rose-400 truncate">{parsed.certificate}</p>
|
| 573 |
+
<p className="text-[9px] text-slate-400 mt-0.5">Medical Certificate Attachment</p>
|
| 574 |
+
</div>
|
| 575 |
+
</div>
|
| 576 |
+
|
| 577 |
+
<div className="flex items-center gap-1.5 shrink-0">
|
| 578 |
+
<button
|
| 579 |
+
type="button"
|
| 580 |
+
onClick={() => handleDownloadDoc(parsed.certificate!)}
|
| 581 |
+
className="px-3 py-1.5 bg-rose-500 hover:bg-rose-600 text-white font-bold rounded-lg text-[10px] flex items-center gap-1 cursor-pointer transition-all active:scale-95 shadow-2xs shadow-rose-500/10"
|
| 582 |
+
>
|
| 583 |
+
<FileDown className="w-3.5 h-3.5" />
|
| 584 |
+
<span>Download</span>
|
| 585 |
+
</button>
|
| 586 |
+
</div>
|
| 587 |
+
</div>
|
| 588 |
+
)}
|
| 589 |
+
|
| 590 |
+
{/* Action Trigger Buttons */}
|
| 591 |
+
{leave.status === "Pending" && (
|
| 592 |
+
<div className="pt-3 border-t border-slate-100 dark:border-zinc-800 flex items-center justify-end gap-2.5">
|
| 593 |
+
<button
|
| 594 |
+
type="button"
|
| 595 |
+
onClick={() => {
|
| 596 |
+
handleAction(leave.id, "Rejected");
|
| 597 |
+
setSelectedLeave(null);
|
| 598 |
+
}}
|
| 599 |
+
disabled={updatingId === leave.id}
|
| 600 |
+
className="px-4 py-2 border border-rose-200 hover:bg-rose-50 dark:border-rose-900/50 dark:hover:bg-rose-950/20 text-rose-600 font-bold rounded-xl text-xs flex items-center gap-1 cursor-pointer transition-all active:scale-95 disabled:opacity-50"
|
| 601 |
+
>
|
| 602 |
+
<X className="w-3.5 h-3.5" />
|
| 603 |
+
<span>Reject Request</span>
|
| 604 |
+
</button>
|
| 605 |
+
<button
|
| 606 |
+
type="button"
|
| 607 |
+
onClick={() => {
|
| 608 |
+
handleAction(leave.id, "Approved");
|
| 609 |
+
setSelectedLeave(null);
|
| 610 |
+
}}
|
| 611 |
+
disabled={updatingId === leave.id}
|
| 612 |
+
className="px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl text-xs flex items-center gap-1.5 cursor-pointer transition-all active:scale-95 disabled:opacity-50 shadow-md shadow-emerald-500/10"
|
| 613 |
+
>
|
| 614 |
+
<Check className="w-3.5 h-3.5" />
|
| 615 |
+
<span>Approve Request</span>
|
| 616 |
+
</button>
|
| 617 |
+
</div>
|
| 618 |
+
)}
|
| 619 |
+
</div>
|
| 620 |
+
);
|
| 621 |
+
})()}
|
| 622 |
+
|
| 623 |
+
</div>
|
| 624 |
+
</div>,
|
| 625 |
+
document.body
|
| 626 |
+
)}
|
| 627 |
+
</SidebarLayout>
|
| 628 |
+
);
|
| 629 |
+
}
|
frontend/app/page.tsx
CHANGED
|
@@ -2,21 +2,47 @@
|
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
-
import { Lock, Mail, Eye, EyeOff, ShieldAlert, Shield, Building2, Users, ArrowLeft, ArrowRight } from "lucide-react";
|
| 6 |
import { fetchApi, setTokens, setUserProfile, getAccessToken } from "@/app/utils/api";
|
| 7 |
|
| 8 |
type RoleType = "Super Admin" | "Admin" | "Employee";
|
| 9 |
|
| 10 |
export default function LoginPage() {
|
| 11 |
const router = useRouter();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const [selectedRole, setSelectedRole] = useState<RoleType | null>(null);
|
| 13 |
const [email, setEmail] = useState("");
|
| 14 |
const [password, setPassword] = useState("");
|
| 15 |
const [showPass, setShowPass] = useState(false);
|
|
|
|
| 16 |
const [loading, setLoading] = useState(false);
|
| 17 |
const [error, setError] = useState<string | null>(null);
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
if (getAccessToken()) {
|
| 21 |
router.push("/dashboard");
|
| 22 |
}
|
|
@@ -27,14 +53,57 @@ export default function LoginPage() {
|
|
| 27 |
setError(null);
|
| 28 |
setEmail("");
|
| 29 |
setPassword("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
if (
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
}
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
};
|
| 40 |
|
|
@@ -58,7 +127,6 @@ export default function LoginPage() {
|
|
| 58 |
setTokens(response.access_token, response.refresh_token);
|
| 59 |
const profile = await fetchApi("/auth/me");
|
| 60 |
|
| 61 |
-
// Verify that the logged-in user matches the selected role
|
| 62 |
const userRole = profile?.role?.name;
|
| 63 |
if (selectedRole === "Super Admin" && userRole !== "Super Admin") {
|
| 64 |
throw new Error("Access denied: This portal is only for Super Admins.");
|
|
@@ -73,189 +141,465 @@ export default function LoginPage() {
|
|
| 73 |
setUserProfile(profile);
|
| 74 |
router.push("/dashboard");
|
| 75 |
} catch (err: any) {
|
| 76 |
-
setError(err.message || "Invalid credentials
|
| 77 |
} finally {
|
| 78 |
setLoading(false);
|
| 79 |
}
|
| 80 |
};
|
| 81 |
|
| 82 |
return (
|
| 83 |
-
<div className="min-h-screen
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
<div className="
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
<circle cx="50" cy="50" r="
|
| 98 |
-
<circle cx="50" cy="50" r="
|
| 99 |
-
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#334155" strokeWidth="2" strokeLinecap="round" />
|
| 100 |
-
<path d="M25 50 C 35 33, 65 33, 75 50 C 65 67, 35 67, 25 50 Z" stroke="#475569" strokeWidth="1" strokeDasharray="3 3" />
|
| 101 |
-
<g className="animate-eye-lid">
|
| 102 |
-
<circle cx="50" cy="50" r="22" fill="#0f172a" stroke="#0891b2" strokeWidth="1.5" />
|
| 103 |
-
<circle cx="50" cy="50" r="14" fill="#0e7490" stroke="#22d3ee" strokeWidth="1" opacity="0.5" />
|
| 104 |
-
<path d="M50 28 L50 34 M50 66 L50 72 M28 50 L34 50 M66 50 L72 50" stroke="#22d3ee" strokeWidth="1" opacity="0.7" />
|
| 105 |
-
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 106 |
-
<circle cx="47" cy="47" r="2" fill="#ffffff" opacity="0.8" />
|
| 107 |
-
</g>
|
| 108 |
-
<path d="M50 5 L50 12 M50 88 L50 95 M5 50 L12 50 M88 50 L95 50" stroke="#475569" strokeWidth="1.5" />
|
| 109 |
-
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="1.5" className="animate-laser" filter="url(#glow-logo)" />
|
| 110 |
-
<defs>
|
| 111 |
-
<filter id="glow-logo" x="-20%" y="-20%" width="140%" height="140%">
|
| 112 |
-
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
| 113 |
-
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
| 114 |
-
</filter>
|
| 115 |
-
</defs>
|
| 116 |
</svg>
|
| 117 |
</div>
|
| 118 |
-
<
|
| 119 |
-
NetraID Portal
|
| 120 |
-
</h1>
|
| 121 |
-
<p className="text-slate-400 text-[11px] mt-0.5">
|
| 122 |
-
Identify your role to access the workspace
|
| 123 |
-
</p>
|
| 124 |
</div>
|
|
|
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
</div>
|
| 141 |
-
<ArrowRight className="w-4 h-4 text-slate-400 group-hover:translate-x-1 transition-all" />
|
| 142 |
-
</button>
|
| 143 |
-
|
| 144 |
-
{/* Company Admin Card */}
|
| 145 |
-
<button
|
| 146 |
-
onClick={() => handleRoleSelect("Admin")}
|
| 147 |
-
className="w-full text-left p-4 rounded-2xl border border-slate-200 bg-white/70 hover:bg-slate-50/80 hover:border-cyan-400 shadow-2xs hover:shadow-xs transition-all flex items-center gap-4 group cursor-pointer"
|
| 148 |
-
>
|
| 149 |
-
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600 group-hover:scale-105 transition-all">
|
| 150 |
-
<Building2 className="w-6 h-6" />
|
| 151 |
-
</div>
|
| 152 |
-
<div className="flex-1 space-y-0.5">
|
| 153 |
-
<p className="text-xs font-bold text-slate-900">Company Admin Portal</p>
|
| 154 |
-
<p className="text-[10px] text-slate-400">Manage shifts, departments & logs</p>
|
| 155 |
-
</div>
|
| 156 |
-
<ArrowRight className="w-4 h-4 text-slate-400 group-hover:translate-x-1 transition-all" />
|
| 157 |
-
</button>
|
| 158 |
-
|
| 159 |
-
{/* Employee Card */}
|
| 160 |
-
<button
|
| 161 |
-
onClick={() => handleRoleSelect("Employee")}
|
| 162 |
-
className="w-full text-left p-4 rounded-2xl border border-slate-200 bg-white/70 hover:bg-slate-50/80 hover:border-cyan-400 shadow-2xs hover:shadow-xs transition-all flex items-center gap-4 group cursor-pointer"
|
| 163 |
-
>
|
| 164 |
-
<div className="w-12 h-12 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 group-hover:scale-105 transition-all">
|
| 165 |
-
<Users className="w-6 h-6" />
|
| 166 |
-
</div>
|
| 167 |
-
<div className="flex-1 space-y-0.5">
|
| 168 |
-
<p className="text-xs font-bold text-slate-900">Employee Portal</p>
|
| 169 |
-
<p className="text-[10px] text-slate-400">Scan attendance & request leave</p>
|
| 170 |
-
</div>
|
| 171 |
-
<ArrowRight className="w-4 h-4 text-slate-400 group-hover:translate-x-1 transition-all" />
|
| 172 |
-
</button>
|
| 173 |
</div>
|
| 174 |
-
) : (
|
| 175 |
-
/* Login Card Form */
|
| 176 |
-
<div className="glass-overlay rounded-2xl p-6 shadow-[0_4px_30px_rgba(0,0,0,0.03)] relative">
|
| 177 |
-
<button
|
| 178 |
-
onClick={() => setSelectedRole(null)}
|
| 179 |
-
className="absolute -top-3 -left-3 w-8 h-8 rounded-full border border-slate-200 bg-white shadow-2xs flex items-center justify-center text-slate-500 hover:text-slate-800 hover:border-slate-350 cursor-pointer transition-all active:scale-90"
|
| 180 |
-
>
|
| 181 |
-
<ArrowLeft className="w-4 h-4" />
|
| 182 |
-
</button>
|
| 183 |
-
|
| 184 |
-
<div className="mb-5 text-center">
|
| 185 |
-
<h2 className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
| 186 |
-
{selectedRole} Login
|
| 187 |
-
</h2>
|
| 188 |
-
</div>
|
| 189 |
-
|
| 190 |
-
{/* Error state */}
|
| 191 |
-
{error && (
|
| 192 |
-
<div className="flex items-start gap-2 bg-rose-500/5 border border-rose-500/15 text-rose-600 p-2.5 rounded-lg mb-4 text-[11.5px] animate-fadeInUp">
|
| 193 |
-
<ShieldAlert className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
| 194 |
-
<span className="leading-relaxed">{error}</span>
|
| 195 |
-
</div>
|
| 196 |
-
)}
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
</div>
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
</div>
|
| 260 |
);
|
| 261 |
}
|
|
|
|
| 2 |
|
| 3 |
import React, { useState, useEffect } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
+
import { Lock, Mail, Eye, EyeOff, ShieldAlert, Shield, Building2, Users, ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
|
| 6 |
import { fetchApi, setTokens, setUserProfile, getAccessToken } from "@/app/utils/api";
|
| 7 |
|
| 8 |
type RoleType = "Super Admin" | "Admin" | "Employee";
|
| 9 |
|
| 10 |
export default function LoginPage() {
|
| 11 |
const router = useRouter();
|
| 12 |
+
|
| 13 |
+
// Mounted state for hydration safety
|
| 14 |
+
const [mounted, setMounted] = useState(false);
|
| 15 |
+
|
| 16 |
+
// Authentication states
|
| 17 |
const [selectedRole, setSelectedRole] = useState<RoleType | null>(null);
|
| 18 |
const [email, setEmail] = useState("");
|
| 19 |
const [password, setPassword] = useState("");
|
| 20 |
const [showPass, setShowPass] = useState(false);
|
| 21 |
+
const [showEmpPass, setShowEmpPass] = useState(false);
|
| 22 |
const [loading, setLoading] = useState(false);
|
| 23 |
const [error, setError] = useState<string | null>(null);
|
| 24 |
|
| 25 |
+
// Pre-onboarding / Login Lookup states
|
| 26 |
+
const [lookupName, setLookupName] = useState("");
|
| 27 |
+
const [matchedCompany, setMatchedCompany] = useState<{ id: number; name: string } | null>(null);
|
| 28 |
+
const [isSelfOnboarding, setIsSelfOnboarding] = useState(false);
|
| 29 |
+
|
| 30 |
+
// Employee self-registration details states
|
| 31 |
+
const [empName, setEmpName] = useState("");
|
| 32 |
+
const [empEmail, setEmpEmail] = useState("");
|
| 33 |
+
const [empPassword, setEmpPassword] = useState("");
|
| 34 |
+
const [empIdInput, setEmpIdInput] = useState("");
|
| 35 |
+
const [empDesignation, setEmpDesignation] = useState("");
|
| 36 |
+
const [empPhone, setEmpPhone] = useState("");
|
| 37 |
+
|
| 38 |
useEffect(() => {
|
| 39 |
+
setMounted(true);
|
| 40 |
+
const params = new URLSearchParams(window.location.search);
|
| 41 |
+
const roleParam = params.get("role");
|
| 42 |
+
if (roleParam === "super-admin") {
|
| 43 |
+
setSelectedRole("Super Admin");
|
| 44 |
+
handleRoleSelect("Super Admin");
|
| 45 |
+
}
|
| 46 |
if (getAccessToken()) {
|
| 47 |
router.push("/dashboard");
|
| 48 |
}
|
|
|
|
| 53 |
setError(null);
|
| 54 |
setEmail("");
|
| 55 |
setPassword("");
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const handleCheckCompany = async (e: React.FormEvent) => {
|
| 59 |
+
e.preventDefault();
|
| 60 |
+
if (!lookupName.trim()) return;
|
| 61 |
|
| 62 |
+
const nameLower = lookupName.trim().toLowerCase();
|
| 63 |
+
if (nameLower === "super admin" || nameLower === "superadmin" || nameLower === "netraid") {
|
| 64 |
+
setSelectedRole("Super Admin");
|
| 65 |
+
handleRoleSelect("Super Admin");
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
setLoading(true);
|
| 70 |
+
setError(null);
|
| 71 |
+
try {
|
| 72 |
+
const company = await fetchApi(`/auth/companies/check?name=${encodeURIComponent(lookupName.trim())}`);
|
| 73 |
+
setMatchedCompany({ id: company.id, name: company.name });
|
| 74 |
+
setError(null);
|
| 75 |
+
} catch (err: any) {
|
| 76 |
+
setError(err.message || "Organization not found. Verify name.");
|
| 77 |
+
setMatchedCompany(null);
|
| 78 |
+
} finally {
|
| 79 |
+
setLoading(false);
|
| 80 |
+
}
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const handleEmployeeRegister = async (e: React.FormEvent) => {
|
| 84 |
+
e.preventDefault();
|
| 85 |
+
if (!matchedCompany) return;
|
| 86 |
+
setLoading(true);
|
| 87 |
+
setError(null);
|
| 88 |
+
try {
|
| 89 |
+
const response = await fetchApi("/auth/register-pending", {
|
| 90 |
+
method: "POST",
|
| 91 |
+
body: JSON.stringify({
|
| 92 |
+
company_id: matchedCompany.id,
|
| 93 |
+
name: empName,
|
| 94 |
+
email: empEmail,
|
| 95 |
+
password: empPassword,
|
| 96 |
+
employee_id: empIdInput,
|
| 97 |
+
phone: empPhone || undefined,
|
| 98 |
+
designation: empDesignation || undefined
|
| 99 |
+
})
|
| 100 |
+
});
|
| 101 |
+
// Redirect to public self-onboarding camera hud page
|
| 102 |
+
router.push(`/self-onboard?employee_id=${response.employee_id}`);
|
| 103 |
+
} catch (err: any) {
|
| 104 |
+
setError(err.message || "Failed to register account. Please check details.");
|
| 105 |
+
} finally {
|
| 106 |
+
setLoading(false);
|
| 107 |
}
|
| 108 |
};
|
| 109 |
|
|
|
|
| 127 |
setTokens(response.access_token, response.refresh_token);
|
| 128 |
const profile = await fetchApi("/auth/me");
|
| 129 |
|
|
|
|
| 130 |
const userRole = profile?.role?.name;
|
| 131 |
if (selectedRole === "Super Admin" && userRole !== "Super Admin") {
|
| 132 |
throw new Error("Access denied: This portal is only for Super Admins.");
|
|
|
|
| 141 |
setUserProfile(profile);
|
| 142 |
router.push("/dashboard");
|
| 143 |
} catch (err: any) {
|
| 144 |
+
setError(err.message || "Invalid credentials or account pending approval.");
|
| 145 |
} finally {
|
| 146 |
setLoading(false);
|
| 147 |
}
|
| 148 |
};
|
| 149 |
|
| 150 |
return (
|
| 151 |
+
<div className="min-h-screen flex flex-col justify-between bg-slate-950 text-white relative overflow-hidden">
|
| 152 |
+
|
| 153 |
+
{/* βββ Immersive Ambient Background Animations βββ */}
|
| 154 |
+
<div className="absolute inset-0 bg-slate-950 pointer-events-none z-0" />
|
| 155 |
+
<div className="absolute top-1/4 left-1/4 w-[600px] h-[600px] bg-cyan-500/10 rounded-full blur-[130px] pointer-events-none z-0 animate-pulse" />
|
| 156 |
+
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-blue-500/10 rounded-full blur-[130px] pointer-events-none z-0 animate-pulse" />
|
| 157 |
+
<div className="absolute inset-0 mesh-bg opacity-85 pointer-events-none z-0" />
|
| 158 |
+
|
| 159 |
+
{/* βββ Premium Header Navbar βββ */}
|
| 160 |
+
<header className="w-full h-16 border-b border-slate-900 bg-slate-950/40 backdrop-blur-md px-6 flex items-center justify-between relative z-10">
|
| 161 |
+
<div className="flex items-center gap-2">
|
| 162 |
+
<div className="w-8 h-8 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-center shrink-0">
|
| 163 |
+
<svg viewBox="0 0 100 100" className="w-5 h-5 text-cyan-400 animate-pulse" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 164 |
+
<circle cx="50" cy="50" r="45" stroke="#0891b2" strokeWidth="2.5" strokeDasharray="10 15" />
|
| 165 |
+
<circle cx="50" cy="50" r="22" fill="#0f172a" stroke="#22d3ee" strokeWidth="2" />
|
| 166 |
+
<circle cx="50" cy="50" r="7" fill="#22d3ee" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
</svg>
|
| 168 |
</div>
|
| 169 |
+
<span className="text-sm font-extrabold tracking-tight text-white uppercase font-mono">NetraID</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
</div>
|
| 171 |
+
</header>
|
| 172 |
|
| 173 |
+
{/* βββ Dual Pane Layout βββ */}
|
| 174 |
+
<main className="flex-1 flex items-center justify-center p-6 md:p-12 relative z-10">
|
| 175 |
+
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16 items-center animate-fadeInUp">
|
| 176 |
+
|
| 177 |
+
{/* Left Side: Welcome back info */}
|
| 178 |
+
<div className="space-y-5 text-center md:text-left">
|
| 179 |
+
<h1 className="text-4xl md:text-5xl font-black text-white tracking-tight leading-tight uppercase tracking-wider">
|
| 180 |
+
{matchedCompany ? matchedCompany.name : "Welcome back"}
|
| 181 |
+
</h1>
|
| 182 |
+
<p className="text-slate-400 text-sm md:text-base font-light leading-relaxed max-w-sm mx-auto md:mx-0">
|
| 183 |
+
{matchedCompany
|
| 184 |
+
? "Organization verified. Proceed to sign in to your employee dashboard or complete self-onboarding."
|
| 185 |
+
: "Select organization to access your biometric portal dashboard."}
|
| 186 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
{/* Right Side: Flow Control */}
|
| 190 |
+
<div className="bg-transparent">
|
| 191 |
+
{mounted && (
|
| 192 |
+
<>
|
| 193 |
+
{selectedRole === "Super Admin" ? (
|
| 194 |
+
/* βββ SUPER ADMIN LOGIN VIEW βββ */
|
| 195 |
+
<div className="relative space-y-6 bg-slate-950/80 border border-slate-800 backdrop-blur-3xl rounded-3xl p-8 md:p-10 shadow-[0_0_50px_rgba(0,0,0,0.65)]">
|
| 196 |
+
<div className="flex items-center gap-4 border-b border-slate-850 pb-5 mb-2 text-left">
|
| 197 |
+
<button
|
| 198 |
+
onClick={() => {
|
| 199 |
+
setSelectedRole(null);
|
| 200 |
+
setError(null);
|
| 201 |
+
}}
|
| 202 |
+
className="w-9 h-9 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 shadow-sm flex items-center justify-center text-slate-400 hover:text-white cursor-pointer transition-all active:scale-90"
|
| 203 |
+
>
|
| 204 |
+
<ArrowLeft className="w-4 h-4" />
|
| 205 |
+
</button>
|
| 206 |
+
<div>
|
| 207 |
+
<span className="text-[9px] font-bold font-mono px-2 py-0.5 rounded bg-cyan-950 border border-cyan-800 text-cyan-400 uppercase tracking-wider">
|
| 208 |
+
Super Admin Login
|
| 209 |
+
</span>
|
| 210 |
+
<h2 className="text-sm font-extrabold text-slate-350 mt-1 leading-none">
|
| 211 |
+
NetraID Portal Management
|
| 212 |
+
</h2>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{error && (
|
| 217 |
+
<div className="flex items-start gap-2 bg-rose-500/5 border border-rose-500/15 text-rose-455 p-3 rounded-xl mb-4 text-xs animate-fadeInUp">
|
| 218 |
+
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
| 219 |
+
<span className="leading-relaxed">{error}</span>
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
|
| 223 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 224 |
+
<div className="space-y-1.5 text-left">
|
| 225 |
+
<label className="block text-[9.5px] font-bold text-slate-400 uppercase tracking-wider font-mono">Email Address</label>
|
| 226 |
+
<div className="relative">
|
| 227 |
+
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
|
| 228 |
+
<input
|
| 229 |
+
type="email"
|
| 230 |
+
required
|
| 231 |
+
placeholder="admin@netraid.ai"
|
| 232 |
+
value={email}
|
| 233 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 234 |
+
className="login-input w-full text-xs h-11 pl-9 pr-4 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-500 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
| 235 |
+
/>
|
| 236 |
+
</div>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div className="space-y-1.5 text-left">
|
| 240 |
+
<label className="text-[9.5px] font-bold text-slate-400 uppercase tracking-wider font-mono">Password</label>
|
| 241 |
+
<div className="relative">
|
| 242 |
+
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
|
| 243 |
+
<input
|
| 244 |
+
type={showPass ? "text" : "password"}
|
| 245 |
+
required
|
| 246 |
+
placeholder="β’β’β’β’β’β’β’β’"
|
| 247 |
+
value={password}
|
| 248 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 249 |
+
className="login-input w-full text-xs h-11 pl-9 pr-10 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-500 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
| 250 |
+
/>
|
| 251 |
+
<button
|
| 252 |
+
type="button"
|
| 253 |
+
onClick={() => setShowPass(!showPass)}
|
| 254 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors cursor-pointer"
|
| 255 |
+
>
|
| 256 |
+
{showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
| 257 |
+
</button>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<button
|
| 262 |
+
type="submit"
|
| 263 |
+
disabled={loading}
|
| 264 |
+
className="w-full h-11 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-slate-950 font-black tracking-wider text-xs rounded-xl shadow-md hover:shadow-lg transition-all active:scale-[0.99] cursor-pointer flex items-center justify-center mt-2"
|
| 265 |
+
>
|
| 266 |
+
{loading ? "Authenticating..." : "Sign In"}
|
| 267 |
+
</button>
|
| 268 |
+
</form>
|
| 269 |
+
</div>
|
| 270 |
+
) : matchedCompany === null ? (
|
| 271 |
+
/* βββ PHASE 1: ORG LOOKUP βββ */
|
| 272 |
+
<div className="relative space-y-6 bg-slate-950/80 border border-slate-800 backdrop-blur-3xl rounded-3xl p-8 md:p-10 shadow-[0_0_50px_rgba(0,0,0,0.65)]">
|
| 273 |
+
<div className="text-center lg:text-left space-y-1.5">
|
| 274 |
+
<h2 className="text-base font-black text-white tracking-tight uppercase tracking-wider">
|
| 275 |
+
Find Your Organization
|
| 276 |
+
</h2>
|
| 277 |
+
<p className="text-[11px] text-slate-400 font-light leading-relaxed">
|
| 278 |
+
Enter your company legal name to sign in or perform self-onboarding.
|
| 279 |
+
</p>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
{error && (
|
| 283 |
+
<div className="flex items-start gap-2 bg-rose-500/5 border border-rose-500/15 text-rose-455 p-3 rounded-xl text-xs animate-fadeInUp animate-pulse">
|
| 284 |
+
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
| 285 |
+
<span className="leading-relaxed">{error}</span>
|
| 286 |
+
</div>
|
| 287 |
+
)}
|
| 288 |
+
|
| 289 |
+
<form onSubmit={handleCheckCompany} className="space-y-4">
|
| 290 |
+
<div className="space-y-1.5 text-left">
|
| 291 |
+
<label className="block text-[9px] font-bold text-slate-400 uppercase tracking-wider">
|
| 292 |
+
Company Name
|
| 293 |
+
</label>
|
| 294 |
+
<input
|
| 295 |
+
type="text"
|
| 296 |
+
required
|
| 297 |
+
placeholder="e.g. NetraID Base"
|
| 298 |
+
value={lookupName}
|
| 299 |
+
onChange={(e) => setLookupName(e.target.value)}
|
| 300 |
+
className="login-input w-full text-xs h-11 px-4 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-600 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
| 301 |
+
/>
|
| 302 |
+
</div>
|
| 303 |
+
<button
|
| 304 |
+
type="submit"
|
| 305 |
+
disabled={loading}
|
| 306 |
+
className="w-full h-11 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-slate-950 font-black tracking-wider text-xs rounded-xl shadow-md hover:shadow-lg transition-all active:scale-[0.99] cursor-pointer flex items-center justify-center gap-1.5"
|
| 307 |
+
>
|
| 308 |
+
{loading ? "Checking..." : "Continue"}
|
| 309 |
+
<ArrowRight className="w-4 h-4" />
|
| 310 |
+
</button>
|
| 311 |
+
</form>
|
| 312 |
+
|
| 313 |
+
<div className="text-center pt-4 border-t border-slate-900/60">
|
| 314 |
+
<button
|
| 315 |
+
onClick={() => {
|
| 316 |
+
setSelectedRole("Super Admin");
|
| 317 |
+
handleRoleSelect("Super Admin");
|
| 318 |
+
}}
|
| 319 |
+
className="text-[10.5px] text-slate-500 hover:text-slate-300 transition-colors cursor-pointer"
|
| 320 |
+
>
|
| 321 |
+
Access Super Admin Portal
|
| 322 |
+
</button>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
) : isSelfOnboarding ? (
|
| 326 |
+
/* βββ PHASE 3: SELF-ONBOARDING DETAILS FORM βββ */
|
| 327 |
+
<div className="relative space-y-6 bg-slate-950/80 border border-slate-800 backdrop-blur-3xl rounded-3xl p-8 md:p-10 shadow-[0_0_50px_rgba(0,0,0,0.65)]">
|
| 328 |
+
<div className="flex items-center gap-4 border-b border-slate-850 pb-5 mb-2 text-left">
|
| 329 |
+
<button
|
| 330 |
+
onClick={() => {
|
| 331 |
+
setIsSelfOnboarding(false);
|
| 332 |
+
setError(null);
|
| 333 |
+
}}
|
| 334 |
+
className="w-9 h-9 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 shadow-sm flex items-center justify-center text-slate-400 hover:text-white cursor-pointer transition-all active:scale-90"
|
| 335 |
+
>
|
| 336 |
+
<ArrowLeft className="w-4 h-4" />
|
| 337 |
+
</button>
|
| 338 |
+
<div className="flex-1 min-w-0">
|
| 339 |
+
<span className="text-[9px] font-bold font-mono px-2 py-0.5 rounded bg-cyan-950 border border-cyan-800 text-cyan-400 uppercase tracking-wider">
|
| 340 |
+
Self-Onboarding Details
|
| 341 |
+
</span>
|
| 342 |
+
<h2 className="text-lg font-black text-white mt-1 leading-tight">
|
| 343 |
+
{matchedCompany.name}
|
| 344 |
+
</h2>
|
| 345 |
+
</div>
|
| 346 |
+
</div>
|
| 347 |
+
|
| 348 |
+
{error && (
|
| 349 |
+
<div className="flex items-start gap-2 bg-rose-500/5 border border-rose-500/15 text-rose-455 p-3 rounded-xl mb-3 text-xs animate-fadeInUp">
|
| 350 |
+
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
| 351 |
+
<span className="leading-relaxed">{error}</span>
|
| 352 |
+
</div>
|
| 353 |
+
)}
|
| 354 |
+
|
| 355 |
+
<form onSubmit={handleEmployeeRegister} className="space-y-4">
|
| 356 |
+
<div className="grid grid-cols-2 gap-4">
|
| 357 |
+
<div className="space-y-1.5 text-left">
|
| 358 |
+
<label className="block text-[9px] font-bold text-slate-400 uppercase tracking-wider">Full Name</label>
|
| 359 |
+
<input type="text" required placeholder="John Doe" value={empName} onChange={e => setEmpName(e.target.value)}
|
| 360 |
+
className="login-input w-full text-xs h-10 px-3.5 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-600 focus:ring-1 focus:ring-cyan-500/20 transition-all" />
|
| 361 |
+
</div>
|
| 362 |
+
<div className="space-y-1.5 text-left">
|
| 363 |
+
<label className="block text-[9px] font-bold text-slate-400 uppercase tracking-wider">Employee ID</label>
|
| 364 |
+
<input type="text" required placeholder="EMP102" value={empIdInput} onChange={e => setEmpIdInput(e.target.value)}
|
| 365 |
+
className="login-input w-full text-xs h-10 px-3.5 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-600 focus:ring-1 focus:ring-cyan-500/20 transition-all" />
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
<div className="space-y-1.5 text-left">
|
| 370 |
+
<label className="block text-[9px] font-bold text-slate-400 uppercase tracking-wider">Email Address</label>
|
| 371 |
+
<input type="email" required placeholder="john@company.com" value={empEmail} onChange={e => setEmpEmail(e.target.value)}
|
| 372 |
+
className="login-input w-full text-xs h-10 px-3.5 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-600 focus:ring-1 focus:ring-cyan-500/20 transition-all" />
|
| 373 |
+
</div>
|
| 374 |
+
|
| 375 |
+
<div className="space-y-1.5 text-left">
|
| 376 |
+
<label className="block text-[9px] font-bold text-slate-400 uppercase tracking-wider">Password</label>
|
| 377 |
+
<div className="relative">
|
| 378 |
+
<input
|
| 379 |
+
type={showEmpPass ? "text" : "password"}
|
| 380 |
+
required
|
| 381 |
+
placeholder="β’β’β’β’β’β’β’β’"
|
| 382 |
+
value={empPassword}
|
| 383 |
+
onChange={e => setEmpPassword(e.target.value)}
|
| 384 |
+
className="login-input w-full text-xs h-10 pl-3.5 pr-10 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-600 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
| 385 |
+
/>
|
| 386 |
+
<button
|
| 387 |
+
type="button"
|
| 388 |
+
onClick={() => setShowEmpPass(!showEmpPass)}
|
| 389 |
+
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors cursor-pointer"
|
| 390 |
+
title={showEmpPass ? "Hide password" : "Show password"}
|
| 391 |
+
>
|
| 392 |
+
{showEmpPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
| 393 |
+
</button>
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
|
| 397 |
+
<div className="grid grid-cols-2 gap-4">
|
| 398 |
+
<div className="space-y-1.5 text-left">
|
| 399 |
+
<label className="block text-[9px] font-bold text-slate-400 uppercase tracking-wider">Designation</label>
|
| 400 |
+
<input type="text" placeholder="Software Engineer" value={empDesignation} onChange={e => setEmpDesignation(e.target.value)}
|
| 401 |
+
className="login-input w-full text-xs h-10 px-3.5 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-600 focus:ring-1 focus:ring-cyan-500/20 transition-all" />
|
| 402 |
+
</div>
|
| 403 |
+
<div className="space-y-1.5 text-left">
|
| 404 |
+
<label className="block text-[9px] font-bold text-slate-400 uppercase tracking-wider">Phone</label>
|
| 405 |
+
<input type="text" placeholder="9876543210" value={empPhone} onChange={e => setEmpPhone(e.target.value)}
|
| 406 |
+
className="login-input w-full text-xs h-10 px-3.5 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-600 focus:ring-1 focus:ring-cyan-500/20 transition-all" />
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
|
| 410 |
+
<button
|
| 411 |
+
type="submit"
|
| 412 |
+
disabled={loading}
|
| 413 |
+
className="w-full h-11 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-slate-950 font-black tracking-wider text-xs rounded-xl shadow-md hover:shadow-lg transition-all active:scale-[0.99] cursor-pointer flex items-center justify-center gap-1.5 mt-2"
|
| 414 |
+
>
|
| 415 |
+
{loading ? "Registering..." : "Continue to Face Scans"}
|
| 416 |
+
<ArrowRight className="w-4 h-4" />
|
| 417 |
+
</button>
|
| 418 |
+
</form>
|
| 419 |
+
</div>
|
| 420 |
+
) : selectedRole !== null ? (
|
| 421 |
+
/* βββ STANDARD SIGN IN CARD FORM βββ */
|
| 422 |
+
<div className="relative space-y-6 bg-slate-950/80 border border-slate-800 backdrop-blur-3xl rounded-3xl p-8 md:p-10 shadow-[0_0_50px_rgba(0,0,0,0.65)]">
|
| 423 |
+
<div className="flex items-center gap-4 border-b border-slate-850 pb-5 mb-2 text-left">
|
| 424 |
+
<button
|
| 425 |
+
onClick={() => {
|
| 426 |
+
setSelectedRole(null);
|
| 427 |
+
setError(null);
|
| 428 |
+
}}
|
| 429 |
+
className="w-9 h-9 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 shadow-sm flex items-center justify-center text-slate-400 hover:text-white cursor-pointer transition-all active:scale-90"
|
| 430 |
+
>
|
| 431 |
+
<ArrowLeft className="w-4 h-4" />
|
| 432 |
+
</button>
|
| 433 |
+
<div>
|
| 434 |
+
<span className="text-[9px] font-bold font-mono px-2 py-0.5 rounded bg-cyan-955 border border-cyan-850 text-cyan-400 uppercase tracking-wider">
|
| 435 |
+
{selectedRole} Login
|
| 436 |
+
</span>
|
| 437 |
+
<h2 className="text-sm font-extrabold text-slate-350 mt-1 leading-none">
|
| 438 |
+
{matchedCompany.name}
|
| 439 |
+
</h2>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
{error && (
|
| 444 |
+
<div className="flex items-start gap-2 bg-rose-500/5 border border-rose-500/15 text-rose-455 p-3 rounded-xl mb-4 text-xs animate-fadeInUp">
|
| 445 |
+
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
| 446 |
+
<span className="leading-relaxed">{error}</span>
|
| 447 |
+
</div>
|
| 448 |
+
)}
|
| 449 |
+
|
| 450 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 451 |
+
<div className="space-y-1.5 text-left">
|
| 452 |
+
<label className="block text-[9.5px] font-bold text-slate-400 uppercase tracking-wider font-mono">
|
| 453 |
+
Email Address
|
| 454 |
+
</label>
|
| 455 |
+
<div className="relative">
|
| 456 |
+
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
|
| 457 |
+
<input
|
| 458 |
+
type="email"
|
| 459 |
+
required
|
| 460 |
+
placeholder="name@organization.com"
|
| 461 |
+
value={email}
|
| 462 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 463 |
+
className="login-input w-full text-xs h-11 pl-9 pr-4 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-500 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
| 464 |
+
/>
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
<div className="space-y-1.5 text-left">
|
| 469 |
+
<label className="text-[9.5px] font-bold text-slate-400 uppercase tracking-wider font-mono">
|
| 470 |
+
Password
|
| 471 |
+
</label>
|
| 472 |
+
<div className="relative">
|
| 473 |
+
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
|
| 474 |
+
<input
|
| 475 |
+
type={showPass ? "text" : "password"}
|
| 476 |
+
required
|
| 477 |
+
placeholder="β’β’β’β’β’β’β’β’"
|
| 478 |
+
value={password}
|
| 479 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 480 |
+
className="login-input w-full text-xs h-11 pl-9 pr-10 rounded-xl border border-slate-800 bg-slate-950/60 text-white focus:outline-none focus:border-cyan-500 placeholder-slate-500 focus:ring-1 focus:ring-cyan-500/20 transition-all"
|
| 481 |
+
/>
|
| 482 |
+
<button
|
| 483 |
+
type="button"
|
| 484 |
+
onClick={() => setShowPass(!showPass)}
|
| 485 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors cursor-pointer"
|
| 486 |
+
>
|
| 487 |
+
{showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
| 488 |
+
</button>
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
|
| 492 |
+
<button
|
| 493 |
+
type="submit"
|
| 494 |
+
disabled={loading}
|
| 495 |
+
className="w-full h-11 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400 text-slate-950 font-black tracking-wider text-xs rounded-xl shadow-md hover:shadow-lg transition-all active:scale-[0.99] cursor-pointer flex items-center justify-center mt-2"
|
| 496 |
+
>
|
| 497 |
+
{loading ? "Authenticating..." : `Sign In`}
|
| 498 |
+
</button>
|
| 499 |
+
</form>
|
| 500 |
+
</div>
|
| 501 |
+
) : (
|
| 502 |
+
/* βββ PHASE 2: SCOPED OPTIONS FOR MATCHED COMPANY βββ */
|
| 503 |
+
<div className="relative space-y-5 bg-slate-950/85 border border-cyan-500/10 backdrop-blur-3xl rounded-3xl p-8 md:p-10 shadow-[0_0_50px_rgba(0,0,0,0.65),0_0_30px_rgba(6,182,212,0.03)] animate-fadeInUp">
|
| 504 |
+
<div className="flex items-center gap-4 border-b border-slate-900 pb-5 mb-3 text-left">
|
| 505 |
+
<button
|
| 506 |
+
onClick={() => {
|
| 507 |
+
setMatchedCompany(null);
|
| 508 |
+
setError(null);
|
| 509 |
+
}}
|
| 510 |
+
className="w-9 h-9 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900/80 hover:border-slate-700/80 shadow-sm flex items-center justify-center text-slate-400 hover:text-cyan-400 cursor-pointer transition-all active:scale-90"
|
| 511 |
+
>
|
| 512 |
+
<ArrowLeft className="w-4 h-4" />
|
| 513 |
+
</button>
|
| 514 |
+
<div className="flex-1 min-w-0">
|
| 515 |
+
<span className="inline-flex items-center gap-1.5 text-[9px] font-black font-mono px-2.5 py-0.5 rounded-full bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 uppercase tracking-widest">
|
| 516 |
+
<span className="w-1 h-1 rounded-full bg-cyan-400 animate-pulse" />
|
| 517 |
+
Organization Verified
|
| 518 |
+
</span>
|
| 519 |
+
<h2 className="text-2xl font-black text-white mt-1.5 leading-none uppercase tracking-wide">
|
| 520 |
+
{matchedCompany.name}
|
| 521 |
+
</h2>
|
| 522 |
+
</div>
|
| 523 |
+
</div>
|
| 524 |
+
|
| 525 |
+
<div className="space-y-4 pt-1">
|
| 526 |
+
<button
|
| 527 |
+
onClick={() => {
|
| 528 |
+
setSelectedRole("Admin");
|
| 529 |
+
handleRoleSelect("Admin");
|
| 530 |
+
}}
|
| 531 |
+
className="w-full text-left p-5 rounded-2xl border border-slate-800/80 bg-slate-900/20 hover:border-cyan-500/30 hover:bg-slate-900/60 shadow-md hover:shadow-[0_0_25px_rgba(6,182,212,0.04)] transition-all duration-300 flex items-center gap-5 group cursor-pointer"
|
| 532 |
+
>
|
| 533 |
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800/80 flex items-center justify-center text-cyan-400 group-hover:scale-105 transition-all shrink-0 shadow-inner">
|
| 534 |
+
<Building2 className="w-5 h-5 text-cyan-400" />
|
| 535 |
+
</div>
|
| 536 |
+
<div className="flex-1 min-w-0">
|
| 537 |
+
<p className="text-[13px] font-bold text-slate-200 group-hover:text-cyan-400 transition-colors">
|
| 538 |
+
Company Admin Sign In
|
| 539 |
+
</p>
|
| 540 |
+
<p className="text-[11px] text-slate-400 font-light mt-1">
|
| 541 |
+
Manage company metrics and logs
|
| 542 |
+
</p>
|
| 543 |
+
</div>
|
| 544 |
+
<ArrowRight className="w-4.5 h-4.5 text-slate-600 group-hover:translate-x-1.5 group-hover:text-cyan-400 transition-all" />
|
| 545 |
+
</button>
|
| 546 |
+
|
| 547 |
+
<button
|
| 548 |
+
onClick={() => {
|
| 549 |
+
setSelectedRole("Employee");
|
| 550 |
+
handleRoleSelect("Employee");
|
| 551 |
+
}}
|
| 552 |
+
className="w-full text-left p-5 rounded-2xl border border-slate-800/80 bg-slate-900/20 hover:border-blue-500/30 hover:bg-slate-900/60 shadow-md hover:shadow-[0_0_25px_rgba(59,130,246,0.04)] transition-all duration-300 flex items-center gap-5 group cursor-pointer"
|
| 553 |
+
>
|
| 554 |
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800/80 flex items-center justify-center text-blue-400 group-hover:scale-105 transition-all shrink-0 shadow-inner">
|
| 555 |
+
<Users className="w-5 h-5 text-blue-400" />
|
| 556 |
+
</div>
|
| 557 |
+
<div className="flex-1 min-w-0">
|
| 558 |
+
<p className="text-[13px] font-bold text-slate-200 group-hover:text-blue-400 transition-colors">
|
| 559 |
+
Employee Sign In
|
| 560 |
+
</p>
|
| 561 |
+
<p className="text-[11px] text-slate-455 font-light mt-1">
|
| 562 |
+
Access attendance and punches
|
| 563 |
+
</p>
|
| 564 |
+
</div>
|
| 565 |
+
<ArrowRight className="w-4.5 h-4.5 text-slate-600 group-hover:translate-x-1.5 group-hover:text-blue-400 transition-all" />
|
| 566 |
+
</button>
|
| 567 |
+
|
| 568 |
+
<button
|
| 569 |
+
onClick={() => {
|
| 570 |
+
setIsSelfOnboarding(true);
|
| 571 |
+
setError(null);
|
| 572 |
+
}}
|
| 573 |
+
className="w-full text-left p-5 rounded-2xl border border-slate-800/80 bg-slate-900/20 hover:border-emerald-500/30 hover:bg-slate-900/60 shadow-md hover:shadow-[0_0_25px_rgba(16,185,129,0.04)] transition-all duration-300 flex items-center gap-5 group cursor-pointer"
|
| 574 |
+
>
|
| 575 |
+
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-805 flex items-center justify-center text-emerald-455 group-hover:scale-105 transition-all shrink-0 shadow-inner">
|
| 576 |
+
<Users className="w-5 h-5" />
|
| 577 |
+
</div>
|
| 578 |
+
<div className="flex-1 min-w-0">
|
| 579 |
+
<p className="text-[13px] font-bold text-slate-200 group-hover:text-emerald-400 transition-colors">
|
| 580 |
+
New Employee Self-Onboarding
|
| 581 |
+
</p>
|
| 582 |
+
<p className="text-[11px] text-slate-455 font-light mt-1">
|
| 583 |
+
Register your account and biometrics
|
| 584 |
+
</p>
|
| 585 |
+
</div>
|
| 586 |
+
<ArrowRight className="w-4.5 h-4.5 text-slate-605 group-hover:translate-x-1.5 group-hover:text-emerald-400 transition-all" />
|
| 587 |
+
</button>
|
| 588 |
+
</div>
|
| 589 |
+
</div>
|
| 590 |
+
)}
|
| 591 |
+
</>
|
| 592 |
+
)}
|
| 593 |
</div>
|
| 594 |
+
|
| 595 |
+
</div>
|
| 596 |
+
</main>
|
| 597 |
+
|
| 598 |
+
{/* βββ Footer note βββ */}
|
| 599 |
+
<footer className="w-full h-12 border-t border-slate-900/60 bg-slate-950/20 px-6 flex items-center justify-center text-[10px] text-slate-500 font-medium relative z-10">
|
| 600 |
+
Β© {new Date().getFullYear()} NetraID Inc. All rights reserved.
|
| 601 |
+
</footer>
|
| 602 |
+
|
| 603 |
</div>
|
| 604 |
);
|
| 605 |
}
|
frontend/app/profile/page.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState } from "react";
|
| 4 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 5 |
+
import { getUserProfile } from "@/app/utils/api";
|
| 6 |
+
import {
|
| 7 |
+
User, Shield, Mail, Key, Clock, ShieldCheck
|
| 8 |
+
} from "lucide-react";
|
| 9 |
+
|
| 10 |
+
export default function ProfilePage() {
|
| 11 |
+
const [user, setUser] = useState<any>(null);
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
setUser(getUserProfile());
|
| 15 |
+
}, []);
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<SidebarLayout>
|
| 19 |
+
<div className="space-y-6 page-enter">
|
| 20 |
+
{/* Header Block */}
|
| 21 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-5 border-b border-zinc-150">
|
| 22 |
+
<div className="space-y-1">
|
| 23 |
+
<h1 className="text-xl font-bold text-zinc-900 tracking-tight flex items-center gap-2">
|
| 24 |
+
<User className="w-5 h-5 text-zinc-700" />
|
| 25 |
+
Platform Profile
|
| 26 |
+
</h1>
|
| 27 |
+
<p className="text-slate-450 text-[11px]">
|
| 28 |
+
View your platform account information, roles, and security details
|
| 29 |
+
</p>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
{/* Profile Card */}
|
| 34 |
+
<div className="max-w-2xl border border-zinc-200 rounded-2xl overflow-hidden bg-white shadow-2xs">
|
| 35 |
+
<div className="bg-zinc-50/80 px-6 py-8 border-b border-zinc-150 flex items-center gap-4">
|
| 36 |
+
<div className="w-16 h-16 rounded-2xl bg-zinc-900 text-white font-black text-2xl flex items-center justify-center shadow-md">
|
| 37 |
+
{user?.email ? user.email[0].toUpperCase() : "U"}
|
| 38 |
+
</div>
|
| 39 |
+
<div className="space-y-1">
|
| 40 |
+
<h2 className="text-base font-extrabold text-zinc-800">{user?.email || "user@netraid.ai"}</h2>
|
| 41 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2.5 py-0.5 rounded-full bg-cyan-50 border border-cyan-150 text-cyan-700 uppercase">
|
| 42 |
+
<ShieldCheck className="w-3 h-3" />
|
| 43 |
+
{user?.role?.name || "Member"}
|
| 44 |
+
</span>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div className="p-6 space-y-4 text-xs">
|
| 49 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 50 |
+
<div className="space-y-1">
|
| 51 |
+
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider block">Account Email</span>
|
| 52 |
+
<div className="flex items-center gap-2 text-zinc-700 border border-zinc-200 rounded-xl p-3 bg-zinc-50/50">
|
| 53 |
+
<Mail className="w-4 h-4 text-zinc-400" />
|
| 54 |
+
<span>{user?.email || "user@netraid.ai"}</span>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="space-y-1">
|
| 59 |
+
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider block">Access Role</span>
|
| 60 |
+
<div className="flex items-center gap-2 text-zinc-700 border border-zinc-200 rounded-xl p-3 bg-zinc-50/50">
|
| 61 |
+
<Shield className="w-4 h-4 text-zinc-400" />
|
| 62 |
+
<span>{user?.role?.name || "Member"}</span>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div className="space-y-1">
|
| 67 |
+
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider block">System Company</span>
|
| 68 |
+
<div className="flex items-center gap-2 text-zinc-700 border border-zinc-200 rounded-xl p-3 bg-zinc-50/50">
|
| 69 |
+
<Key className="w-4 h-4 text-zinc-400" />
|
| 70 |
+
<span>{user?.company?.name || "Global Platform Administrator"}</span>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="space-y-1">
|
| 75 |
+
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider block">Last Active</span>
|
| 76 |
+
<div className="flex items-center gap-2 text-zinc-700 border border-zinc-200 rounded-xl p-3 bg-zinc-50/50">
|
| 77 |
+
<Clock className="w-4 h-4 text-zinc-400" />
|
| 78 |
+
<span>Just now (active session)</span>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</SidebarLayout>
|
| 86 |
+
);
|
| 87 |
+
}
|
frontend/app/reports/page.tsx
CHANGED
|
@@ -52,24 +52,6 @@ export default function ReportsPage() {
|
|
| 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);
|
|
@@ -115,7 +97,7 @@ export default function ReportsPage() {
|
|
| 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
|
| 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" />
|
|
@@ -340,7 +322,7 @@ export default function ReportsPage() {
|
|
| 340 |
|
| 341 |
{/* Sidebar info */}
|
| 342 |
<div className="space-y-4 md:col-span-1">
|
| 343 |
-
<div className="
|
| 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>
|
|
@@ -374,71 +356,6 @@ export default function ReportsPage() {
|
|
| 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 |
);
|
|
|
|
| 52 |
const { data: departments } = useQuery({ queryKey: ["departments"], queryFn: () => fetchApi("/departments/") });
|
| 53 |
const { data: employees } = useQuery({ queryKey: ["employees-list"], queryFn: () => fetchApi("/employees/") });
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
const handleExport = async (e: React.FormEvent) => {
|
| 56 |
e.preventDefault();
|
| 57 |
setExporting(true); setSuccess(false);
|
|
|
|
| 97 |
|
| 98 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
| 99 |
{/* Form */}
|
| 100 |
+
<div className="lg:col-span-2 tech-card-3d-minimal bg-white p-6 space-y-6">
|
| 101 |
<div className="flex items-center gap-2.5 border-b border-slate-100 pb-4">
|
| 102 |
<div className="w-8 h-8 rounded-lg bg-zinc-100 flex items-center justify-center border border-zinc-200">
|
| 103 |
<FileDown className="w-4 h-4 text-zinc-700" />
|
|
|
|
| 322 |
|
| 323 |
{/* Sidebar info */}
|
| 324 |
<div className="space-y-4 md:col-span-1">
|
| 325 |
+
<div className="tech-card-3d-minimal bg-white p-5 space-y-4.5">
|
| 326 |
<div className="flex items-center gap-2 mb-1">
|
| 327 |
<Layers className="w-4 h-4 text-zinc-700" />
|
| 328 |
<h3 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Audit Protocols</h3>
|
|
|
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
</div>
|
| 360 |
</SidebarLayout>
|
| 361 |
);
|
frontend/app/self-onboard/page.tsx
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
+
import { useRouter, useSearchParams } from "next/navigation";
|
| 5 |
+
import { fetchApi, getBackendUrl } from "@/app/utils/api";
|
| 6 |
+
import {
|
| 7 |
+
Camera, Upload, CheckCircle2, ChevronLeft, XCircle, RefreshCw, AlertCircle,
|
| 8 |
+
Play, Pause, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Smile, Meh, Lightbulb, Sun, Glasses, User,
|
| 9 |
+
Trash2, RotateCcw, Sparkles
|
| 10 |
+
} from "lucide-react";
|
| 11 |
+
|
| 12 |
+
interface PoseInfo {
|
| 13 |
+
label: string;
|
| 14 |
+
hint: string;
|
| 15 |
+
speech: string;
|
| 16 |
+
icon: any;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const POSES: Record<string, PoseInfo> = {
|
| 20 |
+
front: { label: "Front Profile", hint: "Look straight into the camera.", speech: "Please look straight into the camera.", icon: User },
|
| 21 |
+
left: { label: "Left Profile", hint: "Turn your head to the left.", speech: "Please turn your head to the left.", icon: ArrowLeft },
|
| 22 |
+
right: { label: "Right Profile", hint: "Turn your head to the right.", speech: "Please turn your head to the right.", icon: ArrowRight },
|
| 23 |
+
up: { label: "Looking Up", hint: "Tilt your chin upwards slightly.", speech: "Please tilt your head upwards.", icon: ArrowUp },
|
| 24 |
+
down: { label: "Looking Down", hint: "Tilt your chin downwards slightly.", speech: "Please tilt your head downwards.", icon: ArrowDown },
|
| 25 |
+
smile: { label: "Smiling Face", hint: "Give a natural, relaxed smile.", speech: "Now, smile naturally.", icon: Smile },
|
| 26 |
+
neutral: { label: "Neutral Face", hint: "Keep a standard neutral expression.", speech: "Relax your face, show a neutral expression.", icon: Meh },
|
| 27 |
+
indoor: { label: "Indoor Light", hint: "Look straight under typical indoor light.", speech: "Look straight for typical indoor lighting.", icon: Lightbulb },
|
| 28 |
+
outdoor: { label: "Outdoor Light", hint: "Look straight with bright/outdoor light.", speech: "Look straight for bright light capture.", icon: Sun },
|
| 29 |
+
glasses: { label: "Glasses Option", hint: "With glasses on (if applicable), or straight.", speech: "If you wear glasses, put them on. Otherwise, look straight.", icon: Glasses }
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const POSE_KEYS = Object.keys(POSES);
|
| 33 |
+
|
| 34 |
+
export default function SelfOnboardPage() {
|
| 35 |
+
const router = useRouter();
|
| 36 |
+
const searchParams = useSearchParams();
|
| 37 |
+
const employeeId = searchParams.get("employee_id");
|
| 38 |
+
|
| 39 |
+
const [employeeName, setEmployeeName] = useState("");
|
| 40 |
+
const [enrolledPoses, setEnrolledPoses] = useState<string[]>([]);
|
| 41 |
+
|
| 42 |
+
// State Machine: "idle" | "capturing" | "review" | "saving" | "done"
|
| 43 |
+
const [captureState, setCaptureState] = useState<"idle" | "capturing" | "review" | "saving" | "done">("idle");
|
| 44 |
+
const [currentPoseIndex, setCurrentPoseIndex] = useState(0);
|
| 45 |
+
const [countdown, setCountdown] = useState(3);
|
| 46 |
+
const [isPaused, setIsPaused] = useState(false);
|
| 47 |
+
const [singleRetakePose, setSingleRetakePose] = useState<string | null>(null);
|
| 48 |
+
|
| 49 |
+
// Local previews for capturing session
|
| 50 |
+
const [capturedImages, setCapturedImages] = useState<Record<string, string>>({});
|
| 51 |
+
|
| 52 |
+
// Upload status info
|
| 53 |
+
const [uploadIndex, setUploadIndex] = useState(0);
|
| 54 |
+
const [uploadProgress, setUploadProgress] = useState(0);
|
| 55 |
+
|
| 56 |
+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
| 57 |
+
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
| 58 |
+
const [webcamActive, setWebcamActive] = useState(false);
|
| 59 |
+
|
| 60 |
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
| 61 |
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
| 62 |
+
const streamRef = useRef<MediaStream | null>(null);
|
| 63 |
+
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
| 64 |
+
|
| 65 |
+
// 1. Load employee details & enrollment status
|
| 66 |
+
const fetchStatus = async () => {
|
| 67 |
+
if (!employeeId) return;
|
| 68 |
+
try {
|
| 69 |
+
const data = await fetchApi(`/enrollment/status/${employeeId}`);
|
| 70 |
+
setEmployeeName(data.name);
|
| 71 |
+
setEnrolledPoses(data.enrolled_poses || []);
|
| 72 |
+
if (data.is_complete) {
|
| 73 |
+
setCaptureState("done");
|
| 74 |
+
}
|
| 75 |
+
} catch (err: any) {
|
| 76 |
+
setErrorMsg(err.message || "Failed to load onboarding status.");
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
if (employeeId) {
|
| 82 |
+
fetchStatus();
|
| 83 |
+
} else {
|
| 84 |
+
router.push("/");
|
| 85 |
+
}
|
| 86 |
+
}, [employeeId]);
|
| 87 |
+
|
| 88 |
+
// Audio & Speech synthesizers
|
| 89 |
+
const playSound = (type: "beep" | "click") => {
|
| 90 |
+
if (typeof window === "undefined") return;
|
| 91 |
+
try {
|
| 92 |
+
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
| 93 |
+
const osc = audioCtx.createOscillator();
|
| 94 |
+
const gain = audioCtx.createGain();
|
| 95 |
+
osc.connect(gain);
|
| 96 |
+
gain.connect(audioCtx.destination);
|
| 97 |
+
|
| 98 |
+
if (type === "beep") {
|
| 99 |
+
osc.type = "sine";
|
| 100 |
+
osc.frequency.setValueAtTime(600, audioCtx.currentTime);
|
| 101 |
+
gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
|
| 102 |
+
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12);
|
| 103 |
+
osc.start();
|
| 104 |
+
osc.stop(audioCtx.currentTime + 0.13);
|
| 105 |
+
} else if (type === "click") {
|
| 106 |
+
osc.type = "triangle";
|
| 107 |
+
osc.frequency.setValueAtTime(100, audioCtx.currentTime);
|
| 108 |
+
osc.frequency.exponentialRampToValueAtTime(1000, audioCtx.currentTime + 0.08);
|
| 109 |
+
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
|
| 110 |
+
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.12);
|
| 111 |
+
osc.start();
|
| 112 |
+
osc.stop(audioCtx.currentTime + 0.12);
|
| 113 |
+
}
|
| 114 |
+
} catch (e) {
|
| 115 |
+
console.warn("Failed to generate audio feedback:", e);
|
| 116 |
+
}
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const speakDirection = (text: string) => {
|
| 120 |
+
if (typeof window !== "undefined" && window.speechSynthesis) {
|
| 121 |
+
window.speechSynthesis.cancel();
|
| 122 |
+
const utterance = new SpeechSynthesisUtterance(text);
|
| 123 |
+
utterance.rate = 0.92;
|
| 124 |
+
utterance.pitch = 1.05;
|
| 125 |
+
|
| 126 |
+
const voices = window.speechSynthesis.getVoices();
|
| 127 |
+
const eng = voices.find(v => v.lang.startsWith("en"));
|
| 128 |
+
if (eng) utterance.voice = eng;
|
| 129 |
+
|
| 130 |
+
window.speechSynthesis.speak(utterance);
|
| 131 |
+
}
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
// Start webcam stream
|
| 135 |
+
const startWebcam = async () => {
|
| 136 |
+
setErrorMsg(null);
|
| 137 |
+
setSuccessMsg(null);
|
| 138 |
+
try {
|
| 139 |
+
if (streamRef.current) {
|
| 140 |
+
streamRef.current.getTracks().forEach(track => track.stop());
|
| 141 |
+
}
|
| 142 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 143 |
+
video: { width: 640, height: 480, facingMode: "user" }
|
| 144 |
+
});
|
| 145 |
+
streamRef.current = stream;
|
| 146 |
+
if (videoRef.current) {
|
| 147 |
+
videoRef.current.srcObject = stream;
|
| 148 |
+
await videoRef.current.play().catch(() => {});
|
| 149 |
+
}
|
| 150 |
+
setWebcamActive(true);
|
| 151 |
+
} catch {
|
| 152 |
+
setErrorMsg("Camera access denied. Please grant webcam permissions or upload files manually.");
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
const stopWebcam = () => {
|
| 157 |
+
if (countdownIntervalRef.current) {
|
| 158 |
+
clearInterval(countdownIntervalRef.current);
|
| 159 |
+
countdownIntervalRef.current = null;
|
| 160 |
+
}
|
| 161 |
+
streamRef.current?.getTracks().forEach(t => t.stop());
|
| 162 |
+
streamRef.current = null;
|
| 163 |
+
if (videoRef.current) videoRef.current.srcObject = null;
|
| 164 |
+
setWebcamActive(false);
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
useEffect(() => {
|
| 168 |
+
return () => stopWebcam();
|
| 169 |
+
}, []);
|
| 170 |
+
|
| 171 |
+
// Trigger auto capture session
|
| 172 |
+
const startAutoCapture = async () => {
|
| 173 |
+
setCapturedImages({});
|
| 174 |
+
setCurrentPoseIndex(0);
|
| 175 |
+
setCountdown(3);
|
| 176 |
+
setIsPaused(false);
|
| 177 |
+
setSingleRetakePose(null);
|
| 178 |
+
setCaptureState("capturing");
|
| 179 |
+
await startWebcam();
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
// Automated capture timer loop
|
| 183 |
+
useEffect(() => {
|
| 184 |
+
if (captureState !== "capturing" || isPaused || !webcamActive) return;
|
| 185 |
+
|
| 186 |
+
const currentKey = POSE_KEYS[currentPoseIndex];
|
| 187 |
+
if (!currentKey) {
|
| 188 |
+
stopWebcam();
|
| 189 |
+
setCaptureState("review");
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if (countdown === 3) {
|
| 194 |
+
speakDirection(POSES[currentKey].speech);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
countdownIntervalRef.current = setInterval(() => {
|
| 198 |
+
if (countdown <= 1) {
|
| 199 |
+
if (countdownIntervalRef.current) {
|
| 200 |
+
clearInterval(countdownIntervalRef.current);
|
| 201 |
+
countdownIntervalRef.current = null;
|
| 202 |
+
}
|
| 203 |
+
captureFrame(currentKey);
|
| 204 |
+
} else {
|
| 205 |
+
playSound("beep");
|
| 206 |
+
setCountdown(countdown - 1);
|
| 207 |
+
}
|
| 208 |
+
}, 1000);
|
| 209 |
+
|
| 210 |
+
return () => {
|
| 211 |
+
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
| 212 |
+
};
|
| 213 |
+
}, [captureState, currentPoseIndex, countdown, isPaused, webcamActive]);
|
| 214 |
+
|
| 215 |
+
// Capture frame
|
| 216 |
+
const captureFrame = (poseKey: string) => {
|
| 217 |
+
if (!videoRef.current || !canvasRef.current) return;
|
| 218 |
+
const video = videoRef.current;
|
| 219 |
+
const canvas = canvasRef.current;
|
| 220 |
+
const ctx = canvas.getContext("2d");
|
| 221 |
+
if (!ctx || video.readyState < 2) return;
|
| 222 |
+
|
| 223 |
+
canvas.width = 640;
|
| 224 |
+
canvas.height = 480;
|
| 225 |
+
|
| 226 |
+
ctx.translate(canvas.width, 0);
|
| 227 |
+
ctx.scale(-1, 1);
|
| 228 |
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 229 |
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 230 |
+
|
| 231 |
+
const base64 = canvas.toDataURL("image/jpeg", 0.90);
|
| 232 |
+
playSound("click");
|
| 233 |
+
|
| 234 |
+
setCapturedImages((prev) => ({ ...prev, [poseKey]: base64 }));
|
| 235 |
+
|
| 236 |
+
if (singleRetakePose) {
|
| 237 |
+
stopWebcam();
|
| 238 |
+
setSingleRetakePose(null);
|
| 239 |
+
setCaptureState("review");
|
| 240 |
+
} else {
|
| 241 |
+
setCurrentPoseIndex((prev) => prev + 1);
|
| 242 |
+
setCountdown(3);
|
| 243 |
+
}
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
const handleRetakeSingle = async (poseKey: string) => {
|
| 247 |
+
setSingleRetakePose(poseKey);
|
| 248 |
+
setCountdown(3);
|
| 249 |
+
setIsPaused(false);
|
| 250 |
+
setCaptureState("capturing");
|
| 251 |
+
|
| 252 |
+
const index = POSE_KEYS.indexOf(poseKey);
|
| 253 |
+
setCurrentPoseIndex(index);
|
| 254 |
+
await startWebcam();
|
| 255 |
+
};
|
| 256 |
+
|
| 257 |
+
const dataURLtoBlob = (dataurl: string) => {
|
| 258 |
+
const arr = dataurl.split(",");
|
| 259 |
+
const mime = arr[0].match(/:(.*?);/)?.[1] || "image/jpeg";
|
| 260 |
+
const bstr = atob(arr[1]);
|
| 261 |
+
let n = bstr.length;
|
| 262 |
+
const u8arr = new Uint8Array(n);
|
| 263 |
+
while (n--) {
|
| 264 |
+
u8arr[n] = bstr.charCodeAt(n);
|
| 265 |
+
}
|
| 266 |
+
return new Blob([u8arr], { type: mime });
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
const saveBiometricProfile = async () => {
|
| 270 |
+
setCaptureState("saving");
|
| 271 |
+
setErrorMsg(null);
|
| 272 |
+
setUploadProgress(0);
|
| 273 |
+
|
| 274 |
+
const keysToUpload = POSE_KEYS;
|
| 275 |
+
let completedCount = 0;
|
| 276 |
+
|
| 277 |
+
for (let i = 0; i < keysToUpload.length; i++) {
|
| 278 |
+
const key = keysToUpload[i];
|
| 279 |
+
const base64 = capturedImages[key];
|
| 280 |
+
if (!base64) continue;
|
| 281 |
+
|
| 282 |
+
setUploadIndex(i + 1);
|
| 283 |
+
|
| 284 |
+
try {
|
| 285 |
+
const blob = dataURLtoBlob(base64);
|
| 286 |
+
|
| 287 |
+
const fd = new FormData();
|
| 288 |
+
fd.append("employee_id", employeeId as string);
|
| 289 |
+
fd.append("pose_type", key);
|
| 290 |
+
fd.append("file", blob, `${key}.jpg`);
|
| 291 |
+
|
| 292 |
+
await fetchApi("/auth/self-onboard/upload", { method: "POST", body: fd });
|
| 293 |
+
completedCount++;
|
| 294 |
+
setUploadProgress((completedCount / keysToUpload.length) * 100);
|
| 295 |
+
} catch (err: any) {
|
| 296 |
+
setErrorMsg(`Failed to save pose '${POSES[key].label}': ${err.message || "Network Error"}.`);
|
| 297 |
+
setCaptureState("review");
|
| 298 |
+
return;
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
await fetchStatus();
|
| 303 |
+
setCaptureState("done");
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
// Manual fallback file upload
|
| 307 |
+
const [selectedPose, setSelectedPose] = useState("front");
|
| 308 |
+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 309 |
+
const file = e.target.files?.[0];
|
| 310 |
+
if (!file) return;
|
| 311 |
+
|
| 312 |
+
setErrorMsg(null);
|
| 313 |
+
setSuccessMsg(null);
|
| 314 |
+
|
| 315 |
+
try {
|
| 316 |
+
const reader = new FileReader();
|
| 317 |
+
reader.onload = (event) => {
|
| 318 |
+
const base64 = event.target?.result as string;
|
| 319 |
+
setCapturedImages(prev => ({ ...prev, [selectedPose]: base64 }));
|
| 320 |
+
if (captureState === "idle") {
|
| 321 |
+
setCaptureState("review");
|
| 322 |
+
}
|
| 323 |
+
};
|
| 324 |
+
reader.readAsDataURL(file);
|
| 325 |
+
} catch (err: any) {
|
| 326 |
+
setErrorMsg(err.message || "Failed to process photo.");
|
| 327 |
+
}
|
| 328 |
+
e.target.value = "";
|
| 329 |
+
};
|
| 330 |
+
|
| 331 |
+
const enrolledCount = enrolledPoses.length;
|
| 332 |
+
const isProfileComplete = enrolledCount >= POSE_KEYS.length;
|
| 333 |
+
|
| 334 |
+
return (
|
| 335 |
+
<div className="min-h-screen bg-slate-950 text-white relative overflow-hidden flex flex-col justify-between">
|
| 336 |
+
<style>{`
|
| 337 |
+
@keyframes scanline {
|
| 338 |
+
0% { top: 0%; opacity: 0; }
|
| 339 |
+
5% { opacity: 1; }
|
| 340 |
+
95% { opacity: 1; }
|
| 341 |
+
100% { top: 100%; opacity: 0; }
|
| 342 |
+
}
|
| 343 |
+
@keyframes pulse-ring {
|
| 344 |
+
0% { transform: scale(0.92); opacity: 0.15; }
|
| 345 |
+
50% { transform: scale(1.08); opacity: 0.5; }
|
| 346 |
+
100% { transform: scale(0.92); opacity: 0.15; }
|
| 347 |
+
}
|
| 348 |
+
.scanner-line {
|
| 349 |
+
position: absolute;
|
| 350 |
+
left: 0;
|
| 351 |
+
right: 0;
|
| 352 |
+
height: 3px;
|
| 353 |
+
background: linear-gradient(to right, transparent, #22d3ee, transparent);
|
| 354 |
+
box-shadow: 0 0 12px #22d3ee, 0 0 24px #0891b2;
|
| 355 |
+
animation: scanline 3s linear infinite;
|
| 356 |
+
z-index: 10;
|
| 357 |
+
pointer-events: none;
|
| 358 |
+
}
|
| 359 |
+
.scanner-target {
|
| 360 |
+
position: absolute;
|
| 361 |
+
width: 65%;
|
| 362 |
+
height: auto;
|
| 363 |
+
aspect-ratio: 1 / 1;
|
| 364 |
+
max-width: 260px;
|
| 365 |
+
max-height: 260px;
|
| 366 |
+
border: 1px dashed rgba(34, 211, 238, 0.4);
|
| 367 |
+
border-radius: 50%;
|
| 368 |
+
animation: pulse-ring 2.5s ease-in-out infinite;
|
| 369 |
+
pointer-events: none;
|
| 370 |
+
display: flex;
|
| 371 |
+
align-items: center;
|
| 372 |
+
justify-content: center;
|
| 373 |
+
}
|
| 374 |
+
.hud-corner {
|
| 375 |
+
position: absolute;
|
| 376 |
+
width: 20px;
|
| 377 |
+
height: 20px;
|
| 378 |
+
border-color: #22d3ee;
|
| 379 |
+
border-width: 2px;
|
| 380 |
+
pointer-events: none;
|
| 381 |
+
}
|
| 382 |
+
`}</style>
|
| 383 |
+
|
| 384 |
+
{/* Background Gradients */}
|
| 385 |
+
<div className="absolute inset-0 bg-slate-950 pointer-events-none z-0" />
|
| 386 |
+
<div className="absolute top-1/4 left-1/4 w-[600px] h-[600px] bg-cyan-500/10 rounded-full blur-[130px] pointer-events-none z-0 animate-pulse" />
|
| 387 |
+
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-blue-500/10 rounded-full blur-[130px] pointer-events-none z-0 animate-pulse" />
|
| 388 |
+
|
| 389 |
+
{/* Header */}
|
| 390 |
+
<header className="w-full h-16 border-b border-slate-900 bg-slate-950/40 backdrop-blur-md px-6 flex items-center justify-between relative z-10">
|
| 391 |
+
<span className="text-sm font-extrabold tracking-tight text-white uppercase font-mono">NetraID Self-Onboarding</span>
|
| 392 |
+
<button
|
| 393 |
+
onClick={() => {
|
| 394 |
+
stopWebcam();
|
| 395 |
+
router.push("/");
|
| 396 |
+
}}
|
| 397 |
+
className="text-xs text-slate-400 hover:text-white flex items-center gap-1 transition-colors cursor-pointer"
|
| 398 |
+
>
|
| 399 |
+
<ChevronLeft className="w-4 h-4" /> Cancel Onboarding
|
| 400 |
+
</button>
|
| 401 |
+
</header>
|
| 402 |
+
|
| 403 |
+
{/* Main Content */}
|
| 404 |
+
<main className="flex-1 flex items-center justify-center p-6 relative z-10">
|
| 405 |
+
<canvas ref={canvasRef} className="hidden" />
|
| 406 |
+
|
| 407 |
+
{captureState === "done" ? (
|
| 408 |
+
/* SUCCESS VIEW */
|
| 409 |
+
<div className="w-full max-w-md bg-slate-900/40 border border-slate-800 backdrop-blur-2xl p-8 rounded-3xl text-center space-y-6 shadow-2xl animate-fadeInUp">
|
| 410 |
+
<div className="w-16 h-16 rounded-full bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-400 mx-auto animate-bounce">
|
| 411 |
+
<CheckCircle2 className="w-8 h-8" />
|
| 412 |
+
</div>
|
| 413 |
+
<div className="space-y-2">
|
| 414 |
+
<h2 className="text-xl font-black text-white">Profile Submitted!</h2>
|
| 415 |
+
<p className="text-slate-400 text-xs leading-relaxed font-light">
|
| 416 |
+
Hello <span className="font-bold text-cyan-400">{employeeName}</span>, your biometric enrollment is complete. Your account is now pending administrator review. Once approved, you can log in to your dashboard.
|
| 417 |
+
</p>
|
| 418 |
+
</div>
|
| 419 |
+
<button
|
| 420 |
+
onClick={() => router.push("/")}
|
| 421 |
+
className="w-full h-11 bg-white hover:bg-slate-100 text-slate-900 font-extrabold text-xs rounded-xl shadow-md transition-all cursor-pointer"
|
| 422 |
+
>
|
| 423 |
+
Return to Login Portal
|
| 424 |
+
</button>
|
| 425 |
+
</div>
|
| 426 |
+
) : captureState === "idle" ? (
|
| 427 |
+
/* IDLE / STARTING SCREEN */
|
| 428 |
+
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
|
| 429 |
+
|
| 430 |
+
{/* Checklist & instructions */}
|
| 431 |
+
<div className="bg-slate-900/40 border border-slate-850 backdrop-blur-xl rounded-2xl p-5 space-y-5">
|
| 432 |
+
<div>
|
| 433 |
+
<span className="text-[10px] font-extrabold tracking-widest text-cyan-400 uppercase">Step 2 of 2</span>
|
| 434 |
+
<h2 className="text-base font-black text-white mt-1">Biometric Scanner</h2>
|
| 435 |
+
<p className="text-[10.5px] text-slate-400 font-light mt-0.5 leading-relaxed">
|
| 436 |
+
Welcome <span className="font-bold text-slate-200">{employeeName}</span>. Capture at least 10 face profiles to register your profile.
|
| 437 |
+
</p>
|
| 438 |
+
</div>
|
| 439 |
+
|
| 440 |
+
<div className="space-y-3.5">
|
| 441 |
+
<div className="flex items-center justify-between text-xs font-mono font-bold text-slate-400">
|
| 442 |
+
<span>Database Status:</span>
|
| 443 |
+
<span className={`inline-flex items-center gap-1.5 ${isProfileComplete ? "text-emerald-400" : "text-amber-400"}`}>
|
| 444 |
+
<span className={`w-1.5 h-1.5 rounded-full ${isProfileComplete ? "bg-emerald-450 animate-pulse" : "bg-amber-400"}`} />
|
| 445 |
+
{isProfileComplete ? "Complete" : "Incomplete"}
|
| 446 |
+
</span>
|
| 447 |
+
</div>
|
| 448 |
+
|
| 449 |
+
<div className="space-y-1.5">
|
| 450 |
+
<div className="flex items-center justify-between text-xs font-mono font-bold text-slate-400">
|
| 451 |
+
<span>Active Vectors:</span>
|
| 452 |
+
<span>{enrolledCount} / {POSE_KEYS.length}</span>
|
| 453 |
+
</div>
|
| 454 |
+
<div className="w-full bg-slate-950 h-2 rounded-full overflow-hidden border border-slate-900">
|
| 455 |
+
<div
|
| 456 |
+
className="bg-cyan-500 h-full rounded-full transition-all duration-500"
|
| 457 |
+
style={{ width: `${(enrolledCount / POSE_KEYS.length) * 100}%` }}
|
| 458 |
+
/>
|
| 459 |
+
</div>
|
| 460 |
+
</div>
|
| 461 |
+
</div>
|
| 462 |
+
|
| 463 |
+
<button
|
| 464 |
+
onClick={startAutoCapture}
|
| 465 |
+
className="w-full h-11 bg-white hover:bg-slate-100 text-slate-900 font-extrabold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2.5 transition-all active:scale-95 cursor-pointer shadow-sm"
|
| 466 |
+
>
|
| 467 |
+
<Camera className="w-4 h-4 text-cyan-500 animate-pulse" />
|
| 468 |
+
Start Auto-Capture Session
|
| 469 |
+
</button>
|
| 470 |
+
|
| 471 |
+
{/* Upload Fallback File Option */}
|
| 472 |
+
<div className="border-t border-slate-900 pt-4 space-y-3">
|
| 473 |
+
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-wider">Manual Photo Upload</h4>
|
| 474 |
+
<div className="flex gap-2">
|
| 475 |
+
<select
|
| 476 |
+
value={selectedPose}
|
| 477 |
+
onChange={(e) => setSelectedPose(e.target.value)}
|
| 478 |
+
className="h-9.5 px-3 text-[11px] font-extrabold bg-slate-900 border border-slate-800 rounded-lg flex-1 outline-none text-slate-300 focus:border-cyan-500 cursor-pointer"
|
| 479 |
+
>
|
| 480 |
+
{POSE_KEYS.map((key) => (
|
| 481 |
+
<option key={key} value={key} className="bg-slate-900">{POSES[key].label}</option>
|
| 482 |
+
))}
|
| 483 |
+
</select>
|
| 484 |
+
<label className="relative cursor-pointer shrink-0">
|
| 485 |
+
<input
|
| 486 |
+
type="file" accept="image/*"
|
| 487 |
+
onChange={handleFileChange}
|
| 488 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 489 |
+
/>
|
| 490 |
+
<div className="h-9.5 px-4 bg-slate-900 hover:bg-slate-850 text-white font-bold text-[11px] border border-slate-800 rounded-lg flex items-center gap-1.5 transition-all shadow-sm cursor-pointer active:scale-95">
|
| 491 |
+
<Upload className="w-3.5 h-3.5" />
|
| 492 |
+
Browse
|
| 493 |
+
</div>
|
| 494 |
+
</label>
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
+
</div>
|
| 498 |
+
|
| 499 |
+
{/* Checklist items list */}
|
| 500 |
+
<div className="md:col-span-2 bg-slate-900/40 border border-slate-850 backdrop-blur-xl rounded-2xl p-6 space-y-4">
|
| 501 |
+
<h3 className="text-xs font-black text-slate-300 uppercase tracking-wider">Facial Pose Checklist</h3>
|
| 502 |
+
|
| 503 |
+
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3.5 pt-2">
|
| 504 |
+
{POSE_KEYS.map((key) => {
|
| 505 |
+
const done = enrolledPoses.includes(key);
|
| 506 |
+
const Icon = POSES[key].icon;
|
| 507 |
+
return (
|
| 508 |
+
<div
|
| 509 |
+
key={key}
|
| 510 |
+
className={`p-4 flex flex-col items-center text-center justify-center transition-all duration-300 select-none cursor-default border rounded-2xl ${
|
| 511 |
+
done
|
| 512 |
+
? "bg-emerald-950/20 border-emerald-500/30 text-emerald-400"
|
| 513 |
+
: "bg-slate-900/20 border-slate-850 hover:border-slate-800"
|
| 514 |
+
}`}
|
| 515 |
+
>
|
| 516 |
+
<div className={`w-9 h-9 rounded-xl flex items-center justify-center mb-2.5 transition-all ${
|
| 517 |
+
done
|
| 518 |
+
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/25"
|
| 519 |
+
: "bg-slate-950 border border-slate-850 text-slate-500"
|
| 520 |
+
}`}>
|
| 521 |
+
<Icon className="w-4.5 h-4.5" />
|
| 522 |
+
</div>
|
| 523 |
+
<span className={`text-[10px] font-bold uppercase tracking-wider font-mono ${done ? "text-emerald-400" : "text-slate-400"}`}>
|
| 524 |
+
{POSES[key].label.replace(" Profile", "").replace(" Face", "").replace(" Option", "").replace(" Light", "")}
|
| 525 |
+
</span>
|
| 526 |
+
{done ? (
|
| 527 |
+
<div className="flex items-center gap-1.5 mt-3 justify-center w-full">
|
| 528 |
+
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
| 529 |
+
</div>
|
| 530 |
+
) : (
|
| 531 |
+
<div className="w-4 h-4 rounded-full border-2 border-slate-800 mt-3 shrink-0 bg-slate-950" />
|
| 532 |
+
)}
|
| 533 |
+
</div>
|
| 534 |
+
);
|
| 535 |
+
})}
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
) : captureState === "capturing" ? (
|
| 540 |
+
/* AUTOMATIC CAMERA SCANNER HUD */
|
| 541 |
+
<div className="flex flex-col items-center space-y-5 w-full max-w-3xl">
|
| 542 |
+
{/* Pose directions banner */}
|
| 543 |
+
<div className="w-full bg-slate-900 text-white rounded-2xl p-5 border border-slate-800 flex items-center justify-between shadow-lg relative overflow-hidden">
|
| 544 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] pointer-events-none" />
|
| 545 |
+
<div className="flex items-center gap-4 relative z-10">
|
| 546 |
+
{(() => {
|
| 547 |
+
const Icon = POSES[POSE_KEYS[currentPoseIndex]]?.icon;
|
| 548 |
+
return (
|
| 549 |
+
<div className="w-12 h-12 rounded-xl bg-white/10 flex items-center justify-center shrink-0 border border-white/10 text-cyan-400">
|
| 550 |
+
{Icon && <Icon className="w-6 h-6" />}
|
| 551 |
+
</div>
|
| 552 |
+
);
|
| 553 |
+
})()}
|
| 554 |
+
<div>
|
| 555 |
+
<p className="text-[10px] font-bold text-cyan-400 font-mono tracking-widest uppercase">
|
| 556 |
+
SCAN PHASE {currentPoseIndex + 1} OF {POSE_KEYS.length}
|
| 557 |
+
</p>
|
| 558 |
+
<h2 className="text-lg font-black tracking-tight text-white mt-0.5">
|
| 559 |
+
{POSES[POSE_KEYS[currentPoseIndex]]?.label}
|
| 560 |
+
</h2>
|
| 561 |
+
<p className="text-xs text-slate-300 font-medium mt-1">
|
| 562 |
+
{POSES[POSE_KEYS[currentPoseIndex]]?.hint}
|
| 563 |
+
</p>
|
| 564 |
+
</div>
|
| 565 |
+
</div>
|
| 566 |
+
|
| 567 |
+
{/* Countdown circle HUD */}
|
| 568 |
+
<div className="relative w-14 h-14 flex items-center justify-center shrink-0 border-2 border-white/10 rounded-full font-mono bg-white/5 shadow-inner">
|
| 569 |
+
<span className="text-2xl font-black text-cyan-400 animate-pulse">{countdown}</span>
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
|
| 573 |
+
{/* Video stream container with HUD overlay */}
|
| 574 |
+
<div className="relative aspect-[3/4] md:aspect-video w-full rounded-3xl overflow-hidden bg-black border border-slate-900 shadow-2xl">
|
| 575 |
+
<video
|
| 576 |
+
ref={videoRef}
|
| 577 |
+
className={`w-full h-full object-cover scale-x-[-1] transition-opacity duration-300 ${isPaused ? "opacity-25" : "opacity-100"}`}
|
| 578 |
+
autoPlay playsInline muted
|
| 579 |
+
/>
|
| 580 |
+
|
| 581 |
+
{isPaused && (
|
| 582 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm z-20">
|
| 583 |
+
<div className="w-16 h-16 rounded-2xl bg-white/10 border border-white/20 flex items-center justify-center shadow-lg text-white mb-3">
|
| 584 |
+
<Pause className="w-8 h-8 fill-current text-cyan-400" />
|
| 585 |
+
</div>
|
| 586 |
+
<p className="text-[11px] font-bold text-cyan-400 font-mono tracking-widest uppercase">
|
| 587 |
+
SYS.STATUS: SCAN_PAUSED
|
| 588 |
+
</p>
|
| 589 |
+
</div>
|
| 590 |
+
)}
|
| 591 |
+
|
| 592 |
+
{/* Cybernetic HUD elements */}
|
| 593 |
+
{!isPaused && <div className="scanner-line" />}
|
| 594 |
+
{!isPaused && (
|
| 595 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
| 596 |
+
<div className="scanner-target">
|
| 597 |
+
<div className="w-4 h-4 border border-cyan-400 rounded-full animate-ping" />
|
| 598 |
+
</div>
|
| 599 |
+
</div>
|
| 600 |
+
)}
|
| 601 |
+
|
| 602 |
+
{/* HUD corners */}
|
| 603 |
+
<div className="hud-corner corner-bracket-tl top-6 left-6 border-t-2 border-l-2" />
|
| 604 |
+
<div className="hud-corner corner-bracket-tr top-6 right-6 border-t-2 border-r-2" />
|
| 605 |
+
<div className="hud-corner corner-bracket-bl bottom-6 left-6 border-b-2 border-l-2" />
|
| 606 |
+
<div className="hud-corner corner-bracket-br bottom-6 right-6 border-b-2 border-r-2" />
|
| 607 |
+
|
| 608 |
+
{/* Scanner stats HUD */}
|
| 609 |
+
<div className="absolute top-6 left-12 right-12 flex justify-between text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none uppercase">
|
| 610 |
+
<span>SYS.STATUS: {isPaused ? "SCAN_PAUSED" : "ACQUIRING_DATA"}</span>
|
| 611 |
+
<span>FPS: {isPaused ? "0" : "60"} Β· ISO: 200 Β· SHUTTER: AUTO</span>
|
| 612 |
+
</div>
|
| 613 |
+
|
| 614 |
+
<div className="absolute bottom-6 left-12 right-12 flex justify-between items-center text-[9px] font-mono font-bold text-cyan-400/80 pointer-events-none">
|
| 615 |
+
<span>ANGLE: {POSE_KEYS[currentPoseIndex]?.toUpperCase()}</span>
|
| 616 |
+
<span>LIVENESS CHECK: ACTIVE</span>
|
| 617 |
+
</div>
|
| 618 |
+
</div>
|
| 619 |
+
|
| 620 |
+
{/* Controls */}
|
| 621 |
+
<div className="flex gap-4 w-full max-w-lg">
|
| 622 |
+
<button
|
| 623 |
+
onClick={async () => {
|
| 624 |
+
if (isPaused) {
|
| 625 |
+
setIsPaused(false);
|
| 626 |
+
await startWebcam();
|
| 627 |
+
} else {
|
| 628 |
+
setIsPaused(true);
|
| 629 |
+
stopWebcam();
|
| 630 |
+
}
|
| 631 |
+
}}
|
| 632 |
+
className="flex-1 h-11 bg-white hover:bg-slate-50 text-slate-900 font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-sm cursor-pointer"
|
| 633 |
+
>
|
| 634 |
+
{isPaused ? <Play className="w-3.5 h-3.5 fill-current" /> : <Pause className="w-3.5 h-3.5 fill-current" />}
|
| 635 |
+
{isPaused ? "Resume Scan" : "Pause Scan"}
|
| 636 |
+
</button>
|
| 637 |
+
|
| 638 |
+
<button
|
| 639 |
+
onClick={async () => {
|
| 640 |
+
if (singleRetakePose) {
|
| 641 |
+
stopWebcam();
|
| 642 |
+
setSingleRetakePose(null);
|
| 643 |
+
setCaptureState("review");
|
| 644 |
+
} else {
|
| 645 |
+
setCurrentPoseIndex(prev => prev + 1);
|
| 646 |
+
setCountdown(3);
|
| 647 |
+
if (isPaused) {
|
| 648 |
+
setIsPaused(false);
|
| 649 |
+
await startWebcam();
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
+
}}
|
| 653 |
+
className="flex-1 h-11 bg-slate-900 hover:bg-slate-855 border border-slate-800 text-white font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2 transition-all shadow-md cursor-pointer"
|
| 654 |
+
>
|
| 655 |
+
Skip Pose
|
| 656 |
+
</button>
|
| 657 |
+
</div>
|
| 658 |
+
</div>
|
| 659 |
+
) : captureState === "review" ? (
|
| 660 |
+
/* REVIEW CAPTURES GRID */
|
| 661 |
+
<div className="space-y-6 w-full max-w-5xl">
|
| 662 |
+
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-5 text-center max-w-2xl mx-auto space-y-2">
|
| 663 |
+
<Sparkles className="w-6 h-6 text-cyan-400 mx-auto animate-pulse" />
|
| 664 |
+
<h2 className="text-base font-black text-white tracking-tight">Scan Sequence Completed</h2>
|
| 665 |
+
<p className="text-xs text-slate-400 max-w-md mx-auto">
|
| 666 |
+
Review the 10 captured biometric pose frames. If any photo is blurry or dark, click the re-take icon. Once ready, click "Save Biometric Profile".
|
| 667 |
+
</p>
|
| 668 |
+
</div>
|
| 669 |
+
|
| 670 |
+
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
| 671 |
+
{POSE_KEYS.map((key) => {
|
| 672 |
+
const imgUrl = capturedImages[key];
|
| 673 |
+
return (
|
| 674 |
+
<div key={key} className="bg-slate-900/30 border border-slate-850 rounded-2xl p-3 shadow-xs relative flex flex-col group overflow-hidden">
|
| 675 |
+
<div className="aspect-[4/3] rounded-xl bg-slate-955 overflow-hidden relative border border-slate-900">
|
| 676 |
+
{imgUrl ? (
|
| 677 |
+
<img src={imgUrl} alt={key} className="w-full h-full object-cover" />
|
| 678 |
+
) : (
|
| 679 |
+
<div className="w-full h-full flex items-center justify-center text-[10px] text-slate-500 font-mono font-bold uppercase tracking-wider bg-slate-955">
|
| 680 |
+
Missing
|
| 681 |
+
</div>
|
| 682 |
+
)}
|
| 683 |
+
|
| 684 |
+
<div className="absolute inset-0 bg-slate-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 685 |
+
<button
|
| 686 |
+
onClick={() => handleRetakeSingle(key)}
|
| 687 |
+
className="bg-white hover:bg-slate-100 text-slate-900 text-[10px] font-bold px-3 py-1.5 rounded-lg flex items-center gap-1.5 shadow-md cursor-pointer"
|
| 688 |
+
>
|
| 689 |
+
<RotateCcw className="w-3 h-3" />
|
| 690 |
+
Re-take
|
| 691 |
+
</button>
|
| 692 |
+
</div>
|
| 693 |
+
</div>
|
| 694 |
+
|
| 695 |
+
<div className="mt-2.5 flex items-center justify-between text-[11px] font-bold">
|
| 696 |
+
<span className="text-slate-350 uppercase font-mono">{POSES[key].label.replace(" Profile", "").replace(" Face", "").replace(" Option", "").replace(" Light", "")}</span>
|
| 697 |
+
{imgUrl ? (
|
| 698 |
+
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-450" />
|
| 699 |
+
) : (
|
| 700 |
+
<XCircle className="w-3.5 h-3.5 text-rose-500" />
|
| 701 |
+
)}
|
| 702 |
+
</div>
|
| 703 |
+
</div>
|
| 704 |
+
);
|
| 705 |
+
})}
|
| 706 |
+
</div>
|
| 707 |
+
|
| 708 |
+
<div className="flex gap-4 max-w-md mx-auto pt-4">
|
| 709 |
+
<button
|
| 710 |
+
onClick={() => setCaptureState("idle")}
|
| 711 |
+
className="flex-1 h-11 border border-slate-800 hover:border-slate-700 bg-slate-900/50 text-slate-300 font-bold text-xs rounded-xl cursor-pointer"
|
| 712 |
+
>
|
| 713 |
+
Cancel
|
| 714 |
+
</button>
|
| 715 |
+
<button
|
| 716 |
+
onClick={saveBiometricProfile}
|
| 717 |
+
className="flex-1 h-11 bg-cyan-500 hover:bg-cyan-600 text-slate-950 font-extrabold text-xs rounded-xl shadow-md transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
| 718 |
+
>
|
| 719 |
+
<CheckCircle2 className="w-4.5 h-4.5" />
|
| 720 |
+
Save Face Profile
|
| 721 |
+
</button>
|
| 722 |
+
</div>
|
| 723 |
+
</div>
|
| 724 |
+
) : (
|
| 725 |
+
/* SAVING PROGRESS OVERLAY */
|
| 726 |
+
<div className="w-full max-w-md bg-slate-900/40 border border-slate-850 p-8 rounded-3xl text-center space-y-6 shadow-2xl">
|
| 727 |
+
<div className="relative w-16 h-16 mx-auto flex items-center justify-center">
|
| 728 |
+
<RefreshCw className="w-8 h-8 text-cyan-400 animate-spin" />
|
| 729 |
+
</div>
|
| 730 |
+
<div className="space-y-2">
|
| 731 |
+
<h3 className="text-base font-black text-white">Indexing Face Vectors</h3>
|
| 732 |
+
<p className="text-xs text-slate-400 font-mono">
|
| 733 |
+
Uploading Pose {uploadIndex} of {POSE_KEYS.length}
|
| 734 |
+
</p>
|
| 735 |
+
<div className="w-full bg-slate-950 h-2.5 rounded-full overflow-hidden border border-slate-900 mt-4">
|
| 736 |
+
<div
|
| 737 |
+
className="bg-cyan-500 h-full transition-all duration-300"
|
| 738 |
+
style={{ width: `${uploadProgress}%` }}
|
| 739 |
+
/>
|
| 740 |
+
</div>
|
| 741 |
+
</div>
|
| 742 |
+
<p className="text-[10px] text-slate-500 font-mono">
|
| 743 |
+
Running deep liveness check & feature alignment...
|
| 744 |
+
</p>
|
| 745 |
+
</div>
|
| 746 |
+
)}
|
| 747 |
+
</main>
|
| 748 |
+
|
| 749 |
+
<footer className="w-full h-12 border-t border-slate-900 bg-slate-950/20 px-6 flex items-center justify-center text-[10px] text-slate-500 font-medium relative z-10">
|
| 750 |
+
Β© {new Date().getFullYear()} NetraID Inc. All rights reserved.
|
| 751 |
+
</footer>
|
| 752 |
+
</div>
|
| 753 |
+
);
|
| 754 |
+
}
|
frontend/app/settings/page.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/app/tenants/page.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/app/tickets/page.tsx
CHANGED
|
@@ -3,11 +3,13 @@
|
|
| 3 |
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
-
import { fetchApi, getUserProfile } from "@/app/utils/api";
|
| 7 |
import { useToast } from "@/app/utils/toast";
|
| 8 |
import {
|
| 9 |
-
MessageSquare, Plus, Send,
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
} from "lucide-react";
|
| 12 |
|
| 13 |
export default function TicketsPage() {
|
|
@@ -18,20 +20,55 @@ export default function TicketsPage() {
|
|
| 18 |
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
| 19 |
const [replyText, setReplyText] = useState("");
|
| 20 |
const [showAddModal, setShowAddModal] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const [title, setTitle] = useState("");
|
| 24 |
-
const [category, setCategory] = useState("
|
|
|
|
| 25 |
const [priority, setPriority] = useState("Medium");
|
| 26 |
const [initialMessage, setInitialMessage] = useState("");
|
| 27 |
|
| 28 |
const chatEndRef = useRef<HTMLDivElement>(null);
|
| 29 |
|
| 30 |
useEffect(() => {
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}, []);
|
| 33 |
|
| 34 |
-
const { data: tickets = [], isLoading } = useQuery({
|
| 35 |
queryKey: ["tickets"],
|
| 36 |
queryFn: async () => {
|
| 37 |
return await fetchApi("/tickets/");
|
|
@@ -49,7 +86,6 @@ export default function TicketsPage() {
|
|
| 49 |
})
|
| 50 |
});
|
| 51 |
|
| 52 |
-
// Post the initial message if provided
|
| 53 |
if (payload.message && payload.message.trim().length > 0) {
|
| 54 |
await fetchApi(`/tickets/${ticket.id}/messages`, {
|
| 55 |
method: "POST",
|
|
@@ -60,10 +96,11 @@ export default function TicketsPage() {
|
|
| 60 |
},
|
| 61 |
onSuccess: (data) => {
|
| 62 |
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 63 |
-
toast.success("Support ticket opened");
|
| 64 |
setShowAddModal(false);
|
| 65 |
setTitle("");
|
| 66 |
setInitialMessage("");
|
|
|
|
| 67 |
setSelectedTicket(data);
|
| 68 |
},
|
| 69 |
onError: (err: any) => {
|
|
@@ -81,9 +118,13 @@ export default function TicketsPage() {
|
|
| 81 |
onSuccess: () => {
|
| 82 |
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 83 |
setReplyText("");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
},
|
| 85 |
onError: (err: any) => {
|
| 86 |
-
toast.error(err.message || "Failed to send
|
| 87 |
}
|
| 88 |
});
|
| 89 |
|
|
@@ -97,7 +138,7 @@ export default function TicketsPage() {
|
|
| 97 |
onSuccess: (data) => {
|
| 98 |
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 99 |
setSelectedTicket(data);
|
| 100 |
-
toast.success(`Ticket status
|
| 101 |
},
|
| 102 |
onError: (err: any) => {
|
| 103 |
toast.error(err.message || "Failed to update ticket status");
|
|
@@ -107,15 +148,70 @@ export default function TicketsPage() {
|
|
| 107 |
// Keep chat scrolled to bottom
|
| 108 |
useEffect(() => {
|
| 109 |
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 110 |
-
}, [selectedTicket?.messages]);
|
| 111 |
|
| 112 |
-
// Sync selected ticket details after query updates
|
| 113 |
useEffect(() => {
|
| 114 |
if (selectedTicket) {
|
| 115 |
const updated = tickets.find((t: any) => t.id === selectedTicket.id);
|
| 116 |
if (updated) setSelectedTicket(updated);
|
| 117 |
}
|
| 118 |
-
}, [tickets, selectedTicket]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
const handleSendReply = (e: React.FormEvent) => {
|
| 121 |
e.preventDefault();
|
|
@@ -126,265 +222,819 @@ export default function TicketsPage() {
|
|
| 126 |
const handleCreateSubmit = (e: React.FormEvent) => {
|
| 127 |
e.preventDefault();
|
| 128 |
if (!title.trim() || !initialMessage.trim()) return;
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
};
|
| 131 |
|
| 132 |
const isHR = profile?.role?.name === "Admin" || profile?.role?.name === "HR" || profile?.role?.name === "Super Admin";
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
return (
|
| 135 |
<SidebarLayout>
|
| 136 |
-
<div className="
|
| 137 |
|
| 138 |
-
{/*
|
| 139 |
-
<div className="
|
| 140 |
-
<div className="
|
| 141 |
-
<h1 className="text-
|
| 142 |
-
<
|
| 143 |
-
|
|
|
|
|
|
|
| 144 |
</h1>
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
className="p-1.5 bg-slate-900 hover:bg-slate-800 text-white rounded-lg cursor-pointer active:scale-95 transition-all"
|
| 149 |
-
title="Create Ticket"
|
| 150 |
-
>
|
| 151 |
-
<Plus className="w-3.5 h-3.5" />
|
| 152 |
-
</button>
|
| 153 |
-
)}
|
| 154 |
</div>
|
| 155 |
|
| 156 |
-
<div className="flex
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
>
|
| 175 |
-
<div className="flex items-start justify-between w-full">
|
| 176 |
-
<span className="text-[9px] font-bold uppercase tracking-wider text-slate-400">{t.category}</span>
|
| 177 |
-
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded border ${
|
| 178 |
-
t.priority === "High"
|
| 179 |
-
? "bg-rose-50 border-rose-100 text-rose-600"
|
| 180 |
-
: t.priority === "Medium"
|
| 181 |
-
? "bg-amber-50 border-amber-100 text-amber-600"
|
| 182 |
-
: "bg-slate-50 border-slate-150 text-slate-600"
|
| 183 |
-
}`}>
|
| 184 |
-
{t.priority}
|
| 185 |
-
</span>
|
| 186 |
-
</div>
|
| 187 |
-
<div>
|
| 188 |
-
<h3 className="text-xs font-bold text-slate-800 truncate">{t.title}</h3>
|
| 189 |
-
{isHR && t.employee && (
|
| 190 |
-
<p className="text-[10px] text-slate-450 mt-0.5">By: {t.employee.name}</p>
|
| 191 |
-
)}
|
| 192 |
-
</div>
|
| 193 |
-
<div className="flex items-center gap-1.5 mt-1">
|
| 194 |
-
<span className={`w-1.5 h-1.5 rounded-full ${
|
| 195 |
-
t.status === "Closed" ? "bg-emerald-500" : t.status === "In Progress" ? "bg-amber-500" : "bg-blue-500"
|
| 196 |
-
}`} />
|
| 197 |
-
<span className="text-[9px] font-bold text-slate-450 uppercase">{t.status}</span>
|
| 198 |
-
</div>
|
| 199 |
-
</button>
|
| 200 |
-
))
|
| 201 |
-
) : (
|
| 202 |
-
<div className="py-12 text-center text-slate-400 text-xs font-medium">
|
| 203 |
-
No support tickets found.
|
| 204 |
-
</div>
|
| 205 |
-
)}
|
| 206 |
</div>
|
| 207 |
</div>
|
| 208 |
|
| 209 |
-
{/*
|
| 210 |
-
<div className="
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
-
{/*
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
</div>
|
| 237 |
-
) : (
|
| 238 |
-
<span className={`text-[9px] font-mono font-bold px-2 py-0.5 border rounded-full ${
|
| 239 |
-
selectedTicket.status === "Closed"
|
| 240 |
-
? "bg-emerald-50 border-emerald-150 text-emerald-700"
|
| 241 |
-
: selectedTicket.status === "In Progress"
|
| 242 |
-
? "bg-amber-50 border-amber-150 text-amber-700"
|
| 243 |
-
: "bg-blue-50 border-blue-150 text-blue-700"
|
| 244 |
-
}`}>
|
| 245 |
-
{selectedTicket.status}
|
| 246 |
-
</span>
|
| 247 |
-
)}
|
| 248 |
-
</div>
|
| 249 |
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
</div>
|
| 259 |
-
<
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
</div>
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
</p>
|
| 270 |
</div>
|
| 271 |
</div>
|
| 272 |
-
);
|
| 273 |
-
})}
|
| 274 |
-
<div ref={chatEndRef} />
|
| 275 |
-
</div>
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
</div>
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
| 308 |
</div>
|
|
|
|
| 309 |
</div>
|
| 310 |
|
| 311 |
-
{/*
|
| 312 |
{showAddModal && (
|
| 313 |
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/
|
| 314 |
-
<div className="bg-white border border-
|
| 315 |
-
<
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
<div className="space-y-1">
|
| 318 |
-
<label className="text-[
|
|
|
|
|
|
|
| 319 |
<input
|
| 320 |
type="text"
|
| 321 |
required
|
| 322 |
-
placeholder="e.g.
|
| 323 |
value={title}
|
| 324 |
onChange={(e) => setTitle(e.target.value)}
|
| 325 |
-
className="w-full text-xs h-9 px-3 rounded-
|
| 326 |
/>
|
| 327 |
</div>
|
|
|
|
| 328 |
<div className="grid grid-cols-2 gap-3">
|
|
|
|
| 329 |
<div className="space-y-1">
|
| 330 |
-
<label className="text-[
|
|
|
|
|
|
|
| 331 |
<select
|
| 332 |
value={category}
|
| 333 |
onChange={(e) => setCategory(e.target.value)}
|
| 334 |
-
className="w-full text-xs h-9 px-3 rounded-
|
| 335 |
>
|
| 336 |
-
<option value="
|
| 337 |
-
<option value="
|
| 338 |
-
<option value="
|
| 339 |
-
<option value="
|
| 340 |
-
<option value="
|
|
|
|
| 341 |
</select>
|
| 342 |
</div>
|
|
|
|
| 343 |
<div className="space-y-1">
|
| 344 |
-
<label className="text-[
|
|
|
|
|
|
|
| 345 |
<select
|
| 346 |
value={priority}
|
| 347 |
onChange={(e) => setPriority(e.target.value)}
|
| 348 |
-
className="w-full text-xs h-9 px-3 rounded-
|
| 349 |
>
|
| 350 |
-
<option value="Low">Low</option>
|
| 351 |
-
<option value="Medium">Medium</option>
|
| 352 |
-
<option value="High">High</option>
|
|
|
|
| 353 |
</select>
|
| 354 |
</div>
|
|
|
|
| 355 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
<div className="space-y-1">
|
| 357 |
-
<label className="text-[
|
|
|
|
|
|
|
| 358 |
<textarea
|
| 359 |
required
|
| 360 |
rows={4}
|
| 361 |
-
placeholder="
|
| 362 |
value={initialMessage}
|
| 363 |
onChange={(e) => setInitialMessage(e.target.value)}
|
| 364 |
-
className="w-full text-xs p-3 rounded-
|
| 365 |
/>
|
| 366 |
</div>
|
| 367 |
-
|
|
|
|
| 368 |
<button
|
| 369 |
type="button"
|
| 370 |
onClick={() => setShowAddModal(false)}
|
| 371 |
-
className="px-
|
| 372 |
>
|
| 373 |
Cancel
|
| 374 |
</button>
|
| 375 |
<button
|
| 376 |
type="submit"
|
| 377 |
disabled={createTicketMutation.isPending}
|
| 378 |
-
className="px-
|
| 379 |
>
|
| 380 |
-
{createTicketMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
| 381 |
-
|
| 382 |
</button>
|
| 383 |
</div>
|
|
|
|
| 384 |
</form>
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
)}
|
|
|
|
| 388 |
</SidebarLayout>
|
| 389 |
);
|
| 390 |
}
|
|
|
|
| 3 |
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
+
import { fetchApi, getUserProfile, getAccessToken, getBackendUrl } from "@/app/utils/api";
|
| 7 |
import { useToast } from "@/app/utils/toast";
|
| 8 |
import {
|
| 9 |
+
MessageSquare, Plus, Send, CheckCircle2, Clock,
|
| 10 |
+
AlertCircle, Filter, Search, ChevronRight, User, Loader2,
|
| 11 |
+
CheckCircle, Shield, Sparkles, Folder, RefreshCw, Lock, HelpCircle,
|
| 12 |
+
Building2, Briefcase, FileText, Check, CheckCheck, Info, Paperclip, MoreVertical, X, ArrowLeft
|
| 13 |
} from "lucide-react";
|
| 14 |
|
| 15 |
export default function TicketsPage() {
|
|
|
|
| 20 |
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
| 21 |
const [replyText, setReplyText] = useState("");
|
| 22 |
const [showAddModal, setShowAddModal] = useState(false);
|
| 23 |
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
| 24 |
+
|
| 25 |
+
// Real-time Chat Connection & Presence States
|
| 26 |
+
const [sseConnected, setSseConnected] = useState(false);
|
| 27 |
+
const [presenceStatus, setPresenceStatus] = useState<"online" | "offline">("online");
|
| 28 |
+
|
| 29 |
+
// WhatsApp-style UI Enhancements States
|
| 30 |
+
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
| 31 |
+
const [isTyping, setIsTyping] = useState(false);
|
| 32 |
+
const [chatSearchQuery, setChatSearchQuery] = useState("");
|
| 33 |
+
const [showChatSearch, setShowChatSearch] = useState(false);
|
| 34 |
+
|
| 35 |
+
const handleRefreshTickets = async () => {
|
| 36 |
+
setIsRefreshing(true);
|
| 37 |
+
try {
|
| 38 |
+
await queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 39 |
+
await refetch();
|
| 40 |
+
} catch (e) {
|
| 41 |
+
console.error(e);
|
| 42 |
+
} finally {
|
| 43 |
+
setTimeout(() => setIsRefreshing(false), 800);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
|
| 47 |
+
// Filtering & Search states
|
| 48 |
+
const [statusTab, setStatusTab] = useState<"ALL" | "UNRESOLVED" | "IN_PROGRESS" | "RESOLVED">("ALL");
|
| 49 |
+
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
|
| 50 |
+
const [selectedPriority, setSelectedPriority] = useState<string>("ALL");
|
| 51 |
+
const [searchQuery, setSearchQuery] = useState<string>("");
|
| 52 |
+
|
| 53 |
+
// Role-aware ticket creation states
|
| 54 |
+
const [orgName, setOrgName] = useState("");
|
| 55 |
const [title, setTitle] = useState("");
|
| 56 |
+
const [category, setCategory] = useState("System Bug / Error");
|
| 57 |
+
const [customCategory, setCustomCategory] = useState("");
|
| 58 |
const [priority, setPriority] = useState("Medium");
|
| 59 |
const [initialMessage, setInitialMessage] = useState("");
|
| 60 |
|
| 61 |
const chatEndRef = useRef<HTMLDivElement>(null);
|
| 62 |
|
| 63 |
useEffect(() => {
|
| 64 |
+
const usr = getUserProfile();
|
| 65 |
+
setProfile(usr);
|
| 66 |
+
if (usr?.company?.name) {
|
| 67 |
+
setOrgName(usr.company.name);
|
| 68 |
+
}
|
| 69 |
}, []);
|
| 70 |
|
| 71 |
+
const { data: tickets = [], isLoading, isRefetching, refetch } = useQuery({
|
| 72 |
queryKey: ["tickets"],
|
| 73 |
queryFn: async () => {
|
| 74 |
return await fetchApi("/tickets/");
|
|
|
|
| 86 |
})
|
| 87 |
});
|
| 88 |
|
|
|
|
| 89 |
if (payload.message && payload.message.trim().length > 0) {
|
| 90 |
await fetchApi(`/tickets/${ticket.id}/messages`, {
|
| 91 |
method: "POST",
|
|
|
|
| 96 |
},
|
| 97 |
onSuccess: (data) => {
|
| 98 |
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 99 |
+
toast.success("Support ticket opened successfully");
|
| 100 |
setShowAddModal(false);
|
| 101 |
setTitle("");
|
| 102 |
setInitialMessage("");
|
| 103 |
+
setCustomCategory("");
|
| 104 |
setSelectedTicket(data);
|
| 105 |
},
|
| 106 |
onError: (err: any) => {
|
|
|
|
| 118 |
onSuccess: () => {
|
| 119 |
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 120 |
setReplyText("");
|
| 121 |
+
setIsTyping(true);
|
| 122 |
+
setTimeout(() => {
|
| 123 |
+
setIsTyping(false);
|
| 124 |
+
}, 2500);
|
| 125 |
},
|
| 126 |
onError: (err: any) => {
|
| 127 |
+
toast.error(err.message || "Failed to send reply");
|
| 128 |
}
|
| 129 |
});
|
| 130 |
|
|
|
|
| 138 |
onSuccess: (data) => {
|
| 139 |
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 140 |
setSelectedTicket(data);
|
| 141 |
+
toast.success(`Ticket status updated to ${data.status}`);
|
| 142 |
},
|
| 143 |
onError: (err: any) => {
|
| 144 |
toast.error(err.message || "Failed to update ticket status");
|
|
|
|
| 148 |
// Keep chat scrolled to bottom
|
| 149 |
useEffect(() => {
|
| 150 |
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 151 |
+
}, [selectedTicket?.messages, isTyping]);
|
| 152 |
|
| 153 |
+
// Sync selected ticket details after query updates - FIXED DEPENDENCY ARRAY SIZE CHANGES
|
| 154 |
useEffect(() => {
|
| 155 |
if (selectedTicket) {
|
| 156 |
const updated = tickets.find((t: any) => t.id === selectedTicket.id);
|
| 157 |
if (updated) setSelectedTicket(updated);
|
| 158 |
}
|
| 159 |
+
}, [tickets, selectedTicket?.id]);
|
| 160 |
+
|
| 161 |
+
// Live SSE real-time messaging stream subscription
|
| 162 |
+
useEffect(() => {
|
| 163 |
+
if (!selectedTicket?.id) {
|
| 164 |
+
setSseConnected(false);
|
| 165 |
+
return;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const token = getAccessToken();
|
| 169 |
+
const backendUrl = getBackendUrl();
|
| 170 |
+
const streamUrl = `${backendUrl}/tickets/${selectedTicket.id}/stream?token=${token}`;
|
| 171 |
+
|
| 172 |
+
const eventSource = new EventSource(streamUrl);
|
| 173 |
+
setSseConnected(true);
|
| 174 |
+
|
| 175 |
+
eventSource.onopen = () => {
|
| 176 |
+
setSseConnected(true);
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
eventSource.onmessage = (event) => {
|
| 180 |
+
try {
|
| 181 |
+
setSseConnected(true);
|
| 182 |
+
const newMsg = JSON.parse(event.data);
|
| 183 |
+
setIsTyping(false);
|
| 184 |
+
|
| 185 |
+
setSelectedTicket((prev: any) => {
|
| 186 |
+
if (!prev || prev.id !== newMsg.ticket_id) return prev;
|
| 187 |
+
|
| 188 |
+
const alreadyExists = prev.messages?.some((m: any) => m.id === newMsg.id);
|
| 189 |
+
if (alreadyExists) return prev;
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
...prev,
|
| 193 |
+
messages: [...(prev.messages || []), newMsg]
|
| 194 |
+
};
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
// Trigger cache update
|
| 198 |
+
queryClient.invalidateQueries({ queryKey: ["tickets"] });
|
| 199 |
+
} catch (err) {
|
| 200 |
+
console.error("SSE message parse error:", err);
|
| 201 |
+
}
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
eventSource.onerror = (err) => {
|
| 205 |
+
console.error("SSE connection closed or error encountered:", err);
|
| 206 |
+
setSseConnected(false);
|
| 207 |
+
eventSource.close();
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
return () => {
|
| 211 |
+
eventSource.close();
|
| 212 |
+
setSseConnected(false);
|
| 213 |
+
};
|
| 214 |
+
}, [selectedTicket?.id]);
|
| 215 |
|
| 216 |
const handleSendReply = (e: React.FormEvent) => {
|
| 217 |
e.preventDefault();
|
|
|
|
| 222 |
const handleCreateSubmit = (e: React.FormEvent) => {
|
| 223 |
e.preventDefault();
|
| 224 |
if (!title.trim() || !initialMessage.trim()) return;
|
| 225 |
+
|
| 226 |
+
const finalCategory = category === "Other" && customCategory.trim()
|
| 227 |
+
? customCategory.trim()
|
| 228 |
+
: category;
|
| 229 |
+
|
| 230 |
+
const formattedTitle = orgName.trim()
|
| 231 |
+
? `[${orgName.trim()}] ${title.trim()}`
|
| 232 |
+
: title.trim();
|
| 233 |
+
|
| 234 |
+
createTicketMutation.mutate({
|
| 235 |
+
title: formattedTitle,
|
| 236 |
+
category: finalCategory,
|
| 237 |
+
priority,
|
| 238 |
+
message: initialMessage
|
| 239 |
+
});
|
| 240 |
};
|
| 241 |
|
| 242 |
const isHR = profile?.role?.name === "Admin" || profile?.role?.name === "HR" || profile?.role?.name === "Super Admin";
|
| 243 |
|
| 244 |
+
// Calculate ticket count metrics
|
| 245 |
+
const totalCount = tickets.length;
|
| 246 |
+
const unresolvedCount = tickets.filter((t: any) => t.status === "Open").length;
|
| 247 |
+
const inProgressCount = tickets.filter((t: any) => t.status === "In Progress").length;
|
| 248 |
+
const resolvedCount = tickets.filter((t: any) => t.status === "Closed").length;
|
| 249 |
+
|
| 250 |
+
// Filter tickets according to selected tab and criteria
|
| 251 |
+
const filteredTickets = tickets.filter((t: any) => {
|
| 252 |
+
// 1. Status Filter Tab
|
| 253 |
+
if (statusTab === "UNRESOLVED" && t.status !== "Open") return false;
|
| 254 |
+
if (statusTab === "IN_PROGRESS" && t.status !== "In Progress") return false;
|
| 255 |
+
if (statusTab === "RESOLVED" && t.status !== "Closed") return false;
|
| 256 |
+
|
| 257 |
+
// 2. Category Filter
|
| 258 |
+
if (selectedCategory !== "ALL" && t.category !== selectedCategory) return false;
|
| 259 |
+
|
| 260 |
+
// 3. Priority Filter
|
| 261 |
+
if (selectedPriority !== "ALL" && t.priority !== selectedPriority) return false;
|
| 262 |
+
|
| 263 |
+
// 4. Search Text Query
|
| 264 |
+
if (searchQuery.trim().length > 0) {
|
| 265 |
+
const q = searchQuery.toLowerCase();
|
| 266 |
+
const matchTitle = t.title?.toLowerCase().includes(q);
|
| 267 |
+
const matchCategory = t.category?.toLowerCase().includes(q);
|
| 268 |
+
const matchEmp = t.employee?.name?.toLowerCase().includes(q);
|
| 269 |
+
const matchId = t.id?.toString().includes(q);
|
| 270 |
+
if (!matchTitle && !matchCategory && !matchEmp && !matchId) return false;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
return true;
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
// Filter individual messages within the active chat (WhatsApp Search Messages feature)
|
| 277 |
+
const filteredMessages = selectedTicket?.messages?.filter((m: any) => {
|
| 278 |
+
if (!chatSearchQuery.trim()) return true;
|
| 279 |
+
return m.message.toLowerCase().includes(chatSearchQuery.toLowerCase());
|
| 280 |
+
}) || [];
|
| 281 |
+
|
| 282 |
+
const getStatusBadge = (statusStr: string) => {
|
| 283 |
+
switch (statusStr) {
|
| 284 |
+
case "Closed":
|
| 285 |
+
return (
|
| 286 |
+
<span className="inline-flex items-center gap-1 text-[9px] font-bold px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/40">
|
| 287 |
+
<CheckCircle2 className="w-3 h-3 text-emerald-500" /> Resolved
|
| 288 |
+
</span>
|
| 289 |
+
);
|
| 290 |
+
case "In Progress":
|
| 291 |
+
return (
|
| 292 |
+
<span className="inline-flex items-center gap-1 text-[9px] font-bold px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-950/50 dark:text-amber-400 border border-amber-200 dark:border-amber-800/40">
|
| 293 |
+
<Clock className="w-3 h-3 text-amber-500 animate-pulse" /> In Progress
|
| 294 |
+
</span>
|
| 295 |
+
);
|
| 296 |
+
default:
|
| 297 |
+
return (
|
| 298 |
+
<span className="inline-flex items-center gap-1 text-[9px] font-bold px-2 py-0.5 rounded-full bg-sky-50 text-sky-700 dark:bg-sky-950/50 dark:text-sky-400 border border-sky-200 dark:border-sky-800/40">
|
| 299 |
+
<AlertCircle className="w-3 h-3 text-sky-500" /> Open
|
| 300 |
+
</span>
|
| 301 |
+
);
|
| 302 |
+
}
|
| 303 |
+
};
|
| 304 |
+
|
| 305 |
+
const getPriorityBadge = (priorityStr: string) => {
|
| 306 |
+
switch (priorityStr) {
|
| 307 |
+
case "High":
|
| 308 |
+
return (
|
| 309 |
+
<span className="text-[8px] font-bold px-1 py-0.5 rounded uppercase tracking-wider bg-rose-50 text-rose-600 dark:bg-rose-950/60 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40">
|
| 310 |
+
High
|
| 311 |
+
</span>
|
| 312 |
+
);
|
| 313 |
+
case "Medium":
|
| 314 |
+
return (
|
| 315 |
+
<span className="text-[8px] font-bold px-1 py-0.5 rounded uppercase tracking-wider bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400 border border-amber-200 dark:border-amber-800/40">
|
| 316 |
+
Med
|
| 317 |
+
</span>
|
| 318 |
+
);
|
| 319 |
+
default:
|
| 320 |
+
return (
|
| 321 |
+
<span className="text-[8px] font-bold px-1 py-0.5 rounded uppercase tracking-wider bg-zinc-100 text-zinc-650 dark:bg-zinc-800 dark:text-zinc-400 border border-zinc-205 dark:border-zinc-700">
|
| 322 |
+
Low
|
| 323 |
+
</span>
|
| 324 |
+
);
|
| 325 |
+
}
|
| 326 |
+
};
|
| 327 |
+
|
| 328 |
+
const renderMessageTicks = (senderId: number) => {
|
| 329 |
+
const isMe = senderId === profile?.id;
|
| 330 |
+
if (!isMe) return null;
|
| 331 |
+
|
| 332 |
+
if (selectedTicket?.status === "Closed") {
|
| 333 |
+
return <CheckCheck className="w-3.5 h-3.5 text-sky-500 stroke-[2.5]" />;
|
| 334 |
+
} else if (selectedTicket?.status === "In Progress") {
|
| 335 |
+
return <CheckCheck className="w-3.5 h-3.5 text-zinc-400 dark:text-zinc-500 stroke-[2]" />;
|
| 336 |
+
} else {
|
| 337 |
+
return <Check className="w-3.5 h-3.5 text-zinc-400 stroke-[2]" />;
|
| 338 |
+
}
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
return (
|
| 342 |
<SidebarLayout>
|
| 343 |
+
<div className="space-y-4">
|
| 344 |
|
| 345 |
+
{/* Top Header Block & Overview Counters */}
|
| 346 |
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-2">
|
| 347 |
+
<div className="space-y-1">
|
| 348 |
+
<h1 className="text-xl font-extrabold text-zinc-900 dark:text-zinc-100 tracking-tight flex items-center gap-2">
|
| 349 |
+
<div className="p-2 rounded-xl bg-zinc-100 dark:bg-zinc-850 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-800">
|
| 350 |
+
<MessageSquare className="w-5 h-5 text-cyan-500" />
|
| 351 |
+
</div>
|
| 352 |
+
Helpdesk & Support Desk
|
| 353 |
</h1>
|
| 354 |
+
<p className="text-zinc-500 dark:text-zinc-400 text-xs">
|
| 355 |
+
Manage categorized support tickets, employee inquiries, and resolution workflows
|
| 356 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
</div>
|
| 358 |
|
| 359 |
+
<div className="flex items-center gap-2">
|
| 360 |
+
<button
|
| 361 |
+
onClick={handleRefreshTickets}
|
| 362 |
+
disabled={isRefreshing}
|
| 363 |
+
className="p-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-655 dark:text-zinc-300 rounded-xl cursor-pointer transition-all active:scale-95 disabled:opacity-70"
|
| 364 |
+
title="Refresh Tickets"
|
| 365 |
+
>
|
| 366 |
+
<RefreshCw
|
| 367 |
+
className={`w-4 h-4 text-zinc-500 dark:text-zinc-400 inline-block ${isRefreshing ? "animate-spin" : ""}`}
|
| 368 |
+
/>
|
| 369 |
+
</button>
|
| 370 |
+
<button
|
| 371 |
+
onClick={() => setShowAddModal(true)}
|
| 372 |
+
className="px-4 py-2 bg-zinc-950 hover:bg-zinc-900 text-white dark:bg-zinc-100 dark:hover:bg-white dark:text-zinc-950 font-bold text-xs rounded-xl flex items-center gap-1.5 cursor-pointer active:scale-95 transition-all"
|
| 373 |
+
>
|
| 374 |
+
<Plus className="w-4 h-4 stroke-[2.5]" />
|
| 375 |
+
New Support Ticket
|
| 376 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
</div>
|
| 378 |
</div>
|
| 379 |
|
| 380 |
+
{/* Categorization Counter Cards */}
|
| 381 |
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
| 382 |
+
|
| 383 |
+
<button
|
| 384 |
+
onClick={() => setStatusTab("ALL")}
|
| 385 |
+
className={`p-3.5 rounded-xl text-left cursor-pointer transition-none ${
|
| 386 |
+
statusTab === "ALL"
|
| 387 |
+
? "tech-card-3d-minimal font-extrabold"
|
| 388 |
+
: "border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"
|
| 389 |
+
}`}
|
| 390 |
+
>
|
| 391 |
+
<div className={`flex justify-between items-center mb-1 ${statusTab === "ALL" ? "text-zinc-900 dark:text-zinc-100 font-bold" : "text-zinc-400 dark:text-zinc-500"}`}>
|
| 392 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">All Tickets</span>
|
| 393 |
+
<Folder className="w-3.5 h-3.5" />
|
| 394 |
+
</div>
|
| 395 |
+
<p className={`text-2xl font-black tracking-tight ${statusTab === "ALL" ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-650 dark:text-zinc-455"}`}>
|
| 396 |
+
{totalCount}
|
| 397 |
+
</p>
|
| 398 |
+
</button>
|
| 399 |
+
|
| 400 |
+
<button
|
| 401 |
+
onClick={() => setStatusTab("UNRESOLVED")}
|
| 402 |
+
className={`p-3.5 rounded-xl text-left cursor-pointer transition-none ${
|
| 403 |
+
statusTab === "UNRESOLVED"
|
| 404 |
+
? "tech-card-3d-minimal font-extrabold"
|
| 405 |
+
: "border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"
|
| 406 |
+
}`}
|
| 407 |
+
>
|
| 408 |
+
<div className={`flex justify-between items-center mb-1 ${statusTab === "UNRESOLVED" ? "text-zinc-900 dark:text-zinc-100 font-bold" : "text-zinc-400 dark:text-zinc-500"}`}>
|
| 409 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">Unresolved / Open</span>
|
| 410 |
+
<AlertCircle className="w-3.5 h-3.5" />
|
| 411 |
+
</div>
|
| 412 |
+
<p className={`text-2xl font-black tracking-tight ${statusTab === "UNRESOLVED" ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-650 dark:text-zinc-455"}`}>
|
| 413 |
+
{unresolvedCount}
|
| 414 |
+
</p>
|
| 415 |
+
</button>
|
| 416 |
+
|
| 417 |
+
<button
|
| 418 |
+
onClick={() => setStatusTab("IN_PROGRESS")}
|
| 419 |
+
className={`p-3.5 rounded-xl text-left cursor-pointer transition-none ${
|
| 420 |
+
statusTab === "IN_PROGRESS"
|
| 421 |
+
? "tech-card-3d-minimal font-extrabold"
|
| 422 |
+
: "border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"
|
| 423 |
+
}`}
|
| 424 |
+
>
|
| 425 |
+
<div className={`flex justify-between items-center mb-1 ${statusTab === "IN_PROGRESS" ? "text-zinc-900 dark:text-zinc-100 font-bold" : "text-zinc-400 dark:text-zinc-500"}`}>
|
| 426 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">In Progress</span>
|
| 427 |
+
<Clock className="w-3.5 h-3.5" />
|
| 428 |
+
</div>
|
| 429 |
+
<p className={`text-2xl font-black tracking-tight ${statusTab === "IN_PROGRESS" ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-650 dark:text-zinc-455"}`}>
|
| 430 |
+
{inProgressCount}
|
| 431 |
+
</p>
|
| 432 |
+
</button>
|
| 433 |
+
|
| 434 |
+
<button
|
| 435 |
+
onClick={() => setStatusTab("RESOLVED")}
|
| 436 |
+
className={`p-3.5 rounded-xl text-left cursor-pointer transition-none ${
|
| 437 |
+
statusTab === "RESOLVED"
|
| 438 |
+
? "tech-card-3d-minimal font-extrabold"
|
| 439 |
+
: "border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"
|
| 440 |
+
}`}
|
| 441 |
+
>
|
| 442 |
+
<div className={`flex justify-between items-center mb-1 ${statusTab === "RESOLVED" ? "text-zinc-900 dark:text-zinc-100 font-bold" : "text-zinc-400 dark:text-zinc-500"}`}>
|
| 443 |
+
<span className="text-[10px] font-extrabold uppercase tracking-wider">Resolved / Closed</span>
|
| 444 |
+
<CheckCircle2 className="w-3.5 h-3.5" />
|
| 445 |
+
</div>
|
| 446 |
+
<p className={`text-2xl font-black tracking-tight ${statusTab === "RESOLVED" ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-650 dark:text-zinc-450"}`}>
|
| 447 |
+
{resolvedCount}
|
| 448 |
+
</p>
|
| 449 |
+
</button>
|
| 450 |
+
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
{/* Ticket Management Main Container - Split-pane view responsive control */}
|
| 454 |
+
<div className="h-[calc(100vh-14.5rem)] min-h-[500px] flex flex-col md:flex-row tech-card-3d-minimal overflow-hidden shadow-none">
|
| 455 |
+
|
| 456 |
+
{/* Left Panel: Ticket Directory & Categorised List (Collapsible on mobile when chat is active) */}
|
| 457 |
+
<div className={`w-full md:w-80 border-r border-zinc-200 dark:border-zinc-800 flex flex-col shrink-0 bg-white dark:bg-zinc-900 ${
|
| 458 |
+
selectedTicket ? "hidden md:flex" : "flex"
|
| 459 |
+
}`}>
|
| 460 |
+
|
| 461 |
+
{/* Filtering & Search Bar */}
|
| 462 |
+
<div className="p-3 border-b border-zinc-200 dark:border-zinc-800 space-y-2.5 bg-white dark:bg-zinc-900">
|
| 463 |
+
|
| 464 |
+
{/* Text Search Input */}
|
| 465 |
+
<div className="relative">
|
| 466 |
+
<Search className="w-3.5 h-3.5 absolute left-3 top-3 text-zinc-400" />
|
| 467 |
+
<input
|
| 468 |
+
type="text"
|
| 469 |
+
placeholder="Search tickets, ID, employee..."
|
| 470 |
+
value={searchQuery}
|
| 471 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 472 |
+
className="w-full h-9 pl-9 pr-3 text-xs bg-zinc-50 dark:bg-zinc-955 border border-zinc-200 dark:border-zinc-800 rounded-xl text-zinc-900 dark:text-zinc-100 focus:outline-none focus:border-cyan-500"
|
| 473 |
+
/>
|
| 474 |
+
</div>
|
| 475 |
+
|
| 476 |
+
{/* Category & Priority Dropdowns */}
|
| 477 |
+
<div className="grid grid-cols-2 gap-2">
|
| 478 |
+
<select
|
| 479 |
+
value={selectedCategory}
|
| 480 |
+
onChange={(e) => setSelectedCategory(e.target.value)}
|
| 481 |
+
className="h-8 text-[11px] font-semibold bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl px-2.5 text-zinc-850 dark:text-zinc-200 focus:outline-none focus:border-cyan-500 cursor-pointer"
|
| 482 |
+
>
|
| 483 |
+
<option value="ALL">All Categories</option>
|
| 484 |
+
<option value="Payroll">Payroll</option>
|
| 485 |
+
<option value="Attendance">Attendance</option>
|
| 486 |
+
<option value="IT Support">IT Support</option>
|
| 487 |
+
<option value="Leave Requests">Leave Requests</option>
|
| 488 |
+
<option value="General Queries">General Queries</option>
|
| 489 |
+
</select>
|
| 490 |
+
|
| 491 |
+
<select
|
| 492 |
+
value={selectedPriority}
|
| 493 |
+
onChange={(e) => setSelectedPriority(e.target.value)}
|
| 494 |
+
className="h-8 text-[11px] font-semibold bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-xl px-2.5 text-zinc-850 dark:text-zinc-200 focus:outline-none focus:border-cyan-500 cursor-pointer"
|
| 495 |
+
>
|
| 496 |
+
<option value="ALL">All Priorities</option>
|
| 497 |
+
<option value="High">High Priority</option>
|
| 498 |
+
<option value="Medium">Medium Priority</option>
|
| 499 |
+
<option value="Low">Low Priority</option>
|
| 500 |
+
</select>
|
| 501 |
+
</div>
|
| 502 |
+
|
| 503 |
+
</div>
|
| 504 |
+
|
| 505 |
+
{/* List Feed - Renders as ultra compact sleek card list */}
|
| 506 |
+
<div className="flex-1 overflow-y-auto p-2 bg-zinc-50/10 dark:bg-zinc-955/5 space-y-1.5">
|
| 507 |
+
{isLoading ? (
|
| 508 |
+
Array.from({ length: 4 }).map((_, i) => (
|
| 509 |
+
<div key={i} className="p-3 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-100 dark:border-zinc-800 animate-pulse space-y-2">
|
| 510 |
+
<div className="h-3 w-28 bg-zinc-200 dark:bg-zinc-800 rounded" />
|
| 511 |
+
<div className="h-2 w-20 bg-zinc-100 dark:bg-zinc-800/60 rounded" />
|
| 512 |
+
</div>
|
| 513 |
+
))
|
| 514 |
+
) : filteredTickets.length > 0 ? (
|
| 515 |
+
filteredTickets.map((t: any) => {
|
| 516 |
+
const isSelected = selectedTicket?.id === t.id;
|
| 517 |
+
return (
|
| 518 |
+
<button
|
| 519 |
+
key={t.id}
|
| 520 |
+
onClick={() => setSelectedTicket(t)}
|
| 521 |
+
className={`w-full text-left p-2.5 rounded-xl border transition-all cursor-pointer flex flex-col gap-1 relative overflow-hidden ${
|
| 522 |
+
isSelected
|
| 523 |
+
? "bg-white dark:bg-zinc-900 border-cyan-500 shadow-sm ring-1 ring-cyan-500/20"
|
| 524 |
+
: "bg-white dark:bg-zinc-900 border-zinc-250 dark:border-zinc-800/80 hover:border-zinc-300 dark:hover:border-zinc-700"
|
| 525 |
+
}`}
|
| 526 |
+
>
|
| 527 |
+
{isSelected && (
|
| 528 |
+
<div className="absolute left-0 top-0 bottom-0 w-1 bg-cyan-500" />
|
| 529 |
+
)}
|
| 530 |
+
|
| 531 |
+
<div className="flex items-center justify-between w-full text-[9px] font-mono">
|
| 532 |
+
<span className="font-bold text-cyan-600 dark:text-cyan-400">
|
| 533 |
+
#TK-{t.id}
|
| 534 |
+
</span>
|
| 535 |
+
<span className="text-zinc-400 font-bold uppercase tracking-wider scale-95 origin-right">{t.category}</span>
|
| 536 |
+
</div>
|
| 537 |
+
|
| 538 |
+
<div className="flex items-center justify-between gap-2">
|
| 539 |
+
<h3 className="text-xs font-bold text-zinc-900 dark:text-zinc-100 truncate flex-1">
|
| 540 |
+
{t.title}
|
| 541 |
+
</h3>
|
| 542 |
+
{getPriorityBadge(t.priority)}
|
| 543 |
+
</div>
|
| 544 |
+
|
| 545 |
+
<div className="flex items-center justify-between text-[9px] text-zinc-400 pt-1 border-t border-zinc-100/50 dark:border-zinc-800/20">
|
| 546 |
+
<span className="flex items-center gap-1">
|
| 547 |
+
<span className={`w-1.5 h-1.5 rounded-full ${t.status === "Closed" ? "bg-emerald-500" : t.status === "In Progress" ? "bg-amber-500 animate-pulse" : "bg-sky-500"}`} />
|
| 548 |
+
<span>{t.status}</span>
|
| 549 |
+
</span>
|
| 550 |
+
<span>
|
| 551 |
+
{t.created_at ? new Date(t.created_at).toLocaleDateString([], { month: 'short', day: 'numeric' }) : "Now"}
|
| 552 |
+
</span>
|
| 553 |
+
</div>
|
| 554 |
+
</button>
|
| 555 |
+
);
|
| 556 |
+
})
|
| 557 |
+
) : (
|
| 558 |
+
<div className="py-16 text-center space-y-2 px-4">
|
| 559 |
+
<HelpCircle className="w-8 h-8 mx-auto text-zinc-400 stroke-[1.5]" />
|
| 560 |
+
<p className="text-xs font-bold text-zinc-655 dark:text-zinc-400">No support tickets found</p>
|
| 561 |
+
<p className="text-[10px] text-zinc-405">Try adjusting your filters or open a new ticket</p>
|
| 562 |
</div>
|
| 563 |
+
)}
|
| 564 |
+
</div>
|
| 565 |
+
|
| 566 |
+
</div>
|
| 567 |
+
|
| 568 |
+
{/* Right Panel: Detailed Conversation & Ticket Thread (Always shown on desktop, shown on mobile only when active) */}
|
| 569 |
+
<div className={`flex-1 flex flex-col bg-white dark:bg-zinc-900 h-full overflow-hidden ${
|
| 570 |
+
selectedTicket ? "flex" : "hidden md:flex"
|
| 571 |
+
}`}>
|
| 572 |
+
{selectedTicket ? (
|
| 573 |
+
<div className="flex-1 flex h-full overflow-hidden">
|
| 574 |
|
| 575 |
+
{/* Chat Section */}
|
| 576 |
+
<div className="flex-1 flex flex-col h-full overflow-hidden border-r border-zinc-200 dark:border-zinc-800">
|
| 577 |
+
|
| 578 |
+
{/* Selected Ticket Header */}
|
| 579 |
+
<div className="p-3.5 border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 shrink-0">
|
| 580 |
+
<div className="space-y-0.5">
|
| 581 |
+
|
| 582 |
+
{/* Back button (always visible to allow deselecting / returning to list) */}
|
| 583 |
+
<div className="flex items-center gap-2">
|
| 584 |
+
<button
|
| 585 |
+
onClick={() => setSelectedTicket(null)}
|
| 586 |
+
className="mr-1 p-1 hover:bg-zinc-150 dark:hover:bg-zinc-800 rounded-lg text-zinc-550 dark:text-zinc-450 cursor-pointer transition-transform active:scale-90"
|
| 587 |
+
title="Close Chat / Go Back"
|
| 588 |
+
>
|
| 589 |
+
<ArrowLeft className="w-4 h-4 text-zinc-700 dark:text-zinc-300" />
|
| 590 |
+
</button>
|
| 591 |
+
|
| 592 |
+
<div className="flex items-center gap-2">
|
| 593 |
+
<span className="text-[10px] font-mono font-bold text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-950/60 px-2 py-0.5 rounded border border-cyan-200 dark:border-cyan-800/40">
|
| 594 |
+
#TK-{selectedTicket.id}
|
| 595 |
+
</span>
|
| 596 |
+
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-450">
|
| 597 |
+
{selectedTicket.category} Support
|
| 598 |
+
</span>
|
| 599 |
+
{getPriorityBadge(selectedTicket.priority)}
|
| 600 |
+
</div>
|
| 601 |
+
</div>
|
| 602 |
+
|
| 603 |
+
<div className="flex items-center gap-2 pl-7">
|
| 604 |
+
<h2 className="text-xs font-extrabold text-zinc-900 dark:text-zinc-100 truncate max-w-[200px] md:max-w-xs" title={selectedTicket.title}>
|
| 605 |
+
{selectedTicket.title}
|
| 606 |
+
</h2>
|
| 607 |
+
<span className={`w-1.5 h-1.5 rounded-full ${sseConnected ? "bg-emerald-500 animate-pulse" : "bg-zinc-450"}`} />
|
| 608 |
+
</div>
|
| 609 |
+
</div>
|
| 610 |
+
|
| 611 |
+
{/* Status & Options Action Strip */}
|
| 612 |
+
<div className="flex items-center gap-2.5 self-end sm:self-auto pl-7">
|
| 613 |
+
|
| 614 |
+
<button
|
| 615 |
+
onClick={() => {
|
| 616 |
+
setShowChatSearch(!showChatSearch);
|
| 617 |
+
if (showChatSearch) setChatSearchQuery("");
|
| 618 |
+
}}
|
| 619 |
+
className={`p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer ${showChatSearch ? "text-cyan-500 bg-cyan-500/10" : "text-zinc-500"}`}
|
| 620 |
+
title="Search messages"
|
| 621 |
+
>
|
| 622 |
+
<Search className="w-4 h-4" />
|
| 623 |
+
</button>
|
| 624 |
+
|
| 625 |
+
<button
|
| 626 |
+
onClick={() => setIsRightPanelOpen(!isRightPanelOpen)}
|
| 627 |
+
className={`p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer ${isRightPanelOpen ? "text-cyan-500 bg-cyan-500/10" : "text-zinc-500"}`}
|
| 628 |
+
title="Ticket Details"
|
| 629 |
+
>
|
| 630 |
+
<Info className="w-4 h-4" />
|
| 631 |
+
</button>
|
| 632 |
+
|
| 633 |
+
<div className="flex items-center gap-2 bg-zinc-50 dark:bg-zinc-850 px-2 py-0.5 rounded-xl border border-zinc-200 dark:border-zinc-700/60">
|
| 634 |
+
<span className="text-[10px] font-bold text-zinc-405 px-0.5 uppercase">Status:</span>
|
| 635 |
+
{isHR ? (
|
| 636 |
+
<select
|
| 637 |
+
value={selectedTicket.status}
|
| 638 |
+
onChange={(e) => updateStatusMutation.mutate({ ticketId: selectedTicket.id, status: e.target.value })}
|
| 639 |
+
className="text-[10px] font-bold h-6.5 border border-zinc-300 dark:border-zinc-600 rounded-lg bg-white dark:bg-zinc-900 px-1 text-zinc-900 dark:text-zinc-100 focus:outline-none"
|
| 640 |
+
>
|
| 641 |
+
<option value="Open">Open</option>
|
| 642 |
+
<option value="In Progress">In Progress</option>
|
| 643 |
+
<option value="Closed">Resolved</option>
|
| 644 |
+
</select>
|
| 645 |
+
) : (
|
| 646 |
+
getStatusBadge(selectedTicket.status)
|
| 647 |
+
)}
|
| 648 |
+
</div>
|
| 649 |
+
|
| 650 |
+
</div>
|
| 651 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
|
| 653 |
+
{/* Inside-chat message text search bar */}
|
| 654 |
+
{showChatSearch && (
|
| 655 |
+
<div className="px-3.5 py-2 bg-zinc-50 dark:bg-zinc-850/50 border-b border-zinc-150 dark:border-zinc-800/80 flex items-center justify-between gap-2">
|
| 656 |
+
<div className="relative flex-1">
|
| 657 |
+
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-zinc-400" />
|
| 658 |
+
<input
|
| 659 |
+
type="text"
|
| 660 |
+
placeholder="Search in this conversation..."
|
| 661 |
+
value={chatSearchQuery}
|
| 662 |
+
onChange={(e) => setChatSearchQuery(e.target.value)}
|
| 663 |
+
className="w-full h-8 pl-8 pr-3 text-xs bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-750 rounded-lg text-zinc-900 dark:text-zinc-100 focus:outline-none"
|
| 664 |
+
/>
|
| 665 |
</div>
|
| 666 |
+
<button
|
| 667 |
+
onClick={() => {
|
| 668 |
+
setShowChatSearch(false);
|
| 669 |
+
setChatSearchQuery("");
|
| 670 |
+
}}
|
| 671 |
+
className="p-1 hover:bg-zinc-250 dark:hover:bg-zinc-700 rounded text-zinc-450"
|
| 672 |
+
>
|
| 673 |
+
<X className="w-4 h-4" />
|
| 674 |
+
</button>
|
| 675 |
+
</div>
|
| 676 |
+
)}
|
| 677 |
+
|
| 678 |
+
{/* Messages Chat Feed */}
|
| 679 |
+
<div
|
| 680 |
+
className="flex-1 overflow-y-auto p-4 space-y-4 relative"
|
| 681 |
+
style={{
|
| 682 |
+
backgroundImage: `radial-gradient(var(--bg-elevated) 0.8px, transparent 0.8px)`,
|
| 683 |
+
backgroundSize: '16px 16px',
|
| 684 |
+
}}
|
| 685 |
+
>
|
| 686 |
+
|
| 687 |
+
<div className="p-3 rounded-xl bg-zinc-50 dark:bg-zinc-850/70 border border-zinc-200 dark:border-zinc-800 text-xs space-y-1">
|
| 688 |
+
<div className="flex items-center justify-between text-zinc-500 dark:text-zinc-400 font-bold text-[10px]">
|
| 689 |
+
<span>TICKET CREATED</span>
|
| 690 |
+
<span>{selectedTicket.created_at ? new Date(selectedTicket.created_at).toLocaleString() : "Recently"}</span>
|
| 691 |
+
</div>
|
| 692 |
+
<p className="text-zinc-655 dark:text-zinc-355 text-[11px]">
|
| 693 |
+
Support request registered under category <strong>{selectedTicket.category}</strong>. Real-time stream is active.
|
| 694 |
+
</p>
|
| 695 |
+
</div>
|
| 696 |
+
|
| 697 |
+
{filteredMessages.map((m: any) => {
|
| 698 |
+
const isMe = m.sender_id === profile?.id;
|
| 699 |
+
return (
|
| 700 |
+
<div key={m.id} className={`flex items-start gap-2 max-w-[80%] ${isMe ? "ml-auto flex-row-reverse" : "mr-auto"}`}>
|
| 701 |
+
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 border ${
|
| 702 |
+
isMe
|
| 703 |
+
? "bg-cyan-500 text-slate-950 border-cyan-400"
|
| 704 |
+
: "bg-zinc-850 text-white border-zinc-700"
|
| 705 |
+
}`}>
|
| 706 |
+
{isMe ? <User className="w-3.5 h-3.5" /> : <Shield className="w-3.5 h-3.5 text-cyan-400" />}
|
| 707 |
+
</div>
|
| 708 |
+
|
| 709 |
+
<div className="space-y-0.5">
|
| 710 |
+
<div className={`p-3 rounded-2xl text-xs leading-relaxed border relative ${
|
| 711 |
+
isMe
|
| 712 |
+
? "bg-cyan-500/10 text-cyan-950 dark:bg-cyan-950/40 dark:text-cyan-300 border-cyan-200/60 dark:border-cyan-900/60 rounded-tr-none"
|
| 713 |
+
: "bg-slate-50 text-slate-900 dark:bg-zinc-900/90 dark:text-zinc-100 border-slate-200/80 dark:border-zinc-800/80 rounded-tl-none"
|
| 714 |
+
}`}>
|
| 715 |
+
<p className="whitespace-pre-wrap font-medium">{m.message}</p>
|
| 716 |
+
</div>
|
| 717 |
+
|
| 718 |
+
<div className={`flex items-center gap-1 mt-0.5 text-[8.5px] text-zinc-450 font-mono ${isMe ? "justify-end" : "justify-start"}`}>
|
| 719 |
+
<span>{new Date(m.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
| 720 |
+
{renderMessageTicks(m.sender_id)}
|
| 721 |
+
</div>
|
| 722 |
+
</div>
|
| 723 |
</div>
|
| 724 |
+
);
|
| 725 |
+
})}
|
| 726 |
+
|
| 727 |
+
{isTyping && (
|
| 728 |
+
<div className="flex items-center gap-2 text-[10px] text-zinc-400 dark:text-zinc-500 font-bold ml-9">
|
| 729 |
+
<div className="flex gap-0.5 items-center">
|
| 730 |
+
<span className="w-1.5 h-1.5 bg-cyan-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
| 731 |
+
<span className="w-1.5 h-1.5 bg-cyan-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
| 732 |
+
<span className="w-1.5 h-1.5 bg-cyan-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
| 733 |
+
</div>
|
| 734 |
+
<span>Support desk typing...</span>
|
| 735 |
+
</div>
|
| 736 |
+
)}
|
| 737 |
+
|
| 738 |
+
<div ref={chatEndRef} />
|
| 739 |
+
</div>
|
| 740 |
+
|
| 741 |
+
{/* Reply Form & Presets */}
|
| 742 |
+
{selectedTicket.status !== "Closed" ? (
|
| 743 |
+
<div className="p-3 bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 space-y-2 shrink-0">
|
| 744 |
+
|
| 745 |
+
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
|
| 746 |
+
<span className="text-[9.5px] font-bold text-zinc-405 uppercase tracking-wider shrink-0 flex items-center gap-1">
|
| 747 |
+
<Sparkles className="w-3 h-3 text-cyan-500" /> Presets:
|
| 748 |
+
</span>
|
| 749 |
+
{(isHR ? [
|
| 750 |
+
"We are reviewing your request.",
|
| 751 |
+
"Issue has been resolved.",
|
| 752 |
+
"Please provide attendance dates."
|
| 753 |
+
] : [
|
| 754 |
+
"Thank you for the update.",
|
| 755 |
+
"I have verified the logs.",
|
| 756 |
+
"Please check my check-in record."
|
| 757 |
+
]).map((preset) => (
|
| 758 |
+
<button
|
| 759 |
+
key={preset}
|
| 760 |
+
type="button"
|
| 761 |
+
onClick={() => setReplyText(preset)}
|
| 762 |
+
className="px-2.5 py-1 text-[10px] font-semibold bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 rounded-lg shrink-0 cursor-pointer transition-all border border-zinc-200 dark:border-zinc-700/60"
|
| 763 |
+
>
|
| 764 |
+
{preset}
|
| 765 |
+
</button>
|
| 766 |
+
))}
|
| 767 |
+
</div>
|
| 768 |
+
|
| 769 |
+
<form onSubmit={handleSendReply} className="flex gap-2">
|
| 770 |
+
<button
|
| 771 |
+
type="button"
|
| 772 |
+
onClick={() => {
|
| 773 |
+
toast.info("Attachment uploading is available in Enterprise edition.");
|
| 774 |
+
}}
|
| 775 |
+
className="p-2.5 text-zinc-400 hover:text-cyan-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl transition-colors border border-zinc-200 dark:border-zinc-750 cursor-pointer flex items-center justify-center shrink-0"
|
| 776 |
+
title="Attach document/screenshot"
|
| 777 |
+
>
|
| 778 |
+
<Paperclip className="w-4 h-4" />
|
| 779 |
+
</button>
|
| 780 |
+
|
| 781 |
+
<input
|
| 782 |
+
type="text"
|
| 783 |
+
placeholder="Write support reply..."
|
| 784 |
+
value={replyText}
|
| 785 |
+
onChange={(e) => setReplyText(e.target.value)}
|
| 786 |
+
className="flex-1 text-xs h-10 border border-zinc-200 dark:border-zinc-700/80 px-3.5 rounded-xl focus:outline-none focus:border-cyan-500 text-zinc-900 dark:text-zinc-100 bg-zinc-50 dark:bg-zinc-955"
|
| 787 |
+
/>
|
| 788 |
+
<button
|
| 789 |
+
type="submit"
|
| 790 |
+
disabled={replyMutation.isPending || !replyText.trim()}
|
| 791 |
+
className="h-10 px-5 bg-cyan-500 hover:bg-cyan-600 text-slate-950 font-extrabold text-xs rounded-xl flex items-center gap-1.5 active:scale-95 transition-all cursor-pointer disabled:opacity-50 shadow-xs"
|
| 792 |
+
>
|
| 793 |
+
{replyMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
| 794 |
+
Send
|
| 795 |
+
</button>
|
| 796 |
+
</form>
|
| 797 |
+
|
| 798 |
+
</div>
|
| 799 |
+
) : (
|
| 800 |
+
<div className="p-4 bg-emerald-500/10 border-t border-emerald-500/20 text-center text-xs font-bold text-emerald-600 dark:text-emerald-400 flex items-center justify-center gap-2 shrink-0">
|
| 801 |
+
<Lock className="w-4 h-4" />
|
| 802 |
+
This support ticket is resolved and marked as closed.
|
| 803 |
+
</div>
|
| 804 |
+
)}
|
| 805 |
+
|
| 806 |
+
</div>
|
| 807 |
+
|
| 808 |
+
{/* WhatsApp-style Contact Details Panel */}
|
| 809 |
+
{isRightPanelOpen && (
|
| 810 |
+
<div className="w-64 bg-zinc-50 dark:bg-zinc-900/50 flex flex-col shrink-0 overflow-y-auto border-l border-zinc-200 dark:border-zinc-800 p-4 space-y-4">
|
| 811 |
+
|
| 812 |
+
<div className="text-center pb-4 border-b border-zinc-200 dark:border-zinc-800 space-y-2">
|
| 813 |
+
<div className="w-16 h-16 mx-auto rounded-full bg-cyan-500/10 text-cyan-500 border border-cyan-500/20 flex items-center justify-center text-2xl font-bold">
|
| 814 |
+
{selectedTicket.employee?.name ? selectedTicket.employee.name.charAt(0).toUpperCase() : "#"}
|
| 815 |
+
</div>
|
| 816 |
+
<div>
|
| 817 |
+
<h3 className="text-xs font-black text-zinc-900 dark:text-zinc-100">
|
| 818 |
+
{selectedTicket.employee?.name || "Support Client"}
|
| 819 |
+
</h3>
|
| 820 |
+
<p className="text-[10px] text-zinc-405">
|
| 821 |
+
{selectedTicket.employee?.email || "No email linked"}
|
| 822 |
</p>
|
| 823 |
</div>
|
| 824 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
|
| 826 |
+
<div className="space-y-3.5 text-[11px]">
|
| 827 |
+
|
| 828 |
+
<div>
|
| 829 |
+
<span className="block text-[9px] font-bold text-zinc-405 uppercase tracking-wider mb-1">Ticket ID</span>
|
| 830 |
+
<span className="font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-200/50 dark:bg-zinc-800 px-2 py-0.5 rounded">
|
| 831 |
+
#TK-{selectedTicket.id}
|
| 832 |
+
</span>
|
| 833 |
+
</div>
|
| 834 |
+
|
| 835 |
+
<div>
|
| 836 |
+
<span className="block text-[9px] font-bold text-zinc-405 uppercase tracking-wider mb-1">Issue Status</span>
|
| 837 |
+
{getStatusBadge(selectedTicket.status)}
|
| 838 |
+
</div>
|
| 839 |
+
|
| 840 |
+
<div>
|
| 841 |
+
<span className="block text-[9px] font-bold text-zinc-405 uppercase tracking-wider mb-1">Issue Category</span>
|
| 842 |
+
<div className="flex items-center gap-1.5 text-zinc-700 dark:text-zinc-305 font-bold">
|
| 843 |
+
<Folder className="w-3.5 h-3.5 text-zinc-450" />
|
| 844 |
+
<span>{selectedTicket.category}</span>
|
| 845 |
+
</div>
|
| 846 |
+
</div>
|
| 847 |
+
|
| 848 |
+
<div>
|
| 849 |
+
<span className="block text-[9px] font-bold text-zinc-405 uppercase tracking-wider mb-1">Priority</span>
|
| 850 |
+
{getPriorityBadge(selectedTicket.priority)}
|
| 851 |
+
</div>
|
| 852 |
+
|
| 853 |
+
<div>
|
| 854 |
+
<span className="block text-[9px] font-bold text-zinc-405 uppercase tracking-wider mb-1">Date Created</span>
|
| 855 |
+
<div className="flex items-center gap-1 text-zinc-500 font-medium">
|
| 856 |
+
<Clock className="w-3.5 h-3.5 text-zinc-400" />
|
| 857 |
+
<span>{selectedTicket.created_at ? new Date(selectedTicket.created_at).toLocaleString() : "Recently"}</span>
|
| 858 |
+
</div>
|
| 859 |
+
</div>
|
| 860 |
+
|
| 861 |
+
{selectedTicket.employee?.company && (
|
| 862 |
+
<div>
|
| 863 |
+
<span className="block text-[9px] font-bold text-zinc-405 uppercase tracking-wider mb-1">Organization</span>
|
| 864 |
+
<div className="flex items-center gap-1 text-zinc-650 dark:text-zinc-355 font-bold">
|
| 865 |
+
<Building2 className="w-3.5 h-3.5 text-zinc-450" />
|
| 866 |
+
<span>{selectedTicket.employee.company.name}</span>
|
| 867 |
+
</div>
|
| 868 |
+
</div>
|
| 869 |
+
)}
|
| 870 |
+
|
| 871 |
+
</div>
|
| 872 |
+
|
| 873 |
+
</div>
|
| 874 |
+
)}
|
| 875 |
+
|
| 876 |
+
</div>
|
| 877 |
+
) : (
|
| 878 |
+
<div className="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-3 p-6 text-center bg-zinc-50/20 dark:bg-zinc-955/5">
|
| 879 |
+
<div className="w-16 h-16 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 border border-zinc-200 dark:border-zinc-700">
|
| 880 |
+
<MessageSquare className="w-8 h-8 text-cyan-500" />
|
| 881 |
</div>
|
| 882 |
+
<div className="space-y-1">
|
| 883 |
+
<h3 className="text-sm font-bold text-zinc-750 dark:text-zinc-300">No Ticket Selected</h3>
|
| 884 |
+
<p className="text-xs text-zinc-405 max-w-xs">
|
| 885 |
+
Select a support ticket from the list to view conversation log and reply.
|
| 886 |
+
</p>
|
| 887 |
+
</div>
|
| 888 |
+
</div>
|
| 889 |
+
)}
|
| 890 |
+
</div>
|
| 891 |
+
|
| 892 |
</div>
|
| 893 |
+
|
| 894 |
</div>
|
| 895 |
|
| 896 |
+
{/* Dynamic Role-Aware Support Ticket Modal */}
|
| 897 |
{showAddModal && (
|
| 898 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 backdrop-blur-xs px-4">
|
| 899 |
+
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden animate-fadeInUp p-6 space-y-4">
|
| 900 |
+
<div className="flex items-center justify-between border-b border-zinc-100 dark:border-zinc-800 pb-3">
|
| 901 |
+
<div>
|
| 902 |
+
<h2 className="text-sm font-bold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
|
| 903 |
+
<MessageSquare className="w-4 h-4 text-cyan-500" />
|
| 904 |
+
{isHR ? "Admin & Organization Ticket Portal" : "Submit Support Inquiry"}
|
| 905 |
+
</h2>
|
| 906 |
+
<p className="text-[10px] text-zinc-400 mt-0.5">
|
| 907 |
+
{isHR ? "Raise system or organization level issues for platform support" : "Describe your grievance or inquiry for prompt resolution"}
|
| 908 |
+
</p>
|
| 909 |
+
</div>
|
| 910 |
+
<button
|
| 911 |
+
onClick={() => setShowAddModal(false)}
|
| 912 |
+
className="text-zinc-400 hover:text-zinc-650 dark:hover:text-zinc-200 text-xs font-bold p-1 cursor-pointer"
|
| 913 |
+
>
|
| 914 |
+
β
|
| 915 |
+
</button>
|
| 916 |
+
</div>
|
| 917 |
+
|
| 918 |
+
<form onSubmit={handleCreateSubmit} className="space-y-3.5">
|
| 919 |
+
|
| 920 |
+
<div className="space-y-1">
|
| 921 |
+
<label className="text-[10px] font-bold text-zinc-405 uppercase tracking-wider flex items-center gap-1">
|
| 922 |
+
<Building2 className="w-3 h-3 text-cyan-500" /> Organization / Company Name
|
| 923 |
+
</label>
|
| 924 |
+
<input
|
| 925 |
+
type="text"
|
| 926 |
+
placeholder="e.g. NetraID Global Corp (or Main HQ)"
|
| 927 |
+
value={orgName}
|
| 928 |
+
onChange={(e) => setOrgName(e.target.value)}
|
| 929 |
+
className="w-full text-xs h-9 px-3 rounded-xl border border-zinc-200 dark:border-zinc-800 focus:outline-none focus:border-cyan-500 bg-zinc-50 dark:bg-zinc-955 text-zinc-900 dark:text-zinc-100 font-semibold"
|
| 930 |
+
/>
|
| 931 |
+
</div>
|
| 932 |
+
|
| 933 |
<div className="space-y-1">
|
| 934 |
+
<label className="text-[10px] font-bold text-zinc-405 uppercase tracking-wider">
|
| 935 |
+
Grievance Title / Problem Summary
|
| 936 |
+
</label>
|
| 937 |
<input
|
| 938 |
type="text"
|
| 939 |
required
|
| 940 |
+
placeholder="e.g. Biometric verification latency on Kiosk #3"
|
| 941 |
value={title}
|
| 942 |
onChange={(e) => setTitle(e.target.value)}
|
| 943 |
+
className="w-full text-xs h-9 px-3 rounded-xl border border-zinc-250 dark:border-zinc-800 focus:outline-none focus:border-cyan-500 bg-zinc-50 dark:bg-zinc-955 text-zinc-900 dark:text-zinc-100"
|
| 944 |
/>
|
| 945 |
</div>
|
| 946 |
+
|
| 947 |
<div className="grid grid-cols-2 gap-3">
|
| 948 |
+
|
| 949 |
<div className="space-y-1">
|
| 950 |
+
<label className="text-[10px] font-bold text-zinc-405 uppercase tracking-wider">
|
| 951 |
+
Grievance Category
|
| 952 |
+
</label>
|
| 953 |
<select
|
| 954 |
value={category}
|
| 955 |
onChange={(e) => setCategory(e.target.value)}
|
| 956 |
+
className="w-full text-xs h-9 px-3 rounded-xl border border-zinc-200 dark:border-zinc-850 focus:outline-none focus:border-cyan-500 bg-zinc-50 dark:bg-zinc-955 text-zinc-900 dark:text-zinc-100 font-semibold cursor-pointer"
|
| 957 |
>
|
| 958 |
+
<option value="Attendance Correction / Missed Punch">Attendance Correction / Missed Punch</option>
|
| 959 |
+
<option value="Salary / Payroll Discrepancy">Salary / Payroll Discrepancy</option>
|
| 960 |
+
<option value="Leave / Shift Schedule Issue">Leave / Shift Schedule Issue</option>
|
| 961 |
+
<option value="Biometric Scan Failed">Biometric Scan Failed</option>
|
| 962 |
+
<option value="App Bug / Technical Issue">App Bug / Technical Issue</option>
|
| 963 |
+
<option value="Other">Other (Custom Grievance)</option>
|
| 964 |
</select>
|
| 965 |
</div>
|
| 966 |
+
|
| 967 |
<div className="space-y-1">
|
| 968 |
+
<label className="text-[10px] font-bold text-zinc-405 uppercase tracking-wider">
|
| 969 |
+
Priority Level
|
| 970 |
+
</label>
|
| 971 |
<select
|
| 972 |
value={priority}
|
| 973 |
onChange={(e) => setPriority(e.target.value)}
|
| 974 |
+
className="w-full text-xs h-9 px-3 rounded-xl border border-zinc-200 dark:border-zinc-850 focus:outline-none focus:border-cyan-500 bg-zinc-50 dark:bg-zinc-955 text-zinc-900 dark:text-zinc-100 font-semibold cursor-pointer"
|
| 975 |
>
|
| 976 |
+
<option value="Low">Low Priority</option>
|
| 977 |
+
<option value="Medium">Medium Priority</option>
|
| 978 |
+
<option value="High">High Priority</option>
|
| 979 |
+
<option value="Critical">Critical / Urgent</option>
|
| 980 |
</select>
|
| 981 |
</div>
|
| 982 |
+
|
| 983 |
</div>
|
| 984 |
+
|
| 985 |
+
{category === "Other" && (
|
| 986 |
+
<div className="space-y-1 animate-fadeIn">
|
| 987 |
+
<label className="text-[10px] font-bold text-amber-500 uppercase tracking-wider">
|
| 988 |
+
Specify Custom Grievance Category
|
| 989 |
+
</label>
|
| 990 |
+
<input
|
| 991 |
+
type="text"
|
| 992 |
+
required
|
| 993 |
+
placeholder="Describe specific grievance category..."
|
| 994 |
+
value={customCategory}
|
| 995 |
+
onChange={(e) => setCustomCategory(e.target.value)}
|
| 996 |
+
className="w-full text-xs h-9 px-3 rounded-xl border border-amber-300 dark:border-amber-500/50 focus:outline-none focus:border-amber-505 bg-amber-500/5 text-zinc-900 dark:text-zinc-100"
|
| 997 |
+
/>
|
| 998 |
+
</div>
|
| 999 |
+
)}
|
| 1000 |
+
|
| 1001 |
<div className="space-y-1">
|
| 1002 |
+
<label className="text-[10px] font-bold text-zinc-405 uppercase tracking-wider">
|
| 1003 |
+
Detailed Grievance Description
|
| 1004 |
+
</label>
|
| 1005 |
<textarea
|
| 1006 |
required
|
| 1007 |
rows={4}
|
| 1008 |
+
placeholder="Provide full context, error codes, logs, or specific issues experienced..."
|
| 1009 |
value={initialMessage}
|
| 1010 |
onChange={(e) => setInitialMessage(e.target.value)}
|
| 1011 |
+
className="w-full text-xs p-3 rounded-xl border border-zinc-200 dark:border-zinc-800 focus:outline-none focus:border-cyan-500 bg-zinc-50 dark:bg-zinc-955 text-zinc-900 dark:text-zinc-100 resize-none"
|
| 1012 |
/>
|
| 1013 |
</div>
|
| 1014 |
+
|
| 1015 |
+
<div className="flex items-center justify-end gap-2 pt-3 border-t border-zinc-100 dark:border-zinc-800">
|
| 1016 |
<button
|
| 1017 |
type="button"
|
| 1018 |
onClick={() => setShowAddModal(false)}
|
| 1019 |
+
className="px-4 py-2 text-xs font-bold text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl cursor-pointer"
|
| 1020 |
>
|
| 1021 |
Cancel
|
| 1022 |
</button>
|
| 1023 |
<button
|
| 1024 |
type="submit"
|
| 1025 |
disabled={createTicketMutation.isPending}
|
| 1026 |
+
className="px-5 py-2.5 bg-cyan-500 hover:bg-cyan-600 text-slate-950 font-extrabold text-xs rounded-xl cursor-pointer flex items-center gap-1.5 shadow-sm active:scale-95 transition-all"
|
| 1027 |
>
|
| 1028 |
+
{createTicketMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
| 1029 |
+
Submit Grievance Ticket
|
| 1030 |
</button>
|
| 1031 |
</div>
|
| 1032 |
+
|
| 1033 |
</form>
|
| 1034 |
</div>
|
| 1035 |
</div>
|
| 1036 |
)}
|
| 1037 |
+
|
| 1038 |
</SidebarLayout>
|
| 1039 |
);
|
| 1040 |
}
|
frontend/app/utils/api.ts
CHANGED
|
@@ -127,7 +127,15 @@ export async function fetchApi(endpoint: string, options: RequestInit = {}): Pro
|
|
| 127 |
|
| 128 |
if (!response.ok) {
|
| 129 |
const errorData = await response.json().catch(() => ({ detail: "Unknown server error" }));
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
// Handle file responses (PDF, Excel, CSV)
|
|
@@ -141,7 +149,6 @@ export async function fetchApi(endpoint: string, options: RequestInit = {}): Pro
|
|
| 141 |
|
| 142 |
export function parseDateTime(dateStr: string | null | undefined): Date | null {
|
| 143 |
if (!dateStr) return null;
|
| 144 |
-
// Hugging Face backend stores datetimes in UTC. Append 'Z' to properly convert to the user's local timezone.
|
| 145 |
const hasTimezone = dateStr.endsWith("Z") || dateStr.includes("+") || /-\d{2}:\d{2}$/.test(dateStr);
|
| 146 |
const formattedStr = hasTimezone ? dateStr : dateStr.replace(" ", "T") + "Z";
|
| 147 |
return new Date(formattedStr);
|
|
|
|
| 127 |
|
| 128 |
if (!response.ok) {
|
| 129 |
const errorData = await response.json().catch(() => ({ detail: "Unknown server error" }));
|
| 130 |
+
let errMsg = "Server error occurred";
|
| 131 |
+
if (typeof errorData.detail === "string") {
|
| 132 |
+
errMsg = errorData.detail;
|
| 133 |
+
} else if (Array.isArray(errorData.detail)) {
|
| 134 |
+
errMsg = errorData.detail.map((err: any) => `${err.loc[err.loc.length - 1] || "field"}: ${err.msg}`).join(", ");
|
| 135 |
+
} else if (errorData.detail && typeof errorData.detail === "object") {
|
| 136 |
+
errMsg = JSON.stringify(errorData.detail);
|
| 137 |
+
}
|
| 138 |
+
throw new Error(errMsg);
|
| 139 |
}
|
| 140 |
|
| 141 |
// Handle file responses (PDF, Excel, CSV)
|
|
|
|
| 149 |
|
| 150 |
export function parseDateTime(dateStr: string | null | undefined): Date | null {
|
| 151 |
if (!dateStr) return null;
|
|
|
|
| 152 |
const hasTimezone = dateStr.endsWith("Z") || dateStr.includes("+") || /-\d{2}:\d{2}$/.test(dateStr);
|
| 153 |
const formattedStr = hasTimezone ? dateStr : dateStr.replace(" ", "T") + "Z";
|
| 154 |
return new Date(formattedStr);
|
frontend/components/AttendanceHeatmap.tsx
CHANGED
|
@@ -89,7 +89,7 @@ export default function AttendanceHeatmap({ employeeId, title }: AttendanceHeatm
|
|
| 89 |
});
|
| 90 |
|
| 91 |
return (
|
| 92 |
-
<div className="
|
| 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">
|
|
|
|
| 89 |
});
|
| 90 |
|
| 91 |
return (
|
| 92 |
+
<div className="tech-card-3d p-5">
|
| 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">
|
frontend/components/CommandPalette.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import {
|
| 6 |
+
Search, Calendar, Users, Building, Shield,
|
| 7 |
+
MessageSquare, FileText, Settings, Sparkles, Sun, Moon
|
| 8 |
+
} from "lucide-react";
|
| 9 |
+
|
| 10 |
+
export default function CommandPalette() {
|
| 11 |
+
const router = useRouter();
|
| 12 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 13 |
+
const [query, setQuery] = useState("");
|
| 14 |
+
|
| 15 |
+
// Listen for Ctrl+K / Cmd+K
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 18 |
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
| 19 |
+
e.preventDefault();
|
| 20 |
+
setIsOpen(prev => !prev);
|
| 21 |
+
}
|
| 22 |
+
if (e.key === "Escape") {
|
| 23 |
+
setIsOpen(false);
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
window.addEventListener("keydown", handleKeyDown);
|
| 27 |
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
| 28 |
+
}, []);
|
| 29 |
+
|
| 30 |
+
const items = [
|
| 31 |
+
{ name: "Go to Dashboard", icon: Sparkles, action: () => router.push("/dashboard") },
|
| 32 |
+
{ name: "Manage Organizations", icon: Building, action: () => router.push("/tenants") },
|
| 33 |
+
{ name: "Check Attendance Logs", icon: Calendar, action: () => router.push("/attendance") },
|
| 34 |
+
{ name: "View Employees", icon: Users, action: () => router.push("/employees") },
|
| 35 |
+
{ name: "Helpdesk Support", icon: MessageSquare, action: () => router.push("/tickets") },
|
| 36 |
+
{ name: "Generate Reports", icon: FileText, action: () => router.push("/reports") },
|
| 37 |
+
{ name: "System Settings", icon: Settings, action: () => router.push("/settings") },
|
| 38 |
+
];
|
| 39 |
+
|
| 40 |
+
const filteredItems = items.filter(item =>
|
| 41 |
+
item.name.toLowerCase().includes(query.toLowerCase())
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
if (!isOpen) return null;
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/40 backdrop-blur-md pt-[15vh] px-4 animate-fadeIn">
|
| 48 |
+
<div className="w-full max-w-lg bg-white/95 border border-slate-200 shadow-2xl rounded-2xl overflow-hidden flex flex-col max-h-[60vh]">
|
| 49 |
+
|
| 50 |
+
{/* Search bar */}
|
| 51 |
+
<div className="flex items-center px-4 py-3 border-b border-slate-100 shrink-0">
|
| 52 |
+
<Search className="w-4 h-4 text-slate-400 mr-2" />
|
| 53 |
+
<input
|
| 54 |
+
type="text"
|
| 55 |
+
placeholder="Type a command or search..."
|
| 56 |
+
value={query}
|
| 57 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 58 |
+
className="flex-1 bg-transparent text-xs h-6 text-slate-800 focus:outline-none placeholder-slate-400"
|
| 59 |
+
autoFocus
|
| 60 |
+
/>
|
| 61 |
+
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[9px] font-mono font-bold text-slate-400 bg-slate-100 border border-slate-200 rounded-md">
|
| 62 |
+
ESC
|
| 63 |
+
</kbd>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{/* Results */}
|
| 67 |
+
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
| 68 |
+
{filteredItems.length > 0 ? (
|
| 69 |
+
filteredItems.map((item, index) => {
|
| 70 |
+
const Icon = item.icon;
|
| 71 |
+
return (
|
| 72 |
+
<button
|
| 73 |
+
key={index}
|
| 74 |
+
onClick={() => { item.action(); setIsOpen(false); }}
|
| 75 |
+
className="w-full text-left flex items-center gap-3 px-3 py-2.5 rounded-xl text-slate-700 hover:text-slate-900 hover:bg-slate-50 cursor-pointer transition-colors text-xs font-semibold"
|
| 76 |
+
>
|
| 77 |
+
<Icon className="w-4 h-4 text-slate-400" />
|
| 78 |
+
{item.name}
|
| 79 |
+
</button>
|
| 80 |
+
);
|
| 81 |
+
})
|
| 82 |
+
) : (
|
| 83 |
+
<div className="py-8 text-center text-slate-400 text-xs font-semibold">
|
| 84 |
+
No matching commands or navigation routes found.
|
| 85 |
+
</div>
|
| 86 |
+
)}
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
{/* Footer info */}
|
| 90 |
+
<div className="p-3 border-t border-slate-100 bg-slate-50 flex items-center justify-between text-[9px] font-bold text-slate-400 uppercase tracking-wider shrink-0">
|
| 91 |
+
<span>Navigate using shortcuts</span>
|
| 92 |
+
<span>NetraID Enterprise Command Palette</span>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
);
|
| 98 |
+
}
|
frontend/components/SidebarLayout.tsx
CHANGED
|
@@ -19,20 +19,15 @@ import {
|
|
| 19 |
Sun,
|
| 20 |
Moon,
|
| 21 |
Building2,
|
| 22 |
-
MessageSquare
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
} from "lucide-react";
|
| 24 |
import { getAccessToken, getUserProfile, clearTokens } from "@/app/utils/api";
|
| 25 |
-
|
| 26 |
-
const navItems = [
|
| 27 |
-
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
| 28 |
-
{ name: "Organizations", href: "/tenants", icon: Building2 },
|
| 29 |
-
{ name: "Attendance", href: "/attendance", icon: Clock },
|
| 30 |
-
{ name: "Employees", href: "/employees", icon: Users },
|
| 31 |
-
{ name: "Helpdesk Support",href: "/tickets", icon: MessageSquare },
|
| 32 |
-
{ name: "Reports", href: "/reports", icon: FileSpreadsheet },
|
| 33 |
-
{ name: "Audit Logs", href: "/audit", icon: History },
|
| 34 |
-
{ name: "Settings", href: "/settings", icon: Settings },
|
| 35 |
-
];
|
| 36 |
|
| 37 |
function NavLink({
|
| 38 |
item,
|
|
@@ -40,7 +35,7 @@ function NavLink({
|
|
| 40 |
isCollapsed,
|
| 41 |
onClick
|
| 42 |
}: {
|
| 43 |
-
item:
|
| 44 |
isActive: boolean,
|
| 45 |
isCollapsed: boolean,
|
| 46 |
onClick?: () => void
|
|
@@ -51,20 +46,15 @@ function NavLink({
|
|
| 51 |
href={item.href}
|
| 52 |
onClick={onClick}
|
| 53 |
data-tooltip={isCollapsed ? item.name : undefined}
|
| 54 |
-
className={`group relative flex items-center ${isCollapsed ? "justify-center px-2" : "gap-
|
| 55 |
isActive
|
| 56 |
-
? "bg-
|
| 57 |
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)]"
|
| 58 |
}`}
|
| 59 |
>
|
| 60 |
-
{
|
| 61 |
-
{isActive && (
|
| 62 |
-
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-r-full bg-[var(--text-primary)]" />
|
| 63 |
-
)}
|
| 64 |
-
|
| 65 |
-
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 transition-all ${
|
| 66 |
isActive
|
| 67 |
-
? "bg-
|
| 68 |
: "bg-[var(--border-subtle)] text-[var(--text-muted)] group-hover:bg-[var(--border-strong)] group-hover:text-[var(--text-secondary)]"
|
| 69 |
}`}>
|
| 70 |
<Icon className="w-4 h-4" />
|
|
@@ -77,8 +67,6 @@ function NavLink({
|
|
| 77 |
</p>
|
| 78 |
</div>
|
| 79 |
)}
|
| 80 |
-
|
| 81 |
-
{isActive && !isCollapsed && <ChevronRight className="w-3.5 h-3.5 text-[var(--text-muted)] shrink-0" />}
|
| 82 |
</Link>
|
| 83 |
);
|
| 84 |
}
|
|
@@ -93,6 +81,20 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 93 |
|
| 94 |
const [theme, setTheme] = useState<"light" | "dark">("light");
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
// Load theme and sidebar state from localStorage on client side
|
| 97 |
useEffect(() => {
|
| 98 |
const saved = localStorage.getItem("sidebar_collapsed");
|
|
@@ -141,14 +143,43 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 141 |
setAuthorized(true);
|
| 142 |
|
| 143 |
// Auto-redirect employees to dashboard if they attempt to access any admin views
|
| 144 |
-
if (profile?.role?.name === "Employee" && pathname !== "/dashboard" && pathname !== "/tickets") {
|
| 145 |
router.push("/dashboard");
|
| 146 |
-
} else if (profile?.role?.name !== "Super Admin" && pathname === "/tenants") {
|
| 147 |
router.push("/dashboard");
|
| 148 |
}
|
| 149 |
}
|
| 150 |
}, [router, pathname]);
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
if (!authorized) {
|
| 153 |
return (
|
| 154 |
<div className="min-h-screen bg-[var(--bg-base)] flex items-center justify-center">
|
|
@@ -171,14 +202,56 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 171 |
const initials = user?.email ? user.email[0].toUpperCase() : "A";
|
| 172 |
const isEmployee = user?.role?.name === "Employee";
|
| 173 |
const isSuperAdmin = user?.role?.name === "Super Admin";
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
return (
|
| 181 |
<div className="min-h-screen flex bg-[var(--bg-base)] text-[var(--text-primary)] font-sans relative">
|
|
|
|
| 182 |
{/* Ambient background */}
|
| 183 |
<div className="ambient-bg" />
|
| 184 |
|
|
@@ -255,58 +328,28 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 255 |
)}
|
| 256 |
|
| 257 |
{/* Navigation */}
|
| 258 |
-
<nav className="flex-1 space-y-
|
| 259 |
{visibleNavItems.map((item) => (
|
| 260 |
<NavLink
|
| 261 |
key={item.href}
|
| 262 |
item={item}
|
| 263 |
-
isActive={
|
| 264 |
isCollapsed={isCollapsed}
|
| 265 |
/>
|
| 266 |
))}
|
| 267 |
-
|
| 268 |
-
{!isEmployee && (
|
| 269 |
-
<>
|
| 270 |
-
{/* Separator */}
|
| 271 |
-
<div className="my-3 border-t border-[var(--border-subtle)]" />
|
| 272 |
-
|
| 273 |
-
{/* Kiosk Launch */}
|
| 274 |
-
<a
|
| 275 |
-
href="/kiosk"
|
| 276 |
-
target="_blank"
|
| 277 |
-
rel="noopener noreferrer"
|
| 278 |
-
data-tooltip={isCollapsed ? "Launch Kiosk" : undefined}
|
| 279 |
-
className={`group flex items-center ${isCollapsed ? "justify-center px-2" : "gap-3 px-3"} py-2.5 rounded-xl text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)] transition-all duration-200`}
|
| 280 |
-
>
|
| 281 |
-
<div className="w-8 h-8 rounded-lg bg-[var(--border-subtle)] flex items-center justify-center group-hover:bg-[var(--text-primary)] transition-all">
|
| 282 |
-
<Monitor className="w-4 h-4 text-[var(--text-secondary)] group-hover:text-[var(--bg-base)]" />
|
| 283 |
-
</div>
|
| 284 |
-
{!isCollapsed && (
|
| 285 |
-
<>
|
| 286 |
-
<div className="flex-1">
|
| 287 |
-
<p className="text-sm">Launch Kiosk</p>
|
| 288 |
-
</div>
|
| 289 |
-
<span className="text-[9px] font-mono text-[var(--text-secondary)] bg-[var(--border-subtle)] px-1.5 py-0.5 rounded uppercase tracking-wider font-semibold">
|
| 290 |
-
Live
|
| 291 |
-
</span>
|
| 292 |
-
</>
|
| 293 |
-
)}
|
| 294 |
-
</a>
|
| 295 |
-
</>
|
| 296 |
-
)}
|
| 297 |
</nav>
|
| 298 |
|
| 299 |
{/* User Profile Footer */}
|
| 300 |
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
| 301 |
<div className={`flex items-center ${isCollapsed ? "justify-center" : "gap-3"} p-2 rounded-xl hover:bg-[var(--border-subtle)] transition-all group cursor-default`}>
|
| 302 |
-
<
|
| 303 |
-
<div className="
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
| 305 |
</div>
|
| 306 |
-
|
| 307 |
-
</div>
|
| 308 |
-
{!isCollapsed && (
|
| 309 |
-
<>
|
| 310 |
<div className="flex-1 min-w-0">
|
| 311 |
<p className="text-xs font-semibold text-[var(--text-primary)] truncate leading-none">
|
| 312 |
{user?.role?.name || "Admin"}
|
|
@@ -315,19 +358,37 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 315 |
{user?.email}
|
| 316 |
</p>
|
| 317 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
<button
|
| 319 |
onClick={handleLogout}
|
| 320 |
title="Sign out"
|
| 321 |
-
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-rose-600 hover:bg-rose-50 transition-all opacity-
|
| 322 |
>
|
| 323 |
<LogOut className="w-3.5 h-3.5" />
|
| 324 |
</button>
|
| 325 |
-
</>
|
| 326 |
)}
|
| 327 |
</div>
|
| 328 |
|
| 329 |
{isCollapsed && (
|
| 330 |
-
<div className="flex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
<button
|
| 332 |
onClick={handleLogout}
|
| 333 |
title="Sign out"
|
|
@@ -358,6 +419,11 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 358 |
</svg>
|
| 359 |
</div>
|
| 360 |
<span className="font-extrabold text-[17px] text-[var(--text-primary)] tracking-tight">NetraID</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
</div>
|
| 362 |
<div className="flex items-center gap-2">
|
| 363 |
{/* Theme Toggle Mobile */}
|
|
@@ -410,45 +476,34 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 410 |
</button>
|
| 411 |
</div>
|
| 412 |
|
| 413 |
-
<nav className="flex-1 overflow-y-auto space-y-
|
| 414 |
<div className="mb-2 px-3 text-[10px] font-bold tracking-widest text-[var(--text-muted)] uppercase">Menu</div>
|
| 415 |
{visibleNavItems.map((item) => (
|
| 416 |
<NavLink
|
| 417 |
key={item.href}
|
| 418 |
item={item}
|
| 419 |
-
isActive={
|
| 420 |
isCollapsed={false}
|
| 421 |
onClick={() => setSidebarOpen(false)}
|
| 422 |
/>
|
| 423 |
))}
|
| 424 |
|
| 425 |
-
{!isEmployee && (
|
| 426 |
-
<>
|
| 427 |
-
<div className="mt-6 mb-2 px-3 text-[10px] font-bold tracking-widest text-[var(--text-muted)] uppercase">System</div>
|
| 428 |
-
<a
|
| 429 |
-
href="/kiosk"
|
| 430 |
-
target="_blank"
|
| 431 |
-
rel="noopener noreferrer"
|
| 432 |
-
className="flex items-center gap-3 px-3 py-3 rounded-xl text-[var(--text-secondary)] hover:text-white hover:bg-slate-800 transition-all border border-transparent hover:border-slate-700 shadow-sm"
|
| 433 |
-
>
|
| 434 |
-
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center">
|
| 435 |
-
<Monitor className="w-4 h-4 text-cyan-400" />
|
| 436 |
-
</div>
|
| 437 |
-
<span className="text-sm font-medium">Launch Kiosk</span>
|
| 438 |
-
</a>
|
| 439 |
-
</>
|
| 440 |
-
)}
|
| 441 |
</nav>
|
| 442 |
|
| 443 |
<div className="p-4 border-t border-[var(--border-subtle)] bg-[var(--bg-surface)]">
|
| 444 |
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--border-subtle)]/50 border border-[var(--border-subtle)]">
|
| 445 |
-
<
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
<
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
<button onClick={handleLogout} className="p-2 rounded-lg text-[var(--text-muted)] hover:text-rose-500 hover:bg-rose-500/10 cursor-pointer transition-all">
|
| 453 |
<LogOut className="w-4 h-4" />
|
| 454 |
</button>
|
|
@@ -460,8 +515,21 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 460 |
{/* βββ Main Content βββ */}
|
| 461 |
<main className="flex-1 min-h-screen overflow-y-auto relative z-10 pt-14 md:pt-0">
|
| 462 |
{/* Desktop Top Navbar */}
|
| 463 |
-
<header className="hidden md:flex h-14 border-b border-[var(--border-subtle)] px-8 items-center justify-
|
| 464 |
-
<div className="flex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
<button
|
| 466 |
onClick={toggleTheme}
|
| 467 |
className="p-2 rounded-lg hover:bg-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all cursor-pointer"
|
|
@@ -472,7 +540,10 @@ export default function SidebarLayout({ children }: { children: React.ReactNode
|
|
| 472 |
</div>
|
| 473 |
</header>
|
| 474 |
|
| 475 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 476 |
{children}
|
| 477 |
</div>
|
| 478 |
</main>
|
|
|
|
| 19 |
Sun,
|
| 20 |
Moon,
|
| 21 |
Building2,
|
| 22 |
+
MessageSquare,
|
| 23 |
+
User,
|
| 24 |
+
TrendingUp,
|
| 25 |
+
Calendar,
|
| 26 |
+
FileText,
|
| 27 |
+
Shield
|
| 28 |
} from "lucide-react";
|
| 29 |
import { getAccessToken, getUserProfile, clearTokens } from "@/app/utils/api";
|
| 30 |
+
import CommandPalette from "@/components/CommandPalette";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
function NavLink({
|
| 33 |
item,
|
|
|
|
| 35 |
isCollapsed,
|
| 36 |
onClick
|
| 37 |
}: {
|
| 38 |
+
item: { name: string; href: string; icon: any },
|
| 39 |
isActive: boolean,
|
| 40 |
isCollapsed: boolean,
|
| 41 |
onClick?: () => void
|
|
|
|
| 46 |
href={item.href}
|
| 47 |
onClick={onClick}
|
| 48 |
data-tooltip={isCollapsed ? item.name : undefined}
|
| 49 |
+
className={`group relative flex items-center ${isCollapsed ? "justify-center px-2" : "gap-2.5 px-2.5 mx-1"} py-2.5 rounded-xl transition-all duration-200 ${
|
| 50 |
isActive
|
| 51 |
+
? "bg-zinc-950 text-white dark:bg-zinc-100 dark:text-zinc-950 font-bold"
|
| 52 |
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)]"
|
| 53 |
}`}
|
| 54 |
>
|
| 55 |
+
<div className={`w-7.5 h-7.5 rounded-lg flex items-center justify-center shrink-0 transition-all ${
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
isActive
|
| 57 |
+
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
|
| 58 |
: "bg-[var(--border-subtle)] text-[var(--text-muted)] group-hover:bg-[var(--border-strong)] group-hover:text-[var(--text-secondary)]"
|
| 59 |
}`}>
|
| 60 |
<Icon className="w-4 h-4" />
|
|
|
|
| 67 |
</p>
|
| 68 |
</div>
|
| 69 |
)}
|
|
|
|
|
|
|
| 70 |
</Link>
|
| 71 |
);
|
| 72 |
}
|
|
|
|
| 81 |
|
| 82 |
const [theme, setTheme] = useState<"light" | "dark">("light");
|
| 83 |
|
| 84 |
+
const [currentTime, setCurrentTime] = useState("");
|
| 85 |
+
const [currentDate, setCurrentDate] = useState("");
|
| 86 |
+
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
const updateTime = () => {
|
| 89 |
+
const now = new Date();
|
| 90 |
+
setCurrentTime(now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
|
| 91 |
+
setCurrentDate(now.toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }));
|
| 92 |
+
};
|
| 93 |
+
updateTime();
|
| 94 |
+
const interval = setInterval(updateTime, 1000);
|
| 95 |
+
return () => clearInterval(interval);
|
| 96 |
+
}, []);
|
| 97 |
+
|
| 98 |
// Load theme and sidebar state from localStorage on client side
|
| 99 |
useEffect(() => {
|
| 100 |
const saved = localStorage.getItem("sidebar_collapsed");
|
|
|
|
| 143 |
setAuthorized(true);
|
| 144 |
|
| 145 |
// Auto-redirect employees to dashboard if they attempt to access any admin views
|
| 146 |
+
if (profile?.role?.name === "Employee" && pathname !== "/dashboard" && pathname !== "/tickets" && pathname !== "/profile" && pathname !== "/calendar") {
|
| 147 |
router.push("/dashboard");
|
| 148 |
+
} else if (profile?.role?.name !== "Super Admin" && (pathname === "/tenants" || pathname === "/users" || pathname === "/analytics")) {
|
| 149 |
router.push("/dashboard");
|
| 150 |
}
|
| 151 |
}
|
| 152 |
}, [router, pathname]);
|
| 153 |
|
| 154 |
+
const [currentQuery, setCurrentQuery] = useState("");
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
if (typeof window !== "undefined") {
|
| 157 |
+
const handleUpdate = () => {
|
| 158 |
+
setCurrentQuery(window.location.search);
|
| 159 |
+
};
|
| 160 |
+
handleUpdate();
|
| 161 |
+
const interval = setInterval(handleUpdate, 200);
|
| 162 |
+
return () => clearInterval(interval);
|
| 163 |
+
}
|
| 164 |
+
}, []);
|
| 165 |
+
|
| 166 |
+
const isLinkActive = (href: string) => {
|
| 167 |
+
if (href.includes("?")) {
|
| 168 |
+
const [linkPath, linkSearch] = href.split("?");
|
| 169 |
+
if (pathname !== linkPath) return false;
|
| 170 |
+
const linkParams = new URLSearchParams(linkSearch);
|
| 171 |
+
const currentParams = new URLSearchParams(currentQuery);
|
| 172 |
+
return linkParams.get("tab") === currentParams.get("tab");
|
| 173 |
+
} else {
|
| 174 |
+
if (href === "/dashboard") {
|
| 175 |
+
const currentParams = new URLSearchParams(currentQuery);
|
| 176 |
+
if (currentParams.has("tab")) return false;
|
| 177 |
+
return pathname === "/dashboard";
|
| 178 |
+
}
|
| 179 |
+
return pathname === href || pathname.startsWith(href + "/");
|
| 180 |
+
}
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
if (!authorized) {
|
| 184 |
return (
|
| 185 |
<div className="min-h-screen bg-[var(--bg-base)] flex items-center justify-center">
|
|
|
|
| 202 |
const initials = user?.email ? user.email[0].toUpperCase() : "A";
|
| 203 |
const isEmployee = user?.role?.name === "Employee";
|
| 204 |
const isSuperAdmin = user?.role?.name === "Super Admin";
|
| 205 |
+
|
| 206 |
+
const getVisibleNavItems = () => {
|
| 207 |
+
const role = user?.role?.name;
|
| 208 |
+
if (role === "Super Admin") {
|
| 209 |
+
return [
|
| 210 |
+
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
| 211 |
+
{ name: "Organizations", href: "/tenants", icon: Building2 },
|
| 212 |
+
{ name: "Analytics", href: "/analytics", icon: TrendingUp },
|
| 213 |
+
{ name: "Helpdesk", href: "/tickets", icon: MessageSquare },
|
| 214 |
+
{ name: "Audit Logs", href: "/audit", icon: History },
|
| 215 |
+
{ name: "Settings", href: "/settings", icon: Settings },
|
| 216 |
+
{ name: "Kiosk Mode", href: "/kiosk", icon: Scan },
|
| 217 |
+
];
|
| 218 |
+
} else if (role === "Admin") {
|
| 219 |
+
return [
|
| 220 |
+
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
| 221 |
+
{ name: "Attendance", href: "/attendance", icon: Clock },
|
| 222 |
+
{ name: "Employees", href: "/employees", icon: Users },
|
| 223 |
+
{ name: "Leave", href: "/leaves", icon: Calendar },
|
| 224 |
+
{ name: "Helpdesk", href: "/tickets", icon: MessageSquare },
|
| 225 |
+
{ name: "Reports", href: "/reports", icon: FileSpreadsheet },
|
| 226 |
+
{ name: "Settings", href: "/settings", icon: Settings },
|
| 227 |
+
{ name: "Kiosk Mode", href: "/kiosk", icon: Scan },
|
| 228 |
+
];
|
| 229 |
+
} else if (role === "HR") {
|
| 230 |
+
return [
|
| 231 |
+
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
| 232 |
+
{ name: "Attendance", href: "/attendance", icon: Clock },
|
| 233 |
+
{ name: "Employees", href: "/employees", icon: Users },
|
| 234 |
+
{ name: "Leave", href: "/leaves", icon: Calendar },
|
| 235 |
+
{ name: "Helpdesk", href: "/tickets", icon: MessageSquare },
|
| 236 |
+
{ name: "Reports", href: "/reports", icon: FileSpreadsheet },
|
| 237 |
+
{ name: "Kiosk Mode", href: "/kiosk", icon: Scan },
|
| 238 |
+
];
|
| 239 |
+
} else {
|
| 240 |
+
return [
|
| 241 |
+
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
| 242 |
+
{ name: "Attendance", href: "/dashboard?tab=attendance", icon: Clock },
|
| 243 |
+
{ name: "Leave", href: "/dashboard?tab=leave", icon: Calendar },
|
| 244 |
+
{ name: "Calendar", href: "/calendar", icon: Calendar },
|
| 245 |
+
{ name: "Contact HR", href: "/tickets", icon: MessageSquare },
|
| 246 |
+
];
|
| 247 |
+
}
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
const visibleNavItems = getVisibleNavItems();
|
| 251 |
|
| 252 |
return (
|
| 253 |
<div className="min-h-screen flex bg-[var(--bg-base)] text-[var(--text-primary)] font-sans relative">
|
| 254 |
+
<CommandPalette />
|
| 255 |
{/* Ambient background */}
|
| 256 |
<div className="ambient-bg" />
|
| 257 |
|
|
|
|
| 328 |
)}
|
| 329 |
|
| 330 |
{/* Navigation */}
|
| 331 |
+
<nav className="flex-1 space-y-4">
|
| 332 |
{visibleNavItems.map((item) => (
|
| 333 |
<NavLink
|
| 334 |
key={item.href}
|
| 335 |
item={item}
|
| 336 |
+
isActive={isLinkActive(item.href)}
|
| 337 |
isCollapsed={isCollapsed}
|
| 338 |
/>
|
| 339 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
</nav>
|
| 341 |
|
| 342 |
{/* User Profile Footer */}
|
| 343 |
<div className="pt-3 border-t border-[var(--border-subtle)]">
|
| 344 |
<div className={`flex items-center ${isCollapsed ? "justify-center" : "gap-3"} p-2 rounded-xl hover:bg-[var(--border-subtle)] transition-all group cursor-default`}>
|
| 345 |
+
<Link href="/profile" className="flex items-center gap-3 flex-1 min-w-0">
|
| 346 |
+
<div className="relative shrink-0">
|
| 347 |
+
<div className="w-8 h-8 rounded-full bg-[var(--text-primary)] flex items-center justify-center font-bold text-sm text-[var(--bg-base)] shadow-sm">
|
| 348 |
+
{initials}
|
| 349 |
+
</div>
|
| 350 |
+
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-emerald-500 border-2 border-white" />
|
| 351 |
</div>
|
| 352 |
+
{!isCollapsed && (
|
|
|
|
|
|
|
|
|
|
| 353 |
<div className="flex-1 min-w-0">
|
| 354 |
<p className="text-xs font-semibold text-[var(--text-primary)] truncate leading-none">
|
| 355 |
{user?.role?.name || "Admin"}
|
|
|
|
| 358 |
{user?.email}
|
| 359 |
</p>
|
| 360 |
</div>
|
| 361 |
+
)}
|
| 362 |
+
</Link>
|
| 363 |
+
{!isCollapsed && (
|
| 364 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 365 |
+
<Link
|
| 366 |
+
href="/profile"
|
| 367 |
+
title="View Profile"
|
| 368 |
+
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)] transition-all opacity-80 hover:opacity-100 cursor-pointer"
|
| 369 |
+
>
|
| 370 |
+
<User className="w-3.5 h-3.5" />
|
| 371 |
+
</Link>
|
| 372 |
<button
|
| 373 |
onClick={handleLogout}
|
| 374 |
title="Sign out"
|
| 375 |
+
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-rose-600 hover:bg-rose-50 transition-all opacity-80 hover:opacity-100 cursor-pointer"
|
| 376 |
>
|
| 377 |
<LogOut className="w-3.5 h-3.5" />
|
| 378 |
</button>
|
| 379 |
+
</div>
|
| 380 |
)}
|
| 381 |
</div>
|
| 382 |
|
| 383 |
{isCollapsed && (
|
| 384 |
+
<div className="flex flex-col items-center gap-2 mt-2">
|
| 385 |
+
<Link
|
| 386 |
+
href="/profile"
|
| 387 |
+
title="View Profile"
|
| 388 |
+
className="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)] transition-all cursor-pointer"
|
| 389 |
+
>
|
| 390 |
+
<User className="w-4 h-4" />
|
| 391 |
+
</Link>
|
| 392 |
<button
|
| 393 |
onClick={handleLogout}
|
| 394 |
title="Sign out"
|
|
|
|
| 419 |
</svg>
|
| 420 |
</div>
|
| 421 |
<span className="font-extrabold text-[17px] text-[var(--text-primary)] tracking-tight">NetraID</span>
|
| 422 |
+
{currentTime && (
|
| 423 |
+
<span className="text-[10px] font-mono bg-[var(--border-subtle)] px-2 py-0.5 rounded-lg text-[var(--text-secondary)] tabular-nums font-semibold">
|
| 424 |
+
{currentTime.split(" ")[0]} {currentTime.split(" ")[1] || ""}
|
| 425 |
+
</span>
|
| 426 |
+
)}
|
| 427 |
</div>
|
| 428 |
<div className="flex items-center gap-2">
|
| 429 |
{/* Theme Toggle Mobile */}
|
|
|
|
| 476 |
</button>
|
| 477 |
</div>
|
| 478 |
|
| 479 |
+
<nav className="flex-1 overflow-y-auto space-y-4 p-4">
|
| 480 |
<div className="mb-2 px-3 text-[10px] font-bold tracking-widest text-[var(--text-muted)] uppercase">Menu</div>
|
| 481 |
{visibleNavItems.map((item) => (
|
| 482 |
<NavLink
|
| 483 |
key={item.href}
|
| 484 |
item={item}
|
| 485 |
+
isActive={isLinkActive(item.href)}
|
| 486 |
isCollapsed={false}
|
| 487 |
onClick={() => setSidebarOpen(false)}
|
| 488 |
/>
|
| 489 |
))}
|
| 490 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
</nav>
|
| 492 |
|
| 493 |
<div className="p-4 border-t border-[var(--border-subtle)] bg-[var(--bg-surface)]">
|
| 494 |
<div className="flex items-center gap-3 p-3 rounded-xl bg-[var(--border-subtle)]/50 border border-[var(--border-subtle)]">
|
| 495 |
+
<Link href="/profile" onClick={() => setSidebarOpen(false)} className="flex items-center gap-3 flex-1 min-w-0">
|
| 496 |
+
<div className="w-9 h-9 rounded-full bg-[var(--text-primary)] flex items-center justify-center font-bold text-sm text-[var(--bg-base)] shadow-md">
|
| 497 |
+
{initials}
|
| 498 |
+
</div>
|
| 499 |
+
<div className="flex-1 min-w-0">
|
| 500 |
+
<p className="text-xs font-bold text-[var(--text-primary)] truncate">{user?.role?.name}</p>
|
| 501 |
+
<p className="text-[10px] text-[var(--text-muted)] truncate">{user?.email}</p>
|
| 502 |
+
</div>
|
| 503 |
+
</Link>
|
| 504 |
+
<Link href="/profile" onClick={() => setSidebarOpen(false)} className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] cursor-pointer transition-all">
|
| 505 |
+
<User className="w-4 h-4" />
|
| 506 |
+
</Link>
|
| 507 |
<button onClick={handleLogout} className="p-2 rounded-lg text-[var(--text-muted)] hover:text-rose-500 hover:bg-rose-500/10 cursor-pointer transition-all">
|
| 508 |
<LogOut className="w-4 h-4" />
|
| 509 |
</button>
|
|
|
|
| 515 |
{/* βββ Main Content βββ */}
|
| 516 |
<main className="flex-1 min-h-screen overflow-y-auto relative z-10 pt-14 md:pt-0">
|
| 517 |
{/* Desktop Top Navbar */}
|
| 518 |
+
<header className="hidden md:flex h-14 border-b border-[var(--border-subtle)] px-8 items-center justify-between bg-[var(--bg-surface)]/95 backdrop-blur-xl sticky top-0 z-30">
|
| 519 |
+
<div className="flex-1" />
|
| 520 |
+
|
| 521 |
+
<div className="flex items-center gap-6">
|
| 522 |
+
<div className="flex items-center gap-2 font-mono text-[12px] text-[var(--text-secondary)] font-medium">
|
| 523 |
+
<Clock className="w-4 h-4 text-cyan-500 animate-pulse" />
|
| 524 |
+
<span>{currentDate}</span>
|
| 525 |
+
{currentTime && (
|
| 526 |
+
<>
|
| 527 |
+
<span className="text-[var(--border-subtle)] px-1">|</span>
|
| 528 |
+
<span className="tabular-nums font-bold text-[var(--text-primary)]">{currentTime}</span>
|
| 529 |
+
</>
|
| 530 |
+
)}
|
| 531 |
+
</div>
|
| 532 |
+
|
| 533 |
<button
|
| 534 |
onClick={toggleTheme}
|
| 535 |
className="p-2 rounded-lg hover:bg-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-all cursor-pointer"
|
|
|
|
| 540 |
</div>
|
| 541 |
</header>
|
| 542 |
|
| 543 |
+
<div
|
| 544 |
+
key={pathname}
|
| 545 |
+
className="max-w-7xl mx-auto px-5 py-6 md:px-8 md:py-8 page-enter"
|
| 546 |
+
>
|
| 547 |
{children}
|
| 548 |
</div>
|
| 549 |
</main>
|
frontend/next.config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
/** @type {import('next').NextConfig} */
|
| 2 |
const nextConfig = {
|
| 3 |
reactStrictMode: true,
|
|
@@ -7,6 +9,8 @@ const nextConfig = {
|
|
| 7 |
typescript: {
|
| 8 |
ignoreBuildErrors: true,
|
| 9 |
},
|
|
|
|
| 10 |
};
|
| 11 |
|
| 12 |
module.exports = nextConfig;
|
|
|
|
|
|
| 1 |
+
const path = require('path');
|
| 2 |
+
|
| 3 |
/** @type {import('next').NextConfig} */
|
| 4 |
const nextConfig = {
|
| 5 |
reactStrictMode: true,
|
|
|
|
| 9 |
typescript: {
|
| 10 |
ignoreBuildErrors: true,
|
| 11 |
},
|
| 12 |
+
outputFileTracingRoot: __dirname,
|
| 13 |
};
|
| 14 |
|
| 15 |
module.exports = nextConfig;
|
| 16 |
+
|