Pavanupadhyay27 commited on
Commit
be2ba8d
Β·
1 Parent(s): f4a356e

feat: complete biometric system features, self-onboarding alignment, bulk approval, and timezone trend fixes

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .vscode/settings.json +3 -0
  2. backend/app/api/v1/analytics.py +114 -10
  3. backend/app/api/v1/attendance.py +15 -5
  4. backend/app/api/v1/auth.py +204 -2
  5. backend/app/api/v1/companies.py +31 -0
  6. backend/app/api/v1/departments.py +4 -2
  7. backend/app/api/v1/devices.py +63 -0
  8. backend/app/api/v1/employees.py +107 -2
  9. backend/app/api/v1/enrollment.py +122 -68
  10. backend/app/api/v1/kiosk.py +302 -268
  11. backend/app/api/v1/notifications.py +73 -0
  12. backend/app/api/v1/policy.py +54 -0
  13. backend/app/api/v1/tickets.py +95 -12
  14. backend/app/api/v1/timeline.py +33 -0
  15. backend/app/core/attendance_policy.py +127 -0
  16. backend/app/core/config.py +1 -1
  17. backend/app/core/event_bus.py +37 -0
  18. backend/app/core/init_db.py +41 -0
  19. backend/app/core/rate_limiter.py +45 -0
  20. backend/app/core/security.py +13 -0
  21. backend/app/crud/crud.py +261 -6
  22. backend/app/main.py +43 -8
  23. backend/app/models/models.py +87 -2
  24. backend/app/schemas/schemas.py +113 -1
  25. backend/app/tests/test_production_features.py +5 -5
  26. backend/delete_all_employees.py +82 -0
  27. frontend/app/analytics/page.tsx +852 -0
  28. frontend/app/attendance/page.tsx +6 -6
  29. frontend/app/audit/page.tsx +198 -89
  30. frontend/app/calendar/page.tsx +482 -0
  31. frontend/app/dashboard/page.tsx +0 -0
  32. frontend/app/employees/[id]/page.tsx +2 -10
  33. frontend/app/employees/page.tsx +112 -15
  34. frontend/app/enroll/[id]/page.tsx +64 -33
  35. frontend/app/globals.css +216 -23
  36. frontend/app/holidays/page.tsx +269 -0
  37. frontend/app/kiosk/page.tsx +2 -2
  38. frontend/app/leaves/page.tsx +629 -0
  39. frontend/app/page.tsx +523 -179
  40. frontend/app/profile/page.tsx +87 -0
  41. frontend/app/reports/page.tsx +2 -85
  42. frontend/app/self-onboard/page.tsx +754 -0
  43. frontend/app/settings/page.tsx +0 -0
  44. frontend/app/tenants/page.tsx +0 -0
  45. frontend/app/tickets/page.tsx +847 -197
  46. frontend/app/utils/api.ts +9 -2
  47. frontend/components/AttendanceHeatmap.tsx +1 -1
  48. frontend/components/CommandPalette.tsx +98 -0
  49. frontend/components/SidebarLayout.tsx +172 -101
  50. 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
- total_active = db.query(func.count(models.Employee.id)).filter(
90
  models.Employee.status == "Active"
91
- ).scalar() or 0
 
 
 
92
 
93
  trends = []
94
  for d in date_list:
95
- present = db.query(func.count(models.Attendance.id)).filter(
 
 
 
96
  and_(
97
  models.Attendance.date == d,
98
- models.Attendance.status.in_(["Present", "Late", "Half Day"])
99
  )
100
- ).scalar() or 0
 
 
 
101
 
102
- late = db.query(func.count(models.Attendance.id)).filter(
 
 
 
103
  and_(
104
  models.Attendance.date == d,
105
  models.Attendance.status == "Late"
106
  )
107
- ).scalar() or 0
 
 
 
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
- departments = db.query(models.Department).all()
 
 
 
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 (current_user.company_id is not None and dept.company_id != current_user.company_id):
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 (current_user.company_id is not None and emp.company_id != current_user.company_id):
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=current_user.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(checker_view)
168
  ):
169
- return crud.get_attendance_logs(db, company_id=current_user.company_id, skip=skip, limit=limit, employee_id=employee_id, date_str=date_str)
 
 
 
 
 
 
 
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
- return crud.get_departments(db, company_id=current_user.company_id, skip=skip, limit=limit)
 
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=current_user.company_id, skip=skip, limit=limit, search=search, department_id=department_id, status=status
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
- count = crud.count_employees(db, company_id=current_user.company_id, search=search, department_id=department_id, status=status)
 
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
- @router.post("/upload")
22
- async def upload_face_image(
23
- request: Request,
24
- employee_id: int = Form(...),
25
- pose_type: str = Form(...), # e.g., front, left, right, up, down, smile, neutral, glasses
26
- file: UploadFile = File(...),
27
- db: Session = Depends(get_db),
28
- current_user: models.User = Depends(checker_manage)
29
  ):
