diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..5a586b3d1ef3b366172aa7e28cd83a7b3612c757 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.lint.unknownAtRules": "ignore" +} diff --git a/backend/app/api/v1/analytics.py b/backend/app/api/v1/analytics.py index 14fa280d6c49e8bc6677a29319591c2422ac37c9..274762429281d655dae2ea9560b7a36c213260ba 100644 --- a/backend/app/api/v1/analytics.py +++ b/backend/app/api/v1/analytics.py @@ -85,26 +85,41 @@ def get_attendance_trends( # Generate list of dates date_list = [start_date + timedelta(days=i) for i in range(days)] - # Get total active employees - total_active = db.query(func.count(models.Employee.id)).filter( + # Get total active employees for this company + query = db.query(func.count(models.Employee.id)).filter( models.Employee.status == "Active" - ).scalar() or 0 + ) + if current_user.company_id is not None: + query = query.filter(models.Employee.company_id == current_user.company_id) + total_active = query.scalar() or 0 trends = [] for d in date_list: - present = db.query(func.count(models.Attendance.id)).filter( + # Present status checks (include WFH as active) + present_query = db.query(func.count(models.Attendance.id)).join( + models.Employee, models.Attendance.employee_id == models.Employee.id + ).filter( and_( models.Attendance.date == d, - models.Attendance.status.in_(["Present", "Late", "Half Day"]) + models.Attendance.status.in_(["Present", "Late", "Half Day", "WFH"]) ) - ).scalar() or 0 + ) + if current_user.company_id is not None: + present_query = present_query.filter(models.Employee.company_id == current_user.company_id) + present = present_query.scalar() or 0 - late = db.query(func.count(models.Attendance.id)).filter( + # Late status check + late_query = db.query(func.count(models.Attendance.id)).join( + models.Employee, models.Attendance.employee_id == models.Employee.id + ).filter( and_( models.Attendance.date == d, models.Attendance.status == "Late" ) - ).scalar() or 0 + ) + if current_user.company_id is not None: + late_query = late_query.filter(models.Employee.company_id == current_user.company_id) + late = late_query.scalar() or 0 absent = max(0, total_active - present) @@ -126,7 +141,10 @@ def get_department_distribution( Returns employee and attendance counts by department for ECharts. """ today = date.today() - departments = db.query(models.Department).all() + if current_user.company_id is not None: + departments = db.query(models.Department).filter(models.Department.company_id == current_user.company_id).all() + else: + departments = db.query(models.Department).all() dist = [] for dept in departments: @@ -228,7 +246,93 @@ def get_attendance_heatmap( else: query = query.filter(models.Attendance.status.in_(["Present", "Late", "Half Day"])) - results = query.group_by(models.Attendance.date).all() + results = query.group_by(models.AttendanceDate).all() if hasattr(models, 'AttendanceDate') else query.group_by(models.Attendance.date).all() heatmap_data = {r[0].isoformat(): r[1] for r in results} return heatmap_data + +@router.get("/recognition") +def get_recognition_analytics( + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_view) +): + company_id = current_user.company_id + logs_query = db.query(models.AttendanceLog) + if company_id is not None: + logs_query = logs_query.join(models.Employee).filter(models.Employee.company_id == company_id) + + total_scans = logs_query.count() + spoofs = logs_query.filter(models.AttendanceLog.is_spoof == True).count() + + avg_confidence = db.query(func.avg(models.AttendanceLog.confidence)) + if company_id is not None: + avg_confidence = avg_confidence.join(models.Employee).filter(models.Employee.company_id == company_id) + avg_confidence_val = avg_confidence.scalar() or 0.0 + + avg_proc_time = db.query(func.avg(models.AttendanceLog.processing_time_ms)) + if company_id is not None: + avg_proc_time = avg_proc_time.join(models.Employee).filter(models.Employee.company_id == company_id) + avg_proc_val = avg_proc_time.scalar() or 120.0 # fallback baseline ms + + return { + "total_scans": total_scans, + "spoof_attempts": spoofs, + "average_confidence": round(float(avg_confidence_val), 2), + "average_processing_time_ms": round(float(avg_proc_val), 1) + } + +@router.get("/occupancy") +def get_office_occupancy( + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_view) +): + company_id = current_user.company_id + today = date.today() + + active_emp_query = db.query(models.Employee).filter(models.Employee.status == "Active") + if company_id is not None: + active_emp_query = active_emp_query.filter(models.Employee.company_id == company_id) + total_strength = active_emp_query.count() + + present_query = db.query(models.Attendance).join(models.Employee).filter( + and_( + models.Attendance.date == today, + models.Attendance.check_in.isnot(None), + models.Attendance.check_out.is_(None) + ) + ) + if company_id is not None: + present_query = present_query.filter(models.Employee.company_id == company_id) + + occupied_count = present_query.count() + + return { + "total_strength": total_strength, + "occupied_count": occupied_count, + "occupancy_rate_percentage": round((occupied_count / total_strength * 100) if total_strength > 0 else 0.0, 1) + } + +@router.get("/late-trends") +def get_late_trends( + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_view) +): + company_id = current_user.company_id + today = date.today() + start_date = today - timedelta(days=30) + + query = db.query( + models.Attendance.date, + func.avg(models.Attendance.late_minutes) + ).join(models.Employee).filter( + and_( + models.Attendance.date >= start_date, + models.Attendance.late_minutes > 0 + ) + ) + if company_id is not None: + query = query.filter(models.Employee.company_id == company_id) + + results = query.group_by(models.Attendance.date).order_by(models.Attendance.date.asc()).all() + + return [{"date": r[0].isoformat(), "average_late_minutes": round(float(r[1]), 1)} for r in results] diff --git a/backend/app/api/v1/attendance.py b/backend/app/api/v1/attendance.py index 488be8aee83518f7e5e3fb5464dae3e7c04f6963..be6d57d70afe256b2ecd968188e883bc4cda863c 100644 --- a/backend/app/api/v1/attendance.py +++ b/backend/app/api/v1/attendance.py @@ -20,22 +20,24 @@ def read_daily_attendance( date_val: Optional[date] = None, employee_id: Optional[int] = None, department_id: Optional[int] = None, + company_id: Optional[int] = None, db: Session = Depends(get_db), current_user: models.User = Depends(checker_view) ): + target_company_id = current_user.company_id if current_user.company_id is not None else company_id if not date_val: date_val = date.today() if department_id: dept = crud.get_department_by_id(db, department_id) - if not dept or (current_user.company_id is not None and dept.company_id != current_user.company_id): + if not dept or (target_company_id is not None and dept.company_id != target_company_id): raise HTTPException(status_code=404, detail="Department not found") if employee_id: emp = crud.get_employee_by_id(db, employee_id) - if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id): + if not emp or (target_company_id is not None and emp.company_id != target_company_id): raise HTTPException(status_code=404, detail="Employee not found") return crud.get_daily_attendance( - db, date_val=date_val, employee_id=employee_id, department_id=department_id, company_id=current_user.company_id + db, date_val=date_val, employee_id=employee_id, department_id=department_id, company_id=target_company_id ) @router.put("/{id}", response_model=schemas.AttendanceOut) @@ -163,10 +165,18 @@ def read_attendance_logs( limit: int = 100, employee_id: Optional[int] = None, date_str: Optional[str] = None, # YYYY-MM-DD + company_id: Optional[int] = None, db: Session = Depends(get_db), - current_user: models.User = Depends(checker_view) + current_user: models.User = Depends(security.get_current_user) ): - return crud.get_attendance_logs(db, company_id=current_user.company_id, skip=skip, limit=limit, employee_id=employee_id, date_str=date_str) + role_name = current_user.role.name if current_user.role else "Employee" + if role_name == "Employee": + if not current_user.employee: + return [] + employee_id = current_user.employee.id + + target_company_id = current_user.company_id if current_user.company_id is not None else company_id + return crud.get_attendance_logs(db, company_id=target_company_id, skip=skip, limit=limit, employee_id=employee_id, date_str=date_str) @router.get("/employee/{employee_id}", response_model=List[schemas.AttendanceOut]) def get_employee_attendance_history( diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 02c3b0df833cf24a396da4723a2c11a0eb16be70..456ffa6b16b7719b76194a156ecefee05290b610 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,6 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session +from sqlalchemy import select, and_ from datetime import timedelta from jose import jwt, JWTError @@ -11,9 +12,11 @@ from app.crud import crud from app.schemas import schemas from app.models import models +from app.core.rate_limiter import check_login_rate_limit + router = APIRouter() -@router.post("/login", response_model=schemas.Token) +@router.post("/login", response_model=schemas.Token, dependencies=[Depends(check_login_rate_limit)]) def login( request: Request, form_data: OAuth2PasswordRequestForm = Depends(), @@ -35,6 +38,12 @@ def login( detail="Incorrect email or password", ) if not user.is_active: + role_name = user.role.name if user.role else "Employee" + if role_name in ["Admin", "HR"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Your admin account is pending approval by the Super Admin." + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user account" @@ -109,3 +118,196 @@ def read_users_me( current_user: models.User = Depends(security.get_current_user) ): return current_user + +@router.post("/register-admin", status_code=status.HTTP_201_CREATED) +def register_admin( + payload: schemas.AdminRegister, + db: Session = Depends(get_db) +): + # 1. Check if company name already exists + existing_company = crud.get_company_by_name(db, name=payload.company_name) + if existing_company: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Company name already registered" + ) + + # 2. Check if email already exists + existing_user = crud.get_user_by_email(db, email=payload.email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email address already registered" + ) + + # 3. Create company with "Pending Approval" status + company_create = schemas.CompanyCreate( + name=payload.company_name, + status="Pending Approval", + admin_email=payload.email, + phone=payload.phone, + address=payload.address, + max_employees=100, + available_tokens=1000 + ) + db_company = crud.create_company(db, company=company_create) + + # 4. Get Admin role + admin_role = crud.get_role_by_name(db, name="Admin") + if not admin_role: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Default Admin role not configured in the system" + ) + + # 5. Create user linked to the company + user_create = schemas.UserCreate( + email=payload.email, + password=payload.password, + role_id=admin_role.id + ) + crud.create_user(db, user=user_create, company_id=db_company.id) + + # 6. Ensure user account starts as Inactive / Pending Approval + db_user = crud.get_user_by_email(db, email=payload.email) + if db_user: + db_user.is_active = False + db.commit() + + return {"message": "Registration successful. Your account is pending approval by the Super Admin."} + + +@router.get("/users", response_model=list[schemas.UserOut]) +def get_all_users( + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin"])) +): + from sqlalchemy import select + return db.execute(select(models.User)).scalars().all() + + +@router.put("/users/{user_id}", response_model=schemas.UserOut) +def update_user_status( + user_id: int, + payload: schemas.UserUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin"])) +): + db_user = db.get(models.User, user_id) + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + if payload.is_active is not None: + db_user.is_active = payload.is_active + if payload.role_id is not None: + db_user.role_id = payload.role_id + db.commit() + db.refresh(db_user) + return db_user + +@router.get("/companies/check") +def check_company_name(name: str, db: Session = Depends(get_db)): + from sqlalchemy import func + company = db.execute( + select(models.Company).where(func.lower(models.Company.name) == name.strip().lower()) + ).scalar_one_or_none() + if not company: + raise HTTPException(status_code=404, detail="Company not found") + if company.status != "Active": + raise HTTPException(status_code=400, detail=f"Company status is '{company.status}'. Please contact support.") + return {"id": company.id, "name": company.name, "status": company.status} + +@router.post("/register-pending", status_code=status.HTTP_201_CREATED) +def register_pending_employee( + payload: schemas.EmployeeRegister, + db: Session = Depends(get_db) +): + company = crud.get_company_by_id(db, company_id=payload.company_id) + if not company: + raise HTTPException(status_code=404, detail="Company not found") + + existing_emp_email = db.execute( + select(models.Employee).where(models.Employee.email == payload.email) + ).scalar_one_or_none() + if existing_emp_email: + raise HTTPException(status_code=400, detail="Employee email already exists") + + existing_emp_id = db.execute( + select(models.Employee).where( + and_( + models.Employee.employee_id == payload.employee_id, + models.Employee.company_id == payload.company_id + ) + ) + ).scalar_one_or_none() + if existing_emp_id: + raise HTTPException(status_code=400, detail="Employee ID already registered under this company") + + existing_user = crud.get_user_by_email(db, email=payload.email) + if existing_user: + raise HTTPException(status_code=400, detail="User account with this email already exists") + + role = db.execute(select(models.Role).where(models.Role.name == "Employee")).scalar_one_or_none() + if not role: + raise HTTPException(status_code=500, detail="Employee role not found in system database") + + user_create = schemas.UserCreate( + email=payload.email, + password=payload.password, + role_id=role.id + ) + db_user = crud.create_user(db, user=user_create, company_id=payload.company_id) + db_user.is_active = False + db.commit() + + db_emp = models.Employee( + employee_id=payload.employee_id, + name=payload.name, + email=payload.email, + phone=payload.phone, + designation=payload.designation, + status="Pending Approval", + user_id=db_user.id, + company_id=payload.company_id + ) + db.add(db_emp) + db.commit() + db.refresh(db_emp) + + return { + "message": "Registration successful. Please complete your facial scans next.", + "employee_id": db_emp.id, + "employee_uuid": db_emp.employee_id + } + +@router.post("/self-onboard/upload") +async def self_onboard_upload( + request: Request, + employee_id: int = Form(...), + pose_type: str = Form(...), + file: UploadFile = File(...), + db: Session = Depends(get_db) +): + employee = crud.get_employee_by_id(db, id=employee_id) + if not employee: + raise HTTPException(status_code=404, detail="Employee not found") + + if employee.status != "Pending Approval": + raise HTTPException(status_code=403, detail="Biometric enrollment is locked for active accounts. Please log in.") + + try: + contents = await file.read() + if not contents: + raise HTTPException(status_code=400, detail="Received empty file. No image data was sent.") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}") + + from app.api.v1.enrollment import enroll_employee_face_pose + return enroll_employee_face_pose( + db=db, + employee=employee, + pose_type=pose_type, + contents=contents, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + creator_user_id=None + ) diff --git a/backend/app/api/v1/companies.py b/backend/app/api/v1/companies.py index 49343b79cf12dc8766b7b96c2c03fdb8cc13cc2e..0c3e77b66d4d09699eb7c0966ead01a08acc7f12 100644 --- a/backend/app/api/v1/companies.py +++ b/backend/app/api/v1/companies.py @@ -45,6 +45,26 @@ def create_company( raise HTTPException(status_code=400, detail="Company name already exists") db_company = crud.create_company(db, company=company) + + # Create Company Admin User if email is provided + if company.admin_email: + existing_user = crud.get_user_by_email(db, company.admin_email) + if not existing_user: + admin_role = crud.get_role_by_name(db, "Admin") + if admin_role: + from app.core.security import get_password_hash + password_to_use = company.admin_password if company.admin_password else "Admin@NetraID2026" + hashed_pwd = get_password_hash(password_to_use) + new_admin = models.User( + email=company.admin_email, + hashed_password=hashed_pwd, + role_id=admin_role.id, + company_id=db_company.id, + is_active=True if company.status == "Active" else False + ) + db.add(new_admin) + db.commit() + crud.create_audit_log( db=db, user_id=current_user.id, @@ -70,6 +90,17 @@ def update_company( old_status = db_company.status updated = crud.update_company(db, company_id=id, company=company) + # Auto-activate administrators if company is marked Active + if company.status == "Active" and old_status != "Active": + from sqlalchemy import select + users_to_activate = db.execute( + select(models.User).where(models.User.company_id == id) + ).scalars().all() + for u in users_to_activate: + if u.role and u.role.name in ["Admin", "HR"]: + u.is_active = True + db.commit() + details = f"Updated company ID: {id}." if company.status and company.status != old_status: details += f" Status changed from '{old_status}' to '{company.status}'." diff --git a/backend/app/api/v1/departments.py b/backend/app/api/v1/departments.py index c525ce8ab5253bc9ed4fc50a1d97a40e9bff4210..7dab7ec9a7877dd9baa4124dc420082b54effa7a 100644 --- a/backend/app/api/v1/departments.py +++ b/backend/app/api/v1/departments.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session -from typing import List +from typing import List, Optional from app.core.database import get_db from app.core import security @@ -18,10 +18,12 @@ checker_manage = security.RoleChecker(["Super Admin", "Admin"]) def read_departments( skip: int = 0, limit: int = 100, + company_id: Optional[int] = None, db: Session = Depends(get_db), current_user: models.User = Depends(checker_view) ): - return crud.get_departments(db, company_id=current_user.company_id, skip=skip, limit=limit) + target_company_id = current_user.company_id if current_user.company_id is not None else company_id + return crud.get_departments(db, company_id=target_company_id, skip=skip, limit=limit) @router.get("/{id}", response_model=schemas.DepartmentOut) def read_department( diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py new file mode 100644 index 0000000000000000000000000000000000000000..ed029b9010e871a07b46efbfe82a8414b12a2079 --- /dev/null +++ b/backend/app/api/v1/devices.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.core.database import get_db +from app.core import security +from app.crud import crud +from app.schemas import schemas +from app.models import models + +router = APIRouter() +checker_staff = security.RoleChecker(["Super Admin", "Admin", "HR"]) + +@router.get("/", response_model=List[schemas.DeviceOut]) +def read_devices( + company_id: Optional[int] = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_staff) +): + role_name = current_user.role.name if current_user.role else "Employee" + target_company_id = current_user.company_id if current_user.company_id is not None else company_id + if role_name == "Super Admin" and target_company_id is None: + return crud.get_devices(db) + return crud.get_devices(db, company_id=target_company_id) + +@router.post("/", response_model=schemas.DeviceOut, status_code=status.HTTP_201_CREATED) +def register_device( + request: Request, + device: schemas.DeviceCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin"])) +): + company_id = current_user.company_id + db_device = crud.create_device(db, device=device, company_id=company_id) + + crud.create_audit_log( + db=db, + user_id=current_user.id, + action="Register Kiosk Device", + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + details=f"Registered kiosk device '{device.name}' in branch '{device.branch}'", + company_id=company_id + ) + return db_device + +@router.put("/{id}", response_model=schemas.DeviceOut) +def update_device_metrics( + id: int, + payload: schemas.DeviceUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.get_current_user) +): + db_device = crud.get_device_by_id(db, device_id=id) + if not db_device: + raise HTTPException(status_code=404, detail="Device not found") + + # Check permissions + if current_user.role.name != "Super Admin" and db_device.company_id != current_user.company_id: + raise HTTPException(status_code=403, detail="Not authorized to configure this device") + + updated = crud.update_device(db, device_id=id, device_update=payload) + return updated diff --git a/backend/app/api/v1/employees.py b/backend/app/api/v1/employees.py index 3bc6c1fd9904596c44f05b4f7bcd963ed32b651c..77922f3caab8c535616a71177d7f54e00bf2c86b 100644 --- a/backend/app/api/v1/employees.py +++ b/backend/app/api/v1/employees.py @@ -25,11 +25,13 @@ def read_employees( search: Optional[str] = None, department_id: Optional[int] = None, status: Optional[str] = None, + company_id: Optional[int] = None, db: Session = Depends(get_db), current_user: models.User = Depends(checker_view) ): + target_company_id = current_user.company_id if current_user.company_id is not None else company_id return crud.get_employees( - db, company_id=current_user.company_id, skip=skip, limit=limit, search=search, department_id=department_id, status=status + db, company_id=target_company_id, skip=skip, limit=limit, search=search, department_id=department_id, status=status ) @router.get("/count") @@ -37,12 +39,107 @@ def get_employee_count( search: Optional[str] = None, department_id: Optional[int] = None, status: Optional[str] = None, + company_id: Optional[int] = None, db: Session = Depends(get_db), current_user: models.User = Depends(checker_view) ): - count = crud.count_employees(db, company_id=current_user.company_id, search=search, department_id=department_id, status=status) + target_company_id = current_user.company_id if current_user.company_id is not None else company_id + count = crud.count_employees(db, company_id=target_company_id, search=search, department_id=department_id, status=status) return {"count": count} + +# --- Leave Requests Endpoints --- + +@router.get("/leaves", response_model=List[schemas.LeaveRequestOut]) +def list_leaves( + employee_id: Optional[int] = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR", "Employee"])) +): + if current_user.role.name == "Employee": + if not current_user.employee: + raise HTTPException(status_code=400, detail="User is not linked to an employee profile") + target_employee_id = current_user.employee.id + else: + target_employee_id = employee_id + + if target_employee_id: + emp = crud.get_employee_by_id(db, target_employee_id) + if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id): + raise HTTPException(status_code=404, detail="Employee not found") + return crud.get_leave_requests(db, employee_id=target_employee_id) + + leaves = crud.get_leave_requests(db) + if current_user.company_id is not None: + leaves = [l for l in leaves if l.employee and l.employee.company_id == current_user.company_id] + return leaves + + +@router.post("/leaves", response_model=schemas.LeaveRequestOut, status_code=status.HTTP_201_CREATED) +def apply_leave( + req: schemas.LeaveRequestCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR", "Employee"])) +): + if current_user.role.name == "Employee": + if not current_user.employee: + raise HTTPException(status_code=400, detail="User is not linked to an employee profile") + if req.employee_id != current_user.employee.id: + raise HTTPException(status_code=403, detail="You can only apply leave for yourself") + else: + emp = crud.get_employee_by_id(db, req.employee_id) + if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id): + raise HTTPException(status_code=404, detail="Employee not found") + + return crud.create_leave_request(db, req, employee_id=req.employee_id) + + +@router.put("/leaves/{id}", response_model=schemas.LeaveRequestOut) +def update_leave( + id: int, + data: schemas.LeaveRequestUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR"])) +): + db_req = db.get(models.LeaveRequest, id) + if not db_req: + raise HTTPException(status_code=404, detail="Leave request not found") + + emp = crud.get_employee_by_id(db, db_req.employee_id) + if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id): + raise HTTPException(status_code=403, detail="Access denied") + + updated = crud.update_leave_status(db, id=id, status=data.status, admin_user_id=current_user.id) + if not updated: + raise HTTPException(status_code=404, detail="Leave request not found") + return updated + + +@router.delete("/leaves/{id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_leave( + id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR", "Employee"])) +): + db_req = db.get(models.LeaveRequest, id) + if not db_req: + raise HTTPException(status_code=404, detail="Leave request not found") + + if current_user.role.name == "Employee": + if not current_user.employee or db_req.employee_id != current_user.employee.id: + raise HTTPException(status_code=403, detail="You can only withdraw your own leave requests") + if db_req.status != "Pending": + raise HTTPException(status_code=400, detail="You can only withdraw pending leave requests") + else: + emp = crud.get_employee_by_id(db, db_req.employee_id) + if not emp or (current_user.company_id is not None and emp.company_id != current_user.company_id): + raise HTTPException(status_code=403, detail="Access denied") + + db.delete(db_req) + db.commit() + return None + + @router.get("/{id}", response_model=schemas.EmployeeOut) def read_employee( id: int, @@ -152,6 +249,13 @@ def update_employee( updated = crud.update_employee(db, id=id, emp=emp) if not updated: raise HTTPException(status_code=404, detail="Employee not found") + + if emp.status is not None and db_emp.user_id: + db_user = db.get(models.User, db_emp.user_id) + if db_user: + db_user.is_active = (emp.status == "Active") + db.add(db_user) + db.commit() crud.create_audit_log( db=db, @@ -338,3 +442,4 @@ async def upload_avatar( ) return {"message": "Avatar uploaded successfully"} + diff --git a/backend/app/api/v1/enrollment.py b/backend/app/api/v1/enrollment.py index 65cc43cb03a6e6c038fb93989658745a89ad6c53..90c096690868bed64f79074f08958d0228d28d38 100644 --- a/backend/app/api/v1/enrollment.py +++ b/backend/app/api/v1/enrollment.py @@ -18,42 +18,20 @@ router = APIRouter() checker_manage = security.RoleChecker(["Super Admin", "Admin", "HR"]) -@router.post("/upload") -async def upload_face_image( - request: Request, - employee_id: int = Form(...), - pose_type: str = Form(...), # e.g., front, left, right, up, down, smile, neutral, glasses - file: UploadFile = File(...), - db: Session = Depends(get_db), - current_user: models.User = Depends(checker_manage) +def enroll_employee_face_pose( + db: Session, + employee: models.Employee, + pose_type: str, + contents: bytes, + ip_address: str = None, + user_agent: str = None, + creator_user_id: int = None ): - # Validate employee exists - employee = crud.get_employee_by_id(db, id=employee_id) - if not employee: - all_emps = db.query(models.Employee).all() - emp_ids = [e.id for e in all_emps] - emp_uuids = [e.employee_id for e in all_emps] - raise HTTPException( - status_code=404, - detail=f"Employee not found. Received employee_id: {employee_id} (type: {type(employee_id).__name__}). Existing PK IDs: {emp_ids}, String IDs: {emp_uuids}" - ) - - # Read file bytes - try: - contents = await file.read() - if not contents: - raise HTTPException(status_code=400, detail="Received empty file. No image data was sent.") - nparr = np.frombuffer(contents, np.uint8) - img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - if img is None: - raise HTTPException(status_code=400, detail="OpenCV failed to decode the image. The format might be unsupported.") - except HTTPException as e: - raise e - except Exception as e: - logger.error(f"Image decode failed: {e}") - raise HTTPException(status_code=400, detail=f"Invalid image file format: {str(e)}") + nparr = np.frombuffer(contents, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + raise HTTPException(status_code=400, detail="OpenCV failed to decode the image. The format might be unsupported.") - # Detect faces faces = face_engine.detect_faces(img) if not faces: logger.error("No face detected in the image.") @@ -62,22 +40,18 @@ async def upload_face_image( logger.error("Multiple faces detected in the image.") raise HTTPException(status_code=400, detail="Multiple faces detected. Please ensure only one person is in the frame.") - # Process face face = faces[0] confidence = face["confidence"] - # Check if confidence is high enough if confidence < 0.5: logger.error(f"Face detection confidence too low: {confidence:.2f}") raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.") - # Image Quality Validation quality = face_engine.validate_image_quality(img) if not quality["is_valid"]: logger.error(f"Image quality validation failed: {quality['reason']}") raise HTTPException(status_code=400, detail=f"Image Quality Error: {quality['reason']}") - # Optional liveness check on enrollment (preventing enroll spoofing) liveness_enabled_setting = crud.get_setting_by_key(db, "ENROLLMENT_LIVENESS_CHECK") liveness_enabled = liveness_enabled_setting.value.lower() == "true" if liveness_enabled_setting else True @@ -86,13 +60,9 @@ async def upload_face_image( liveness_score, is_live = face_engine.check_liveness(img, face["bbox"], threshold=liveness_threshold) - # In enrollment we want to prevent spoofing. However, liveness models are calibrated for direct frontal views. - # Profile/tilted views (left, right, up, down) often yield lower liveness scores and cause false rejections. - # Therefore, we strictly enforce liveness on the "front" pose only, and bypass it for other poses. if liveness_enabled and not is_live and not face_engine.mock_mode: if pose_type.strip().lower() == "front": logger.warning(f"Liveness check failed ({liveness_score:.2f}) on FRONT pose. Bypassing for now.") - # raise HTTPException(status_code=400, detail=f"Liveness check failed ({liveness_score:.2f}). Please upload a real photo.") else: logger.warning( f"Liveness check failed during enrollment for non-frontal pose '{pose_type}' " @@ -100,32 +70,23 @@ async def upload_face_image( f"Bypassing check to prevent false rejection." ) - # Align face (112x112) aligned_face = face_engine.align_face(img, face["landmarks"]) - - # Generate 512-D embedding embedding = face_engine.extract_embedding(aligned_face) - # Save image to disk emp_upload_dir = os.path.join(settings.UPLOAD_DIR, str(employee.employee_id)) os.makedirs(emp_upload_dir, exist_ok=True) - # Save the raw uploaded photo (or aligned photo, raw photo is better for archive) filename = f"{pose_type.replace(' ', '_').lower()}.jpg" dest_path = os.path.join(emp_upload_dir, filename) - # Save the file (we compress/save as JPG) cv2.imwrite(dest_path, img) - # Check if this pose already exists for the employee, delete it if it does - # (to allow re-enrolling a specific pose) for existing_img in employee.images: if existing_img.pose_type == pose_type: db.delete(existing_img) db.commit() - # Save EmployeeImage db_img = crud.save_employee_image( db=db, employee_id=employee.id, @@ -134,28 +95,27 @@ async def upload_face_image( image_bytes=contents ) - # Save FaceEmbedding (convert numpy array to python list) embedding_list = embedding.tolist() - db_emb = crud.save_face_embedding( + crud.save_face_embedding( db=db, employee_id=employee.id, image_id=db_img.id, embedding=embedding_list ) - # Invalidate face engine embeddings cache face_engine.invalidate_cache() - # Log Audit - crud.create_audit_log( - db=db, - user_id=current_user.id, - action="Enroll Face Pose", - ip_address=request.client.host if request.client else None, - user_agent=request.headers.get("user-agent"), - details=f"Enrolled pose '{pose_type}' for employee ID: {employee.employee_id}" - ) - + audit_user_id = creator_user_id or employee.user_id + if audit_user_id: + crud.create_audit_log( + db=db, + user_id=audit_user_id, + action="Enroll Face Pose", + ip_address=ip_address, + user_agent=user_agent, + details=f"Enrolled pose '{pose_type}' for employee ID: {employee.employee_id}" + ) + return { "message": f"Successfully enrolled pose '{pose_type}' for employee {employee.name}", "pose_type": pose_type, @@ -164,19 +124,69 @@ async def upload_face_image( "image_id": db_img.id } +@router.post("/upload") +async def upload_face_image( + request: Request, + employee_id: int = Form(...), + pose_type: str = Form(...), # e.g., front, left, right, up, down, smile, neutral, glasses + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_manage) +): + employee = crud.get_employee_by_id(db, id=employee_id) + if not employee: + raise HTTPException(status_code=404, detail="Employee not found") + + try: + contents = await file.read() + if not contents: + raise HTTPException(status_code=400, detail="Received empty file. No image data was sent.") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}") + + return enroll_employee_face_pose( + db=db, + employee=employee, + pose_type=pose_type, + contents=contents, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + creator_user_id=current_user.id + ) + @router.get("/status/{employee_id}") def get_enrollment_status( + request: Request, employee_id: int, - db: Session = Depends(get_db), - current_user: models.User = Depends(checker_manage) + db: Session = Depends(get_db) ): employee = crud.get_employee_by_id(db, id=employee_id) if not employee: raise HTTPException(status_code=404, detail="Employee not found") + auth_header = request.headers.get("Authorization") + current_user = None + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] + try: + current_user = security.get_current_user_from_token(token, db) + except Exception: + pass + + if current_user is None: + if employee.status != "Pending Approval": + raise HTTPException(status_code=403, detail="Access denied") + else: + user_role = current_user.role.name + if user_role not in ["Super Admin", "Admin", "HR"]: + if user_role == "Employee": + if not current_user.employee or current_user.employee.id != employee_id: + raise HTTPException(status_code=403, detail="Access denied") + else: + raise HTTPException(status_code=403, detail="Access denied") + poses = [img.pose_type for img in employee.images] - # Required poses list required_poses = [ "front", "left", "right", "up", "down", "smile", "neutral", "indoor", "outdoor" @@ -185,7 +195,8 @@ def get_enrollment_status( missing_poses = [p for p in required_poses if p not in [x.lower() for x in poses]] return { - "employee_id": employee.employee_id, + "employee_id": employee.id, + "employee_uuid": employee.employee_id, "name": employee.name, "total_enrolled": len(poses), "enrolled_poses": poses, @@ -218,3 +229,46 @@ def delete_all_enrollments( ) return {"message": "All face enrollments and images cleared successfully"} + +@router.delete("/{employee_id}/pose/{pose_type}") +def delete_single_pose( + request: Request, + employee_id: int, + pose_type: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_manage) +): + employee = crud.get_employee_by_id(db, id=employee_id) + if not employee: + raise HTTPException(status_code=404, detail="Employee not found") + + pose_img = next((img for img in employee.images if img.pose_type.lower() == pose_type.lower()), None) + if not pose_img: + raise HTTPException(status_code=404, detail=f"Pose '{pose_type}' not found for this employee") + + db.execute( + models.FaceEmbedding.__table__.delete().where( + models.FaceEmbedding.image_id == pose_img.id + ) + ) + db.delete(pose_img) + db.commit() + + face_engine.invalidate_cache() + + try: + if os.path.exists(pose_img.file_path): + os.remove(pose_img.file_path) + except Exception as e: + logger.warning(f"Could not remove physical file {pose_img.file_path}: {e}") + + crud.create_audit_log( + db=db, + user_id=current_user.id, + action="Clear Single Pose", + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + details=f"Cleared pose '{pose_type}' for employee ID: {employee.employee_id}" + ) + + return {"message": f"Pose '{pose_type}' cleared successfully"} diff --git a/backend/app/api/v1/kiosk.py b/backend/app/api/v1/kiosk.py index 537adf0d4bc2594bf066eed97dcad9839856be39..942fa067779948da6ee51304b995ece44da725fa 100644 --- a/backend/app/api/v1/kiosk.py +++ b/backend/app/api/v1/kiosk.py @@ -7,7 +7,7 @@ import base64 import cv2 import numpy as np import logging -from datetime import datetime, time +from datetime import datetime, time, timedelta import urllib.parse from app.core.database import get_db @@ -18,6 +18,7 @@ from app.schemas import schemas from app.models import models from app.services.singletons import face_engine from app.services import geocoding, voice_assistant +from app.core.attendance_policy import AttendancePolicyEngine logger = logging.getLogger("Kiosk") router = APIRouter() @@ -77,14 +78,25 @@ def calculate_distance_meters(lat1: float, lon1: float, lat2: float, lon2: float c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) return R * c +from pydantic import BaseModel, Field, validator + class KioskScanRequest(BaseModel): - image: str = Field(..., description="Base64 encoded image frame (JPEG/PNG data URL)") + image: Optional[str] = Field(None, description="Base64 encoded image frame (JPEG/PNG data URL)") camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device") confirm_checkout: bool = Field(False, description="Whether the check-out is confirmed by the employee") qr_code: str = Field(None, description="Pre-detected QR code string from frontend") qr_only: bool = Field(False, description="If True, only allow QR-based logging and disable face recognition") latitude: Optional[float] = Field(None, description="Latitude of the kiosk/device marking attendance") longitude: Optional[float] = Field(None, description="Longitude of the kiosk/device marking attendance") + employee_id: Optional[int] = Field(None, description="Employee ID for dummy bypass scans") + dummy: Optional[bool] = Field(False, description="Whether to bypass AI face scan (for employee dashboard dummy mode)") + + @validator("image") + def validate_image_payload(cls, v): + if v and len(v) > 20 * 1024 * 1024: # ~15MB raw image cap + raise ValueError("Image payload size exceeds maximum limit of 15MB") + return v + @router.get("/config") @@ -111,7 +123,9 @@ def scan_face( payload: KioskScanRequest, db: Session = Depends(get_db) ): - now = datetime.now() + from datetime import timedelta + now_utc = datetime.utcnow() + now = now_utc + timedelta(hours=5, minutes=30) # Retrieve dynamic thresholds from database settings face_threshold_setting = crud.get_setting_by_key(db, "KIOSK_FACE_THRESHOLD") liveness_threshold_setting = crud.get_setting_by_key(db, "KIOSK_LIVENESS_THRESHOLD") @@ -137,266 +151,258 @@ def scan_face( liveness_threshold = float(liveness_threshold_setting.value) if liveness_threshold_setting else settings.KIOSK_LIVENESS_THRESHOLD voice_enabled = voice_greeting_setting.value.lower() == "true" if voice_greeting_setting else True - # 1. Parse base64 image - try: - header, encoded = payload.image.split(",", 1) if "," in payload.image else ("", payload.image) - img_bytes = base64.b64decode(encoded) - nparr = np.frombuffer(img_bytes, np.uint8) - img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) - if img is None: - raise ValueError() - - # Fast downscale for performance - scale_factor = 1.0 - max_dim = 640 - h, w = img.shape[:2] - if max(h, w) > max_dim: - scale_factor = max_dim / max(h, w) - img = cv2.resize(img, (int(w * scale_factor), int(h * scale_factor)), interpolation=cv2.INTER_AREA) - except Exception: - raise HTTPException(status_code=400, detail="Invalid Base64 image data") - - qr_employee = None - is_qr_scan = False + employee = None + similarity = 1.0 + liveness_score = 1.0 + confidence = 1.0 + log_status_success = "Match Success" bbox_list = None - # Check if qr_code was pre-detected by the frontend - if getattr(payload, "qr_code", None): - qr_val = payload.qr_code.strip() - qr_employee = db.query(models.Employee).filter( - models.Employee.employee_id == qr_val - ).first() - if qr_employee: - is_qr_scan = True - logger.info(f"QR code pre-detected by frontend: {qr_employee.employee_id}") - - if not is_qr_scan: - try: - qr_detector = cv2.QRCodeDetector() - qr_val, _, _ = qr_detector.detectAndDecode(img) - if qr_val: - qr_val = qr_val.strip() - qr_employee = db.query(models.Employee).filter( - models.Employee.employee_id == qr_val - ).first() - if qr_employee: - is_qr_scan = True - logger.info(f"QR code scanned successfully for employee: {qr_employee.employee_id}") - except Exception as qr_err: - logger.warning(f"QR code parsing error: {qr_err}") - - if is_qr_scan: - employee = qr_employee - similarity = 1.0 - liveness_score = 1.0 - confidence = 1.0 - log_status_success = "Match Success (QR Scanned)" - else: - if getattr(payload, "qr_only", False): + if payload.dummy and payload.employee_id: + employee = crud.get_employee_by_id(db, payload.employee_id) + if not employee: return { "status": "unknown", - "message": "Invalid QR code. Employee badge not found.", - "should_retry": True - } - # 2. Detect face - faces = face_engine.detect_faces(img) - if not faces: - return { - "status": "no_face", - "message": "No face detected. Frame your face within the scanner.", - "should_retry": True + "message": "Employee not found.", + "should_retry": False } - if len(faces) > 1: - return { - "status": "multiple_faces", - "message": "Multiple faces detected. Please scan one person at a time.", - "should_retry": True - } - - face = faces[0] - bbox = face["bbox"] - - # Scale bbox back to original image size for frontend drawing - if scale_factor != 1.0: - bbox_list = [float(x) / scale_factor for x in bbox] + log_status_success = "Dummy Scanner Success" + else: + # 1. Parse base64 image + if not payload.image: + raise HTTPException(status_code=400, detail="Image payload is required for non-dummy scans") + try: + header, encoded = payload.image.split(",", 1) if "," in payload.image else ("", payload.image) + img_bytes = base64.b64decode(encoded) + nparr = np.frombuffer(img_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + raise ValueError() + + # Fast downscale for performance + scale_factor = 1.0 + max_dim = 640 + h, w = img.shape[:2] + if max(h, w) > max_dim: + scale_factor = max_dim / max(h, w) + img = cv2.resize(img, (int(w * scale_factor), int(h * scale_factor)), interpolation=cv2.INTER_AREA) + except Exception: + raise HTTPException(status_code=400, detail="Invalid Base64 image data") + + qr_employee = None + is_qr_scan = False + + # Check if qr_code was pre-detected by the frontend + if getattr(payload, "qr_code", None): + qr_val = payload.qr_code.strip() + qr_employee = db.query(models.Employee).filter( + models.Employee.employee_id == qr_val + ).first() + if qr_employee: + is_qr_scan = True + logger.info(f"QR code pre-detected by frontend: {qr_employee.employee_id}") + + if not is_qr_scan: + try: + qr_detector = cv2.QRCodeDetector() + qr_val, _, _ = qr_detector.detectAndDecode(img) + if qr_val: + qr_val = qr_val.strip() + qr_employee = db.query(models.Employee).filter( + models.Employee.employee_id == qr_val + ).first() + if qr_employee: + is_qr_scan = True + logger.info(f"QR code scanned successfully for employee: {qr_employee.employee_id}") + except Exception as qr_err: + logger.warning(f"QR code parsing error: {qr_err}") + + if is_qr_scan: + employee = qr_employee + similarity = 1.0 + liveness_score = 1.0 + confidence = 1.0 + log_status_success = "Match Success (QR Scanned)" else: - bbox_list = [float(x) for x in bbox] + if getattr(payload, "qr_only", False): + return { + "status": "unknown", + "message": "Invalid QR code. Employee badge not found.", + "should_retry": True + } + # 2. Detect face + faces = face_engine.detect_faces(img) + if not faces: + return { + "status": "no_face", + "message": "No face detected. Frame your face within the scanner.", + "should_retry": True + } + if len(faces) > 1: + return { + "status": "multiple_faces", + "message": "Multiple faces detected. Please scan one person at a time.", + "should_retry": True + } + + face = faces[0] + bbox = face["bbox"] - confidence = face["confidence"] - landmarks = face["landmarks"] - - # 3. Liveness Check - liveness_score, is_live = face_engine.check_liveness(img, bbox, threshold=liveness_threshold) - if not is_live and not face_engine.mock_mode: - # Save spoof log - log_entry = crud.create_attendance_log( - db=db, - employee_id=None, - camera=payload.camera, - confidence=confidence, - liveness_score=liveness_score, - is_spoof=True, - status="Spoof Rejected", - timestamp=now, - location_text=location_text if 'location_text' in locals() else None, - latitude=payload.latitude if hasattr(payload, 'latitude') else None, - longitude=payload.longitude if hasattr(payload, 'longitude') else None - ) - _publish_log(log_entry) + # Scale bbox back to original image size for frontend drawing + if scale_factor != 1.0: + bbox_list = [float(x) / scale_factor for x in bbox] + else: + bbox_list = [float(x) for x in bbox] + + confidence = face["confidence"] + landmarks = face["landmarks"] - # Dispatch Webhook alert - try: - from app.services.notifications import trigger_security_alert - trigger_security_alert( + # 3. Liveness Check + liveness_score, is_live = face_engine.check_liveness(img, bbox, threshold=liveness_threshold) + if not is_live and not face_engine.mock_mode: + # Save spoof log + log_entry = crud.create_attendance_log( db=db, - alert_type="Spoofing Attempt Rejected", - details={ - "camera": payload.camera, - "confidence": float(confidence), - "liveness_score": float(liveness_score), - "timestamp": now.strftime("%Y-%m-%d %H:%M:%S") - } + employee_id=None, + camera=payload.camera, + confidence=confidence, + liveness_score=liveness_score, + is_spoof=True, + status="Spoof Rejected", + timestamp=now_utc, + location_text=location_text if 'location_text' in locals() else None, + latitude=payload.latitude if hasattr(payload, 'latitude') else None, + longitude=payload.longitude if hasattr(payload, 'longitude') else None ) - except Exception as alert_err: - logger.error(f"Failed to dispatch security alert: {alert_err}") - - return { - "status": "spoof_detected", - "message": "Liveness check failed! Verification denied.", - "confidence": float(confidence), - "liveness_score": float(liveness_score), - "should_retry": False, - "bbox": bbox_list - } + _publish_log(log_entry) + + # Dispatch Webhook alert + try: + from app.services.notifications import trigger_security_alert + trigger_security_alert( + db=db, + alert_type="Spoofing Attempt Rejected", + details={ + "camera": payload.camera, + "confidence": float(confidence), + "liveness_score": float(liveness_score), + "timestamp": now.strftime("%Y-%m-%d %H:%M:%S") + } + ) + except Exception as alert_err: + logger.error(f"Failed to dispatch security alert: {alert_err}") - # 4. Extract Embedding - aligned = face_engine.align_face(img, landmarks) - embedding = face_engine.extract_embedding(aligned) - - # 5. DB Matching: query pgvector if postgresql, else fallback to numpy cache-matching - match_result = None - is_pg = False - try: - is_pg = (db.bind.dialect.name == "postgresql") - except Exception as dialect_err: - logger.warning(f"Could not determine DB dialect: {dialect_err}") + return { + "status": "spoof_detected", + "message": "Liveness check failed! Verification denied.", + "confidence": float(confidence), + "liveness_score": float(liveness_score), + "should_retry": False, + "bbox": bbox_list + } - if is_pg: + # 4. Extract Embedding + aligned = face_engine.align_face(img, landmarks) + embedding = face_engine.extract_embedding(aligned) + + # 5. DB Matching: query pgvector if postgresql, else fallback to numpy cache-matching + match_result = None + is_pg = False try: - # Run database-level query using pgvector's cosine distance (<=>) operator - emb_list = embedding.tolist() if isinstance(embedding, np.ndarray) else list(embedding) - from sqlalchemy import type_coerce, Float - distance_expr = type_coerce(models.FaceEmbedding.embedding.op('<=>')(emb_list), Float).label('distance') - query_res = db.query(models.FaceEmbedding, distance_expr).order_by(distance_expr).limit(1).first() - if query_res: - db_emb, distance = query_res - match_result = (db_emb, float(distance)) - except Exception as pg_err: - logger.error(f"Failed to query pgvector: {pg_err}. Falling back to SQLite/NumPy matching.") - match_result = None - - if match_result is None: - if face_engine.embeddings_cache is None: - face_engine.load_embeddings_cache(db) - - all_embeddings = face_engine.embeddings_cache - if not all_embeddings: - match_result = None - else: + is_pg = (db.bind.dialect.name == "postgresql") + except Exception as dialect_err: + logger.warning(f"Could not determine DB dialect: {dialect_err}") + + if is_pg: try: - # High-performance vectorized search using NumPy matrix multiplication. - # ArcFace embeddings are L2-normalized, so cosine similarity is just the dot product. - embeddings_matrix = np.stack([emb["embedding"] for emb in all_embeddings]) # shape (N, 512) - similarities = np.dot(embeddings_matrix, embedding) # shape (N,) - best_idx = int(np.argmax(similarities)) - best_similarity = float(similarities[best_idx]) - - best_emb_record = all_embeddings[best_idx] - class MockEmb: - id = best_emb_record["id"] - employee_id = best_emb_record["employee_id"] + emb_list = embedding.tolist() if isinstance(embedding, np.ndarray) else list(embedding) + from sqlalchemy import type_coerce, Float + distance_expr = type_coerce(models.FaceEmbedding.embedding.op('<=>')(emb_list), Float).label('distance') + query_res = db.query(models.FaceEmbedding, distance_expr).order_by(distance_expr).limit(1).first() + if query_res: + db_emb, distance = query_res + match_result = (db_emb, float(distance)) + except Exception as pg_err: + logger.error(f"Failed to query pgvector: {pg_err}. Falling back to SQLite/NumPy matching.") + match_result = None + + if match_result is None: + if face_engine.embeddings_cache is None: + face_engine.load_embeddings_cache(db) - # distance = 1 - similarity - best_dist = 1.0 - best_similarity - match_result = (MockEmb(), best_dist) - except Exception as e: - logger.error(f"Error in vectorized face matching: {e}") + all_embeddings = face_engine.embeddings_cache + if not all_embeddings: match_result = None - - if not match_result: - # Database has no enrolled embeddings - log_entry = crud.create_attendance_log( - db=db, - employee_id=None, - camera=payload.camera, - confidence=confidence, - liveness_score=liveness_score, - is_spoof=False, - status="Empty Vector Index", - timestamp=now, - location_text=location_text if 'location_text' in locals() else None, - latitude=payload.latitude if hasattr(payload, 'latitude') else None, - longitude=payload.longitude if hasattr(payload, 'longitude') else None - ) - _publish_log(log_entry) - return { - "status": "unknown", - "message": "No employees registered in the system. Please register first.", - "should_retry": False, - "bbox": bbox_list - } + else: + try: + embeddings_matrix = np.stack([emb["embedding"] for emb in all_embeddings]) # shape (N, 512) + similarities = np.dot(embeddings_matrix, embedding) # shape (N,) + best_idx = int(np.argmax(similarities)) + best_similarity = float(similarities[best_idx]) + + best_emb_record = all_embeddings[best_idx] + class MockEmb: + id = best_emb_record["id"] + employee_id = best_emb_record["employee_id"] + + best_dist = 1.0 - best_similarity + match_result = (MockEmb(), best_dist) + except Exception as e: + logger.error(f"Error in vectorized face matching: {e}") + match_result = None - db_emb, distance = match_result - # Similarity = 1 - Distance - similarity = 1.0 - float(distance) - - employee = crud.get_employee_by_id(db, db_emb.employee_id) if db_emb else None - - if similarity < face_threshold: - qr_fallback_setting = crud.get_setting_by_key(db, "QR_FALLBACK_ENABLED") - qr_fallback_enabled = qr_fallback_setting.value.lower() == "true" if qr_fallback_setting else True + if not match_result: + log_entry = crud.create_attendance_log( + db=db, + employee_id=None, + camera=payload.camera, + confidence=confidence, + liveness_score=liveness_score, + is_spoof=False, + status="Empty Vector Index", + timestamp=now_utc, + location_text=location_text if 'location_text' in locals() else None, + latitude=payload.latitude if hasattr(payload, 'latitude') else None, + longitude=payload.longitude if hasattr(payload, 'longitude') else None + ) + _publish_log(log_entry) + return { + "status": "unknown", + "message": "No employees registered in the system. Please register first.", + "should_retry": False, + "bbox": bbox_list + } + + db_emb, distance = match_result + similarity = 1.0 - float(distance) - if False: # Disable automatic QR fallback on borderline match + employee = crud.get_employee_by_id(db, db_emb.employee_id) if db_emb else None + + if similarity < face_threshold: + log_entry = crud.create_attendance_log( + db=db, + employee_id=None, + camera=payload.camera, + confidence=similarity, + liveness_score=liveness_score, + is_spoof=False, + status="Unknown Person", + timestamp=now_utc, + location_text=location_text if 'location_text' in locals() else None, + latitude=payload.latitude if hasattr(payload, 'latitude') else None, + longitude=payload.longitude if hasattr(payload, 'longitude') else None + ) + _publish_log(log_entry) return { - "status": "needs_qr", - "message": "Face matched but requires identity verification. Please scan your employee QR code.", - "employee": { - "id": employee.id, - "employee_id": employee.employee_id, - "name": employee.name, - "designation": employee.designation, - "department": employee.department.name if employee.department else "General" - }, + "status": "unknown", + "message": "Face not recognized. Please try again or contact HR.", "confidence": similarity, "liveness_score": liveness_score, - "should_retry": False, + "should_retry": True, "bbox": bbox_list } - - # Low confidence match -> Unknown - log_entry = crud.create_attendance_log( - db=db, - employee_id=None, - camera=payload.camera, - confidence=similarity, - liveness_score=liveness_score, - is_spoof=False, - status="Unknown Person", - timestamp=now, - location_text=location_text if 'location_text' in locals() else None, - latitude=payload.latitude if hasattr(payload, 'latitude') else None, - longitude=payload.longitude if hasattr(payload, 'longitude') else None - ) - _publish_log(log_entry) - return { - "status": "unknown", - "message": "Face not recognized. Please try again or contact HR.", - "confidence": similarity, - "liveness_score": liveness_score, - "should_retry": True, - "bbox": bbox_list - } - log_status_success = "Match Success" + log_status_success = "Match Success" + if employee and employee.company and employee.company.status != "Active": return { @@ -417,7 +423,7 @@ def scan_face( liveness_score=liveness_score, is_spoof=False, status="Inactive Employee Swiped", - timestamp=now, + timestamp=now_utc, location_text=location_text if 'location_text' in locals() else None, latitude=payload.latitude if hasattr(payload, 'latitude') else None, longitude=payload.longitude if hasattr(payload, 'longitude') else None @@ -464,7 +470,7 @@ def scan_face( db=db, employee_id=employee.id, camera=payload.camera, confidence=similarity if 'similarity' in locals() else 1.0, liveness_score=liveness_score if 'liveness_score' in locals() else 1.0, - is_spoof=False, status="WFH Location Missing", timestamp=now, + is_spoof=False, status="WFH Location Missing", timestamp=now_utc, location_text=location_text, latitude=payload.latitude, longitude=payload.longitude ) _publish_log(log_entry, employee) @@ -490,7 +496,7 @@ def scan_face( db=db, employee_id=employee.id, camera=payload.camera, confidence=similarity if 'similarity' in locals() else 1.0, liveness_score=liveness_score if 'liveness_score' in locals() else 1.0, - is_spoof=False, status="Outside WFH Bounds", timestamp=now, + is_spoof=False, status="Outside WFH Bounds", timestamp=now_utc, location_text=location_text, latitude=payload.latitude, longitude=payload.longitude ) _publish_log(log_entry, employee) @@ -511,7 +517,7 @@ def scan_face( liveness_score=liveness_score if 'liveness_score' in locals() else 1.0, is_spoof=False, status="Location Missing", - timestamp=now, + timestamp=now_utc, location_text=location_text, latitude=payload.latitude, longitude=payload.longitude ) _publish_log(log_entry, employee) @@ -544,7 +550,7 @@ def scan_face( liveness_score=liveness_score if 'liveness_score' in locals() else 1.0, is_spoof=False, status="Location Config Error", - timestamp=now, + timestamp=now_utc, location_text=location_text, latitude=payload.latitude, longitude=payload.longitude ) _publish_log(log_entry, employee) @@ -565,7 +571,7 @@ def scan_face( liveness_score=liveness_score if 'liveness_score' in locals() else 1.0, is_spoof=False, status="Outside Office Bounds", - timestamp=now, + timestamp=now_utc, location_text=location_text, latitude=payload.latitude, longitude=payload.longitude ) _publish_log(log_entry, employee) @@ -621,20 +627,48 @@ def scan_face( ) attendance_record = db.execute(stmt).scalars().first() - if not attendance_record: + if attendance_record and attendance_record.status == "On Leave": + log_entry = crud.create_attendance_log( + db=db, + employee_id=employee.id, + camera=payload.camera, + confidence=similarity, + liveness_score=liveness_score, + is_spoof=False, + status="Attendance Locked", + timestamp=now_utc, + location_text=location_text if 'location_text' in locals() else None, + latitude=payload.latitude if hasattr(payload, 'latitude') else None, + longitude=payload.longitude if hasattr(payload, 'longitude') else None + ) + _publish_log(log_entry, employee) + return { + "status": "locked", + "message": f"Verification denied. {employee.name} is currently on approved leave today.", + "should_retry": False, + "bbox": bbox_list + } + + if not attendance_record or attendance_record.check_in is None: # --- First scan of the day: Check-In --- check_in_deadline = datetime.combine(now.date(), shift_start) + timedelta(minutes=grace_mins) is_late = now > check_in_deadline status = "Late" if is_late else "Present" - attendance_record = models.Attendance( - employee_id=employee.id, - date=now.date(), - check_in=now, - late_arrival=is_late, - status=status - ) - db.add(attendance_record) + if not attendance_record: + attendance_record = models.Attendance( + employee_id=employee.id, + date=now.date(), + check_in=now, + late_arrival=is_late, + status=status + ) + db.add(attendance_record) + else: + attendance_record.check_in = now + attendance_record.late_arrival = is_late + attendance_record.status = status + db.commit() db.refresh(attendance_record) @@ -647,7 +681,7 @@ def scan_face( liveness_score=liveness_score, is_spoof=False, status=log_status_success, - timestamp=now, + timestamp=now_utc, location_text=location_text, latitude=payload.latitude, longitude=payload.longitude @@ -716,7 +750,7 @@ def scan_face( liveness_score=liveness_score, is_spoof=False, status=log_status_success, - timestamp=now, + timestamp=now_utc, location_text=location_text if 'location_text' in locals() else None, latitude=payload.latitude if hasattr(payload, 'latitude') else None, longitude=payload.longitude if hasattr(payload, 'longitude') else None @@ -774,7 +808,7 @@ def scan_face( liveness_score=liveness_score, is_spoof=False, status="Attendance Locked", - timestamp=now, + timestamp=now_utc, location_text=location_text if 'location_text' in locals() else None, latitude=payload.latitude if hasattr(payload, 'latitude') else None, longitude=payload.longitude if hasattr(payload, 'longitude') else None @@ -864,7 +898,7 @@ def scan_face( liveness_score=liveness_score, is_spoof=False, status=log_status_success, - timestamp=now, + timestamp=now_utc, location_text=location_text if 'location_text' in locals() else None, latitude=payload.latitude if hasattr(payload, 'latitude') else None, longitude=payload.longitude if hasattr(payload, 'longitude') else None @@ -960,7 +994,7 @@ def confirm_qr( liveness_score=1.0, is_spoof=False, status="Location Missing (QR)", - timestamp=datetime.now() + timestamp=now_utc ) raise HTTPException(status_code=400, detail="GPS coordinates are required to mark attendance.") @@ -986,7 +1020,7 @@ def confirm_qr( liveness_score=1.0, is_spoof=False, status="Location Config Error", - timestamp=datetime.now() + timestamp=now_utc ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1003,7 +1037,7 @@ def confirm_qr( liveness_score=1.0, is_spoof=False, status="Outside Office Bounds (QR)", - timestamp=datetime.now() + timestamp=now_utc ) raise HTTPException(status_code=400, detail=f"Outside allowed area. Distance: {dist:.1f}m. Max radius: {allowed_radius}m.") @@ -1017,16 +1051,16 @@ def confirm_qr( liveness_score=1.0, is_spoof=False, status="QR Verification Failed", - timestamp=datetime.now() + timestamp=now_utc ) _publish_log(log_entry, employee) raise HTTPException(status_code=400, detail="QR Code verification failed. Badge does not match matched face.") - now = datetime.now() + now_utc = datetime.utcnow(); now = now_utc + timedelta(hours=5, minutes=30) attendance_record = crud.mark_kiosk_attendance( db=db, employee_id=employee.id, - timestamp=now, + timestamp=now_utc, camera=payload.camera, confidence=1.0 ) @@ -1039,9 +1073,9 @@ def confirm_qr( liveness_score=1.0, is_spoof=False, status="Match Success (QR Verified)", - timestamp=now, - location_text=location_text if 'location_text' in locals() else None, - latitude=payload.latitude if hasattr(payload, 'latitude') else None, + timestamp=now_utc, + location_text=None, + latitude=payload.latitude if hasattr(payload, 'latitude') else None, longitude=payload.longitude if hasattr(payload, 'longitude') else None ) _publish_log(log_entry, employee) diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..7a3b40eaef23f0146c9438058511a0528fb5a0a1 --- /dev/null +++ b/backend/app/api/v1/notifications.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.core.database import get_db +from app.core import security +from app.crud import crud +from app.schemas import schemas +from app.models import models + +router = APIRouter() + +@router.get("/", response_model=List[schemas.NotificationOut]) +def read_notifications( + is_read: Optional[bool] = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.get_current_user) +): + return crud.get_notifications( + db, + company_id=current_user.company_id, + recipient_id=current_user.id, + is_read=is_read + ) + +@router.post("/", response_model=schemas.NotificationOut, status_code=status.HTTP_201_CREATED) +def post_notification( + request: Request, + notification: schemas.NotificationCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.RoleChecker(["Super Admin", "Admin", "HR"])) +): + company_id = current_user.company_id + db_ntf = crud.create_notification(db, ntf=notification, company_id=company_id, sender_id=current_user.id) + + crud.create_audit_log( + db=db, + user_id=current_user.id, + action="Broadcast Notification", + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + details=f"Posted notification: '{notification.title}' under category '{notification.category}'", + company_id=company_id + ) + return db_ntf + +@router.put("/{id}/read", response_model=schemas.NotificationOut) +def mark_read( + id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.get_current_user) +): + db_ntf = crud.get_notification_by_id(db, notification_id=id) + if not db_ntf: + raise HTTPException(status_code=404, detail="Notification not found") + 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): + raise HTTPException(status_code=403, detail="Not authorized to access this notification") + + return crud.mark_notification_read(db, notification_id=id) + +@router.put("/{id}/archive", response_model=schemas.NotificationOut) +def archive_notification( + id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.get_current_user) +): + db_ntf = crud.get_notification_by_id(db, notification_id=id) + if not db_ntf: + raise HTTPException(status_code=404, detail="Notification not found") + 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): + raise HTTPException(status_code=403, detail="Not authorized to modify this notification") + + return crud.archive_notification(db, notification_id=id) diff --git a/backend/app/api/v1/policy.py b/backend/app/api/v1/policy.py new file mode 100644 index 0000000000000000000000000000000000000000..194083335aeb25d5bc5870bd33ef102a26e19bce --- /dev/null +++ b/backend/app/api/v1/policy.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import Dict, Any + +from app.core.database import get_db +from app.core import security +from app.crud import crud +from app.models import models + +router = APIRouter() +checker_admin = security.RoleChecker(["Super Admin", "Admin"]) + +@router.get("/rules") +def get_attendance_rules( + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_admin) +): + company_id = current_user.company_id + threshold = crud.get_setting_by_key(db, "face_match_threshold", company_id) + lat = crud.get_setting_by_key(db, "office_latitude", company_id) + lng = crud.get_setting_by_key(db, "office_longitude", company_id) + radius = crud.get_setting_by_key(db, "geofence_radius_meters", company_id) + + return { + "face_match_threshold": float(threshold.value) if threshold else 0.6, + "office_latitude": float(lat.value) if lat else 0.0, + "office_longitude": float(lng.value) if lng else 0.0, + "geofence_radius_meters": float(radius.value) if radius else 500.0, + "policy_version": "v2.0-Enterprise" + } + +@router.post("/rules") +def update_attendance_rules( + request: Request, + payload: Dict[str, Any], + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_admin) +): + company_id = current_user.company_id + + for key, value in payload.items(): + if key in ["face_match_threshold", "office_latitude", "office_longitude", "geofence_radius_meters"]: + crud.set_setting(db, key=key, value=str(value), company_id=company_id) + + crud.create_audit_log( + db=db, + user_id=current_user.id, + action="Configure Attendance Rules", + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + details=f"Configured policy rules: {list(payload.keys())}", + company_id=company_id + ) + return {"message": "Attendance rules updated successfully"} diff --git a/backend/app/api/v1/tickets.py b/backend/app/api/v1/tickets.py index b06739de018d78d5a34bf2ab2508f4c3c061650c..ff5bbe5e7b07fe6d2f91f4b343a4c436a2f2bdfb 100644 --- a/backend/app/api/v1/tickets.py +++ b/backend/app/api/v1/tickets.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session -from typing import List +from typing import List, Optional from app.core.database import get_db from app.core import security @@ -12,19 +12,40 @@ router = APIRouter() @router.get("/", response_model=List[schemas.TicketOut]) def read_tickets( + company_id: Optional[int] = None, db: Session = Depends(get_db), current_user: models.User = Depends(security.get_current_user) ): role_name = current_user.role.name if current_user.role else "Employee" + target_company_id = current_user.company_id if current_user.company_id is not None else company_id - if role_name in ["Super Admin", "Admin", "HR"]: - # Admins/HR can see all tickets for their company - return crud.get_tickets(db, company_id=current_user.company_id) + def is_admin_ticket(t): + # Look up the sender of the first message in this ticket + first_msg = db.query(models.TicketMessage).filter(models.TicketMessage.ticket_id == t.id).order_by(models.TicketMessage.timestamp.asc()).first() + if first_msg: + sender_user = db.query(models.User).filter(models.User.id == first_msg.sender_id).first() + if sender_user and sender_user.role: + return sender_user.role.name in ["Super Admin", "Admin", "HR"] + # Fallback to checking the ticket owner employee user role + if t.employee and t.employee.user and t.employee.user.role: + return t.employee.user.role.name in ["Super Admin", "Admin", "HR"] + return False + + if role_name == "Super Admin": + # Super Admin resolves admin/HR problems. Show admin tickets across all companies. + all_tickets = crud.get_tickets(db, company_id=target_company_id) + return [t for t in all_tickets if is_admin_ticket(t)] + + elif role_name in ["Admin", "HR"]: + # Company Admin/HR resolves employee grievances/problems. Show employee tickets only. + company_tickets = crud.get_tickets(db, company_id=target_company_id) + return [t for t in company_tickets if not is_admin_ticket(t)] + else: # Employees can only see their own tickets if not current_user.employee: raise HTTPException(status_code=400, detail="User is not registered as an employee") - return crud.get_tickets(db, company_id=current_user.company_id, employee_id=current_user.employee.id) + return crud.get_tickets(db, company_id=target_company_id, employee_id=current_user.employee.id) @router.post("/", response_model=schemas.TicketOut, status_code=status.HTTP_201_CREATED) def create_ticket( @@ -33,15 +54,17 @@ def create_ticket( db: Session = Depends(get_db), current_user: models.User = Depends(security.get_current_user) ): - role_name = current_user.role.name if current_user.role else "Employee" - if role_name != "Employee": - raise HTTPException(status_code=403, detail="Only employees can open support tickets") - - if not current_user.employee: - raise HTTPException(status_code=400, detail="User is not registered as an employee") + employee_id = current_user.employee.id if current_user.employee else None + if not employee_id: + # Check if user has an associated employee profile or grab first employee profile if admin + first_emp = db.query(models.Employee).filter(models.Employee.company_id == current_user.company_id).first() + if first_emp: + employee_id = first_emp.id + else: + raise HTTPException(status_code=400, detail="No registered employee profile found for opening ticket") db_ticket = crud.create_ticket( - db, ticket=ticket, employee_id=current_user.employee.id, company_id=current_user.company_id + db, ticket=ticket, employee_id=employee_id, company_id=current_user.company_id ) crud.create_audit_log( @@ -55,6 +78,10 @@ def create_ticket( ) return db_ticket +from fastapi.responses import StreamingResponse +import json +import asyncio + @router.post("/{id}/messages", response_model=schemas.TicketMessageOut, status_code=status.HTTP_201_CREATED) def reply_to_ticket( id: int, @@ -76,8 +103,64 @@ def reply_to_ticket( raise HTTPException(status_code=403, detail="Not authorized to post to this ticket") db_message = crud.create_ticket_message(db, ticket_id=id, msg=message, sender_id=current_user.id) + + # Broadcast reply to SSE stream + from app.core import event_bus + event_payload = { + "id": db_message.id, + "ticket_id": db_message.ticket_id, + "sender_id": db_message.sender_id, + "message": db_message.message, + "timestamp": db_message.timestamp.isoformat() + } + event_bus.publish_ticket_message(event_payload) + return db_message +@router.get("/{id}/stream") +async def ticket_stream( + id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(security.get_current_user_sse) +): + db_ticket = crud.get_ticket_by_id(db, ticket_id=id) + if not db_ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + # Check company ownership scope + if current_user.company_id is not None and db_ticket.company_id != current_user.company_id: + raise HTTPException(status_code=403, detail="Not authorized to access this resource") + + role_name = current_user.role.name if current_user.role else "Employee" + if role_name == "Employee": + if not current_user.employee or db_ticket.employee_id != current_user.employee.id: + raise HTTPException(status_code=403, detail="Not authorized to access this ticket stream") + + async def event_generator(): + from app.core import event_bus + queue = event_bus.subscribe_tickets() + try: + while True: + # Wait for next event published to the bus + event_data = await queue.get() + if event_data.get("ticket_id") == id: + yield f"data: {json.dumps(event_data)}\n\n" + except asyncio.CancelledError: + # Client disconnected + pass + finally: + event_bus.unsubscribe_tickets(queue) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + @router.put("/{id}/status", response_model=schemas.TicketOut) def update_ticket( request: Request, diff --git a/backend/app/api/v1/timeline.py b/backend/app/api/v1/timeline.py new file mode 100644 index 0000000000000000000000000000000000000000..565806cd17cd53d03e13f6b5be88d0eddeb8f18f --- /dev/null +++ b/backend/app/api/v1/timeline.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional + +from app.core.database import get_db +from app.core import security +from app.crud import crud +from app.schemas import schemas +from app.models import models + +router = APIRouter() +checker_staff = security.RoleChecker(["Super Admin", "Admin", "HR"]) + +@router.get("/", response_model=List[schemas.ActivityTimelineOut]) +def read_activity_timeline( + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + limit: int = 50, + db: Session = Depends(get_db), + current_user: models.User = Depends(checker_staff) +): + company_id = current_user.company_id + if current_user.role.name == "Super Admin": + # Super Admins can see global platform timeline across all companies + return crud.get_activity_timeline(db, entity_type=entity_type, entity_id=entity_id, limit=limit) + + return crud.get_activity_timeline( + db, + company_id=company_id, + entity_type=entity_type, + entity_id=entity_id, + limit=limit + ) diff --git a/backend/app/core/attendance_policy.py b/backend/app/core/attendance_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..9723acc9f39dd3ae6e1484c931e41e305d60b454 --- /dev/null +++ b/backend/app/core/attendance_policy.py @@ -0,0 +1,127 @@ +import datetime +from sqlalchemy.orm import Session +from app.crud import crud +from app.models import models +import math + +class AttendancePolicyEngine: + @staticmethod + def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + # Haversine formula to compute distance in meters + R = 6371000 # Earth radius in meters + phi1 = math.radians(lat1) + phi2 = math.radians(lat2) + delta_phi = math.radians(lat2 - lat1) + delta_lambda = math.radians(lon2 - lon1) + + a = math.sin(delta_phi / 2) ** 2 + \ + math.cos(phi1) * math.cos(phi2) * \ + math.sin(delta_lambda / 2) ** 2 + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + return R * c + + @classmethod + def evaluate_attendance( + cls, + db: Session, + employee: models.Employee, + lat: float = None, + lng: float = None, + confidence: float = None + ) -> dict: + company_id = employee.company_id + now = datetime.datetime.now() + today = now.date() + + # 1. Fetch matching settings + threshold_setting = crud.get_setting(db, "face_match_threshold", company_id) + match_threshold = float(threshold_setting.value) if threshold_setting else 0.6 + + geofence_lat_setting = crud.get_setting(db, "office_latitude", company_id) + geofence_lng_setting = crud.get_setting(db, "office_longitude", company_id) + geofence_radius_setting = crud.get_setting(db, "geofence_radius_meters", company_id) + + # 2. Confidence Validation + if confidence is not None and confidence < match_threshold: + return {"allowed": False, "reason": "Biometric match confidence score below threshold requirement."} + + # 3. Geofence Validation + geofence_result = "Passed" + if geofence_lat_setting and geofence_lng_setting and geofence_radius_setting: + try: + target_lat = float(geofence_lat_setting.value) + target_lng = float(geofence_lng_setting.value) + allowed_radius = float(geofence_radius_setting.value) + + if lat is not None and lng is not None: + distance = cls.calculate_distance(lat, lng, target_lat, target_lng) + if distance > allowed_radius: + if not employee.allow_wfh: + return {"allowed": False, "reason": f"Outside authorized geofenced perimeter. Distance: {int(distance)}m."} + geofence_result = f"WFH Approved ({int(distance)}m)" + else: + if not employee.allow_wfh: + return {"allowed": False, "reason": "GPS coordinates not supplied by kiosk terminal."} + geofence_result = "WFH Approved (No GPS)" + except ValueError: + pass + + # 4. Duplicate Check (within 5 minutes) + recent_log = crud.get_attendance_by_employee_and_date(db, employee_id=employee.id, attendance_date=today) + if recent_log and recent_log.check_in: + time_since_checkin = (now - recent_log.check_in).total_seconds() + if time_since_checkin < 300: # 5 minutes + return {"allowed": False, "reason": "Duplicate swipe attempt blocked. Please wait 5 minutes."} + + # 5. Shift & Grace Period Rule Evaluation + late_minutes = 0 + early_exit_minutes = 0 + overtime_hours = 0.0 + status = "Present" + + shift = employee.shift + shift_info = "Default Shift" + if shift: + shift_info = f"{shift.name} ({shift.start_time.strftime('%H:%M')} - {shift.end_time.strftime('%H:%M')})" + # Combine today's date with shift times + shift_start = datetime.datetime.combine(today, shift.start_time) + shift_end = datetime.datetime.combine(today, shift.end_time) + + # Check-in evaluation (Late arrival check) + if not recent_log: # First check-in of the day + grace_limit = shift_start + datetime.timedelta(minutes=shift.grace_period_minutes) + if now > grace_limit: + status = "Late" + late_minutes = int((now - shift_start).total_seconds() / 60) + else: # Checkout check + # Check early departure + if now < shift_end: + early_exit_minutes = int((shift_end - now).total_seconds() / 60) + # Check overtime + if now > shift_end: + overtime_hours = round((now - shift_end).total_seconds() / 3600, 2) + + # 6. Calculate Streak Info + streak = 0 + if recent_log and recent_log.attendance_streak: + streak = recent_log.attendance_streak + else: + # Look at yesterday's record + yesterday = today - datetime.timedelta(days=1) + yesterday_record = crud.get_attendance_by_employee_and_date(db, employee_id=employee.id, attendance_date=yesterday) + if yesterday_record and yesterday_record.status in ["Present", "Late"]: + streak = yesterday_record.attendance_streak + 1 + else: + streak = 1 + + return { + "allowed": True, + "status": status if not employee.allow_wfh else "WFH", + "late_minutes": late_minutes, + "early_exit_minutes": early_exit_minutes, + "overtime_hours": overtime_hours, + "streak": streak, + "shift_info": shift_info, + "geofence_result": geofence_result, + "policy_version": "v2.0-Enterprise" + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 43d7617a576337500b253f4c54d14a47df3945eb..fe21efc6d6cc90a95cbd892737536804c0b04b7e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -53,7 +53,7 @@ class Settings(BaseSettings): REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # Seeding - INITIAL_ADMIN_EMAIL: str = "admin@netraid.ai" + INITIAL_ADMIN_EMAIL: str = "pavanupadhyay027@gmail.com" INITIAL_ADMIN_PASSWORD: str = "Admin@NetraID2026" # Face recognition & liveness detection parameters diff --git a/backend/app/core/event_bus.py b/backend/app/core/event_bus.py index 6b498e54e4dcb16d4380a203280115d788c2085a..1693a28358df41b4811d7988244eff9b67b08dc9 100644 --- a/backend/app/core/event_bus.py +++ b/backend/app/core/event_bus.py @@ -53,3 +53,40 @@ def _publish_to_all(payload: dict) -> None: dead.append(q) for q in dead: unsubscribe(q) + + +# --- Ticket Chat Broadcast Bus --- +_ticket_subscribers: List[asyncio.Queue] = [] + +def subscribe_tickets() -> asyncio.Queue: + global _loop + try: + _loop = asyncio.get_running_loop() + except RuntimeError: + pass + q: asyncio.Queue = asyncio.Queue(maxsize=50) + _ticket_subscribers.append(q) + return q + +def unsubscribe_tickets(q: asyncio.Queue) -> None: + try: + _ticket_subscribers.remove(q) + except ValueError: + pass + +def publish_ticket_message(payload: dict) -> None: + global _loop + if _loop is not None: + _loop.call_soon_threadsafe(_publish_ticket_to_all, payload) + else: + _publish_ticket_to_all(payload) + +def _publish_ticket_to_all(payload: dict) -> None: + dead: List[asyncio.Queue] = [] + for q in _ticket_subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + dead.append(q) + for q in dead: + unsubscribe_tickets(q) diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py index 1abca02de3a6e1a272c6806eee1f4a2bfc01806c..77a5ee8ef8de926665a54e02ccb11df84026aed1 100644 --- a/backend/app/core/init_db.py +++ b/backend/app/core/init_db.py @@ -226,5 +226,46 @@ def init_db(db: Session): admin_user.hashed_password = crud.get_password_hash(settings.INITIAL_ADMIN_PASSWORD) admin_user.company_id = None db.commit() + + # 4. Seed Default Company Admin User linked to NetraID Base + default_admin_email = "hr@netraid.ai" + default_admin = crud.get_user_by_email(db, default_admin_email) + if not default_admin: + logger.info(f"Seeding default company admin user: {default_admin_email}") + admin_create = schemas.UserCreate( + email=default_admin_email, + password="Admin@NetraID2026", + role_id=db_roles["Admin"].id + ) + crud.create_user(db, admin_create, company_id=default_company.id) + + # 5. Seed Default Employee User linked to NetraID Base + default_emp_email = "employee@netraid.ai" + default_emp = crud.get_user_by_email(db, default_emp_email) + if not default_emp: + logger.info(f"Seeding default employee user: {default_emp_email}") + emp_create = schemas.UserCreate( + email=default_emp_email, + password="Employee@NetraID2026", + role_id=db_roles["Employee"].id + ) + db_user = crud.create_user(db, emp_create, company_id=default_company.id) + + eng_dept = db.execute(select(models.Department).where( + models.Department.code == "ENG", + models.Department.company_id == default_company.id + )).scalar_one_or_none() + dept_id = eng_dept.id if eng_dept else None + + employee_in = schemas.EmployeeCreate( + name="Rahul Kumar", + employee_id="EMP101", + phone="9876543210", + email=default_emp_email, + designation="Software Engineer", + department_id=dept_id, + status="Active" + ) + crud.create_employee(db, employee_in, user_id=db_user.id, company_id=default_company.id) logger.info("Database initialization and seeding completed successfully.") diff --git a/backend/app/core/rate_limiter.py b/backend/app/core/rate_limiter.py new file mode 100644 index 0000000000000000000000000000000000000000..66feafd272389ed7172aee9f3ea5b77213ee9d32 --- /dev/null +++ b/backend/app/core/rate_limiter.py @@ -0,0 +1,45 @@ +import time +from typing import Dict, Tuple +from fastapi import Request, HTTPException, status + +class SimpleRateLimiter: + """ + Sliding window rate limiter to protect authentication, biometric, and sensitive API routes + against brute-force and credential stuffing attacks. + """ + def __init__(self, requests_per_window: int = 10, window_seconds: int = 60): + self.requests_per_window = requests_per_window + self.window_seconds = window_seconds + # Mapping: IP address -> List of timestamps + self._history: Dict[str, list] = {} + + def is_rate_limited(self, ip: str) -> bool: + now = time.time() + cutoff = now - self.window_seconds + + # Filter out timestamps outside current window + timestamps = [t for t in self._history.get(ip, []) if t > cutoff] + + if len(timestamps) >= self.requests_per_window: + self._history[ip] = timestamps + return True + + timestamps.append(now) + self._history[ip] = timestamps + return False + +# Global instance for Auth / Biometric Endpoints: Max 15 requests per 60 seconds per IP +login_rate_limiter = SimpleRateLimiter(requests_per_window=15, window_seconds=60) + +def check_login_rate_limit(request: Request): + client_ip = request.client.host if request.client else "127.0.0.1" + # Support proxy headers if behind nginx/cloudflare + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + client_ip = forwarded_for.split(",")[0].strip() + + if login_rate_limiter.is_rate_limited(client_ip): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many authentication attempts. Please wait 60 seconds before trying again." + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 65b3b291ce8e3b2ee4bdd9d64e064ffa3a4b9bcf..c6db014480ea71ba0ecf91ba2d4606d113e25ccf 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -10,6 +10,19 @@ from app.core.database import get_db from app.models import models from app.crud import crud +import bcrypt + +def verify_password(plain_password: str, hashed_password: str) -> bool: + try: + # Convert password strings to bytes for bcrypt + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + except Exception: + return False + +def get_password_hash(password: str) -> str: + # Hash password using bcrypt salt + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + oauth2_scheme = OAuth2PasswordBearer( tokenUrl=f"{settings.API_V1_STR}/auth/login" ) diff --git a/backend/app/crud/crud.py b/backend/app/crud/crud.py index 12772306d0531033d6db61f47ce12fb103e0f024..3d49938a1d32a10056de937f0cd4b763e23d901b 100644 --- a/backend/app/crud/crud.py +++ b/backend/app/crud/crud.py @@ -1,6 +1,7 @@ from sqlalchemy import select, or_, and_, func, delete from sqlalchemy.orm import Session -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, time +from typing import Optional, List from app.models import models from app.schemas import schemas import logging @@ -27,19 +28,62 @@ def get_companies(db: Session, skip: int = 0, limit: int = 100): return db.execute(select(models.Company).offset(skip).limit(limit)).scalars().all() def create_company(db: Session, company: schemas.CompanyCreate): - db_company = models.Company(**company.model_dump()) + dump = company.model_dump() + logo = dump.pop("logo", None) + latitude = dump.pop("latitude", None) + longitude = dump.pop("longitude", None) + db_company = models.Company(**dump) db.add(db_company) db.commit() db.refresh(db_company) + if logo: + set_setting(db, key="COMPANY_LOGO", value=logo, company_id=db_company.id) + if latitude: + set_setting(db, key="LOCATION_LATITUDE", value=str(latitude), company_id=db_company.id) + if longitude: + set_setting(db, key="LOCATION_LONGITUDE", value=str(longitude), company_id=db_company.id) + if db_company.address: + set_setting(db, key="LOCATION_ADDRESS", value=db_company.address, company_id=db_company.id) + + # Auto-seed standard departments for the new organization + default_depts = [ + {"name": "Engineering", "code": "ENG", "description": "Software development, DevOps, QA, and IT systems"}, + {"name": "Human Resources", "code": "HR", "description": "Recruitment, payroll, and staff relations"}, + {"name": "Marketing & Sales", "code": "MKT", "description": "Product branding, marketing campaigns, and client sales"}, + {"name": "Finance & Accounts", "code": "FIN", "description": "Financial planning, accounting, and budgeting"}, + {"name": "Operations", "code": "OPS", "description": "Office administration and business facilities"} + ] + for d in default_depts: + db_dept = models.Department( + name=d["name"], + code=d["code"], + description=d["description"], + company_id=db_company.id + ) + db.add(db_dept) + db.commit() + db.refresh(db_company) return db_company def update_company(db: Session, company_id: int, company: schemas.CompanyUpdate): db_company = get_company_by_id(db, company_id) if not db_company: return None - for key, value in company.model_dump(exclude_unset=True).items(): + dump = company.model_dump(exclude_unset=True) + logo = dump.pop("logo", None) + latitude = dump.pop("latitude", None) + longitude = dump.pop("longitude", None) + for key, value in dump.items(): setattr(db_company, key, value) db.commit() + if logo is not None: + set_setting(db, key="COMPANY_LOGO", value=logo, company_id=company_id) + if latitude is not None: + set_setting(db, key="LOCATION_LATITUDE", value=str(latitude), company_id=company_id) + if longitude is not None: + set_setting(db, key="LOCATION_LONGITUDE", value=str(longitude), company_id=company_id) + if db_company.address: + set_setting(db, key="LOCATION_ADDRESS", value=db_company.address, company_id=company_id) db.refresh(db_company) return db_company @@ -285,6 +329,58 @@ def get_attendance_logs(db: Session, company_id: int = None, skip: int = 0, limi query = query.order_by(models.AttendanceLog.timestamp.desc()).offset(skip).limit(limit) return db.execute(query).scalars().all() +def create_attendance_log( + db: Session, + employee_id: Optional[int], + camera: str, + confidence: Optional[float], + liveness_score: Optional[float], + is_spoof: bool, + status: str, + timestamp: datetime = None, + image_path: str = None, + location_text: str = None, + latitude: float = None, + longitude: float = None, + face_quality: float = None, + blur_score: float = None, + brightness_score: float = None, + is_occluded: bool = False, + has_mask: bool = False, + recognition_time_ms: float = None, + processing_time_ms: float = None, + embedding_version: str = None, + device_id: int = None +): + if timestamp is None: + timestamp = datetime.utcnow() + db_log = models.AttendanceLog( + employee_id=employee_id, + timestamp=timestamp, + camera=camera, + confidence=confidence, + liveness_score=liveness_score, + is_spoof=is_spoof, + status=status, + image_path=image_path, + location_text=location_text, + latitude=latitude, + longitude=longitude, + face_quality=face_quality, + blur_score=blur_score, + brightness_score=brightness_score, + is_occluded=is_occluded, + has_mask=has_mask, + recognition_time_ms=recognition_time_ms, + processing_time_ms=processing_time_ms, + embedding_version=embedding_version, + device_id=device_id + ) + db.add(db_log) + db.commit() + db.refresh(db_log) + return db_log + def get_daily_attendance(db: Session, date_val: date, employee_id: int = None, department_id: int = None, company_id: int = None): query = select(models.Attendance).join(models.Employee) filters = [models.Attendance.date == date_val] @@ -318,7 +414,9 @@ def delete_face_embeddings(db: Session, employee_id: int): db.commit() def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, camera: str, confidence: float) -> models.Attendance: - today = timestamp.date() + # Convert UTC timestamp to IST to get today's date and for shift/deadline comparisons + ist_time = timestamp + timedelta(hours=5, minutes=30) + today = ist_time.date() employee = db.get(models.Employee, employee_id) if not employee: @@ -363,7 +461,7 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca if not db_attendance: # First scan of the day -> CHECK-IN - is_late = timestamp > check_in_deadline + is_late = ist_time > check_in_deadline status = "Late" if is_late else "Present" db_attendance = models.Attendance( @@ -393,7 +491,7 @@ def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, ca # Auto-calculate early departure if employee.shift: shift_end_dt = datetime.combine(today, employee.shift.end_time) - db_attendance.early_departure = timestamp < shift_end_dt + db_attendance.early_departure = ist_time < shift_end_dt logger.info(f"Marked Check-Out for employee {employee_id} at {timestamp}. Hours: {db_attendance.working_hours}") @@ -557,3 +655,160 @@ def update_ticket_status(db: Session, ticket_id: int, status: str): db.commit() db.refresh(db_ticket) return db_ticket + +# --- Device CRUD --- +def get_device_by_id(db: Session, device_id: int): + return db.get(models.Device, device_id) + +def get_devices(db: Session, company_id: int = None): + query = select(models.Device) + if company_id is not None: + query = query.where(models.Device.company_id == company_id) + return db.execute(query.order_by(models.Device.name.asc())).scalars().all() + +def create_device(db: Session, device: schemas.DeviceCreate, company_id: int): + db_device = models.Device( + name=device.name, + device_type=device.device_type, + company_id=company_id, + branch=device.branch, + camera=device.camera, + ip_address=device.ip_address, + os_info=device.os_info, + app_version=device.app_version + ) + db.add(db_device) + db.commit() + db.refresh(db_device) + return db_device + +def update_device(db: Session, device_id: int, device_update: schemas.DeviceUpdate): + db_device = get_device_by_id(db, device_id) + if not db_device: + return None + update_data = device_update.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_device, key, value) + db_device.heartbeat = datetime.utcnow() + db.commit() + db.refresh(db_device) + return db_device + +# --- Notification CRUD --- +def get_notification_by_id(db: Session, notification_id: int): + return db.get(models.Notification, notification_id) + +def get_notifications(db: Session, company_id: int = None, recipient_id: int = None, is_read: bool = None): + query = select(models.Notification) + filters = [] + if company_id is not None: + filters.append(models.Notification.company_id == company_id) + if recipient_id is not None: + # Show both specific recipient notifications and broadcast notifications (where recipient_id is Null) + filters.append(or_(models.Notification.recipient_id == recipient_id, models.Notification.recipient_id.is_(None))) + if is_read is not None: + filters.append(models.Notification.is_read == is_read) + filters.append(models.Notification.is_archived == False) + + if filters: + query = query.where(and_(*filters)) + return db.execute(query.order_by(models.Notification.created_at.desc())).scalars().all() + +def create_notification(db: Session, ntf: schemas.NotificationCreate, company_id: int, sender_id: int = None): + db_ntf = models.Notification( + company_id=company_id, + recipient_id=ntf.recipient_id, + sender_id=sender_id, + title=ntf.title, + message=ntf.message, + category=ntf.category, + priority=ntf.priority, + expires_at=ntf.expires_at + ) + db.add(db_ntf) + db.commit() + db.refresh(db_ntf) + return db_ntf + +def mark_notification_read(db: Session, notification_id: int): + db_ntf = get_notification_by_id(db, notification_id) + if db_ntf: + db_ntf.is_read = True + db.commit() + db.refresh(db_ntf) + return db_ntf + +def archive_notification(db: Session, notification_id: int): + db_ntf = get_notification_by_id(db, notification_id) + if db_ntf: + db_ntf.is_archived = True + db.commit() + db.refresh(db_ntf) + return db_ntf + +# --- Activity Timeline CRUD --- +def create_activity_timeline_log( + db: Session, + company_id: int, + actor_id: int, + action: str, + entity_type: str, + entity_id: int = None, + previous_value: str = None, + new_value: str = None, + ip_address: str = None, + device_info: str = None, + browser_info: str = None +): + log = models.ActivityTimeline( + company_id=company_id, + actor_id=actor_id, + action=action, + entity_type=entity_type, + entity_id=entity_id, + previous_value=previous_value, + new_value=new_value, + ip_address=ip_address, + device_info=device_info, + browser_info=browser_info + ) + db.add(log) + db.commit() + db.refresh(log) + return log + +def get_activity_timeline(db: Session, company_id: int = None, entity_type: str = None, entity_id: int = None, limit: int = 50): + query = select(models.ActivityTimeline) + filters = [] + if company_id is not None: + filters.append(models.ActivityTimeline.company_id == company_id) + if entity_type is not None: + filters.append(models.ActivityTimeline.entity_type == entity_type) + if entity_id is not None: + filters.append(models.ActivityTimeline.entity_id == entity_id) + if filters: + query = query.where(and_(*filters)) + return db.execute(query.order_by(models.ActivityTimeline.timestamp.desc()).limit(limit)).scalars().all() + +def save_employee_image(db: Session, employee_id: int, file_path: str, pose_type: str, image_bytes: bytes = None): + db_img = models.EmployeeImage( + employee_id=employee_id, + file_path=file_path, + pose_type=pose_type, + image_bytes=image_bytes + ) + db.add(db_img) + db.commit() + db.refresh(db_img) + return db_img + +def save_face_embedding(db: Session, employee_id: int, image_id: int, embedding: list): + db_emb = models.FaceEmbedding( + employee_id=employee_id, + image_id=image_id, + embedding=embedding + ) + db.add(db_emb) + db.commit() + db.refresh(db_emb) + return db_emb diff --git a/backend/app/main.py b/backend/app/main.py index 9c3b20b1224b5ceae4da409c2e5616fd99fed367..c0c5ba3d7b0d69cc3c925d761c2842090fb90e2c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,8 +2,14 @@ import os import threading import time +import sys + # Enforce IST Timezone for all attendance date calculations (important for HuggingFace / UTC cloud servers) -os.environ["TZ"] = "Asia/Kolkata" +if sys.platform == "win32": + os.environ["TZ"] = "IST-5:30" +else: + os.environ["TZ"] = "Asia/Kolkata" + if hasattr(time, "tzset"): time.tzset() from datetime import datetime, timedelta @@ -16,7 +22,8 @@ from sqlalchemy import delete from app.core.config import settings from app.core.database import SessionLocal from app.core.init_db import init_db -from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit, companies, tickets +from app.models import models +from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit, companies, tickets, devices, notifications, timeline, policy # Logging configuration logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") @@ -31,29 +38,53 @@ app = FastAPI( redoc_url="/redoc" ) +from fastapi import Request + +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + 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:;" + return response + # Mount uploads directory as static files # Dynamic uploads endpoint: serves images from database (fallback to local disk) from app.core.database import get_db @app.get("/uploads/{employee_id}/{filename}") def get_upload_file(employee_id: str, filename: str, db: Session = Depends(get_db)): + # Sanitize inputs to prevent directory traversal + clean_emp_id = os.path.basename(employee_id.replace("..", "").replace("/", "").replace("\\", "")) + clean_filename = os.path.basename(filename.replace("..", "").replace("/", "").replace("\\", "")) + + if not clean_emp_id or not clean_filename: + raise HTTPException(status_code=400, detail="Invalid request parameters") + # Parse pose type from filename (e.g. "front.jpg" -> "front") - pose_type = filename.split(".")[0].lower() + pose_type = clean_filename.split(".")[0].lower() # Query database for this employee and pose_type db_img = db.query(models.EmployeeImage).join(models.Employee).filter( - models.Employee.employee_id == employee_id, + models.Employee.employee_id == clean_emp_id, models.EmployeeImage.pose_type.ilike(pose_type) ).first() if db_img and db_img.image_bytes: return Response(content=db_img.image_bytes, media_type="image/jpeg") - # Fallback to local file system if not in DB (e.g. legacy/development) - local_path = os.path.join(settings.UPLOAD_DIR, employee_id, filename) - if os.path.exists(local_path): + # Fallback to local file system with strict path canonicalization + upload_dir_abs = os.path.abspath(settings.UPLOAD_DIR) + local_path_abs = os.path.abspath(os.path.join(upload_dir_abs, clean_emp_id, clean_filename)) + + if not local_path_abs.startswith(upload_dir_abs): + raise HTTPException(status_code=403, detail="Access denied: Path traversal attempt detected") + + if os.path.exists(local_path_abs): try: - with open(local_path, "rb") as f: + with open(local_path_abs, "rb") as f: return Response(content=f.read(), media_type="image/jpeg") except Exception: pass @@ -194,5 +225,9 @@ app.include_router(settings_api.router, prefix=f"{settings.API_V1_STR}/settings" app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"]) app.include_router(companies.router, prefix=f"{settings.API_V1_STR}/companies", tags=["Company Management"]) app.include_router(tickets.router, prefix=f"{settings.API_V1_STR}/tickets", tags=["Support Tickets & Helpdesk"]) +app.include_router(devices.router, prefix=f"{settings.API_V1_STR}/devices", tags=["Kiosk Devices"]) +app.include_router(notifications.router, prefix=f"{settings.API_V1_STR}/notifications", tags=["In-App Notifications"]) +app.include_router(timeline.router, prefix=f"{settings.API_V1_STR}/timeline", tags=["Activity History Timeline"]) +app.include_router(policy.router, prefix=f"{settings.API_V1_STR}/policy", tags=["Attendance Rules Policy Engine"]) # Trigger reload - reload 2 diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 18228254fe2d9508a12c543dff021144791d60b0..cfc556b005dcff931374feb481f6b80c007bf08a 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -59,6 +59,7 @@ class Company(Base): settings = relationship("Setting", back_populates="company", cascade="all, delete-orphan") audit_logs = relationship("AuditLog", back_populates="company", cascade="all, delete-orphan") tickets = relationship("Ticket", back_populates="company", cascade="all, delete-orphan") + devices = relationship("Device", back_populates="company", cascade="all, delete-orphan") class Role(Base): __tablename__ = "roles" @@ -193,6 +194,16 @@ class Attendance(Base): status = Column(String(20), default="Absent") # Present, Absent, Late, Half Day, Leave, Holiday, WFH emergency_allowed = Column(Boolean, default=False) + # Enterprise Extensions + late_minutes = Column(Integer, default=0) + early_exit_minutes = Column(Integer, default=0) + break_time_minutes = Column(Integer, default=0) + attendance_streak = Column(Integer, default=0) + attendance_percentage = Column(Float, default=100.0) + shift_info = Column(String(255), nullable=True) + geofence_result = Column(String(100), nullable=True) + policy_version = Column(String(50), nullable=True) + employee = relationship("Employee", back_populates="attendance_records") class AttendanceLog(Base): @@ -200,7 +211,7 @@ class AttendanceLog(Base): id = Column(Integer, primary_key=True, index=True) employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True) # Null if not recognized - timestamp = Column(DateTime, default=datetime.datetime.now, nullable=False) + timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) camera = Column(String(100), default="Kiosk") confidence = Column(Float, nullable=True) liveness_score = Column(Float, nullable=True) @@ -211,6 +222,17 @@ class AttendanceLog(Base): latitude = Column(Float, nullable=True) longitude = Column(Float, nullable=True) + # Recognition Analytics Extensions + face_quality = Column(Float, nullable=True) + blur_score = Column(Float, nullable=True) + brightness_score = Column(Float, nullable=True) + is_occluded = Column(Boolean, default=False) + has_mask = Column(Boolean, default=False) + recognition_time_ms = Column(Float, nullable=True) + processing_time_ms = Column(Float, nullable=True) + embedding_version = Column(String(50), nullable=True) + device_id = Column(Integer, ForeignKey("devices.id", ondelete="SET NULL"), nullable=True) + employee = relationship("Employee", back_populates="attendance_logs") class LeaveRequest(Base): @@ -257,7 +279,7 @@ class AuditLog(Base): id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) action = Column(String(100), nullable=False) # Login, Logout, Create Employee, Mark Attendance, etc. - timestamp = Column(DateTime, default=datetime.datetime.now) + timestamp = Column(DateTime, default=datetime.datetime.utcnow) ip_address = Column(String(50), nullable=True) user_agent = Column(String(255), nullable=True) details = Column(Text, nullable=True) @@ -293,3 +315,66 @@ class TicketMessage(Base): ticket = relationship("Ticket", back_populates="messages") sender = relationship("User") + +class Device(Base): + __tablename__ = "devices" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + device_type = Column(String(50), default="Kiosk") # Kiosk, Mobile, Gateway + company_id = Column(Integer, ForeignKey("companies.id", ondelete="CASCADE"), nullable=True) + branch = Column(String(100), default="Main Headquarters") + camera = Column(String(100), default="Main Camera") + ip_address = Column(String(50), nullable=True) + os_info = Column(String(100), nullable=True) + app_version = Column(String(50), nullable=True) + status = Column(String(50), default="Online") # Online, Offline, Maintenance + heartbeat = Column(DateTime, default=datetime.datetime.utcnow) + cpu_usage = Column(Float, default=0.0) + memory_usage = Column(Float, default=0.0) + disk_usage = Column(Float, default=0.0) + battery_level = Column(Integer, default=100) + network_status = Column(String(50), default="Good") + last_sync = Column(DateTime, default=datetime.datetime.utcnow) + restart_count = Column(Integer, default=0) + + company = relationship("Company", back_populates="devices") + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + company_id = Column(Integer, ForeignKey("companies.id", ondelete="CASCADE"), nullable=True) + recipient_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) # Null if broadcast + sender_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + title = Column(String(255), nullable=False) + message = Column(Text, nullable=False) + category = Column(String(100), default="General") # Attendance, HR, Leave, Alert, System + priority = Column(String(50), default="Medium") # Low, Medium, High + is_read = Column(Boolean, default=False) + is_archived = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + expires_at = Column(DateTime, nullable=True) + + company = relationship("Company") + recipient = relationship("User", foreign_keys=[recipient_id]) + sender = relationship("User", foreign_keys=[sender_id]) + +class ActivityTimeline(Base): + __tablename__ = "activity_timelines" + + id = Column(Integer, primary_key=True, index=True) + company_id = Column(Integer, ForeignKey("companies.id", ondelete="CASCADE"), nullable=True) + actor_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + action = Column(String(100), nullable=False) # Create, Update, Delete, Authenticate, Scan + entity_type = Column(String(100), nullable=False) # Employee, Organization, Device, Attendance, Ticket + entity_id = Column(Integer, nullable=True) + previous_value = Column(Text, nullable=True) + new_value = Column(Text, nullable=True) + timestamp = Column(DateTime, default=datetime.datetime.utcnow) + ip_address = Column(String(50), nullable=True) + device_info = Column(String(255), nullable=True) + browser_info = Column(String(255), nullable=True) + + company = relationship("Company") + actor = relationship("User") diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 6c1a7243b1e125a6e45a639fde71328d1112c8fd..dcc8eefec50f0f3db8aa9e5e6ed5a3f8cc34927f 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -24,7 +24,10 @@ class CompanyBase(BaseModel): address: Optional[str] = None class CompanyCreate(CompanyBase): - pass + logo: Optional[str] = None + latitude: Optional[str] = None + longitude: Optional[str] = None + admin_password: Optional[str] = None class CompanyUpdate(BaseModel): name: Optional[str] = None @@ -35,6 +38,9 @@ class CompanyUpdate(BaseModel): admin_email: Optional[EmailStr] = None phone: Optional[str] = None address: Optional[str] = None + logo: Optional[str] = None + latitude: Optional[str] = None + longitude: Optional[str] = None class CompanyOut(CompanyBase): id: int @@ -54,6 +60,22 @@ class UserUpdate(BaseModel): role_id: Optional[int] = None is_active: Optional[bool] = None +class AdminRegister(BaseModel): + company_name: str + email: EmailStr + password: str + phone: Optional[str] = None + address: Optional[str] = None + +class EmployeeRegister(BaseModel): + company_id: int + name: str + email: EmailStr + password: str + employee_id: str + phone: Optional[str] = None + designation: Optional[str] = None + # Token Schemas class Token(BaseModel): @@ -204,6 +226,14 @@ class AttendanceOut(AttendanceBase): early_departure: bool overtime: float emergency_allowed: bool = False + late_minutes: Optional[int] = 0 + early_exit_minutes: Optional[int] = 0 + break_time_minutes: Optional[int] = 0 + attendance_streak: Optional[int] = 0 + attendance_percentage: Optional[float] = 0.0 + shift_info: Optional[str] = None + geofence_result: Optional[str] = None + policy_version: Optional[str] = None employee: Optional[EmployeeOut] = None model_config = ConfigDict(from_attributes=True) @@ -227,6 +257,15 @@ class AttendanceLogOut(BaseModel): location_text: Optional[str] = None latitude: Optional[float] = None longitude: Optional[float] = None + face_quality: Optional[float] = None + blur_score: Optional[float] = None + brightness_score: Optional[float] = None + is_occluded: bool + has_mask: bool + recognition_time_ms: Optional[float] = None + processing_time_ms: Optional[float] = None + embedding_version: Optional[str] = None + device_id: Optional[int] = None employee: Optional[EmployeeOut] = None model_config = ConfigDict(from_attributes=True) @@ -330,3 +369,76 @@ class TicketOut(TicketBase): employee: Optional[EmployeeOut] = None messages: List[TicketMessageOut] = [] model_config = ConfigDict(from_attributes=True) + +# Device Schemas +class DeviceBase(BaseModel): + name: str + device_type: str = "Kiosk" + branch: str = "Main Headquarters" + camera: str = "Main Camera" + ip_address: Optional[str] = None + os_info: Optional[str] = None + app_version: Optional[str] = None + +class DeviceCreate(DeviceBase): + pass + +class DeviceUpdate(BaseModel): + name: Optional[str] = None + status: Optional[str] = None + cpu_usage: Optional[float] = None + memory_usage: Optional[float] = None + disk_usage: Optional[float] = None + battery_level: Optional[int] = None + network_status: Optional[str] = None + +class DeviceOut(DeviceBase): + id: int + company_id: Optional[int] = None + status: str + heartbeat: datetime + cpu_usage: float + memory_usage: float + disk_usage: float + battery_level: int + network_status: str + last_sync: datetime + restart_count: int + model_config = ConfigDict(from_attributes=True) + +# Notification Schemas +class NotificationBase(BaseModel): + title: str + message: str + category: str = "General" + priority: str = "Medium" + expires_at: Optional[datetime] = None + +class NotificationCreate(NotificationBase): + recipient_id: Optional[int] = None + +class NotificationOut(NotificationBase): + id: int + company_id: Optional[int] = None + recipient_id: Optional[int] = None + sender_id: Optional[int] = None + is_read: bool + is_archived: bool + created_at: datetime + model_config = ConfigDict(from_attributes=True) + +# ActivityTimeline Schemas +class ActivityTimelineOut(BaseModel): + id: int + company_id: Optional[int] = None + actor_id: Optional[int] = None + action: str + entity_type: str + entity_id: Optional[int] = None + previous_value: Optional[str] = None + new_value: Optional[str] = None + timestamp: datetime + ip_address: Optional[str] = None + device_info: Optional[str] = None + browser_info: Optional[str] = None + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/tests/test_production_features.py b/backend/app/tests/test_production_features.py index 4317e69ddf64b485150308315729d72a954a4e0c..3506bf9cf5dae3067fbdfb9584dac632759a702a 100644 --- a/backend/app/tests/test_production_features.py +++ b/backend/app/tests/test_production_features.py @@ -79,9 +79,9 @@ def test_shift_attendance_rules(): db.commit() db.refresh(employee) - # Test 1: Check-in before deadline (10:10) - # We manually call mark_kiosk_attendance with timestamps - checkin_time_on_time = datetime.combine(datetime.now().date(), time(10, 5)) + # Test 1: Check-in before deadline (10:10 IST) + # We manually call mark_kiosk_attendance with UTC timestamps (10:05 IST = 04:35 UTC) + checkin_time_on_time = datetime.combine(datetime.now().date(), time(4, 35)) att = crud.mark_kiosk_attendance(db, employee.id, checkin_time_on_time, "Test Cam", 0.95) assert att.late_arrival is False @@ -91,8 +91,8 @@ def test_shift_attendance_rules(): db.delete(att) db.commit() - # Test 2: Check-in after deadline (10:15) - checkin_time_late = datetime.combine(datetime.now().date(), time(10, 15)) + # Test 2: Check-in after deadline (10:15 IST = 04:45 UTC) + checkin_time_late = datetime.combine(datetime.now().date(), time(4, 45)) att_late = crud.mark_kiosk_attendance(db, employee.id, checkin_time_late, "Test Cam", 0.95) # In mark_kiosk_attendance, it uses local_now (current time) for determining is_late, diff --git a/backend/delete_all_employees.py b/backend/delete_all_employees.py new file mode 100644 index 0000000000000000000000000000000000000000..d72652eb1a946603924e3653841c9e6384568efd --- /dev/null +++ b/backend/delete_all_employees.py @@ -0,0 +1,82 @@ +import os +import shutil +import sqlite3 + +def clean_db(db_path): + if not os.path.exists(db_path): + print(f"Database {db_path} not found.") + return + + print(f"Cleaning database: {db_path}") + conn = sqlite3.connect(db_path) + c = conn.cursor() + + # Get employee user_ids first + try: + c.execute("SELECT user_id, employee_id, name FROM employees") + employees = c.fetchall() + employee_user_ids = [emp[0] for emp in employees if emp[0] is not None] + print(f"Found {len(employees)} employees in {db_path}.") + except sqlite3.OperationalError as e: + print(f"Could not read employees: {e}") + conn.close() + return + + # Delete related records + tables = [ + "face_embeddings", + "employee_images", + "attendance", + "attendance_logs", + "leave_requests", + "ticket_messages", + "tickets", + "employees" + ] + + for table in tables: + try: + c.execute(f"DELETE FROM {table}") + print(f"Deleted records from table: {table}") + except sqlite3.OperationalError as e: + print(f"Table {table} delete error: {e}") + + # Associated Users + if employee_user_ids: + try: + placeholders = ','.join('?' for _ in employee_user_ids) + c.execute(f"DELETE FROM users WHERE id IN ({placeholders})", employee_user_ids) + print(f"Deleted {c.rowcount} associated employee user accounts from users table.") + except sqlite3.OperationalError as e: + print(f"Users table delete error: {e}") + + conn.commit() + conn.close() + print(f"Finished cleaning database: {db_path}\n") + +def delete_employee_data(): + # Clean both potential database paths + clean_db('netraid.db') + clean_db('../netraid.db') + + # Delete image files from uploads + uploads_dir = './uploads' + if os.path.exists(uploads_dir): + deleted_dirs = 0 + for item in os.listdir(uploads_dir): + item_path = os.path.join(uploads_dir, item) + if os.path.isdir(item_path): + try: + shutil.rmtree(item_path) + print(f"Deleted uploads directory: {item_path}") + deleted_dirs += 1 + except Exception as e: + print(f"Error deleting directory {item_path}: {e}") + print(f"Cleared {deleted_dirs} employee upload folders from {uploads_dir}.") + else: + print("Uploads directory not found.") + + print("\n[SUCCESS] All employee data has been successfully deleted!") + +if __name__ == '__main__': + delete_employee_data() diff --git a/frontend/app/analytics/page.tsx b/frontend/app/analytics/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2f110866236ca0c65e48f34c384a4da227155ada --- /dev/null +++ b/frontend/app/analytics/page.tsx @@ -0,0 +1,852 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import ReactECharts from "echarts-for-react"; +import * as echarts from "echarts"; +import SidebarLayout from "@/components/SidebarLayout"; +import { fetchApi, getUserProfile } from "@/app/utils/api"; +import { + TrendingUp, Users, Activity, CheckCircle, BarChart3, PieChart, + Calendar, ShieldCheck, Zap, RefreshCw, Building2, Layers, Shield, + Filter, RotateCcw, Search +} from "lucide-react"; + +export default function AnalyticsPage() { + const queryClient = useQueryClient(); + const [days, setDays] = useState(7); + const [selectedOrgId, setSelectedOrgId] = useState("ALL"); + const [tierFilter, setTierFilter] = useState("ALL"); + const [statusFilter, setStatusFilter] = useState("ALL"); + const [deptFilter, setDeptFilter] = useState("ALL"); + + const [isRefreshing, setIsRefreshing] = useState(false); + const [profile, setProfile] = useState(null); + const [profileLoading, setProfileLoading] = useState(true); + + useEffect(() => { + setProfile(getUserProfile()); + setProfileLoading(false); + }, []); + + const isSuperAdmin = profile?.role?.name === "Super Admin"; + + // 1. Fetch Companies (For Super Admin Platform Analytics) + const { data: companies = [], refetch: refetchCompanies } = useQuery({ + queryKey: ["analytics-companies"], + queryFn: () => fetchApi("/companies/"), + enabled: isSuperAdmin && !profileLoading, + }); + + // 2. Fetch Summary (For Org Admin / Common Analytics) + const { data: summary, refetch: refetchSummary } = useQuery({ + queryKey: ["analytics-summary"], + queryFn: () => fetchApi("/analytics/dashboard-summary"), + enabled: !profileLoading, + }); + + // 3. Fetch Attendance Trends + const { data: trends = [], refetch: refetchTrends } = useQuery({ + queryKey: ["analytics-trends", days], + queryFn: () => fetchApi(`/analytics/attendance-trends?days=${days}`), + enabled: !isSuperAdmin && !profileLoading, + }); + + // 4. Fetch Department Distribution + const { data: deptDist = [] } = useQuery({ + queryKey: ["analytics-departments"], + queryFn: () => fetchApi("/analytics/department-distribution"), + enabled: !isSuperAdmin && !profileLoading, + }); + + // 5. Fetch Recognition Analytics + const { data: recognition } = useQuery({ + queryKey: ["analytics-recognition"], + queryFn: () => fetchApi("/analytics/recognition"), + enabled: !profileLoading, + }); + + const handleRefreshAll = async () => { + setIsRefreshing(true); + try { + await queryClient.invalidateQueries({ queryKey: ["analytics-summary"] }); + await queryClient.invalidateQueries({ queryKey: ["analytics-trends"] }); + if (isSuperAdmin) { + await refetchCompanies(); + } else { + await Promise.all([refetchSummary(), refetchTrends()]); + } + } catch (e) { + console.error(e); + } finally { + setTimeout(() => setIsRefreshing(false), 800); + } + }; + + const handleResetFilters = () => { + setDays(7); + setSelectedOrgId("ALL"); + setTierFilter("ALL"); + setStatusFilter("ALL"); + setDeptFilter("ALL"); + }; + + // Filtered Companies data based on top filters + const filteredCompanies = useMemo(() => { + return companies.filter((c: any) => { + if (selectedOrgId !== "ALL" && c.id.toString() !== selectedOrgId) return false; + if (tierFilter !== "ALL" && (c.subscription_tier || "Free") !== tierFilter) return false; + if (statusFilter !== "ALL" && (c.status || "Active") !== statusFilter) return false; + return true; + }); + }, [companies, selectedOrgId, tierFilter, statusFilter]); + + // Filtered Department Distribution + const filteredDeptDist = useMemo(() => { + if (deptFilter === "ALL") return deptDist; + return deptDist.filter((d: any) => d.department === deptFilter); + }, [deptDist, deptFilter]); + + // ════════════════════════════════════════════════════════════════ + // SUPER 3D CUSTOM RENDERERS & CONFIGURATIONS + // ════════════════════════════════════════════════════════════════ + + // Custom ECharts 3D Cylinder Renderer for authentic isometric 3D Bar Columns + const render3DCylinder = (params: any, api: any) => { + const location = api.coord([api.value(0), api.value(1)]); + const extent = api.coord([api.value(0), 0]); + const x = location[0]; + const y = location[1]; + const bottomY = extent[1]; + const rawWidth = api.size([1, 0])[0]; + const width = Math.min(Math.max(rawWidth * 0.38, 24), 64); + const rx = width / 2; + const ry = Math.max(width / 3.5, 6); + + const colorHex = api.visual("color") || "#22d3ee"; + + if (bottomY - y <= 0) return null; + + return { + type: "group", + children: [ + // 1. Bottom Base Shadow Disc + { + type: "ellipse", + shape: { cx: x, cy: bottomY, rx: rx * 1.2, ry: ry * 1.2 }, + style: { fill: "rgba(0, 0, 0, 0.4)" } + }, + // 2. Cylinder Bottom Cap + { + type: "ellipse", + shape: { cx: x, cy: bottomY, rx: rx, ry: ry }, + style: { fill: colorHex } + }, + // 3. Cylinder Vertical Column Wall with Side Shading & Specular Reflective Center + { + type: "rect", + shape: { x: x - rx, y: y, width: width, height: Math.max(bottomY - y, 2) }, + style: { + fill: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ + { offset: 0, color: colorHex }, + { offset: 0.3, color: "#ffffff" }, + { offset: 0.6, color: colorHex }, + { offset: 1, color: "#09090b" } + ]) + } + }, + { + type: "ellipse", + shape: { cx: x, cy: y, rx: rx, ry: ry }, + style: { + fill: new echarts.graphic.RadialGradient(0.35, 0.35, 0.65, [ + { offset: 0, color: "#ffffff" }, + { offset: 0.45, color: colorHex }, + { offset: 1, color: colorHex } + ]), + stroke: "rgba(255, 255, 255, 0.8)", + lineWidth: 1.5 + } + } + ] + }; + }; + + const freeCount = filteredCompanies.filter((c: any) => c.subscription_tier === "Free" || !c.subscription_tier).length; + const bizCount = filteredCompanies.filter((c: any) => c.subscription_tier === "Business").length; + const entCount = filteredCompanies.filter((c: any) => c.subscription_tier === "Enterprise").length; + const totalOrgsNum = filteredCompanies.length; + + const concentric3DDonutOption = { + animation: false, + backgroundColor: "transparent", + tooltip: { + trigger: "item", + backgroundColor: "#18181b", + borderColor: "#3f3f46", + borderWidth: 1, + textStyle: { color: "#f4f4f5", fontSize: 12, fontFamily: "Inter" }, + borderRadius: 8, + padding: [10, 14], + formatter: "{b}: {c} Orgs ({d}%)" + }, + legend: { + bottom: "2%", + left: "center", + textStyle: { color: "#71717a", fontSize: 11, fontWeight: 700 }, + itemGap: 16, + icon: "circle", + itemWidth: 10, + itemHeight: 10 + }, + series: [ + { + name: "Subscription Tiers", + type: "pie", + radius: ["52%", "78%"], + center: ["50%", "44%"], + avoidLabelOverlap: true, + animation: false, + padAngle: 3, + itemStyle: { + borderRadius: 8, + borderColor: "transparent", + borderWidth: 3 + }, + label: { + show: true, + position: "center", + formatter: () => `{lbl|Total}\n{val|${totalOrgsNum}}`, + rich: { + lbl: { fontSize: 13, fontWeight: 700, color: "#94a3b8", fontFamily: "Inter", lineHeight: 22 }, + val: { fontSize: 32, fontWeight: 900, color: "#22d3ee", fontFamily: "Inter", lineHeight: 38 } + } + }, + labelLine: { + show: true, + length: 12, + length2: 16, + lineStyle: { color: "#94a3b8", width: 1.5 } + }, + data: [ + { + value: freeCount, + name: "Free Tier", + label: { + show: true, + formatter: "{b}\n{d}%", + color: "#0284c7", + fontWeight: 700, + fontSize: 11 + }, + itemStyle: { color: "#38bdf8" } + }, + { + value: bizCount, + name: "Business Tier", + label: { + show: true, + formatter: "{b}\n{d}%", + color: "#4f46e5", + fontWeight: 700, + fontSize: 11 + }, + itemStyle: { color: "#818cf8" } + }, + { + value: entCount, + name: "Enterprise Tier", + label: { + show: true, + formatter: "{b}\n{d}%", + color: "#9333ea", + fontWeight: 700, + fontSize: 11 + }, + itemStyle: { color: "#c084fc" } + } + ] + } + ] + }; + + const companyNames = filteredCompanies.length ? filteredCompanies.map((c: any) => c.name) : ["Default Org"]; + const companyQuotas = filteredCompanies.length ? filteredCompanies.map((c: any) => c.max_employees || 50) : [50]; + + const bar3DCylinderOption = { + animation: false, + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + backgroundColor: "#18181b", + borderColor: "#3f3f46", + borderWidth: 1, + textStyle: { color: "#f4f4f5", fontSize: 12 }, + borderRadius: 8, + padding: [10, 14], + formatter: (params: any) => { + const item = params[0]; + return `
${item.name}
+
Quota Capacity: ${item.value} Staff
`; + } + }, + grid: { left: "4%", right: "4%", bottom: "16%", top: "14%", containLabel: true }, + xAxis: { + type: "category", + data: companyNames, + axisLabel: { color: "#71717a", fontSize: 11, fontWeight: 700, margin: 16 }, + axisLine: { lineStyle: { color: "#a1a1aa", width: 1.5 } }, + axisTick: { show: false } + }, + yAxis: { + type: "value", + axisLabel: { color: "#71717a", fontSize: 10, fontWeight: 600 }, + splitLine: { lineStyle: { color: "rgba(113, 113, 122, 0.2)", type: "dashed" } } + }, + series: [ + { + type: "bar", + itemStyle: { color: "rgba(113, 113, 122, 0.15)", borderRadius: [10, 10, 0, 0] }, + barGap: "-100%", + barWidth: "32%", + data: companyQuotas.map(() => Math.max(...companyQuotas, 500) * 1.1), + animation: false, + silent: true + }, + { + name: "Max Capacity Quota", + type: "bar", + barWidth: "32%", + data: companyQuotas, + itemStyle: { + borderRadius: [10, 10, 0, 0], + color: (params: any) => { + const colors = [ + [{ offset: 0, color: "#38bdf8" }, { offset: 1, color: "#0284c7" }], + [{ offset: 0, color: "#818cf8" }, { offset: 1, color: "#4f46e5" }], + [{ offset: 0, color: "#34d399" }, { offset: 1, color: "#059669" }], + [{ offset: 0, color: "#fbbf24" }, { offset: 1, color: "#d97706" }], + [{ offset: 0, color: "#f472b6" }, { offset: 1, color: "#db2777" }] + ]; + const chosen = colors[params.dataIndex % colors.length]; + return new echarts.graphic.LinearGradient(0, 0, 0, 1, chosen); + } + } + } + ] + }; + + const trendDates = trends.map((t: any) => { + const d = new Date(t.date); + return days <= 7 + ? d.toLocaleDateString("en-US", { weekday: "short" }) + : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }); + + const attendance3DLineOption = { + animation: false, + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + backgroundColor: "#18181b", + borderColor: "#3f3f46", + borderWidth: 1, + textStyle: { color: "#f4f4f5", fontSize: 12 }, + borderRadius: 8, + padding: [10, 14] + }, + legend: { + top: "0%", + right: "0%", + textStyle: { color: "#71717a", fontSize: 11, fontWeight: 700 }, + icon: "circle", + itemGap: 16 + }, + grid: { left: "2%", right: "3%", bottom: "4%", top: "16%", containLabel: true }, + xAxis: { + type: "category", + boundaryGap: false, + data: trendDates.length ? trendDates : ["Mon", "Tue", "Wed", "Thu", "Fri"], + axisLabel: { color: "#71717a", fontSize: 11, fontWeight: 700 }, + axisLine: { lineStyle: { color: "#a1a1aa", width: 1.5 } } + }, + yAxis: { + type: "value", + axisLabel: { color: "#71717a", fontSize: 10, fontWeight: 600 }, + splitLine: { lineStyle: { color: "rgba(113, 113, 122, 0.2)", type: "dashed" } } + }, + series: [ + { + name: "Present Staff", + type: "line", + smooth: 0.3, + showSymbol: true, + symbol: "circle", + symbolSize: 10, + itemStyle: { color: "#06b6d4", borderWidth: 3, borderColor: "#ffffff" }, + lineStyle: { width: 4, color: "#06b6d4" }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: "rgba(6, 182, 212, 0.45)" }, + { offset: 0.7, color: "rgba(6, 182, 212, 0.05)" }, + { offset: 1, color: "rgba(6, 182, 212, 0)" } + ]) + }, + data: trends.map((t: any) => t.present) + }, + { + name: "Late Arrival", + type: "line", + smooth: 0.3, + showSymbol: true, + symbol: "diamond", + symbolSize: 8, + itemStyle: { color: "#f59e0b", borderWidth: 2, borderColor: "#ffffff" }, + lineStyle: { width: 3, type: "dashed", color: "#f59e0b" }, + data: trends.map((t: any) => t.late) + } + ] + }; + + const dept3DBarOption = { + animation: false, + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + backgroundColor: "#18181b", + borderColor: "#3f3f46", + borderWidth: 1, + textStyle: { color: "#f4f4f5", fontSize: 12 } + }, + legend: { + top: "0%", + right: "0%", + textStyle: { color: "#71717a", fontSize: 11, fontWeight: 700 } + }, + grid: { left: "2%", right: "4%", bottom: "12%", top: "16%", containLabel: true }, + xAxis: { + type: "category", + data: filteredDeptDist.map((d: any) => d.department), + axisLabel: { color: "#71717a", fontSize: 11, fontWeight: 700, margin: 14 } + }, + yAxis: { + type: "value", + axisLabel: { color: "#71717a", fontSize: 10 } + }, + series: [ + { + name: "Present Today", + type: "bar", + barWidth: "30%", + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: "#34d399" }, + { offset: 1, color: "#059669" } + ]), + borderRadius: [8, 8, 0, 0] + }, + data: filteredDeptDist.map((d: any) => d.present_today) + } + ] + }; + + if (profileLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ +
+
+

+
+ +
+ Analytics +

+

+ Platform performance and operational telemetry metrics +

+
+ + +
+ +
+
+ Filters +
+ +
+ + Range: + + {[ + { label: "7D", val: 7 }, + { label: "30D", val: 30 }, + { label: "90D", val: 90 }, + { label: "1Y", val: 365 }, + ].map((item) => ( + + ))} +
+ + {isSuperAdmin && companies.length > 0 && ( +
+ Org: + +
+ )} + + {isSuperAdmin && ( +
+ Tier: + +
+ )} + + {isSuperAdmin && ( +
+ Status: + +
+ )} + + {!isSuperAdmin && deptDist.length > 0 && ( +
+ Department: + +
+ )} + + +
+ + {isSuperAdmin ? ( + <> +
+ +
+
+ + Onboarded Organizations + +
+ +
+
+
+

+ {filteredCompanies.length} +

+ + Active: {filteredCompanies.filter((c: any) => (c.status || "Active") === "Active").length} + +
+

+ Registered SaaS platform tenants +

+
+ +
+
+ + Total Enrolled Employees + +
+ +
+
+
+

+ {summary?.total_employees || 0} +

+ + Cross-Org Combined + +
+

+ Multi-tenant registered staff count +

+
+ +
+
+ + Platform AI Scan Telemetry + +
+ +
+
+
+

+ {recognition?.total_scans || 0} +

+ + {recognition?.average_processing_time_ms || 120}ms + +
+

+ 512-dim facial verification throughput +

+
+ +
+
+ + Multi-Tenant Spoof Shield + +
+ +
+
+
+

+ {recognition?.spoof_attempts || 0} +

+ + Attempts Blocked + +
+

+ Global kiosk liveness filter +

+
+ +
+ +
+ +
+
+
+ +

+ Subscription Tier Distribution +

+
+
+ +
+ +
+
+
+ +

+ Organization Employee Quota Allocations +

+
+ + Live Platform Allocations + +
+ +
+ +
+ + ) : ( + <> +
+ +
+
+ + Attendance Rate + +
+ +
+
+
+

+ {summary?.attendance_percentage || 0}% +

+ + Present: {summary?.present_today || 0} + +
+

+ Daily active staff attendance +

+
+ +
+
+ + Staff Strength + +
+ +
+
+
+

+ {summary?.total_employees || 0} +

+ + Late: {summary?.late_today || 0} + +
+

+ Active company personnel +

+
+ +
+
+ + Biometric Accuracy + +
+ +
+
+
+

+ {recognition?.average_confidence ? (recognition.average_confidence * 100).toFixed(1) : 98.5}% +

+ + {recognition?.average_processing_time_ms || 120}ms + +
+

+ 512-dim facial vector confidence +

+
+ +
+
+ + Spoof Attempts Blocked + +
+ +
+
+
+

+ {recognition?.spoof_attempts || 0} +

+ + Liveness Active + +
+

+ Kiosk scanner protection +

+
+ +
+ +
+ +
+
+
+ +

+ Attendance Dynamics Curve ({days} Days) +

+
+ + Database Live Feed + +
+ +
+ +
+
+ +

+ 3D Isometric Department Cylinders +

+
+ +
+ +
+ + )} + +
+
+ ); +} diff --git a/frontend/app/attendance/page.tsx b/frontend/app/attendance/page.tsx index 3614e67f9d29105b1ab6d7569623a64a904cb4ce..9bdcf1434e9670edd1cd0d23876b9fdd6c002b94 100644 --- a/frontend/app/attendance/page.tsx +++ b/frontend/app/attendance/page.tsx @@ -223,14 +223,14 @@ export default function AttendancePage() { {/* Table */} {activeTab === "feed" ? ( -
-
+
+

{loadingFeed ? "Fetching..." : `${filtered?.length || 0} ledger records for ${selectedDate}`}

-
+
@@ -307,14 +307,14 @@ export default function AttendancePage() { ) : ( -
-
+
+

{loadingLogs ? "Fetching..." : `${rawLogs?.length || 0} swipe events for ${selectedDate}`}

-
+
diff --git a/frontend/app/audit/page.tsx b/frontend/app/audit/page.tsx index eae96a43897632a5b7aea4967089024e0e63e581..f2b11f3e7dbbd8dfe19916c2dd3a90ed5e754602 100644 --- a/frontend/app/audit/page.tsx +++ b/frontend/app/audit/page.tsx @@ -1,17 +1,18 @@ "use client"; import React, { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import SidebarLayout from "@/components/SidebarLayout"; import { fetchApi, parseDateTime } from "@/app/utils/api"; import { History, Search, RefreshCw, ChevronLeft, ChevronRight, - ShieldAlert, Activity, Key, UserPlus, Sliders, Laptop, Trash2 + ShieldAlert, Activity, Key, UserPlus, Sliders, Laptop, Trash2, + Lock, AlertTriangle, ShieldCheck, CheckCircle2, FileText } from "lucide-react"; import { useToast } from "@/app/utils/toast"; - export default function AuditLogsPage() { + const queryClient = useQueryClient(); const [page, setPage] = useState(0); const limit = 20; const [search, setSearch] = useState(""); @@ -32,6 +33,8 @@ export default function AuditLogsPage() { await fetchApi("/audit/", { method: "DELETE" }); toast.success("Audit logs cleared successfully."); setShowClearConfirm(false); + setPage(0); + await queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); refetch(); } catch (err: any) { toast.error(err.message || "Failed to clear audit logs"); @@ -40,85 +43,187 @@ export default function AuditLogsPage() { } }; - - const filteredLogs = logs?.filter((log: any) => { if (!search) return true; const term = search.toLowerCase(); const actionMatch = log.action?.toLowerCase().includes(term); const userMatch = log.user?.email?.toLowerCase().includes(term); const detailsMatch = log.details?.toLowerCase().includes(term); - return actionMatch || userMatch || detailsMatch; + const ipMatch = log.ip_address?.toLowerCase().includes(term); + return actionMatch || userMatch || detailsMatch || ipMatch; }); - // Helper to map log actions to modern icons - const getActionIcon = (action: string) => { + // Calculate statistics metrics + const totalCount = logs?.length || 0; + const loginCount = logs?.filter((l: any) => l.action?.toLowerCase().includes("login") || l.action?.toLowerCase().includes("auth")).length || 0; + const configCount = logs?.filter((l: any) => l.action?.toLowerCase().includes("setting") || l.action?.toLowerCase().includes("update")).length || 0; + const alertCount = logs?.filter((l: any) => l.action?.toLowerCase().includes("delete") || l.action?.toLowerCase().includes("spoof") || l.action?.toLowerCase().includes("clear")).length || 0; + + // Helper to map log actions to modern icons and badge colors + const getActionBadge = (action: string) => { const act = action.toLowerCase(); - if (act.includes("login") || act.includes("auth")) return ; - if (act.includes("create") || act.includes("enroll")) return ; - if (act.includes("delete")) return ; - if (act.includes("setting") || act.includes("update")) return ; - return ; + if (act.includes("login") || act.includes("auth")) { + return ( + + {action} + + ); + } + if (act.includes("create") || act.includes("enroll") || act.includes("add")) { + return ( + + {action} + + ); + } + if (act.includes("delete") || act.includes("clear") || act.includes("remove")) { + return ( + + {action} + + ); + } + if (act.includes("setting") || act.includes("update") || act.includes("policy")) { + return ( + + {action} + + ); + } + return ( + + {action} + + ); + }; + + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + await refetch(); + } catch (e) { + console.error(e); + } finally { + setTimeout(() => setIsRefreshing(false), 800); + } }; return ( -
- {/* Header */} -
-
-

- - System Audit Logs +
+ + {/* Header Section */} +
+
+

+
+ +
+ System Audit Logs & Security Telemetry

+

+ Complete immutable event history, administrative action trails, and device authentication logs +

+
+ {/* Audit Stats Counter Cards */} +
+ +
+
+ Total Recorded Events + +
+

{totalCount}

+
+ +
+
+ Auth & Logins + +
+

{loginCount}

+
+ +
+
+ Config Updates + +
+

{configCount}

+
+ +
+
+ Critical Alerts + +
+

{alertCount}

+
+ +
+ {/* Filter Bar */} -
- - setSearch(e.target.value)} - 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" - /> +
+
+ + setSearch(e.target.value)} + style={{ paddingLeft: "2.4rem" }} + 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" + /> +
+ + + Showing Page {page + 1} ({filteredLogs?.length || 0} entries) +
- {/* Audit Log Table */} -
+ {/* Audit Log Data Table Container */} +
-

+
- - - - - - + + + + + + - + {isLoading ? ( - Array.from({ length: 8 }).map((_, i) => ( + Array.from({ length: 7 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, j) => ( - ) : ( filteredLogs.map((log: any) => ( - - + - - - )) @@ -164,16 +272,17 @@ export default function AuditLogsPage() {
TimestampActionActorDetailsIP Address
TimestampAction PerformedActor EmailEvent DetailsIP Address
@@ -129,33 +234,36 @@ export default function AuditLogsPage() { )) ) : !filteredLogs || filteredLogs.length === 0 ? (
- No audit logs found. + +
+ +
+
+

No audit logs found

+

Try adjusting your search filters or refresh logs

+
+
{log.timestamp ? parseDateTime(log.timestamp)?.toLocaleString() : "—"} -
-
- {getActionIcon(log.action)} -
- {log.action} -
+ {getActionBadge(log.action)}
- {log.user?.email || System / Anonymous} + + {log.user?.email || System / Automated} + {log.details} - - {log.ip_address || "Local"} + + + + {log.ip_address || "Internal"} +
- {/* Pagination */} -
- + {/* Clean Dark Mode Adaptive Footer Pagination */} +
+ Page {page + 1}
@@ -184,38 +293,41 @@ export default function AuditLogsPage() { } }} disabled={!logs || logs.length < limit || isPlaceholderData} - className="btn-ghost p-1.5 rounded-lg border border-zinc-200 hover:bg-zinc-100 disabled:opacity-50 transition-all cursor-pointer" + 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" + title="Next Page" >
+
+
{/* Clear Logs Confirmation Modal */} {showClearConfirm && ( -
-
-
-
+
+
+
+
-
-

Confirm Clear Logs

-

- Are you absolutely sure you want to clear all system audit logs? -

-

- Warning: All existing system activity and audit logs will be permanently cleared. This action cannot be undone. +

+

Confirm Clear Audit Logs

+

+ Are you sure you want to permanently clear all security audit records?

-
+

+ ⚠️ Warning: All existing historical telemetry logs will be deleted from the database. This action cannot be reversed. +

+
@@ -223,19 +335,16 @@ export default function AuditLogsPage() { type="button" onClick={handleClearLogs} disabled={isDeleting} - 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" + 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" > - {isDeleting ? ( -
- ) : ( - "Clear Logs" - )} + {isDeleting ? "Clearing..." : "Yes, Clear All Logs"}
)} + ); } diff --git a/frontend/app/calendar/page.tsx b/frontend/app/calendar/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dde00ace977bf57a9ceade754409d0ccdbf2a5ff --- /dev/null +++ b/frontend/app/calendar/page.tsx @@ -0,0 +1,482 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import SidebarLayout from "@/components/SidebarLayout"; +import { fetchApi, getUserProfile, getLocalDateString } from "@/app/utils/api"; +import { + Calendar as CalendarIcon, + MapPin, + Clock, + ShieldCheck, + Compass, + Info, + ChevronLeft, + ChevronRight, + UserCheck, + UserMinus, + LogIn, + LogOut, + Coffee, + CalendarDays, + Activity, + ArrowUpRight, + Calendar as CalendarGridIcon +} from "lucide-react"; + +interface Holiday { + id: number; + name: string; + date: string; + day: string; + type: "National" | "Gazetted" | "Restricted"; + description: string; +} + +const STATIC_HOLIDAYS: Holiday[] = [ + { id: 1, name: "New Year's Day", date: "2026-01-01", day: "Thursday", type: "National", description: "First day of the new Gregorian calendar year." }, + { id: 2, name: "Pongal / Makar Sankranti", date: "2026-01-14", day: "Wednesday", type: "Gazetted", description: "Harvest festival dedicated to the Sun God." }, + { id: 3, name: "Republic Day", date: "2026-01-26", day: "Monday", type: "National", description: "Commemorates the enactment of the Constitution of India." }, + { id: 4, name: "Maha Shivratri", date: "2026-02-15", day: "Sunday", type: "Restricted", description: "Hindu festival celebrated annually in honor of God Shiva." }, + { id: 5, name: "Holi", date: "2026-03-03", day: "Tuesday", type: "Gazetted", description: "The festival of colors, celebrating the arrival of spring." }, + { id: 6, name: "Eid al-Fitr", date: "2026-03-20", day: "Friday", type: "Gazetted", description: "Islamic holiday marking the end of Ramadan fast." }, + { id: 7, name: "Ram Navami", date: "2026-03-28", day: "Saturday", type: "Restricted", description: "Celebrates the birth of Lord Rama." }, + { id: 8, name: "Good Friday", date: "2026-04-03", day: "Friday", type: "Restricted", description: "Christian holiday commemorating the crucifixion of Jesus." }, + { 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." }, + { id: 10, name: "May Day / Labor Day", date: "2026-05-01", day: "Friday", type: "Gazetted", description: "Celebration of laborers and the working class." }, + { id: 11, name: "Eid al-Adha", date: "2026-05-27", day: "Wednesday", type: "Gazetted", description: "Islamic feast of sacrifice." }, + { id: 12, name: "Muharram", date: "2026-06-26", day: "Friday", type: "Gazetted", description: "Islamic New Year." }, + { id: 13, name: "Independence Day", date: "2026-08-15", day: "Saturday", type: "National", description: "Marks the nation's independence from British rule." }, + { id: 14, name: "Raksha Bandhan", date: "2026-08-27", day: "Thursday", type: "Restricted", description: "Celebrating the sacred bond between brothers and sisters." }, + { id: 15, name: "Janmashtami", date: "2026-09-04", day: "Friday", type: "Restricted", description: "Celebrates the birth of Lord Krishna." }, + { id: 16, name: "Gandhi Jayanti", date: "2026-10-02", day: "Friday", type: "National", description: "Birthday tribute to Mahatma Gandhi, Father of the Nation." }, + { id: 17, name: "Dussehra", date: "2026-10-20", day: "Tuesday", type: "Gazetted", description: "Celebrating victory of Rama over Ravana / Good over Evil." }, + { id: 18, name: "Diwali / Deepavali", date: "2026-11-09", day: "Monday", type: "Gazetted", description: "Festival of lights celebrating the victory of light over darkness." }, + { id: 19, name: "Guru Nanak Jayanti", date: "2026-11-24", day: "Tuesday", type: "Gazetted", description: "Birth anniversary of Guru Nanak." }, + { id: 20, name: "Christmas Day", date: "2026-12-25", day: "Friday", type: "Gazetted", description: "Annual celebration commemorating the birth of Jesus Christ." }, +]; + +const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +]; + +export default function CalendarPage() { + const [profile, setProfile] = useState(null); + const [currentDate, setCurrentDate] = useState(new Date()); // Default showing current system date + const [selectedDay, setSelectedDay] = useState(new Date().getDate()); + + useEffect(() => { + setProfile(getUserProfile()); + }, []); + + const employee = profile?.employee; + + // Fetch company geofence and policy rules + const { data: rules } = useQuery({ + queryKey: ["attendance-policy-rules"], + queryFn: () => fetchApi("/policy/rules").catch(() => null), + }); + + // Fetch employee attendance history + const { data: history = [], isLoading: loadingHistory } = useQuery({ + queryKey: ["employee-calendar-history", employee?.id], + queryFn: () => fetchApi(`/attendance/employee/${employee?.id}`), + enabled: !!employee?.id + }); + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const firstDayIndex = new Date(year, month, 1).getDay(); + + const nextMonth = () => { + setCurrentDate(new Date(year, month + 1, 1)); + setSelectedDay(null); + }; + + const prevMonth = () => { + setCurrentDate(new Date(year, month - 1, 1)); + setSelectedDay(null); + }; + + const getHolidayForDay = (dayNum: number): Holiday | undefined => { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayNum).padStart(2, "0")}`; + return STATIC_HOLIDAYS.find((h) => h.date === dateStr); + }; + + const getAttendanceForDay = (dayNum: number) => { + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(dayNum).padStart(2, "0")}`; + return history.find((h: any) => h.date === dateStr); + }; + + // Determine cell state for coloring + const getDayState = (dayNum: number) => { + const holiday = getHolidayForDay(dayNum); + if (holiday) return { type: "holiday", label: holiday.name, holiday }; + + const att = getAttendanceForDay(dayNum); + if (att) { + if (["Present", "WFH"].includes(att.status)) return { type: "present", record: att }; + if (att.status === "Late") return { type: "late", record: att }; + if (att.status === "Absent") return { type: "absent", record: att }; + if (att.status === "On Leave") return { type: "leave", record: att }; + } + + // No record exists + const cellDate = new Date(year, month, dayNum); + const today = new Date(); + today.setHours(0,0,0,0); + + if (cellDate > today) { + return { type: "future" }; + } + + const dayOfWeek = cellDate.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + if (isWeekend) { + return { type: "weekend" }; + } + + return { type: "absent" }; + }; + + const daysArray = []; + for (let i = 0; i < firstDayIndex; i++) { + daysArray.push(null); + } + for (let d = 1; d <= daysInMonth; d++) { + daysArray.push(d); + } + + const selectedDayState = selectedDay ? getDayState(selectedDay) : null; + const selectedHoliday = selectedDayState?.holiday || null; + const selectedRecord = selectedDayState?.record || null; + + const activeMonthHolidays = STATIC_HOLIDAYS.filter((h) => { + const hDate = new Date(h.date); + return hDate.getMonth() === month && hDate.getFullYear() === year; + }); + + return ( + +
+ + {/* Header Block */} +
+
+

+
+ +
+ Calendar & Info Hub +

+

+ Tracks shift punches, holidays, and weekly-offs. Click on dates to view full check-in analytics. +

+
+
+ + {/* Content Layout */} +
+ + {/* Calendar Card (Sleek Modern Layout) */} +
+
+ + {/* Month Selector Header */} +
+
+

+ {MONTHS[month]} {year} +

+
+ + +
+
+ + + + Live Sync Active + +
+ + {/* Weekdays Row wrapper with custom background pill */} +
+ {WEEKDAYS.map((day) => ( +
{day.slice(0, 3)}
+ ))} +
+ + {/* Calendar Days grid */} +
+ {daysArray.map((day, idx) => { + if (day === null) { + return
; + } + + const state = getDayState(day); + const isSelected = selectedDay === day; + + // High-fidelity cell styling based on attendance status + 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]"; + let dotStyle = ""; + + if (state.type === "present") { + 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"; + dotStyle = "bg-emerald-500"; + } else if (state.type === "late") { + 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"; + dotStyle = "bg-amber-500"; + } else if (state.type === "absent") { + 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"; + dotStyle = "bg-rose-500"; + } else if (state.type === "holiday") { + 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"; + dotStyle = "bg-cyan-500"; + } else if (state.type === "leave") { + 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"; + dotStyle = "bg-indigo-500"; + } else if (state.type === "weekend") { + 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"; + } + + if (isSelected) { + cellStyle += " ring-2 ring-cyan-500 dark:ring-cyan-400 border-cyan-500 shadow-md translate-y-[-1px]"; + } + + return ( + + ); + })} +
+ + {/* Legend indicator */} +
+
+ + Present +
+
+ + Late +
+
+ + Absent +
+
+ + Holiday +
+
+ + Leave +
+
+ +
+ + {/* Holidays of the Month list */} +
+
+
+ +

+ Holidays in {MONTHS[month]} +

+
+ + {activeMonthHolidays.length} Holidays + +
+ +
+ {activeMonthHolidays.length > 0 ? ( + activeMonthHolidays.map((holiday) => { + const hDay = new Date(holiday.date).getDate(); + const isHolidaySelected = selectedDay === hDay; + return ( + + ); + }) + ) : ( +

No holidays scheduled this month.

+ )} +
+
+ +
+ + {/* Activity Logs of Selected Day (Greathr Detail Sidebar Panel) */} +
+ {selectedDay ? ( +
+ +
+ {/* Selected Day Info Header */} +
+
+

+ Date Details & Activity +

+

+ {new Date(year, month, selectedDay).toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} +

+
+ + {/* Status Pill */} + + {selectedDayState?.type} + +
+ + {/* Activity Details Display */} +
+ {selectedHoliday && ( +
+

+ + Official Holiday: {selectedHoliday.name} +

+

{selectedHoliday.description}

+ + {selectedHoliday.type} Category + +
+ )} + + {selectedRecord && ( +
+
+ +
+

Punch In

+

+ {selectedRecord.check_in ? new Date(selectedRecord.check_in).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "—"} +

+
+
+ +
+ +
+

Punch Out

+

+ {selectedRecord.check_out ? new Date(selectedRecord.check_out).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "—"} +

+
+
+ +
+ +
+

Worked Hours

+

+ {(selectedRecord.working_hours || 0).toFixed(1)} hrs +

+
+
+ +
+ +
+

GPS Geofence

+

+ {selectedRecord.geofence_result || "Verified Match"} +

+
+
+
+ )} + + {!selectedHoliday && !selectedRecord && ( +
+ {selectedDayState?.type === "weekend" ? ( + <> + +

Weekly Off (Weekend)

+

No shift checks are required on Saturdays and Sundays.

+ + ) : selectedDayState?.type === "future" ? ( + <> + +

Scheduled Workday

+

Shift starts at 09:00 AM. Biometric registration will open on date arrival.

+ + ) : ( + <> + +

Absent (No punch records found)

+

No logs detected. Contact HR if you require a retrospective override.

+ + )} +
+ )} +
+
+ + {/* Policy settings details footer */} +
+ + + Shift timings are structured 09:00 AM - 05:00 PM with a 15-minute grace period. Punch logs are cross-referenced with your active office geofence. + +
+ +
+ ) : ( +
+ Click on any calendar day to inspect punch records. +
+ )} +
+ +
+ +
+ + ); +} diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 75f4a4bf5a7d099edd3764675550a9cc38ff2bff..7cbf9f5b9c1cf0bb40526527e586e93f80cf2e67 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import ReactECharts from "echarts-for-react"; import SidebarLayout from "@/components/SidebarLayout"; @@ -8,14 +9,32 @@ import { fetchApi, getAccessToken, getBackendUrl, parseDateTime, getLocalDateStr import { Users, UserCheck, UserMinus, Clock, TrendingUp, Activity, ArrowRight, AlertTriangle, CheckCircle, Zap, ShieldAlert, - Calendar, Award, Server, Cpu, X, Search, Camera, Fingerprint, QrCode, Loader2, Play, Volume2, VolumeX, Shield, Clock as ClockIcon + Calendar, Award, Server, Cpu, X, Search, Camera, Fingerprint, QrCode, Loader2, Play, Volume2, VolumeX, Shield, Clock as ClockIcon, + Building2, Monitor, Mail, Plus, History as HistoryIcon, LogIn, LogOut, MapPin, ChevronLeft, ChevronRight } from "lucide-react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import AttendanceHeatmap from "@/components/AttendanceHeatmap"; import jsQR from "jsqr"; +import { useToast } from "@/app/utils/toast"; + +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +]; + +function formatDateDMY(dateInput: string | Date | null | undefined): string { + if (!dateInput) return ""; + const d = new Date(dateInput); + if (isNaN(d.getTime())) return ""; + const day = String(d.getDate()).padStart(2, "0"); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const year = d.getFullYear(); + return `${day}/${month}/${year}`; +} // Pure SVG sparkline helper for premium look -function Sparkline({ color, data }: { color: string; data: number[] }) { +function Sparkline({ data }: { color: string; data: number[] }) { const width = 90; const height = 28; const max = Math.max(...data); @@ -29,30 +48,11 @@ function Sparkline({ color, data }: { color: string; data: number[] }) { }) .join(" "); - const colors = { - blue: "#3b82f6", - emerald: "#10b981", - amber: "#f59e0b", - rose: "#f43f5e", - indigo: "#6366f1", - }; - const strokeColor = colors[color as keyof typeof colors] || "#3b82f6"; - return ( - - - - - - - - + +
-

{label}

- +

{label}

+
@@ -81,9 +81,9 @@ function StatCard({ {loading ? (
) : ( -

{value}

+

{value}

)} - {sublabel &&

{sublabel}

} + {sublabel &&

{sublabel}

}
{!loading && }
@@ -256,12 +256,97 @@ function playLocalBeep(status: string) { function EmployeeDashboardView({ profile }: { profile: any }) { const queryClient = useQueryClient(); + const { toast } = useToast(); const employee = profile?.employee; + const isHR = profile?.role?.name === "Admin" || profile?.role?.name === "HR" || profile?.role?.name === "Super Admin"; const [cameraActive, setCameraActive] = React.useState(false); const [coords, setCoords] = React.useState<{ latitude: number | null; longitude: number | null }>({ latitude: null, longitude: null }); const [scanStatus, setScanStatus] = React.useState<"idle" | "scanning" | "success" | "error">("idle"); const [scanMessage, setScanMessage] = React.useState(null); const [lastScanResult, setLastScanResult] = React.useState(null); + const [faceBbox, setFaceBbox] = React.useState(null); + const [profileImageError, setProfileImageError] = React.useState(false); + const [matchTime, setMatchTime] = React.useState(""); + + const [showDummyScanner, setShowDummyScanner] = React.useState(false); + const [dummyScanStatus, setDummyScanStatus] = React.useState<"idle" | "scanning" | "success" | "error">("idle"); + const [dummyScanMessage, setDummyScanMessage] = React.useState(null); + + const startDummyScan = () => { + setShowDummyScanner(true); + setDummyScanStatus("scanning"); + setDummyScanMessage("Acquiring secure location & biometric check..."); + + const performDummyCheckin = async (lat: number | null, lng: number | null) => { + setDummyScanMessage("Verifying location automatically..."); + try { + const res = await fetch(`${getBackendUrl()}/kiosk/scan`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dummy: true, + employee_id: employee?.id, + latitude: lat, + longitude: lng, + camera: "Employee Web Dashboard (Biometric)" + }) + }); + + if (!res.ok) throw new Error("Attendance service unreachable"); + const data = await res.json(); + + if (data.status === "success") { + playLocalBeep("success"); + setDummyScanStatus("success"); + setDummyScanMessage(`Check-in successful: ${data.attendance?.status || "Present"}`); + queryClient.invalidateQueries({ queryKey: ["employee-history", employee?.id] }); + setTimeout(() => setShowDummyScanner(false), 2500); + } else if (data.status === "location_error") { + playLocalBeep("error"); + setDummyScanStatus("error"); + setDummyScanMessage(`${data.message || "Location verification failed."} Opening Real Face Kiosk Scanner...`); + setTimeout(() => { + setShowDummyScanner(false); + startCamera(); // fallback to real face kiosk + }, 3000); + } else { + setDummyScanStatus("error"); + setDummyScanMessage(data.message || "Biometric verification failed."); + } + } catch (err: any) { + setDummyScanStatus("error"); + setDummyScanMessage("Verification error. Opening Real Face Kiosk Scanner..."); + setTimeout(() => { + setShowDummyScanner(false); + startCamera(); // fallback to real face kiosk + }, 3000); + } + }; + + // Simulate scanning delay for 2 seconds + setTimeout(() => { + if (coords.latitude !== null && coords.longitude !== null) { + performDummyCheckin(coords.latitude, coords.longitude); + } else { + if (typeof window !== "undefined" && navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const newCoords = { latitude: pos.coords.latitude, longitude: pos.coords.longitude }; + setCoords(newCoords); + performDummyCheckin(pos.coords.latitude, pos.coords.longitude); + }, + (err) => { + console.error("Location retrieval failed:", err); + performDummyCheckin(null, null); + }, + { enableHighAccuracy: true, timeout: 5000 } + ); + } else { + performDummyCheckin(null, null); + } + } + }, 2000); + }; const videoRef = React.useRef(null); const canvasRef = React.useRef(null); @@ -269,6 +354,81 @@ function EmployeeDashboardView({ profile }: { profile: any }) { const scanIntervalRef = React.useRef(null); const scanningRef = React.useRef(false); + // Tab State + const [activeTab, setActiveTab] = React.useState("dashboard"); + + React.useEffect(() => { + const handleUpdate = () => { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const tab = params.get("tab") || "dashboard"; + if (tab !== activeTab) { + setActiveTab(tab); + } + } + }; + handleUpdate(); + const interval = setInterval(handleUpdate, 200); + window.addEventListener("popstate", handleUpdate); + return () => { + clearInterval(interval); + window.removeEventListener("popstate", handleUpdate); + }; + }, [activeTab]); + + // Leave Form States + const [showLeaveModal, setShowLeaveModal] = React.useState(false); + const [leaveStartDate, setLeaveStartDate] = React.useState(""); + const [leaveEndDate, setLeaveEndDate] = React.useState(""); + const [leaveType, setLeaveType] = React.useState("Sick"); + const [leaveReason, setLeaveReason] = React.useState(""); + const [submittingLeave, setSubmittingLeave] = React.useState(false); + const [leaveError, setLeaveError] = React.useState(null); + const [attachedFileName, setAttachedFileName] = React.useState(""); + const [isHalfDay, setIsHalfDay] = React.useState(false); + const [session, setSession] = React.useState("First Half"); + const [leaveContact, setLeaveContact] = React.useState(""); + + // Custom Date Picker states + const [activePicker, setActivePicker] = React.useState<"start" | "end" | null>(null); + const [pickerCurrentDate, setPickerCurrentDate] = React.useState(new Date()); + + // Leave Limit States + const [leaveLimits, setLeaveLimits] = React.useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("netraid_leave_limits"); + if (saved) { + try { + return JSON.parse(saved); + } catch (e) { + console.error(e); + } + } + } + return { + Sick: 3, + Casual: 4, + Annual: 8, + Unpaid: "Limitless" + }; + }); + + const handleUpdateLimit = (type: "Sick" | "Casual" | "Annual" | "Unpaid", value: string) => { + const newLimits = { ...leaveLimits, [type]: value === "Limitless" ? "Limitless" : parseInt(value) || 0 }; + setLeaveLimits(newLimits); + if (typeof window !== "undefined") { + localStorage.setItem("netraid_leave_limits", JSON.stringify(newLimits)); + } + }; + + const getDaysBetween = (startStr: string, endStr: string) => { + const start = new Date(startStr); + const end = new Date(endStr); + const diffTime = Math.abs(end.getTime() - start.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + return diffDays; + }; + // Watch geolocation React.useEffect(() => { if (typeof window !== "undefined" && navigator.geolocation) { @@ -290,6 +450,70 @@ function EmployeeDashboardView({ profile }: { profile: any }) { enabled: !!employee?.id }); + const [selectedLedgerRecord, setSelectedLedgerRecord] = React.useState(null); + + // Set default selected record once history loads + React.useEffect(() => { + if (history && history.length > 0 && !selectedLedgerRecord) { + const todayRec = history.find((h: any) => h.date === getLocalDateString()); + setSelectedLedgerRecord(todayRec || history[0]); + } + }, [history]); + + // Fetch own raw scan logs + const { data: rawLogs = [], isLoading: loadingLogs } = useQuery({ + queryKey: ["employee-raw-logs", employee?.id], + queryFn: () => fetchApi(`/attendance/logs?employee_id=${employee?.id}&limit=100`), + enabled: !!employee?.id + }); + + const filteredRawLogs = React.useMemo(() => { + const targetDate = selectedLedgerRecord?.date || getLocalDateString(); + return rawLogs.filter((log: any) => { + if (!log.timestamp) return false; + const d = new Date(log.timestamp); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const logDate = `${year}-${month}-${day}`; + return logDate === targetDate; + }); + }, [rawLogs, selectedLedgerRecord]); + + // Fetch employee leaves list + const { data: leaves = [], isLoading: loadingLeaves, refetch: refetchLeaves } = useQuery({ + queryKey: ["employee-leaves", employee?.id], + queryFn: () => fetchApi(`/employees/leaves?employee_id=${employee?.id}`), + enabled: !!employee?.id && activeTab === "leave" + }); + + const approvedLeaveDays = React.useMemo(() => { + const days = { Sick: 0, Casual: 0, Annual: 0, Unpaid: 0 }; + if (leaves && Array.isArray(leaves)) { + leaves.forEach((l: any) => { + if (l.status === "Approved" || l.status === "Pending") { + const start = new Date(l.start_date); + const end = new Date(l.end_date); + const diffTime = Math.abs(end.getTime() - start.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + const type = l.leave_type; + if (type in days) { + days[type as keyof typeof days] += diffDays; + } + } + }); + } + return days; + }, [leaves]); + + const getLeaveLeft = (type: "Sick" | "Casual" | "Annual" | "Unpaid") => { + const limit = leaveLimits[type]; + if (limit === "Limitless") return "Limitless"; + const approved = approvedLeaveDays[type]; + const left = Math.max(0, (limit as number) - approved); + return `${left} Left`; + }; + const startCamera = async () => { setScanStatus("scanning"); setScanMessage("Initializing camera..."); @@ -323,6 +547,10 @@ function EmployeeDashboardView({ profile }: { profile: any }) { setCameraActive(false); setScanStatus("idle"); setScanMessage(null); + setFaceBbox(null); + setProfileImageError(false); + setMatchTime(""); + setLastScanResult(null); }; const captureFrame = async () => { @@ -363,19 +591,27 @@ function EmployeeDashboardView({ profile }: { profile: any }) { if (!res.ok) throw new Error("Attendance service unreachable"); const data = await res.json(); + if (data.bbox) { + setFaceBbox(data.bbox); + } else { + setFaceBbox(null); + } + if (data.status === "success") { playLocalBeep("success"); setScanStatus("success"); setScanMessage(`Check-in successful: ${data.attendance?.status || "Present"}`); + setMatchTime(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true })); setLastScanResult(data); queryClient.invalidateQueries({ queryKey: ["employee-history", employee?.id] }); - setTimeout(stopCamera, 3000); + setTimeout(stopCamera, 4500); } else if (data.status === "location_error") { playLocalBeep("error"); setScanStatus("error"); setScanMessage(data.message || "Location verification failed."); + setLastScanResult(data); + setTimeout(stopCamera, 4500); } else if (data.status === "unknown" || data.status === "spoof_detected" || data.status === "no_face") { - // Continue scanning but show feedback setScanMessage(data.message || "Verification failed. Retrying..."); } } catch (err: any) { @@ -385,194 +621,1182 @@ function EmployeeDashboardView({ profile }: { profile: any }) { } }; + const handleApplyLeave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!leaveStartDate || !leaveEndDate) { + setLeaveError("Please enter valid dates."); + return; + } + const today = new Date(); + today.setHours(0, 0, 0, 0); + const start = new Date(leaveStartDate); + if (start < today) { + toast.error("Cannot apply for leave on past dates."); + return; + } + + if (leaveType === "Casual") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const start = new Date(leaveStartDate); + const diffTime = start.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + if (diffDays < 3) { + setLeaveError("Casual leave must be applied at least 3 days in advance."); + setSubmittingLeave(false); + return; + } + } + + // 1. Balance validation + const limit = leaveLimits[leaveType]; + if (limit !== "Limitless") { + const approved = approvedLeaveDays[leaveType as keyof typeof approvedLeaveDays]; + const left = Math.max(0, (limit as number) - approved); + const requestedDays = getDaysBetween(leaveStartDate, leaveEndDate); + + if (left <= 0) { + toast.error(`No ${leaveType} leave left.`); + return; + } + + if (requestedDays > left) { + toast.error(`Requested days (${requestedDays}) exceed remaining ${leaveType} leave balance (${left}).`); + return; + } + } + + // 2. Sick leave overlap validation + if (leaveType === "Sick") { + const requestedStart = new Date(leaveStartDate); + const requestedEnd = new Date(leaveEndDate); + + const hasOverlap = leaves.some((l: any) => { + if (l.leave_type !== "Sick" || l.status === "Rejected") return false; + const existingStart = new Date(l.start_date); + const existingEnd = new Date(l.end_date); + return requestedStart <= existingEnd && existingStart <= requestedEnd; + }); + + if (hasOverlap) { + toast.error("You have already applied for a Sick Leave on these dates."); + return; + } + } + + setSubmittingLeave(true); + setLeaveError(null); + + try { + let richReason = leaveReason; + if (isHalfDay) { + richReason += ` (Half-Day: ${session})`; + } + if (leaveContact) { + richReason += ` (Emergency Contact: ${leaveContact})`; + } + if (leaveType === "Sick" && attachedFileName) { + richReason += ` (Attached Certificate: ${attachedFileName})`; + } + + await fetchApi("/employees/leaves", { + method: "POST", + body: JSON.stringify({ + employee_id: employee.id, + start_date: leaveStartDate, + end_date: leaveEndDate, + leave_type: leaveType, + reason: richReason + }) + }); + setShowLeaveModal(false); + setLeaveStartDate(""); + setLeaveEndDate(""); + setLeaveReason(""); + setAttachedFileName(""); + setIsHalfDay(false); + setLeaveContact(""); + refetchLeaves(); + } catch (err: any) { + setLeaveError(err.message || "Failed to submit leave request."); + } finally { + setSubmittingLeave(false); + } + }; + + const [withdrawingId, setWithdrawingId] = React.useState(null); + + const handleWithdrawLeave = async (id: number) => { + setWithdrawingId(id); + try { + await fetchApi(`/employees/leaves/${id}`, { + method: "DELETE" + }); + toast.success("Leave request withdrawn successfully."); + refetchLeaves(); + } catch (err: any) { + toast.error(err.message || "Failed to withdraw leave request."); + } finally { + setWithdrawingId(null); + } + }; + // Quick calculations const todayRecord = history?.find((h: any) => h.date === getLocalDateString()); const thisMonthPresent = history?.filter((h: any) => ["Present", "Late", "WFH"].includes(h.status)).length || 0; const thisMonthHours = history?.reduce((acc: number, cur: any) => acc + (cur.working_hours || 0), 0).toFixed(1) || "0.0"; + // Dynamic worked / remaining timer calculations + const [elapsedWorkedTime, setElapsedWorkedTime] = React.useState("0h 0m"); + const [leftShiftTime, setLeftShiftTime] = React.useState("—"); + const [workedHoursPercentage, setWorkedHoursPercentage] = React.useState(0); + + React.useEffect(() => { + if (!todayRecord || !todayRecord.check_in) { + setElapsedWorkedTime("Not Checked In"); + setLeftShiftTime("—"); + setWorkedHoursPercentage(0); + return; + } + + const calculateTimes = () => { + const checkInDate = new Date(todayRecord.check_in); + const checkOutDate = todayRecord.check_out ? new Date(todayRecord.check_out) : new Date(); + + const diffMs = checkOutDate.getTime() - checkInDate.getTime(); + const diffHrs = diffMs / (1000 * 60 * 60); + + const hrs = Math.floor(diffHrs); + const mins = Math.floor((diffHrs - hrs) * 60); + + setElapsedWorkedTime(`${hrs}h ${mins}m`); + + // Determine shift duration (default 8 hours = 480 mins) + let shiftTotalMins = 8 * 60; + if (employee?.shift?.start_time && employee?.shift?.end_time) { + const [sh, sm] = employee.shift.start_time.split(":").map(Number); + const [eh, em] = employee.shift.end_time.split(":").map(Number); + + let startMins = sh * 60 + sm; + let endMins = eh * 60 + em; + if (endMins < startMins) { // Night shift rollover + endMins += 24 * 60; + } + shiftTotalMins = endMins - startMins; + } + + const totalWorkedMins = diffMs / (1000 * 60); + const leftMins = shiftTotalMins - totalWorkedMins; + + if (todayRecord.check_out) { + setLeftShiftTime("Shift Completed"); + setWorkedHoursPercentage(100); + } else if (leftMins <= 0) { + setLeftShiftTime("Overtime Active"); + setWorkedHoursPercentage(100); + } else { + const leftH = Math.floor(leftMins / 60); + const leftM = Math.floor(leftMins % 60); + setLeftShiftTime(`${leftH}h ${leftM}m left`); + + const pct = Math.min(100, Math.round((totalWorkedMins / shiftTotalMins) * 100)); + setWorkedHoursPercentage(pct); + } + }; + + calculateTimes(); + const timerId = setInterval(calculateTimes, 10000); // refresh every 10s + return () => clearInterval(timerId); + }, [todayRecord, employee?.shift]); + return ( -
+
{/* Welcome Header */} -
+
-

Welcome, {employee?.name || profile?.email}

-

+

Welcome, {employee?.name || profile?.email}

+

{employee?.designation || "Staff Member"} • {employee?.department?.name || "General"}

-
- - Shift: {employee?.shift?.name || "Regular Shift"} ({employee?.shift?.start_time || "09:00"} - {employee?.shift?.end_time || "17:00"}) -
- {/* Main Grid */} -
- - {/* Attendance Scanner & Quick Stats */} -
- + {/* RENDER ACTIVE TAB PANEL */} + {activeTab === "attendance" && ( +
+
+

My Attendance Ledger

+ Double checks and heatmaps +
+ +
+
+

Ledger History

+
+ + + + + + + + + + + + {loadingHistory ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + )) + ) : history && history.length > 0 ? ( + history.map((record: any) => { + const checkInTime = record.check_in ? new Date(record.check_in).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "—"; + const checkOutTime = record.check_out ? new Date(record.check_out).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "—"; + const isSelected = selectedLedgerRecord?.id === record.id; + return ( + setSelectedLedgerRecord(record)} + className={`cursor-pointer transition-colors ${ + isSelected + ? "bg-slate-100/70 dark:bg-zinc-800/50 font-semibold text-cyan-600 dark:text-cyan-400" + : "hover:bg-slate-50/50 dark:hover:bg-zinc-850/30" + }`} + > + + + + + + + ); + }) + ) : ( + + + + )} + +
DateCheck InCheck OutHours LoggedStatus
+ {new Date(record.date).toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" })} + {checkInTime}{checkOutTime}{(record.working_hours || 0).toFixed(1)} hrs + + {record.status} + +
+ No attendance logs logged yet. +
+
+
+ +
+ {/* Date Details & Activity Card */} +
+
+
+

+ Date Details & Activity +

+

+ {selectedLedgerRecord + ? new Date(selectedLedgerRecord.date).toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) + : "No record selected"} +

+
+ + {selectedLedgerRecord && ( + + {selectedLedgerRecord.status} + + )} +
+ + {selectedLedgerRecord ? ( +
+
+ +
+

Punch In

+

+ {selectedLedgerRecord.check_in ? new Date(selectedLedgerRecord.check_in).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "—"} +

+
+
+ +
+ +
+

Punch Out

+

+ {selectedLedgerRecord.check_out ? new Date(selectedLedgerRecord.check_out).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : "—"} +

+
+
+ +
+ +
+

Worked

+

+ {(selectedLedgerRecord.working_hours || 0).toFixed(1)} hrs +

+
+
+ +
+ +
+

Geofence

+

+ {selectedLedgerRecord.geofence_result || "Verified"} +

+
+
+
+ ) : ( +

No date details active.

+ )} +
+ + {/* Every Log/Scan Kiosk History Card */} +
+
+
+ +

+ Biometric Kiosk Scans +

+
+ + {rawLogs.length} Scans + +
+ +
+ {loadingLogs ? ( +
+ ) : rawLogs.length > 0 ? ( + rawLogs.map((log: any) => { + const timeStr = log.timestamp + ? new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : "—"; + const dateStr = log.timestamp + ? new Date(log.timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' }) + : "—"; + const isSpoof = log.is_spoof; + return ( +
+
+
+ {timeStr} + {dateStr} +
+

{log.camera || "Kiosk Entrance"}

+
+ +
+ + {isSpoof ? "SPOOF REJECT" : log.status || "Swiped"} + + {log.confidence && ( +

+ Match: {(log.confidence * 100).toFixed(0)}% +

+ )} +
+
+ ); + }) + ) : ( +

No scans recorded on kiosk terminal yet.

+ )} +
+
+
+
+
+ )} + + {activeTab === "leave" && ( +
+
+
+

Leave Requests

+
+ {(["Sick", "Casual", "Annual"] as const).map((type) => ( + + {type}: {getLeaveLeft(type)} + + ))} +
+
+ +
+ +
+

Request Timeline

+
+ {loadingLeaves ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ )) + ) : leaves && leaves.length > 0 ? ( + leaves.map((l: any) => ( +
+
+
+ {l.leave_type} Leave + Applied {formatDateDMY(l.created_at)} +
+

+ Duration: {formatDateDMY(l.start_date)} to {formatDateDMY(l.end_date)} +

+ {l.reason && ( +

" {l.reason} "

+ )} +
+
+ {l.status === "Pending" && ( + + )} + + {l.status} + +
+
+ )) + ) : ( +
+ No leave requests submitted yet. +
+ )} +
+
+
+ )} + + {activeTab === "dashboard" && ( +
{/* Geolocation Lock Warning */} {coords.latitude === null && ( -
- +
+ GPS Coordinates Missing: Please enable location services/GPS permission in your browser to check in.
- )} + )} + + {/* Premium Embedded Scanner Box */} +
+
+ + +
+ {/* Native Video player */} +