30
- # Validate employee exists
31
- employee = crud.get_employee_by_id(db, id=employee_id)
32
- if not employee:
33
- all_emps = db.query(models.Employee).all()
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
- db_emb = crud.save_face_embedding(
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
- # Log Audit
150
- crud.create_audit_log(
151
- db=db,
152
- user_id=current_user.id,
153
- action="Enroll Face Pose",
154
- ip_address=request.client.host if request.client else None,
155
- user_agent=request.headers.get("user-agent"),
156
- details=f"Enrolled pose '{pose_type}' for employee ID: {employee.employee_id}"
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.employee_id,
 
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(..., description="Base64 encoded image frame (JPEG/PNG data URL)")
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
- now = datetime.now()
 
 
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
- # 1. Parse base64 image
141
- try:
142
- header, encoded = payload.image.split(",", 1) if "," in payload.image else ("", payload.image)
143
- img_bytes = base64.b64decode(encoded)
144
- nparr = np.frombuffer(img_bytes, np.uint8)
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
- # Check if qr_code was pre-detected by the frontend
164
- if getattr(payload, "qr_code", None):
165
- qr_val = payload.qr_code.strip()
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": "Invalid QR code. Employee badge not found.",
199
- "should_retry": True
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
- if len(faces) > 1:
210
- return {
211
- "status": "multiple_faces",
212
- "message": "Multiple faces detected. Please scan one person at a time.",
213
- "should_retry": True
214
- }
215
-
216
- face = faces[0]
217
- bbox = face["bbox"]
218
-
219
- # Scale bbox back to original image size for frontend drawing
220
- if scale_factor != 1.0:
221
- bbox_list = [float(x) / scale_factor for x in bbox]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  else:
223
- bbox_list = [float(x) for x in bbox]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
- confidence = face["confidence"]
226
- landmarks = face["landmarks"]
227
-
228
- # 3. Liveness Check
229
- liveness_score, is_live = face_engine.check_liveness(img, bbox, threshold=liveness_threshold)
230
- if not is_live and not face_engine.mock_mode:
231
- # Save spoof log
232
- log_entry = crud.create_attendance_log(
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
- # Dispatch Webhook alert
248
- try:
249
- from app.services.notifications import trigger_security_alert
250
- trigger_security_alert(
 
251
  db=db,
252
- alert_type="Spoofing Attempt Rejected",
253
- details={
254
- "camera": payload.camera,
255
- "confidence": float(confidence),
256
- "liveness_score": float(liveness_score),
257
- "timestamp": now.strftime("%Y-%m-%d %H:%M:%S")
258
- }
 
 
 
259
  )
260
- except Exception as alert_err:
261
- logger.error(f"Failed to dispatch security alert: {alert_err}")
262
-
263
- return {
264
- "status": "spoof_detected",
265
- "message": "Liveness check failed! Verification denied.",
266
- "confidence": float(confidence),
267
- "liveness_score": float(liveness_score),
268
- "should_retry": False,
269
- "bbox": bbox_list
270
- }
 
 
 
 
 
 
271
 
272
- # 4. Extract Embedding
273
- aligned = face_engine.align_face(img, landmarks)
274
- embedding = face_engine.extract_embedding(aligned)
275
-
276
- # 5. DB Matching: query pgvector if postgresql, else fallback to numpy cache-matching
277
- match_result = None
278
- is_pg = False
279
- try:
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
- if is_pg:
 
 
 
 
 
 
285
  try:
286
- # Run database-level query using pgvector's cosine distance (<=>) operator
287
- emb_list = embedding.tolist() if isinstance(embedding, np.ndarray) else list(embedding)
288
- from sqlalchemy import type_coerce, Float
289
- distance_expr = type_coerce(models.FaceEmbedding.embedding.op('<=>')(emb_list), Float).label('distance')
290
- query_res = db.query(models.FaceEmbedding, distance_expr).order_by(distance_expr).limit(1).first()
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
- # High-performance vectorized search using NumPy matrix multiplication.
308
- # ArcFace embeddings are L2-normalized, so cosine similarity is just the dot product.
309
- embeddings_matrix = np.stack([emb["embedding"] for emb in all_embeddings]) # shape (N, 512)
310
- similarities = np.dot(embeddings_matrix, embedding) # shape (N,)
311
- best_idx = int(np.argmax(similarities))
312
- best_similarity = float(similarities[best_idx])
313
-
314
- best_emb_record = all_embeddings[best_idx]
315
- class MockEmb:
316
- id = best_emb_record["id"]
317
- employee_id = best_emb_record["employee_id"]
 
 
 
318
 
319
- # distance = 1 - similarity
320
- best_dist = 1.0 - best_similarity
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
- if not match_result:
327
- # Database has no enrolled embeddings
328
- log_entry = crud.create_attendance_log(
329
- db=db,
330
- employee_id=None,
331
- camera=payload.camera,
332
- confidence=confidence,
333
- liveness_score=liveness_score,
334
- is_spoof=False,
335
- status="Empty Vector Index",
336
- timestamp=now,
337
- location_text=location_text if 'location_text' in locals() else None,
338
- latitude=payload.latitude if hasattr(payload, 'latitude') else None,
339
- longitude=payload.longitude if hasattr(payload, 'longitude') else None
340
- )
341
- _publish_log(log_entry)
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
- db_emb, distance = match_result
350
- # Similarity = 1 - Distance
351
- similarity = 1.0 - float(distance)
352
-
353
- employee = crud.get_employee_by_id(db, db_emb.employee_id) if db_emb else None
354
-
355
- if similarity < face_threshold:
356
- qr_fallback_setting = crud.get_setting_by_key(db, "QR_FALLBACK_ENABLED")
357
- qr_fallback_enabled = qr_fallback_setting.value.lower() == "true" if qr_fallback_setting else True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
 
359
- if False: # Disable automatic QR fallback on borderline match
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  return {
361
- "status": "needs_qr",
362
- "message": "Face matched but requires identity verification. Please scan your employee QR code.",
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": False,
373
  "bbox": bbox_list
374
  }
375
-
376
- # Low confidence match -> Unknown
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=now,
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=now,
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=now,
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=now,
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=now,
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=now,
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 not attendance_record:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- attendance_record = models.Attendance(
631
- employee_id=employee.id,
632
- date=now.date(),
633
- check_in=now,
634
- late_arrival=is_late,
635
- status=status
636
- )
637
- db.add(attendance_record)
 
 
 
 
 
 
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=now,
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=now,
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=now,
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=now,
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=datetime.now()
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=datetime.now()
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=datetime.now()
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=datetime.now()
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
- now = datetime.now()
1026
  attendance_record = crud.mark_kiosk_attendance(
1027
  db=db,
1028
  employee_id=employee.id,
1029
- timestamp=now,
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=now,
1043
- location_text=location_text if 'location_text' in locals() else None,
1044
- latitude=payload.latitude if hasattr(payload, 'latitude') else None,
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
- if role_name in ["Super Admin", "Admin", "HR"]:
21
- # Admins/HR can see all tickets for their company
22
- return crud.get_tickets(db, company_id=current_user.company_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=current_user.company_id, employee_id=current_user.employee.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
- role_name = current_user.role.name if current_user.role else "Employee"
37
- if role_name != "Employee":
38
- raise HTTPException(status_code=403, detail="Only employees can open support tickets")
39
-
40
- if not current_user.employee:
41
- raise HTTPException(status_code=400, detail="User is not registered as an employee")
 
 
42
 
43
  db_ticket = crud.create_ticket(
44
- db, ticket=ticket, employee_id=current_user.employee.id, company_id=current_user.company_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 = "admin@netraid.ai"
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
- db_company = models.Company(**company.model_dump())
 
 
 
 
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
- for key, value in company.model_dump(exclude_unset=True).items():
 
 
 
 
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
- today = timestamp.date()
 
 
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 = timestamp > check_in_deadline
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 = timestamp < shift_end_dt
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
- os.environ["TZ"] = "Asia/Kolkata"
 
 
 
 
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.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit, companies, tickets
 
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 = filename.split(".")[0].lower()
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 == 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 if not in DB (e.g. legacy/development)
53
- local_path = os.path.join(settings.UPLOAD_DIR, employee_id, filename)
54
- if os.path.exists(local_path):
 
 
 
 
 
55
  try:
56
- with open(local_path, "rb") as f:
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.now, nullable=False)
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.now)
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
- pass
 
 
 
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(10, 5))
85
  att = crud.mark_kiosk_attendance(db, employee.id, checkin_time_on_time, "Test Cam", 0.95)
86
 
87
  assert att.late_arrival is False
@@ -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(10, 15))
96
  att_late = crud.mark_kiosk_attendance(db, employee.id, checkin_time_late, "Test Cam", 0.95)
97
 
98
  # In mark_kiosk_attendance, it uses local_now (current time) for determining is_late,
 
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="glass-card rounded-2xl border border-white/6 overflow-hidden">
227
- <div className="px-5 py-3.5 border-b border-white/5 bg-white/[0.005]">
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="glass-card rounded-2xl border border-white/6 overflow-hidden">
311
- <div className="px-5 py-3.5 border-b border-white/5 bg-white/[0.005]">
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
- return actionMatch || userMatch || detailsMatch;
 
52
  });
53
 
54
- // Helper to map log actions to modern icons
55
- const getActionIcon = (action: string) => {
 
 
 
 
 
 
56
  const act = action.toLowerCase();
57
- if (act.includes("login") || act.includes("auth")) return <Key className="w-4 h-4 text-emerald-600" />;
58
- if (act.includes("create") || act.includes("enroll")) return <UserPlus className="w-4 h-4 text-blue-600" />;
59
- if (act.includes("delete")) return <ShieldAlert className="w-4 h-4 text-rose-600" />;
60
- if (act.includes("setting") || act.includes("update")) return <Sliders className="w-4 h-4 text-amber-600" />;
61
- return <Activity className="w-4 h-4 text-zinc-500" />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  };
63
 
64
  return (
65
  <SidebarLayout>
66
- <div className="space-y-6 page-enter">
67
- {/* Header */}
68
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-zinc-100">
69
- <div>
70
- <h1 className="text-xl font-bold text-zinc-900 tracking-tight flex items-center gap-2">
71
- <History className="w-5 h-5 text-zinc-700" />
72
- System Audit Logs
 
 
 
73
  </h1>
 
 
 
74
  </div>
 
75
  <div className="flex items-center gap-2">
76
  <button
77
- onClick={() => setShowClearConfirm(true)}
78
- disabled={isDeleting}
79
- className="text-[12px] h-10 px-5 flex items-center gap-2 rounded-xl bg-gradient-to-r from-red-600 to-rose-600 text-white hover:opacity-90 active:scale-95 shadow-md shadow-red-950/20 border border-red-500/20 cursor-pointer transition-all font-bold"
80
  >
81
- <Trash2 className="w-3.5 h-3.5" />
82
- Clear Logs
 
 
 
83
  </button>
84
  <button
85
- onClick={() => refetch()}
86
- className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl border border-zinc-200 hover:bg-zinc-50 text-zinc-700 cursor-pointer transition-all active:scale-95"
 
87
  >
88
- <RefreshCw className="w-3.5 h-3.5" />
89
- Refresh Logs
90
  </button>
91
  </div>
92
  </div>
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  {/* Filter Bar */}
95
- <div className="relative max-w-md">
96
- <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400 pointer-events-none" />
97
- <input
98
- type="text"
99
- placeholder="Search by action, email, or details..."
100
- value={search}
101
- onChange={(e) => setSearch(e.target.value)}
102
- className="input-field h-9.5 pl-10 text-[12.5px] bg-white border-zinc-200 focus:border-zinc-800 text-zinc-900 rounded-xl transition-all w-full shadow-sm"
103
- />
 
 
 
 
 
 
 
104
  </div>
105
 
106
- {/* Audit Log Table */}
107
- <div className="glass-card rounded-2xl border border-zinc-200 overflow-hidden shadow-sm bg-white">
108
  <div className="overflow-x-auto">
109
- <table className="w-full text-left border-collapse text-[12px]">
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-[140px]">Timestamp</th>
113
- <th className="py-3 px-5 w-[180px]">Action</th>
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-150 text-zinc-700">
120
  {isLoading ? (
121
- Array.from({ length: 8 }).map((_, i) => (
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 text-zinc-400 font-medium font-mono">
133
- No audit logs found.
 
 
 
 
 
 
134
  </td>
135
  </tr>
136
  ) : (
137
  filteredLogs.map((log: any) => (
138
- <tr key={log.id} className="hover:bg-zinc-50/50 transition-colors">
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
- <div className="flex items-center gap-2">
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-650 font-medium">
151
- {log.user?.email || <span className="text-zinc-400 italic">System / Anonymous</span>}
152
  </td>
153
- <td className="py-3.5 px-5 text-zinc-600 pr-8">
154
  {log.details}
155
  </td>
156
- <td className="py-3.5 px-5 font-mono text-zinc-500 text-[11px] flex items-center gap-1.5">
157
- <Laptop className="w-3.5 h-3.5 text-zinc-400" />
158
- {log.ip_address || "Local"}
 
 
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-100 bg-zinc-50/50 flex items-center justify-between">
169
- <span className="text-xs text-zinc-500 font-mono">
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="btn-ghost p-1.5 rounded-lg border border-zinc-200 hover:bg-zinc-100 disabled:opacity-50 transition-all cursor-pointer"
 
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="btn-ghost p-1.5 rounded-lg border border-zinc-200 hover:bg-zinc-100 disabled:opacity-50 transition-all cursor-pointer"
 
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-50">
199
- <div className="modal-content max-w-sm border border-red-500/10 shadow-[0_12px_40px_rgba(239,68,68,0.12)]">
200
- <div className="flex flex-col items-center text-center p-2 space-y-4">
201
- <div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/15 flex items-center justify-center text-rose-500 shadow-inner">
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-slate-500 mt-2 leading-relaxed">
207
- Are you absolutely sure you want to clear all system audit logs?
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
- <div className="flex gap-2.5 w-full pt-2 border-t border-zinc-100">
 
 
 
214
  <button
215
  type="button"
216
  onClick={() => setShowClearConfirm(false)}
217
  disabled={isDeleting}
218
- className="flex-1 btn-ghost h-9.5 text-[12px] rounded-xl cursor-pointer hover:bg-zinc-100 text-zinc-700 font-medium border border-zinc-200"
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-gradient-to-r from-red-650 to-rose-600 hover:opacity-90 active:scale-95 text-white font-bold text-[12px] rounded-xl cursor-pointer h-10 flex items-center justify-center gap-2 shadow-md shadow-rose-950/15 border border-rose-500/10 transition-all"
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} &bull; {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-[100px] flex items-center justify-between px-5 pb-2 shrink-0 z-10">
1171
- <div className="flex flex-col min-w-0 pr-1.5">
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
- showToast.success("Profile photo uploaded successfully!");
225
  } catch (err: any) {
226
- showToast.error(err.message || "Failed to upload photo");
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="glass-card rounded-2xl border border-white/6 overflow-hidden">
361
- <div className="px-5 py-3.5 border-b border-white/5 bg-white/[0.005] flex items-center justify-between">
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: 5 }).map((_, j) => (
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={5} className="py-20 text-center">
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
- <span className={`badge ${emp.status === "Active" ? "badge-emerald" : "badge-slate"} flex items-center gap-1 w-fit`}>
425
- {emp.status === "Active" && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />}
426
- {emp.status}
427
- </span>
 
 
 
 
 
 
 
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.images && emp.images.length > 0 ? (
 
 
 
 
 
 
 
 
 
 
 
 
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
- <input type="password" required placeholder="Minimum 8 characters" value={password}
606
- onChange={(e) => setPassword(e.target.value)} className={inputCls} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4">
997
- <h3 className="text-[13px] font-bold text-slate-900 uppercase tracking-wider">Facial Registry</h3>
998
 
999
- <div className="space-y-1">
1000
- <div className="flex items-center justify-between text-xs font-mono font-bold text-slate-650">
1001
  <span>Database Status:</span>
1002
- <span className={isProfileComplete ? "text-emerald-600" : "text-amber-500"}>
 
1003
  {isProfileComplete ? "Complete" : "Incomplete"}
1004
  </span>
1005
  </div>
1006
- <div className="flex items-center justify-between text-xs font-mono font-bold text-slate-650">
1007
- <span>Active Vectors:</span>
1008
- <span>{enrolledCount} / {POSE_KEYS.length}</span>
 
 
 
 
 
 
 
 
 
1009
  </div>
1010
  </div>
1011
 
1012
  <button
1013
  onClick={startAutoCapture}
1014
- className="w-full h-11 bg-slate-950 hover:bg-slate-900 border border-slate-950 text-white font-bold text-xs uppercase tracking-wider rounded-xl flex items-center justify-center gap-2.5 transition-all shadow-md cursor-pointer"
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="bg-slate-50 border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
1023
- <h4 className="text-[11.5px] font-bold text-slate-700 uppercase tracking-wider">Manual Photo Upload</h4>
1024
  <div className="flex gap-2">
1025
  <select
1026
  value={selectedPose}
1027
  onChange={(e) => setSelectedPose(e.target.value)}
1028
- className="h-9 px-2 text-[11px] font-bold bg-white border border-slate-200 rounded-lg flex-1 outline-none text-slate-700"
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-3.5 bg-white border border-slate-250 text-slate-800 hover:bg-slate-50 font-bold text-[11px] rounded-lg flex items-center gap-1.5 transition-all shadow-sm">
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 bg-white border border-slate-200 rounded-2xl p-6 shadow-sm space-y-4">
1051
- <h3 className="text-sm font-black text-slate-900 tracking-tight">Facial Pose Checklist</h3>
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 rounded-2xl border flex flex-col items-center text-center justify-center transition-all hover:scale-[1.03] duration-200 select-none ${
1061
  done
1062
- ? "bg-emerald-500/[0.03] border-emerald-500/20 text-emerald-800 shadow-[0_2px_12px_rgba(16,185,129,0.02)]"
1063
- : "bg-slate-50/50 border-slate-200/80 hover:border-slate-350"
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-100 text-slate-400"
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-505"}`}>
1074
  {POSES[key].label.replace(" Profile", "").replace(" Face", "").replace(" Option", "").replace(" Light", "")}
1075
  </span>
1076
  {done ? (
1077
- <CheckCircle2 className="w-4 h-4 text-emerald-500 mt-3 shrink-0" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-[120px] flex items-center justify-between px-6 pb-2.5 shrink-0 z-10">
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-[75px] h-[75px] bg-white rounded-lg border border-slate-200/80 p-1 flex items-center justify-center shadow-2xs shrink-0 font-mono">
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: var(--bg-base);
269
  background-image:
270
- radial-gradient(circle at 50% 50%, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 40%, transparent 70%),
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.15'/><stop offset='100%' stop-color='%2306b6d4' stop-opacity='0'/></linearGradient><linearGradient id='grad-indigo-light' x1='0' y1='0' x2='0' y2='1'><stop offset='0%' stop-color='%236366f1' stop-opacity='0.1'/><stop offset='100%' stop-color='%236366f1' stop-opacity='0'/></linearGradient></defs><style>.wave-1{animation:flow-1 38s linear infinite, bob-1 12s ease-in-out infinite alternate}.wave-2{animation:flow-2 28s linear infinite, bob-2 8s ease-in-out infinite alternate}.wave-3{animation:flow-3 22s linear infinite, bob-3 10s ease-in-out infinite alternate}.wave-4{animation:flow-4 16s linear infinite, bob-4 6s ease-in-out infinite alternate}@keyframes flow-1{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-2{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes flow-3{0%{transform:translate3d(0,0,0)}100%{transform:translate3d(-1440px,0,0)}}@keyframes flow-4{0%{transform:translate3d(-1440px,0,0)}100%{transform:translate3d(0,0,0)}}@keyframes bob-1{0%{transform:translateY(-10px)}100%{transform:translateY(10px)}}@keyframes bob-2{0%{transform:translateY(8px)}100%{transform:translateY(-8px)}}@keyframes bob-3{0%{transform:translateY(-6px)}100%{transform:translateY(6px)}}@keyframes bob-4{0%{transform:translateY(5px)}100%{transform:translateY(-5px)}}</style><path class='wave-1' fill='url(%23grad-cyan-light)' d='M-1440,320 C-1080,240 -720,400 -360,320 C0,240 360,400 720,320 C1080,240 1440,400 1800,320 C2160,240 2520,400 2880,320 L2880,600 L-1440,600 Z'/><path class='wave-2' fill='url(%23grad-indigo-light)' d='M-1440,340 C-1080,440 -720,240 -360,340 C0,440 360,240 720,340 C1080,440 1440,240 1800,340 C2160,440 2520,240 2880,340 L2880,600 L-1440,600 Z'/><path class='wave-3' stroke='%2306b6d4' stroke-width='1.5' fill='none' opacity='0.45' filter='url(%23glow-light)' d='M-1440,310 C-1080,250 -720,370 -360,310 C0,250 360,370 720,310 C1080,250 1440,370 1800,310 C2160,250 2520,370 2880,310'/><path class='wave-4' stroke='%236366f1' stroke-width='1.25' fill='none' opacity='0.35' d='M-1440,330 C-1080,380 -720,280 -360,330 C0,380 360,280 720,330 C1080,380 1440,280 1800,330 C2160,380 2520,280 2880,330'/><path class='wave-3' stroke='%233b82f6' stroke-width='0.75' fill='none' opacity='0.5' d='M-1440,300 C-1080,220 -720,380 -360,300 C0,220 360,380 720,300 C1080,220 1440,380 1800,300 C2160,220 2520,380 2880,300'/></svg>");
275
- background-size: cover, cover, 80px 80px, 80px 80px, cover;
276
  background-position: center;
277
- background-repeat: no-repeat, no-repeat, repeat, 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: fadeInUp 0.3s cubic-bezier(0.4,0,0.2,1) both;
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.2);
422
  backdrop-filter: blur(4px);
423
  -webkit-backdrop-filter: blur(4px);
424
- z-index: 50;
425
  display: flex;
426
- align-items: center;
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
- max-height: 90vh;
439
- overflow-y: auto;
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.06);
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: #a1a1aa !important; /* Zinc 400 */
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: #d4d4d8 !important; /* Zinc 300 */
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: #27272a !important;
661
  }
662
 
663
  .dark .bg-zinc-100 {
664
- background-color: #18181b !important;
665
  }
666
 
667
  .dark .border-zinc-200 {
@@ -724,13 +755,68 @@ select.input-field option {
724
  border-color: #ffffff !important;
725
  }
726
 
727
- .dark select.input-field option {
728
- background: #18181b;
729
- color: #f4f4f5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  }
731
 
732
  .dark .bg-zinc-50\/30 {
733
- background-color: rgba(39, 39, 42, 0.3) !important;
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 &bull; 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} &bull; {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
- // Autofill defaults for convenience (can be edited by the user)
32
- if (role === "Super Admin") {
33
- setEmail("admin@netraid.ai");
34
- } else if (role === "Admin") {
35
- setEmail("hr@netraid.ai");
36
- } else {
37
- setEmail("employee@netraid.ai");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. Please try again.");
77
  } finally {
78
  setLoading(false);
79
  }
80
  };
81
 
82
  return (
83
- <div className="min-h-screen relative bg-[var(--bg-base)] flex items-center justify-center overflow-hidden px-4">
84
- {/* Grid Mesh background */}
85
- <div className="absolute inset-0 mesh-bg pointer-events-none" />
86
-
87
- <div className="w-full max-w-md relative z-10 animate-fadeInUp">
88
-
89
- {/* Logo block */}
90
- <div className="text-center mb-6">
91
- <div className="relative inline-flex items-center justify-center w-18 h-18 rounded-2xl bg-slate-950 border border-slate-800/80 shadow-[0_0_20px_rgba(6,182,212,0.25)] mb-4 overflow-hidden group">
92
- {/* Ambient glowing background inside the container */}
93
- <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
94
-
95
- {/* Robotic eye SVG */}
96
- <svg viewBox="0 0 100 100" className="w-11 h-11 relative z-10" fill="none" xmlns="http://www.w3.org/2000/svg">
97
- <circle cx="50" cy="50" r="45" stroke="#1e293b" strokeWidth="1.5" strokeDasharray="6 12" className="animate-rotate-ring" />
98
- <circle cx="50" cy="50" r="40" stroke="#0891b2" strokeWidth="1" strokeDasharray="40 10 15 5" className="animate-rotate-ring-reverse" style={{ opacity: 0.6 }} />
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
- <h1 className="text-xl font-extrabold text-slate-900 tracking-tight">
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
- {/* Role Selector Screen */}
127
- {selectedRole === null ? (
128
- <div className="space-y-3">
129
- {/* Super Admin Card */}
130
- <button
131
- onClick={() => handleRoleSelect("Super Admin")}
132
- 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"
133
- >
134
- <div className="w-12 h-12 rounded-xl bg-cyan-50 flex items-center justify-center text-cyan-600 group-hover:scale-105 transition-all">
135
- <Shield className="w-6 h-6" />
136
- </div>
137
- <div className="flex-1 space-y-0.5">
138
- <p className="text-xs font-bold text-slate-900">Super Admin Portal</p>
139
- <p className="text-[10px] text-slate-400">Onboard & monitor organizations</p>
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
- <form onSubmit={handleSubmit} className="space-y-3.5">
199
- {/* Email */}
200
- <div className="space-y-1.5">
201
- <label className="block text-[9.5px] font-bold text-slate-400 uppercase tracking-wider">
202
- Email Address
203
- </label>
204
- <div className="relative">
205
- <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-450 pointer-events-none" />
206
- <input
207
- id="login-email"
208
- type="email"
209
- required
210
- placeholder="name@organization.com"
211
- value={email}
212
- onChange={(e) => setEmail(e.target.value)}
213
- className="input-field pl-icon text-xs h-10 w-full rounded-xl border border-slate-200 px-3 bg-white"
214
- autoComplete="email"
215
- />
216
- </div>
217
- </div>
218
-
219
- {/* Password */}
220
- <div className="space-y-1.5">
221
- <div className="flex items-center justify-between">
222
- <label className="text-[9.5px] font-bold text-slate-400 uppercase tracking-wider">
223
- Password
224
- </label>
225
- </div>
226
- <div className="relative">
227
- <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-455 pointer-events-none" />
228
- <input
229
- id="login-password"
230
- type={showPass ? "text" : "password"}
231
- required
232
- placeholder="β€’β€’β€’β€’β€’β€’β€’β€’"
233
- value={password}
234
- onChange={(e) => setPassword(e.target.value)}
235
- className="input-field pl-icon pr-icon text-xs h-10 w-full rounded-xl border border-slate-200 px-3 bg-white"
236
- autoComplete="current-password"
237
- />
238
- <button
239
- type="button"
240
- onClick={() => setShowPass(!showPass)}
241
- className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
242
- >
243
- {showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
244
- </button>
245
- </div>
246
- </div>
247
-
248
- <button
249
- type="submit"
250
- disabled={loading}
251
- className="w-full h-10 bg-slate-900 hover:bg-slate-800 text-white font-extrabold text-xs rounded-xl shadow-xs transition-all active:scale-98 cursor-pointer flex items-center justify-center gap-1.5"
252
- >
253
- {loading ? "Authenticating..." : `Sign In as ${selectedRole}`}
254
- </button>
255
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  </div>
257
- )}
258
- </div>
 
 
 
 
 
 
 
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 glass-card rounded-2xl border border-slate-200 p-6 space-y-6 shadow-2xs">
119
  <div className="flex items-center gap-2.5 border-b border-slate-100 pb-4">
120
  <div className="w-8 h-8 rounded-lg bg-zinc-100 flex items-center justify-center border border-zinc-200">
121
  <FileDown className="w-4 h-4 text-zinc-700" />
@@ -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="glass-card rounded-2xl border border-slate-200 p-5 space-y-4.5 bg-slate-50/10">
344
  <div className="flex items-center gap-2 mb-1">
345
  <Layers className="w-4 h-4 text-zinc-700" />
346
  <h3 className="text-xs font-bold text-[var(--text-primary)] uppercase tracking-wider">Audit Protocols</h3>
@@ -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, CheckCircle, Clock,
10
- AlertTriangle, Filter, Search, ChevronRight, User, Loader2
 
 
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
- // New ticket form states
 
 
 
 
 
 
 
23
  const [title, setTitle] = useState("");
24
- const [category, setCategory] = useState("Payroll");
 
25
  const [priority, setPriority] = useState("Medium");
26
  const [initialMessage, setInitialMessage] = useState("");
27
 
28
  const chatEndRef = useRef<HTMLDivElement>(null);
29
 
30
  useEffect(() => {
31
- setProfile(getUserProfile());
 
 
 
 
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 message");
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 marked as ${data.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
- createTicketMutation.mutate({ title, category, priority, message: initialMessage });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="h-[calc(100vh-6.5rem)] flex flex-col md:flex-row border border-slate-200 rounded-2xl overflow-hidden bg-white shadow-2xs">
137
 
138
- {/* Left pane: Tickets list */}
139
- <div className="w-full md:w-80 border-r border-slate-200 flex flex-col shrink-0">
140
- <div className="p-4 border-b border-slate-100 flex items-center justify-between">
141
- <h1 className="text-sm font-extrabold text-slate-900 tracking-tight flex items-center gap-1.5">
142
- <MessageSquare className="w-4 h-4 text-slate-700" />
143
- Helpdesk Support
 
 
144
  </h1>
145
- {!isHR && (
146
- <button
147
- onClick={() => setShowAddModal(true)}
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-1 overflow-y-auto p-2 space-y-1">
157
- {isLoading ? (
158
- Array.from({ length: 4 }).map((_, i) => (
159
- <div key={i} className="p-3 border border-slate-50 rounded-xl animate-pulse space-y-2">
160
- <div className="h-3 w-28 bg-slate-100 rounded" />
161
- <div className="h-2 w-20 bg-slate-100 rounded" />
162
- </div>
163
- ))
164
- ) : tickets.length > 0 ? (
165
- tickets.map((t: any) => (
166
- <button
167
- key={t.id}
168
- onClick={() => setSelectedTicket(t)}
169
- className={`w-full text-left p-3 rounded-xl border transition-all cursor-pointer flex flex-col gap-1.5 ${
170
- selectedTicket?.id === t.id
171
- ? "border-slate-300 bg-slate-50"
172
- : "border-transparent hover:bg-slate-50/50"
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
- {/* Right pane: Message details */}
210
- <div className="flex-1 flex flex-col bg-slate-50/50 h-full">
211
- {selectedTicket ? (
212
- <>
213
- {/* Ticket header details */}
214
- <div className="p-4 border-b border-slate-200 bg-white flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 shrink-0">
215
- <div className="space-y-0.5">
216
- <span className="text-[9px] font-extrabold uppercase tracking-wider text-slate-400">{selectedTicket.category} Support Ticket</span>
217
- <h2 className="text-xs font-bold text-slate-900">{selectedTicket.title}</h2>
218
- {isHR && selectedTicket.employee && (
219
- <p className="text-[10px] text-slate-450">Submitted by: <strong className="text-slate-700">{selectedTicket.employee.name}</strong> ({selectedTicket.employee.email})</p>
220
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  </div>
 
 
 
 
 
 
 
 
 
 
 
222
 
223
- {/* HR Status updater action */}
224
- {isHR ? (
225
- <div className="flex items-center gap-2">
226
- <span className="text-[10px] font-bold text-slate-400">STATUS:</span>
227
- <select
228
- value={selectedTicket.status}
229
- onChange={(e) => updateStatusMutation.mutate({ ticketId: selectedTicket.id, status: e.target.value })}
230
- className="text-[10px] font-bold h-7 border border-slate-200 rounded-lg bg-white px-2 text-slate-800"
231
- >
232
- <option value="Open">Open</option>
233
- <option value="In Progress">In Progress</option>
234
- <option value="Closed">Closed</option>
235
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- {/* Chat Thread */}
251
- <div className="flex-1 overflow-y-auto p-4 space-y-3.5">
252
- {selectedTicket.messages && selectedTicket.messages.map((m: any) => {
253
- const isMe = m.sender_id === profile?.id;
254
- return (
255
- <div key={m.id} className={`flex items-start gap-2.5 max-w-[85%] ${isMe ? "ml-auto flex-row-reverse" : "mr-auto"}`}>
256
- <div className="w-7 h-7 rounded-full bg-slate-200 border border-slate-300 flex items-center justify-center text-slate-600 shrink-0">
257
- <User className="w-3.5 h-3.5" />
 
 
 
 
258
  </div>
259
- <div className="space-y-1">
260
- <div className={`p-3 rounded-2xl text-xs leading-relaxed ${
261
- isMe
262
- ? "bg-slate-900 text-white rounded-tr-none"
263
- : "bg-white border border-slate-200 text-slate-800 rounded-tl-none"
264
- }`}>
265
- <p>{m.message}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  </div>
267
- <p className={`text-[8px] text-slate-400 font-mono ${isMe ? "text-right" : ""}`}>
268
- {new Date(m.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </p>
270
  </div>
271
  </div>
272
- );
273
- })}
274
- <div ref={chatEndRef} />
275
- </div>
276
 
277
- {/* Reply Form */}
278
- {selectedTicket.status !== "Closed" ? (
279
- <form onSubmit={handleSendReply} className="p-3 bg-white border-t border-slate-200 flex gap-2 shrink-0">
280
- <input
281
- type="text"
282
- placeholder="Type support reply message..."
283
- value={replyText}
284
- onChange={(e) => setReplyText(e.target.value)}
285
- className="flex-1 text-xs h-9 border border-slate-200 px-3 rounded-xl focus:outline-none focus:border-cyan-400 text-slate-900 bg-white"
286
- />
287
- <button
288
- type="submit"
289
- disabled={replyMutation.isPending || !replyText.trim()}
290
- className="h-9 px-4 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-xs font-bold flex items-center gap-1 active:scale-95 transition-all cursor-pointer disabled:opacity-50"
291
- >
292
- {replyMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
293
- Send
294
- </button>
295
- </form>
296
- ) : (
297
- <div className="p-4 bg-slate-100 border-t border-slate-200 text-center text-[10px] text-slate-500 font-semibold uppercase tracking-wider shrink-0">
298
- πŸ”’ This support ticket is closed and resolved.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  </div>
300
- )}
301
- </>
302
- ) : (
303
- <div className="flex-1 flex flex-col items-center justify-center text-slate-400 gap-2 p-6">
304
- <MessageSquare className="w-10 h-10 text-slate-300" />
305
- <p className="text-xs font-medium">Select a ticket from the left panel to open chat support</p>
306
- </div>
307
- )}
 
 
308
  </div>
 
309
  </div>
310
 
311
- {/* Add modal */}
312
  {showAddModal && (
313
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs px-4">
314
- <div className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-fadeInUp p-6 space-y-4">
315
- <h2 className="text-sm font-bold text-slate-900 border-b border-slate-100 pb-3">Open Support Ticket</h2>
316
- <form onSubmit={handleCreateSubmit} className="space-y-3">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  <div className="space-y-1">
318
- <label className="text-[9.5px] font-bold text-slate-400 uppercase tracking-wider">Subject Title</label>
 
 
319
  <input
320
  type="text"
321
  required
322
- placeholder="e.g. Discrepancy in check-in logs"
323
  value={title}
324
  onChange={(e) => setTitle(e.target.value)}
325
- className="w-full text-xs h-9 px-3 rounded-lg border border-slate-200 focus:outline-none focus:border-cyan-400 bg-white text-slate-900"
326
  />
327
  </div>
 
328
  <div className="grid grid-cols-2 gap-3">
 
329
  <div className="space-y-1">
330
- <label className="text-[9.5px] font-bold text-slate-400 uppercase tracking-wider">Category</label>
 
 
331
  <select
332
  value={category}
333
  onChange={(e) => setCategory(e.target.value)}
334
- className="w-full text-xs h-9 px-3 rounded-lg border border-slate-200 focus:outline-none focus:border-cyan-400 bg-white text-slate-900"
335
  >
336
- <option value="Payroll">Payroll</option>
337
- <option value="Attendance">Attendance</option>
338
- <option value="IT Support">IT Support</option>
339
- <option value="Leave Requests">Leave Requests</option>
340
- <option value="General Queries">General Queries</option>
 
341
  </select>
342
  </div>
 
343
  <div className="space-y-1">
344
- <label className="text-[9.5px] font-bold text-slate-400 uppercase tracking-wider">Priority</label>
 
 
345
  <select
346
  value={priority}
347
  onChange={(e) => setPriority(e.target.value)}
348
- className="w-full text-xs h-9 px-3 rounded-lg border border-slate-200 focus:outline-none focus:border-cyan-400 bg-white text-slate-900"
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-[9.5px] font-bold text-slate-400 uppercase tracking-wider">Message Description</label>
 
 
358
  <textarea
359
  required
360
  rows={4}
361
- placeholder="Describe your issue or query here..."
362
  value={initialMessage}
363
  onChange={(e) => setInitialMessage(e.target.value)}
364
- className="w-full text-xs p-3 rounded-lg border border-slate-200 focus:outline-none focus:border-cyan-400 bg-white text-slate-900 resize-none"
365
  />
366
  </div>
367
- <div className="flex items-center justify-end gap-2 pt-4 border-t border-slate-100">
 
368
  <button
369
  type="button"
370
  onClick={() => setShowAddModal(false)}
371
- className="px-3.5 py-2 text-xs font-bold text-slate-500 hover:bg-slate-100 rounded-lg cursor-pointer"
372
  >
373
  Cancel
374
  </button>
375
  <button
376
  type="submit"
377
  disabled={createTicketMutation.isPending}
378
- className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-white font-extrabold text-xs rounded-lg cursor-pointer flex items-center gap-1"
379
  >
380
- {createTicketMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
381
- Open Ticket
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
- throw new Error(errorData.detail || "Server error occurred");
 
 
 
 
 
 
 
 
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="bg-white border border-zinc-100 rounded-xl p-5 shadow-xs">
93
  <div className="flex items-center gap-2 mb-4 border-b border-zinc-50 pb-3">
94
  <Calendar className="w-4 h-4 text-slate-450" />
95
  <h3 className="text-[11px] font-bold text-slate-450 uppercase tracking-wider">
 
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: typeof navItems[0],
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-3 px-3"} py-2.5 rounded-xl transition-all duration-200 ${
55
  isActive
56
- ? "bg-[var(--border-subtle)] text-[var(--text-primary)] font-semibold"
57
  : "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-subtle)]"
58
  }`}
59
  >
60
- {/* Active indicator */}
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-[var(--text-primary)] text-[var(--bg-base)]"
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
- const visibleNavItems = isEmployee
175
- ? navItems.filter(item => item.href === "/dashboard" || item.href === "/tickets")
176
- : isSuperAdmin
177
- ? navItems
178
- : navItems.filter(item => item.href !== "/tenants");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-0.5">
259
  {visibleNavItems.map((item) => (
260
  <NavLink
261
  key={item.href}
262
  item={item}
263
- isActive={pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))}
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
- <div className="relative shrink-0">
303
- <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">
304
- {initials}
 
 
 
305
  </div>
306
- <div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-emerald-500 border-2 border-white" />
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-0 group-hover:opacity-100 cursor-pointer"
322
  >
323
  <LogOut className="w-3.5 h-3.5" />
324
  </button>
325
- </>
326
  )}
327
  </div>
328
 
329
  {isCollapsed && (
330
- <div className="flex justify-center mt-2">
 
 
 
 
 
 
 
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-1 p-4">
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={pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))}
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
- <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">
446
- {initials}
447
- </div>
448
- <div className="flex-1 min-w-0">
449
- <p className="text-xs font-bold text-[var(--text-primary)] truncate">{user?.role?.name}</p>
450
- <p className="text-[10px] text-[var(--text-muted)] truncate">{user?.email}</p>
451
- </div>
 
 
 
 
 
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-end bg-[var(--bg-surface)]/95 backdrop-blur-xl sticky top-0 z-30">
464
- <div className="flex items-center gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
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 className="max-w-7xl mx-auto px-5 py-6 md:px-8 md:py-8">
 
 
 
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
+