Spaces:
Sleeping
Sleeping
093xpku commited on
Commit ·
9bc686b
0
Parent(s):
Clean project layout deployment
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/workflows/ci.yml +59 -0
- .gitignore +32 -0
- README.md +157 -0
- backend/.env +25 -0
- backend/.env.example +29 -0
- backend/Dockerfile +25 -0
- backend/app/api/v1/analytics.py +191 -0
- backend/app/api/v1/attendance.py +80 -0
- backend/app/api/v1/audit.py +23 -0
- backend/app/api/v1/auth.py +109 -0
- backend/app/api/v1/departments.py +100 -0
- backend/app/api/v1/employees.py +246 -0
- backend/app/api/v1/enrollment.py +183 -0
- backend/app/api/v1/kiosk.py +348 -0
- backend/app/api/v1/reports.py +127 -0
- backend/app/api/v1/settings.py +47 -0
- backend/app/core/config.py +50 -0
- backend/app/core/database.py +43 -0
- backend/app/core/download_models.py +49 -0
- backend/app/core/event_bus.py +42 -0
- backend/app/core/init_db.py +86 -0
- backend/app/core/security.py +153 -0
- backend/app/crud/crud.py +521 -0
- backend/app/main.py +77 -0
- backend/app/models/models.py +196 -0
- backend/app/schemas/schemas.py +210 -0
- backend/app/services/face_engine.py +467 -0
- backend/app/services/reports.py +192 -0
- backend/app/services/singletons.py +6 -0
- backend/app/services/voice_assistant.py +109 -0
- backend/app/tests/conftest.py +54 -0
- backend/app/tests/test_auth.py +100 -0
- backend/app/tests/test_employees.py +63 -0
- backend/app/tests/test_face_engine.py +54 -0
- backend/check_db.py +17 -0
- backend/clear_enrollments.py +45 -0
- backend/re_enroll.py +103 -0
- backend/requirements.txt +23 -0
- docker-compose.yml +79 -0
- docker/nginx.conf +49 -0
- frontend/Dockerfile +29 -0
- frontend/app/attendance/page.tsx +469 -0
- frontend/app/audit/page.tsx +166 -0
- frontend/app/dashboard/page.tsx +798 -0
- frontend/app/employees/page.tsx +710 -0
- frontend/app/enroll/[id]/page.tsx +376 -0
- frontend/app/globals.css +559 -0
- frontend/app/kiosk/page.tsx +499 -0
- frontend/app/layout.tsx +42 -0
- frontend/app/page.tsx +233 -0
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: NetraID CI/CD Pipeline
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main, master ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main, master ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
test-backend:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v3
|
| 14 |
+
|
| 15 |
+
- name: Set up Python 3.12
|
| 16 |
+
uses: actions/setup-python@v4
|
| 17 |
+
with:
|
| 18 |
+
python-version: "3.12"
|
| 19 |
+
cache: "pip"
|
| 20 |
+
|
| 21 |
+
- name: Install System Dependencies
|
| 22 |
+
run: |
|
| 23 |
+
sudo apt-get update
|
| 24 |
+
sudo apt-get install -y libgl1-mesa-glx libglib2.0-0 espeak
|
| 25 |
+
|
| 26 |
+
- name: Install Python Dependencies
|
| 27 |
+
run: |
|
| 28 |
+
python -m pip install --upgrade pip
|
| 29 |
+
pip install -r backend/requirements.txt
|
| 30 |
+
|
| 31 |
+
- name: Run Pytest Test Suite
|
| 32 |
+
run: |
|
| 33 |
+
cd backend
|
| 34 |
+
pytest --cov=app --cov-report=xml -v
|
| 35 |
+
|
| 36 |
+
build-containers:
|
| 37 |
+
runs-on: ubuntu-latest
|
| 38 |
+
needs: test-backend
|
| 39 |
+
steps:
|
| 40 |
+
- uses: actions/checkout@v3
|
| 41 |
+
|
| 42 |
+
- name: Set up Docker Buildx
|
| 43 |
+
uses: docker/setup-buildx-action@v2
|
| 44 |
+
|
| 45 |
+
- name: Build Backend Image
|
| 46 |
+
uses: docker/build-push-action@v4
|
| 47 |
+
with:
|
| 48 |
+
context: ./backend
|
| 49 |
+
file: ./backend/Dockerfile
|
| 50 |
+
push: false
|
| 51 |
+
tags: netraid-backend:latest
|
| 52 |
+
|
| 53 |
+
- name: Build Frontend Image
|
| 54 |
+
uses: docker/build-push-action@v4
|
| 55 |
+
with:
|
| 56 |
+
context: ./frontend
|
| 57 |
+
file: ./frontend/Dockerfile
|
| 58 |
+
push: false
|
| 59 |
+
tags: netraid-frontend:latest
|
.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
frontend/node_modules/
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
env/
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
*.pyo
|
| 10 |
+
*.pyd
|
| 11 |
+
|
| 12 |
+
# Build outputs
|
| 13 |
+
.next/
|
| 14 |
+
frontend/.next/
|
| 15 |
+
out/
|
| 16 |
+
build/
|
| 17 |
+
dist/
|
| 18 |
+
|
| 19 |
+
# Large model files
|
| 20 |
+
*.onnx
|
| 21 |
+
backend/models/*.onnx
|
| 22 |
+
|
| 23 |
+
# Database and uploads
|
| 24 |
+
*.db
|
| 25 |
+
*.sqlite3
|
| 26 |
+
uploads/
|
| 27 |
+
.env
|
| 28 |
+
.env.local
|
| 29 |
+
.env.development.local
|
| 30 |
+
.env.test.local
|
| 31 |
+
.env.production.local
|
| 32 |
+
.DS_Store
|
README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NetraID - Production-Grade AI Face Authentication Attendance System
|
| 2 |
+
|
| 3 |
+
NetraID is an enterprise-grade, 100% free, and self-hosted face-authentication attendance platform. Using advanced facial biometrics, liveness classifiers, and high-performance vector indexes, it provides an offline, secure, and production-ready system suitable for digital kiosks, schools, colleges, and startups.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Technical Stack & Architecture
|
| 8 |
+
|
| 9 |
+
NetraID uses clean-architecture layers (Repository and Service layers) to structure code modularly.
|
| 10 |
+
|
| 11 |
+
- **AI & Computer Vision**: SCRFD (Face Detection), ArcFace (Face Recognition), MiniFASNet (Silent Face Anti-Spoofing) running on pure ONNX Runtime.
|
| 12 |
+
- **Backend API**: Python 3.12, FastAPI, SQLAlchemy 2.0, Alembic, Pydantic V2, JWT RBAC, and Audit Logging.
|
| 13 |
+
- **Voice synthesis**: Offline `pyttsx3` text-to-speech engine.
|
| 14 |
+
- **Vector Database**: PostgreSQL 16 with the native `pgvector` extension.
|
| 15 |
+
- **Frontend Kiosk & Dashboard**: Next.js 15, TypeScript, Tailwind CSS, Framer Motion, and Apache ECharts.
|
| 16 |
+
- **Deployment**: Docker, Docker Compose, and Nginx.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Project Structure
|
| 21 |
+
|
| 22 |
+
```text
|
| 23 |
+
NetraID/
|
| 24 |
+
├── backend/
|
| 25 |
+
│ ├── app/
|
| 26 |
+
│ │ ├── api/ # API Router endpoints
|
| 27 |
+
│ │ ├── core/ # Database connection, security, seeding
|
| 28 |
+
│ │ ├── crud/ # CRUD repositories (SQLAlchemy 2.0)
|
| 29 |
+
│ │ ├── models/ # Database ORM models
|
| 30 |
+
│ │ ├── schemas/ # Pydantic V2 validation schemas
|
| 31 |
+
│ │ └── services/ # FaceEngine AI & pyttsx3 TTS helper
|
| 32 |
+
│ ├── requirements.txt # Python requirements
|
| 33 |
+
│ └── Dockerfile # Backend image builder
|
| 34 |
+
├── frontend/
|
| 35 |
+
│ ├── app/ # Next.js pages (Login, Dashboard, Kiosk)
|
| 36 |
+
│ ├── components/ # Shared components (SidebarLayout)
|
| 37 |
+
│ ├── package.json # Node requirements
|
| 38 |
+
│ └── Dockerfile # Frontend image builder
|
| 39 |
+
├── docker/
|
| 40 |
+
│ └── nginx.conf # Reverse proxy configuration
|
| 41 |
+
├── docker-compose.yml # Docker orchestrator
|
| 42 |
+
└── README.md # System documentation
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Setup Instructions
|
| 48 |
+
|
| 49 |
+
NetraID runs natively on both **Windows** and **Linux**.
|
| 50 |
+
|
| 51 |
+
### Option A: Quick Launch via Docker Compose (Recommended)
|
| 52 |
+
|
| 53 |
+
1. **Clone & Navigate**:
|
| 54 |
+
```bash
|
| 55 |
+
cd NetraID
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
2. **Download AI Model Weights**:
|
| 59 |
+
Run the model downloader script locally to pull the ONNX models to the `./models` folder, or let the backend do it on boot. To run it manually:
|
| 60 |
+
```bash
|
| 61 |
+
cd backend
|
| 62 |
+
python -m pip install -r requirements.txt
|
| 63 |
+
python app/core/download_models.py
|
| 64 |
+
cd ..
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
3. **Start Stack**:
|
| 68 |
+
```bash
|
| 69 |
+
docker compose up --build
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
4. **Access Applications**:
|
| 73 |
+
- **Dashboard & Kiosk**: [http://localhost](http://localhost) (Nginx proxy)
|
| 74 |
+
- **FastAPI API Swagger Docs**: [http://localhost/docs](http://localhost/docs)
|
| 75 |
+
- **PostgreSQL Database**: Port `5432`
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
### Option B: Local Development Setup (Windows & Linux)
|
| 80 |
+
|
| 81 |
+
#### 1. Setup PostgreSQL + pgvector
|
| 82 |
+
Make sure you have a running PostgreSQL instance and install the `pgvector` extension:
|
| 83 |
+
```sql
|
| 84 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
#### 2. Run Backend
|
| 88 |
+
1. **Initialize Virtual Env**:
|
| 89 |
+
```bash
|
| 90 |
+
cd backend
|
| 91 |
+
python -m venv venv
|
| 92 |
+
# On Windows:
|
| 93 |
+
venv\Scripts\activate
|
| 94 |
+
# On Linux:
|
| 95 |
+
source venv/bin/activate
|
| 96 |
+
```
|
| 97 |
+
2. **Install Packages**:
|
| 98 |
+
- *On Linux*: Install `espeak` for pyttsx3 and Mesa GL for OpenCV:
|
| 99 |
+
```bash
|
| 100 |
+
sudo apt-get install -y libgl1-mesa-glx libglib2.0-0 espeak
|
| 101 |
+
```
|
| 102 |
+
- *On Windows*: Python packages will install SAPI5 dependencies automatically.
|
| 103 |
+
```bash
|
| 104 |
+
pip install -r requirements.txt
|
| 105 |
+
```
|
| 106 |
+
3. **Download ONNX Models**:
|
| 107 |
+
```bash
|
| 108 |
+
python app/core/download_models.py
|
| 109 |
+
```
|
| 110 |
+
4. **Boot Server**:
|
| 111 |
+
```bash
|
| 112 |
+
uvicorn app.main:app --reload --port 8000
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
#### 3. Run Frontend
|
| 116 |
+
1. **Navigate & Install**:
|
| 117 |
+
```bash
|
| 118 |
+
cd ../frontend
|
| 119 |
+
npm install
|
| 120 |
+
```
|
| 121 |
+
2. **Boot Dev Server**:
|
| 122 |
+
```bash
|
| 123 |
+
npm run dev
|
| 124 |
+
```
|
| 125 |
+
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## System Workflows
|
| 130 |
+
|
| 131 |
+
### 1. Seeding & Credentials
|
| 132 |
+
On startup, the system seeds default values.
|
| 133 |
+
- **Initial Super Admin**: `admin@netraid.ai` / `Admin@NetraID2026`
|
| 134 |
+
- **Default Shift Timing**: Start: `09:00`, End: `17:00`
|
| 135 |
+
- **Grace Period**: 15 minutes.
|
| 136 |
+
- **Similarity Thresholds**: Face Cosine distance: `0.60`, Liveness threshold: `0.75`
|
| 137 |
+
|
| 138 |
+
### 2. Biometric Face Enrollment (10 Poses)
|
| 139 |
+
To complete a registration, navigate to the **Employees** page, select a user, and click **Camera**. The admin must capture/upload 10 distinct poses to achieve high-accuracy matches:
|
| 140 |
+
- *Poses*: Front, Left, Right, Looking Up, Looking Down, Smiling, Neutral, Indoor Light, Outdoor Light, Glasses (Optional).
|
| 141 |
+
- Reference embeddings are saved as L2-normalized 512-D float vectors inside PostgreSQL.
|
| 142 |
+
|
| 143 |
+
### 3. Kiosk Scanning & Liveness
|
| 144 |
+
When an employee stands in front of the kiosk webcam:
|
| 145 |
+
1. The system captures frames and scans for exactly one face (SCRFD).
|
| 146 |
+
2. Runs **Silent-Face-Anti-Spoofing** (MiniFASNet). If the liveness score is below `0.75`, the scan is rejected as a spoof (e.g. photos, phone screen).
|
| 147 |
+
3. Extracts the 512-D face embedding (ArcFace) and runs a **pgvector cosine distance** query:
|
| 148 |
+
```sql
|
| 149 |
+
SELECT * FROM face_embeddings
|
| 150 |
+
ORDER BY embedding <=> :search_vector LIMIT 1;
|
| 151 |
+
```
|
| 152 |
+
4. Calculates similarity `1.0 - distance`. If similarity is $\ge 0.60$, the employee is identified.
|
| 153 |
+
5. Marks Check-In (first swipe) or Check-Out (subsequent swipes) and logs the entry.
|
| 154 |
+
6. Synthesizes a greeting offline using `pyttsx3` (Windows SAPI5 / Linux espeak) based on time-of-day:
|
| 155 |
+
- `05:00 - 11:59`: "Good Morning"
|
| 156 |
+
- `12:00 - 16:59`: "Good Afternoon"
|
| 157 |
+
- `17:00 - 23:59`: "Good Evening"
|
backend/.env
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PROJECT_NAME="NetraID AI Face Attendance"
|
| 2 |
+
API_V1_STR="/api/v1"
|
| 3 |
+
|
| 4 |
+
# Database Configuration
|
| 5 |
+
# Natively uses SQLite for zero-setup local Windows run
|
| 6 |
+
# Docker Compose will override this to use high-performance pgvector on PostgreSQL
|
| 7 |
+
# DATABASE_URL="sqlite:///./netraid.db"
|
| 8 |
+
# For Supabase production deployment (Option B), uncomment and replace credentials:
|
| 9 |
+
DATABASE_URL="postgresql://postgres:Netra9334##@db.erzowqgbpeobbzpjkmtt.supabase.co:5432/postgres"
|
| 10 |
+
JWT_SECRET_KEY="c01c0be1b621e25e985b96ea6e88e2cbdfcbce52ff6ea96be220f8623eb0b21a"
|
| 11 |
+
JWT_ALGORITHM="HS256"
|
| 12 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 13 |
+
REFRESH_TOKEN_EXPIRE_DAYS=7
|
| 14 |
+
|
| 15 |
+
INITIAL_ADMIN_EMAIL="admin@netraid.ai"
|
| 16 |
+
INITIAL_ADMIN_PASSWORD="Admin@NetraID2026"
|
| 17 |
+
|
| 18 |
+
KIOSK_FACE_THRESHOLD=0.60
|
| 19 |
+
KIOSK_LIVENESS_THRESHOLD=0.75
|
| 20 |
+
|
| 21 |
+
UPLOAD_DIR="./uploads"
|
| 22 |
+
MODELS_DIR="./models"
|
| 23 |
+
|
| 24 |
+
RATE_LIMIT_PER_MINUTE=100
|
| 25 |
+
ALLOWED_HOSTS="http://localhost:3001,http://localhost:3000,http://127.0.0.1:3001,http://127.0.0.1:3000"
|
backend/.env.example
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PROJECT_NAME="NetraID AI Face Attendance"
|
| 2 |
+
API_V1_STR="/api/v1"
|
| 3 |
+
|
| 4 |
+
# Database Configuration
|
| 5 |
+
# Standard local docker-compose configuration
|
| 6 |
+
DATABASE_URL="postgresql://netraid:netraid123@localhost:5432/netraid_db"
|
| 7 |
+
|
| 8 |
+
# Security Configuration
|
| 9 |
+
# Generate a secure key using: openssl rand -hex 32
|
| 10 |
+
JWT_SECRET_KEY="replace_this_with_a_secure_random_key_for_production"
|
| 11 |
+
JWT_ALGORITHM="HS256"
|
| 12 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 13 |
+
REFRESH_TOKEN_EXPIRE_DAYS=7
|
| 14 |
+
|
| 15 |
+
# Initial Super Admin Details
|
| 16 |
+
INITIAL_ADMIN_EMAIL="admin@netraid.ai"
|
| 17 |
+
INITIAL_ADMIN_PASSWORD="Admin@NetraID2026"
|
| 18 |
+
|
| 19 |
+
# Kiosk & AI Parameters
|
| 20 |
+
KIOSK_FACE_THRESHOLD=0.60
|
| 21 |
+
KIOSK_LIVENESS_THRESHOLD=0.75
|
| 22 |
+
|
| 23 |
+
# Folders for Assets
|
| 24 |
+
UPLOAD_DIR="./uploads"
|
| 25 |
+
MODELS_DIR="./models"
|
| 26 |
+
|
| 27 |
+
# Rate Limiting & Security
|
| 28 |
+
RATE_LIMIT_PER_MINUTE=100
|
| 29 |
+
ALLOWED_HOSTS="*"
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# Install system dependencies for OpenCV and pyttsx3/espeak
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
build-essential \
|
| 6 |
+
libgl1-mesa-glx \
|
| 7 |
+
libglib2.0-0 \
|
| 8 |
+
espeak \
|
| 9 |
+
alsa-utils \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
WORKDIR /workspace
|
| 13 |
+
|
| 14 |
+
# Copy and install python dependencies
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy backend code
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Expose FastAPI port
|
| 22 |
+
EXPOSE 8000
|
| 23 |
+
|
| 24 |
+
# Run FastAPI app
|
| 25 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
backend/app/api/v1/analytics.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from sqlalchemy import select, func, and_, desc
|
| 5 |
+
from datetime import date, datetime, timedelta
|
| 6 |
+
from typing import Dict, Any, List
|
| 7 |
+
import asyncio
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
from app.core.database import get_db
|
| 11 |
+
from app.core import security
|
| 12 |
+
from app.core.security import RoleCheckerSSE
|
| 13 |
+
from app.core import event_bus
|
| 14 |
+
from app.crud import crud
|
| 15 |
+
from app.schemas import schemas
|
| 16 |
+
from app.models import models
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
checker_view = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 21 |
+
|
| 22 |
+
@router.get("/dashboard-summary")
|
| 23 |
+
def get_dashboard_summary(
|
| 24 |
+
db: Session = Depends(get_db),
|
| 25 |
+
current_user: models.User = Depends(checker_view)
|
| 26 |
+
):
|
| 27 |
+
today = date.today()
|
| 28 |
+
|
| 29 |
+
# 1. Total Employees
|
| 30 |
+
total_employees = db.query(func.count(models.Employee.id)).filter(models.Employee.status == "Active").scalar() or 0
|
| 31 |
+
|
| 32 |
+
# 2. Present Today (Status = Present or Late or Half Day)
|
| 33 |
+
present_today = db.query(func.count(models.Attendance.id)).filter(
|
| 34 |
+
and_(
|
| 35 |
+
models.Attendance.date == today,
|
| 36 |
+
models.Attendance.status.in_(["Present", "Late", "Half Day"])
|
| 37 |
+
)
|
| 38 |
+
).scalar() or 0
|
| 39 |
+
|
| 40 |
+
# 3. Late Today
|
| 41 |
+
late_today = db.query(func.count(models.Attendance.id)).filter(
|
| 42 |
+
and_(
|
| 43 |
+
models.Attendance.date == today,
|
| 44 |
+
models.Attendance.status == "Late"
|
| 45 |
+
)
|
| 46 |
+
).scalar() or 0
|
| 47 |
+
|
| 48 |
+
# 4. Checked Out
|
| 49 |
+
checked_out_today = db.query(func.count(models.Attendance.id)).filter(
|
| 50 |
+
and_(
|
| 51 |
+
models.Attendance.date == today,
|
| 52 |
+
models.Attendance.check_out.isnot(None)
|
| 53 |
+
)
|
| 54 |
+
).scalar() or 0
|
| 55 |
+
|
| 56 |
+
# 5. Absent Today
|
| 57 |
+
# Active employees minus present today
|
| 58 |
+
absent_today = max(0, total_employees - present_today)
|
| 59 |
+
|
| 60 |
+
# 6. Attendance Rate
|
| 61 |
+
attendance_percentage = (present_today / total_employees * 100) if total_employees > 0 else 0.0
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
"total_employees": total_employees,
|
| 65 |
+
"present_today": present_today,
|
| 66 |
+
"absent_today": absent_today,
|
| 67 |
+
"late_today": late_today,
|
| 68 |
+
"checked_out_today": checked_out_today,
|
| 69 |
+
"attendance_percentage": round(attendance_percentage, 1)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
@router.get("/attendance-trends")
|
| 73 |
+
def get_attendance_trends(
|
| 74 |
+
days: int = 7,
|
| 75 |
+
db: Session = Depends(get_db),
|
| 76 |
+
current_user: models.User = Depends(checker_view)
|
| 77 |
+
):
|
| 78 |
+
"""
|
| 79 |
+
Returns daily stats for the last N days (date, present, late, absent) for ECharts.
|
| 80 |
+
"""
|
| 81 |
+
end_date = date.today()
|
| 82 |
+
start_date = end_date - timedelta(days=days - 1)
|
| 83 |
+
|
| 84 |
+
# Generate list of dates
|
| 85 |
+
date_list = [start_date + timedelta(days=i) for i in range(days)]
|
| 86 |
+
|
| 87 |
+
# Get total active employees
|
| 88 |
+
total_active = db.query(func.count(models.Employee.id)).filter(models.Employee.status == "Active").scalar() or 0
|
| 89 |
+
|
| 90 |
+
trends = []
|
| 91 |
+
for d in date_list:
|
| 92 |
+
present = db.query(func.count(models.Attendance.id)).filter(
|
| 93 |
+
and_(
|
| 94 |
+
models.Attendance.date == d,
|
| 95 |
+
models.Attendance.status.in_(["Present", "Late", "Half Day"])
|
| 96 |
+
)
|
| 97 |
+
).scalar() or 0
|
| 98 |
+
|
| 99 |
+
late = db.query(func.count(models.Attendance.id)).filter(
|
| 100 |
+
and_(
|
| 101 |
+
models.Attendance.date == d,
|
| 102 |
+
models.Attendance.status == "Late"
|
| 103 |
+
)
|
| 104 |
+
).scalar() or 0
|
| 105 |
+
|
| 106 |
+
absent = max(0, total_active - present)
|
| 107 |
+
|
| 108 |
+
trends.append({
|
| 109 |
+
"date": d.isoformat(),
|
| 110 |
+
"present": present,
|
| 111 |
+
"late": late,
|
| 112 |
+
"absent": absent
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
return trends
|
| 116 |
+
|
| 117 |
+
@router.get("/department-distribution")
|
| 118 |
+
def get_department_distribution(
|
| 119 |
+
db: Session = Depends(get_db),
|
| 120 |
+
current_user: models.User = Depends(checker_view)
|
| 121 |
+
):
|
| 122 |
+
"""
|
| 123 |
+
Returns employee and attendance counts by department for ECharts.
|
| 124 |
+
"""
|
| 125 |
+
today = date.today()
|
| 126 |
+
departments = db.query(models.Department).all()
|
| 127 |
+
|
| 128 |
+
dist = []
|
| 129 |
+
for dept in departments:
|
| 130 |
+
# Total active in dept
|
| 131 |
+
total_in_dept = db.query(func.count(models.Employee.id)).filter(
|
| 132 |
+
and_(
|
| 133 |
+
models.Employee.department_id == dept.id,
|
| 134 |
+
models.Employee.status == "Active"
|
| 135 |
+
)
|
| 136 |
+
).scalar() or 0
|
| 137 |
+
|
| 138 |
+
# Present today in dept
|
| 139 |
+
present_in_dept = db.query(func.count(models.Attendance.id)).join(models.Employee).filter(
|
| 140 |
+
and_(
|
| 141 |
+
models.Employee.department_id == dept.id,
|
| 142 |
+
models.Attendance.date == today,
|
| 143 |
+
models.Attendance.status.in_(["Present", "Late", "Half Day"])
|
| 144 |
+
)
|
| 145 |
+
).scalar() or 0
|
| 146 |
+
|
| 147 |
+
dist.append({
|
| 148 |
+
"id": dept.id,
|
| 149 |
+
"department": dept.name,
|
| 150 |
+
"code": dept.code,
|
| 151 |
+
"total_employees": total_in_dept,
|
| 152 |
+
"present_today": present_in_dept
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
return dist
|
| 156 |
+
|
| 157 |
+
@router.get("/recent-activity", response_model=List[schemas.AttendanceLogOut])
|
| 158 |
+
def get_recent_activity(
|
| 159 |
+
limit: int = 10,
|
| 160 |
+
db: Session = Depends(get_db),
|
| 161 |
+
current_user: models.User = Depends(checker_view)
|
| 162 |
+
):
|
| 163 |
+
"""
|
| 164 |
+
Returns latest kiosk scan logs for the dashboard tickfeed.
|
| 165 |
+
"""
|
| 166 |
+
cutoff = datetime.utcnow() - timedelta(hours=24)
|
| 167 |
+
return db.query(models.AttendanceLog).filter(models.AttendanceLog.timestamp >= cutoff).order_by(desc(models.AttendanceLog.timestamp)).limit(limit).all()
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@router.get("/live-stream")
|
| 171 |
+
async def live_stream(
|
| 172 |
+
current_user: models.User = Depends(RoleCheckerSSE(["Super Admin", "Admin", "HR"]))
|
| 173 |
+
):
|
| 174 |
+
"""
|
| 175 |
+
Real-time Server-Sent Events (SSE) feed of kiosk scans.
|
| 176 |
+
"""
|
| 177 |
+
async def event_generator():
|
| 178 |
+
queue = event_bus.subscribe()
|
| 179 |
+
try:
|
| 180 |
+
while True:
|
| 181 |
+
# Wait for next event published to the bus
|
| 182 |
+
event_data = await queue.get()
|
| 183 |
+
# Yield standard SSE formatted message
|
| 184 |
+
yield f"data: {json.dumps(event_data)}\n\n"
|
| 185 |
+
except asyncio.CancelledError:
|
| 186 |
+
# Client disconnected
|
| 187 |
+
pass
|
| 188 |
+
finally:
|
| 189 |
+
event_bus.unsubscribe(queue)
|
| 190 |
+
|
| 191 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
backend/app/api/v1/attendance.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from datetime import date, datetime, timedelta
|
| 5 |
+
|
| 6 |
+
from app.core.database import get_db
|
| 7 |
+
from app.core import security
|
| 8 |
+
from app.crud import crud
|
| 9 |
+
from app.schemas import schemas
|
| 10 |
+
from app.models import models
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
checker_view = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 15 |
+
checker_manage = security.RoleChecker(["Super Admin", "Admin"])
|
| 16 |
+
|
| 17 |
+
@router.get("/daily", response_model=List[schemas.AttendanceOut])
|
| 18 |
+
def read_daily_attendance(
|
| 19 |
+
date_val: Optional[date] = None,
|
| 20 |
+
employee_id: Optional[int] = None,
|
| 21 |
+
department_id: Optional[int] = None,
|
| 22 |
+
db: Session = Depends(get_db),
|
| 23 |
+
current_user: models.User = Depends(checker_view)
|
| 24 |
+
):
|
| 25 |
+
if not date_val:
|
| 26 |
+
date_val = date.today()
|
| 27 |
+
return crud.get_daily_attendance(db, date_val=date_val, employee_id=employee_id, department_id=department_id)
|
| 28 |
+
|
| 29 |
+
@router.put("/{id}", response_model=schemas.AttendanceOut)
|
| 30 |
+
def manual_update_attendance(
|
| 31 |
+
request: Request,
|
| 32 |
+
id: int,
|
| 33 |
+
data: schemas.AttendanceUpdate,
|
| 34 |
+
db: Session = Depends(get_db),
|
| 35 |
+
current_user: models.User = Depends(checker_manage)
|
| 36 |
+
):
|
| 37 |
+
updated = crud.update_attendance(db, id=id, data=data)
|
| 38 |
+
if not updated:
|
| 39 |
+
raise HTTPException(status_code=404, detail="Attendance record not found")
|
| 40 |
+
|
| 41 |
+
crud.create_audit_log(
|
| 42 |
+
db=db,
|
| 43 |
+
user_id=current_user.id,
|
| 44 |
+
action="Manual Attendance Correction",
|
| 45 |
+
ip_address=request.client.host if request.client else None,
|
| 46 |
+
user_agent=request.headers.get("user-agent"),
|
| 47 |
+
details=f"Adjusted attendance ID: {id} for date {updated.date}. New Status: {updated.status}"
|
| 48 |
+
)
|
| 49 |
+
return updated
|
| 50 |
+
|
| 51 |
+
@router.get("/logs", response_model=List[schemas.AttendanceLogOut])
|
| 52 |
+
def read_attendance_logs(
|
| 53 |
+
skip: int = 0,
|
| 54 |
+
limit: int = 100,
|
| 55 |
+
employee_id: Optional[int] = None,
|
| 56 |
+
date_str: Optional[str] = None, # YYYY-MM-DD
|
| 57 |
+
db: Session = Depends(get_db),
|
| 58 |
+
current_user: models.User = Depends(checker_view)
|
| 59 |
+
):
|
| 60 |
+
return crud.get_attendance_logs(db, skip=skip, limit=limit, employee_id=employee_id, date_str=date_str)
|
| 61 |
+
|
| 62 |
+
@router.get("/employee/{employee_id}", response_model=List[schemas.AttendanceOut])
|
| 63 |
+
def get_employee_attendance_history(
|
| 64 |
+
employee_id: int,
|
| 65 |
+
start_date: Optional[date] = None,
|
| 66 |
+
end_date: Optional[date] = None,
|
| 67 |
+
db: Session = Depends(get_db),
|
| 68 |
+
current_user: models.User = Depends(checker_view)
|
| 69 |
+
):
|
| 70 |
+
if not start_date:
|
| 71 |
+
start_date = date.today() - timedelta(days=30)
|
| 72 |
+
if not end_date:
|
| 73 |
+
end_date = date.today()
|
| 74 |
+
from sqlalchemy import and_
|
| 75 |
+
return db.query(models.Attendance).filter(
|
| 76 |
+
and_(
|
| 77 |
+
models.Attendance.employee_id == employee_id,
|
| 78 |
+
models.Attendance.date.between(start_date, end_date)
|
| 79 |
+
)
|
| 80 |
+
).order_by(models.Attendance.date.desc()).all()
|
backend/app/api/v1/audit.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core import security
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
from app.schemas import schemas
|
| 9 |
+
from app.models import models
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
# Restrict viewing audit logs to Super Admins & Admins
|
| 14 |
+
checker_view = security.RoleChecker(["Super Admin", "Admin"])
|
| 15 |
+
|
| 16 |
+
@router.get("/", response_model=List[schemas.AuditLogOut])
|
| 17 |
+
def read_audit_logs(
|
| 18 |
+
skip: int = 0,
|
| 19 |
+
limit: int = 100,
|
| 20 |
+
db: Session = Depends(get_db),
|
| 21 |
+
current_user: models.User = Depends(checker_view)
|
| 22 |
+
):
|
| 23 |
+
return crud.get_audit_logs(db, skip=skip, limit=limit)
|
backend/app/api/v1/auth.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from datetime import timedelta
|
| 5 |
+
from jose import jwt, JWTError
|
| 6 |
+
|
| 7 |
+
from app.core.database import get_db
|
| 8 |
+
from app.core import security
|
| 9 |
+
from app.core.config import settings
|
| 10 |
+
from app.crud import crud
|
| 11 |
+
from app.schemas import schemas
|
| 12 |
+
from app.models import models
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
@router.post("/login", response_model=schemas.Token)
|
| 17 |
+
def login(
|
| 18 |
+
request: Request,
|
| 19 |
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
| 20 |
+
db: Session = Depends(get_db)
|
| 21 |
+
):
|
| 22 |
+
user = crud.get_user_by_email(db, email=form_data.username)
|
| 23 |
+
if not user or not crud.verify_password(form_data.password, user.hashed_password):
|
| 24 |
+
# Log failed login attempt
|
| 25 |
+
crud.create_audit_log(
|
| 26 |
+
db=db,
|
| 27 |
+
user_id=None,
|
| 28 |
+
action="Failed Login Attempt",
|
| 29 |
+
ip_address=request.client.host if request.client else None,
|
| 30 |
+
user_agent=request.headers.get("user-agent"),
|
| 31 |
+
details=f"Attempted email: {form_data.username}"
|
| 32 |
+
)
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 35 |
+
detail="Incorrect email or password",
|
| 36 |
+
)
|
| 37 |
+
if not user.is_active:
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 40 |
+
detail="Inactive user account"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 44 |
+
access_token = security.create_access_token(
|
| 45 |
+
user.email, role=user.role.name, expires_delta=access_token_expires
|
| 46 |
+
)
|
| 47 |
+
refresh_token = security.create_refresh_token(
|
| 48 |
+
user.email, role=user.role.name
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Audit log login
|
| 52 |
+
crud.create_audit_log(
|
| 53 |
+
db=db,
|
| 54 |
+
user_id=user.id,
|
| 55 |
+
action="User Login",
|
| 56 |
+
ip_address=request.client.host if request.client else None,
|
| 57 |
+
user_agent=request.headers.get("user-agent"),
|
| 58 |
+
details=f"Successful login for user email {user.email}"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
"access_token": access_token,
|
| 63 |
+
"refresh_token": refresh_token,
|
| 64 |
+
"token_type": "bearer"
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
@router.post("/refresh", response_model=schemas.Token)
|
| 68 |
+
def refresh_token(
|
| 69 |
+
refresh_token: str,
|
| 70 |
+
db: Session = Depends(get_db)
|
| 71 |
+
):
|
| 72 |
+
credentials_exception = HTTPException(
|
| 73 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 74 |
+
detail="Could not validate refresh token",
|
| 75 |
+
)
|
| 76 |
+
try:
|
| 77 |
+
payload = jwt.decode(
|
| 78 |
+
refresh_token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
|
| 79 |
+
)
|
| 80 |
+
email: str = payload.get("sub")
|
| 81 |
+
token_type: str = payload.get("type")
|
| 82 |
+
|
| 83 |
+
if email is None or token_type != "refresh":
|
| 84 |
+
raise credentials_exception
|
| 85 |
+
except JWTError:
|
| 86 |
+
raise credentials_exception
|
| 87 |
+
|
| 88 |
+
user = crud.get_user_by_email(db, email=email)
|
| 89 |
+
if user is None or not user.is_active:
|
| 90 |
+
raise credentials_exception
|
| 91 |
+
|
| 92 |
+
access_token = security.create_access_token(
|
| 93 |
+
user.email, role=user.role.name
|
| 94 |
+
)
|
| 95 |
+
new_refresh_token = security.create_refresh_token(
|
| 96 |
+
user.email, role=user.role.name
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"access_token": access_token,
|
| 101 |
+
"refresh_token": new_refresh_token,
|
| 102 |
+
"token_type": "bearer"
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
@router.get("/me", response_model=schemas.UserOut)
|
| 106 |
+
def read_users_me(
|
| 107 |
+
current_user: models.User = Depends(security.get_current_user)
|
| 108 |
+
):
|
| 109 |
+
return current_user
|
backend/app/api/v1/departments.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core import security
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
from app.schemas import schemas
|
| 9 |
+
from app.models import models
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
# HR, Admin, and Super Admin can manage or view departments
|
| 14 |
+
checker_view = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 15 |
+
checker_manage = security.RoleChecker(["Super Admin", "Admin"])
|
| 16 |
+
|
| 17 |
+
@router.get("/", response_model=List[schemas.DepartmentOut])
|
| 18 |
+
def read_departments(
|
| 19 |
+
skip: int = 0,
|
| 20 |
+
limit: int = 100,
|
| 21 |
+
db: Session = Depends(get_db),
|
| 22 |
+
current_user: models.User = Depends(checker_view)
|
| 23 |
+
):
|
| 24 |
+
return crud.get_departments(db, skip=skip, limit=limit)
|
| 25 |
+
|
| 26 |
+
@router.get("/{id}", response_model=schemas.DepartmentOut)
|
| 27 |
+
def read_department(
|
| 28 |
+
id: int,
|
| 29 |
+
db: Session = Depends(get_db),
|
| 30 |
+
current_user: models.User = Depends(checker_view)
|
| 31 |
+
):
|
| 32 |
+
db_dept = crud.get_department_by_id(db, department_id=id)
|
| 33 |
+
if not db_dept:
|
| 34 |
+
raise HTTPException(status_code=404, detail="Department not found")
|
| 35 |
+
return db_dept
|
| 36 |
+
|
| 37 |
+
@router.post("/", response_model=schemas.DepartmentOut, status_code=status.HTTP_201_CREATED)
|
| 38 |
+
def create_department(
|
| 39 |
+
request: Request,
|
| 40 |
+
dept: schemas.DepartmentCreate,
|
| 41 |
+
db: Session = Depends(get_db),
|
| 42 |
+
current_user: models.User = Depends(checker_manage)
|
| 43 |
+
):
|
| 44 |
+
existing = crud.get_department_by_code(db, code=dept.code)
|
| 45 |
+
if existing:
|
| 46 |
+
raise HTTPException(status_code=400, detail="Department code already exists")
|
| 47 |
+
|
| 48 |
+
db_dept = crud.create_department(db, dept=dept)
|
| 49 |
+
crud.create_audit_log(
|
| 50 |
+
db=db,
|
| 51 |
+
user_id=current_user.id,
|
| 52 |
+
action="Create Department",
|
| 53 |
+
ip_address=request.client.host if request.client else None,
|
| 54 |
+
user_agent=request.headers.get("user-agent"),
|
| 55 |
+
details=f"Created department: {dept.name} ({dept.code})"
|
| 56 |
+
)
|
| 57 |
+
return db_dept
|
| 58 |
+
|
| 59 |
+
@router.put("/{id}", response_model=schemas.DepartmentOut)
|
| 60 |
+
def update_department(
|
| 61 |
+
request: Request,
|
| 62 |
+
id: int,
|
| 63 |
+
dept: schemas.DepartmentUpdate,
|
| 64 |
+
db: Session = Depends(get_db),
|
| 65 |
+
current_user: models.User = Depends(checker_manage)
|
| 66 |
+
):
|
| 67 |
+
updated = crud.update_department(db, department_id=id, dept=dept)
|
| 68 |
+
if not updated:
|
| 69 |
+
raise HTTPException(status_code=404, detail="Department not found")
|
| 70 |
+
|
| 71 |
+
crud.create_audit_log(
|
| 72 |
+
db=db,
|
| 73 |
+
user_id=current_user.id,
|
| 74 |
+
action="Update Department",
|
| 75 |
+
ip_address=request.client.host if request.client else None,
|
| 76 |
+
user_agent=request.headers.get("user-agent"),
|
| 77 |
+
details=f"Updated department ID: {id}"
|
| 78 |
+
)
|
| 79 |
+
return updated
|
| 80 |
+
|
| 81 |
+
@router.delete("/{id}")
|
| 82 |
+
def delete_department(
|
| 83 |
+
request: Request,
|
| 84 |
+
id: int,
|
| 85 |
+
db: Session = Depends(get_db),
|
| 86 |
+
current_user: models.User = Depends(checker_manage)
|
| 87 |
+
):
|
| 88 |
+
success = crud.delete_department(db, department_id=id)
|
| 89 |
+
if not success:
|
| 90 |
+
raise HTTPException(status_code=404, detail="Department not found")
|
| 91 |
+
|
| 92 |
+
crud.create_audit_log(
|
| 93 |
+
db=db,
|
| 94 |
+
user_id=current_user.id,
|
| 95 |
+
action="Delete Department",
|
| 96 |
+
ip_address=request.client.host if request.client else None,
|
| 97 |
+
user_agent=request.headers.get("user-agent"),
|
| 98 |
+
details=f"Deleted department ID: {id}"
|
| 99 |
+
)
|
| 100 |
+
return {"message": "Department deleted successfully"}
|
backend/app/api/v1/employees.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from datetime import datetime, date
|
| 6 |
+
import io
|
| 7 |
+
|
| 8 |
+
from app.core.database import get_db
|
| 9 |
+
from app.core import security
|
| 10 |
+
from app.crud import crud
|
| 11 |
+
from app.schemas import schemas
|
| 12 |
+
from app.models import models
|
| 13 |
+
from app.services.singletons import face_engine
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
checker_view = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 18 |
+
checker_manage = security.RoleChecker(["Super Admin", "Admin"])
|
| 19 |
+
|
| 20 |
+
@router.get("/", response_model=List[schemas.EmployeeOut])
|
| 21 |
+
def read_employees(
|
| 22 |
+
skip: int = 0,
|
| 23 |
+
limit: int = 100,
|
| 24 |
+
search: Optional[str] = None,
|
| 25 |
+
department_id: Optional[int] = None,
|
| 26 |
+
status: Optional[str] = None,
|
| 27 |
+
db: Session = Depends(get_db),
|
| 28 |
+
current_user: models.User = Depends(checker_view)
|
| 29 |
+
):
|
| 30 |
+
return crud.get_employees(
|
| 31 |
+
db, skip=skip, limit=limit, search=search, department_id=department_id, status=status
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
@router.get("/count")
|
| 35 |
+
def get_employee_count(
|
| 36 |
+
search: Optional[str] = None,
|
| 37 |
+
department_id: Optional[int] = None,
|
| 38 |
+
status: Optional[str] = None,
|
| 39 |
+
db: Session = Depends(get_db),
|
| 40 |
+
current_user: models.User = Depends(checker_view)
|
| 41 |
+
):
|
| 42 |
+
count = crud.count_employees(db, search=search, department_id=department_id, status=status)
|
| 43 |
+
return {"count": count}
|
| 44 |
+
|
| 45 |
+
@router.get("/{id}", response_model=schemas.EmployeeOut)
|
| 46 |
+
def read_employee(
|
| 47 |
+
id: int,
|
| 48 |
+
db: Session = Depends(get_db),
|
| 49 |
+
current_user: models.User = Depends(checker_view)
|
| 50 |
+
):
|
| 51 |
+
db_emp = crud.get_employee_by_id(db, id=id)
|
| 52 |
+
if not db_emp:
|
| 53 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 54 |
+
return db_emp
|
| 55 |
+
|
| 56 |
+
@router.post("/", response_model=schemas.EmployeeOut, status_code=status.HTTP_201_CREATED)
|
| 57 |
+
def create_employee(
|
| 58 |
+
request: Request,
|
| 59 |
+
emp: schemas.EmployeeCreate,
|
| 60 |
+
db: Session = Depends(get_db),
|
| 61 |
+
current_user: models.User = Depends(checker_manage)
|
| 62 |
+
):
|
| 63 |
+
# Check duplicates
|
| 64 |
+
existing_id = crud.get_employee_by_uuid(db, employee_id=emp.employee_id)
|
| 65 |
+
if existing_id:
|
| 66 |
+
raise HTTPException(status_code=400, detail="Employee ID already exists")
|
| 67 |
+
|
| 68 |
+
existing_email = crud.get_employee_by_email(db, email=emp.email)
|
| 69 |
+
if existing_email:
|
| 70 |
+
raise HTTPException(status_code=400, detail="Employee email already exists")
|
| 71 |
+
|
| 72 |
+
user_id = None
|
| 73 |
+
if emp.create_user_login:
|
| 74 |
+
if not emp.password:
|
| 75 |
+
raise HTTPException(status_code=400, detail="Password is required for creating user login")
|
| 76 |
+
|
| 77 |
+
# Check user duplicate
|
| 78 |
+
existing_user = crud.get_user_by_email(db, email=emp.email)
|
| 79 |
+
if existing_user:
|
| 80 |
+
raise HTTPException(status_code=400, detail="User account with this email already exists")
|
| 81 |
+
|
| 82 |
+
# Get employee role
|
| 83 |
+
role_emp = crud.get_role_by_name(db, "Employee")
|
| 84 |
+
if not role_emp:
|
| 85 |
+
role_emp = crud.create_role(db, "Employee", "Standard employee role")
|
| 86 |
+
|
| 87 |
+
user_in = schemas.UserCreate(
|
| 88 |
+
email=emp.email,
|
| 89 |
+
password=emp.password,
|
| 90 |
+
role_id=role_emp.id
|
| 91 |
+
)
|
| 92 |
+
db_user = crud.create_user(db, user_in)
|
| 93 |
+
user_id = db_user.id
|
| 94 |
+
|
| 95 |
+
db_emp = crud.create_employee(db, emp=emp, user_id=user_id)
|
| 96 |
+
|
| 97 |
+
crud.create_audit_log(
|
| 98 |
+
db=db,
|
| 99 |
+
user_id=current_user.id,
|
| 100 |
+
action="Create Employee",
|
| 101 |
+
ip_address=request.client.host if request.client else None,
|
| 102 |
+
user_agent=request.headers.get("user-agent"),
|
| 103 |
+
details=f"Created employee: {emp.name} ({emp.employee_id})"
|
| 104 |
+
)
|
| 105 |
+
return db_emp
|
| 106 |
+
|
| 107 |
+
@router.put("/{id}", response_model=schemas.EmployeeOut)
|
| 108 |
+
def update_employee(
|
| 109 |
+
request: Request,
|
| 110 |
+
id: int,
|
| 111 |
+
emp: schemas.EmployeeUpdate,
|
| 112 |
+
db: Session = Depends(get_db),
|
| 113 |
+
current_user: models.User = Depends(checker_manage)
|
| 114 |
+
):
|
| 115 |
+
updated = crud.update_employee(db, id=id, emp=emp)
|
| 116 |
+
if not updated:
|
| 117 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 118 |
+
|
| 119 |
+
crud.create_audit_log(
|
| 120 |
+
db=db,
|
| 121 |
+
user_id=current_user.id,
|
| 122 |
+
action="Update Employee",
|
| 123 |
+
ip_address=request.client.host if request.client else None,
|
| 124 |
+
user_agent=request.headers.get("user-agent"),
|
| 125 |
+
details=f"Updated employee ID: {id}"
|
| 126 |
+
)
|
| 127 |
+
return updated
|
| 128 |
+
|
| 129 |
+
@router.delete("/{id}")
|
| 130 |
+
def delete_employee(
|
| 131 |
+
request: Request,
|
| 132 |
+
id: int,
|
| 133 |
+
db: Session = Depends(get_db),
|
| 134 |
+
current_user: models.User = Depends(checker_manage)
|
| 135 |
+
):
|
| 136 |
+
db_emp = crud.get_employee_by_id(db, id)
|
| 137 |
+
if not db_emp:
|
| 138 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 139 |
+
|
| 140 |
+
# Clear face embeddings from disk and database first
|
| 141 |
+
crud.delete_face_embeddings(db, employee_id=db_emp.id)
|
| 142 |
+
face_engine.invalidate_cache()
|
| 143 |
+
|
| 144 |
+
success = crud.delete_employee(db, id=id)
|
| 145 |
+
if not success:
|
| 146 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 147 |
+
|
| 148 |
+
crud.create_audit_log(
|
| 149 |
+
db=db,
|
| 150 |
+
user_id=current_user.id,
|
| 151 |
+
action="Delete Employee",
|
| 152 |
+
ip_address=request.client.host if request.client else None,
|
| 153 |
+
user_agent=request.headers.get("user-agent"),
|
| 154 |
+
details=f"Deleted employee ID: {id}"
|
| 155 |
+
)
|
| 156 |
+
return {"message": "Employee deleted successfully"}
|
| 157 |
+
|
| 158 |
+
@router.post("/import-csv")
|
| 159 |
+
def import_csv(
|
| 160 |
+
request: Request,
|
| 161 |
+
file: UploadFile = File(...),
|
| 162 |
+
db: Session = Depends(get_db),
|
| 163 |
+
current_user: models.User = Depends(checker_manage)
|
| 164 |
+
):
|
| 165 |
+
"""
|
| 166 |
+
Bulk import employees from a CSV file.
|
| 167 |
+
Expected CSV columns: employee_id, name, email, phone, designation, joining_date (YYYY-MM-DD), department_code
|
| 168 |
+
"""
|
| 169 |
+
if not file.filename.endswith('.csv'):
|
| 170 |
+
raise HTTPException(status_code=400, detail="Uploaded file must be a CSV")
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
contents = file.file.read()
|
| 174 |
+
df = pd.read_csv(io.BytesIO(contents))
|
| 175 |
+
|
| 176 |
+
required_cols = ["employee_id", "name", "email"]
|
| 177 |
+
for col in required_cols:
|
| 178 |
+
if col not in df.columns:
|
| 179 |
+
raise HTTPException(status_code=400, detail=f"Missing required column: {col}")
|
| 180 |
+
|
| 181 |
+
imported_count = 0
|
| 182 |
+
skipped_count = 0
|
| 183 |
+
errors = []
|
| 184 |
+
|
| 185 |
+
for idx, row in df.iterrows():
|
| 186 |
+
emp_id = str(row["employee_id"]).strip()
|
| 187 |
+
name = str(row["name"]).strip()
|
| 188 |
+
email = str(row["email"]).strip()
|
| 189 |
+
phone = str(row.get("phone", "")).strip() if pd.notna(row.get("phone")) else None
|
| 190 |
+
designation = str(row.get("designation", "")).strip() if pd.notna(row.get("designation")) else None
|
| 191 |
+
|
| 192 |
+
# Date handling
|
| 193 |
+
j_date = date.today()
|
| 194 |
+
if "joining_date" in df.columns and pd.notna(row["joining_date"]):
|
| 195 |
+
try:
|
| 196 |
+
j_date = pd.to_datetime(row["joining_date"]).date()
|
| 197 |
+
except Exception:
|
| 198 |
+
pass
|
| 199 |
+
|
| 200 |
+
# Resolve department
|
| 201 |
+
dept_id = None
|
| 202 |
+
if "department_code" in df.columns and pd.notna(row["department_code"]):
|
| 203 |
+
dept_code = str(row["department_code"]).strip()
|
| 204 |
+
dept = crud.get_department_by_code(db, code=dept_code)
|
| 205 |
+
if dept:
|
| 206 |
+
dept_id = dept.id
|
| 207 |
+
|
| 208 |
+
# Check duplicates
|
| 209 |
+
if crud.get_employee_by_uuid(db, employee_id=emp_id) or crud.get_employee_by_email(db, email=email):
|
| 210 |
+
skipped_count += 1
|
| 211 |
+
continue
|
| 212 |
+
|
| 213 |
+
try:
|
| 214 |
+
emp_in = schemas.EmployeeCreate(
|
| 215 |
+
employee_id=emp_id,
|
| 216 |
+
name=name,
|
| 217 |
+
email=email,
|
| 218 |
+
phone=phone,
|
| 219 |
+
designation=designation,
|
| 220 |
+
joining_date=j_date,
|
| 221 |
+
status="Active",
|
| 222 |
+
department_id=dept_id,
|
| 223 |
+
create_user_login=False
|
| 224 |
+
)
|
| 225 |
+
crud.create_employee(db, emp=emp_in)
|
| 226 |
+
imported_count += 1
|
| 227 |
+
except Exception as e:
|
| 228 |
+
errors.append(f"Row {idx+2}: {str(e)}")
|
| 229 |
+
skipped_count += 1
|
| 230 |
+
|
| 231 |
+
crud.create_audit_log(
|
| 232 |
+
db=db,
|
| 233 |
+
user_id=current_user.id,
|
| 234 |
+
action="Bulk Import CSV",
|
| 235 |
+
ip_address=request.client.host if request.client else None,
|
| 236 |
+
user_agent=request.headers.get("user-agent"),
|
| 237 |
+
details=f"Imported {imported_count} employees, skipped {skipped_count}"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
return {
|
| 241 |
+
"message": f"Import completed. Successfully imported {imported_count} employees.",
|
| 242 |
+
"skipped": skipped_count,
|
| 243 |
+
"errors": errors
|
| 244 |
+
}
|
| 245 |
+
except Exception as e:
|
| 246 |
+
raise HTTPException(status_code=500, detail=f"Failed to process CSV file: {str(e)}")
|
backend/app/api/v1/enrollment.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
import os
|
| 4 |
+
import cv2
|
| 5 |
+
import numpy as np
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from app.core.database import get_db
|
| 9 |
+
from app.core import security
|
| 10 |
+
from app.core.config import settings
|
| 11 |
+
from app.crud import crud
|
| 12 |
+
from app.schemas import schemas
|
| 13 |
+
from app.models import models
|
| 14 |
+
from app.services.singletons import face_engine
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("Enrollment")
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
checker_manage = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 20 |
+
|
| 21 |
+
@router.post("/upload")
|
| 22 |
+
async def upload_face_image(
|
| 23 |
+
request: Request,
|
| 24 |
+
employee_id: int = Form(...),
|
| 25 |
+
pose_type: str = Form(...), # e.g., front, left, right, up, down, smile, neutral, glasses
|
| 26 |
+
file: UploadFile = File(...),
|
| 27 |
+
db: Session = Depends(get_db),
|
| 28 |
+
current_user: models.User = Depends(checker_manage)
|
| 29 |
+
):
|
| 30 |
+
# Validate employee exists
|
| 31 |
+
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 32 |
+
if not employee:
|
| 33 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 34 |
+
|
| 35 |
+
# Read file bytes
|
| 36 |
+
try:
|
| 37 |
+
contents = await file.read()
|
| 38 |
+
nparr = np.frombuffer(contents, np.uint8)
|
| 39 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 40 |
+
if img is None:
|
| 41 |
+
raise ValueError()
|
| 42 |
+
except Exception:
|
| 43 |
+
raise HTTPException(status_code=400, detail="Invalid image file")
|
| 44 |
+
|
| 45 |
+
# Detect faces
|
| 46 |
+
faces = face_engine.detect_faces(img)
|
| 47 |
+
if not faces:
|
| 48 |
+
raise HTTPException(status_code=400, detail="No face detected in the image. Please try again.")
|
| 49 |
+
if len(faces) > 1:
|
| 50 |
+
raise HTTPException(status_code=400, detail="Multiple faces detected. Please ensure only one person is in the frame.")
|
| 51 |
+
|
| 52 |
+
# Process face
|
| 53 |
+
face = faces[0]
|
| 54 |
+
confidence = face["confidence"]
|
| 55 |
+
|
| 56 |
+
# Check if confidence is high enough
|
| 57 |
+
if confidence < 0.5:
|
| 58 |
+
raise HTTPException(status_code=400, detail=f"Face detection confidence too low ({confidence:.2f}). Please upload a clearer image.")
|
| 59 |
+
|
| 60 |
+
# Optional liveness check on enrollment (preventing enroll spoofing)
|
| 61 |
+
liveness_score, is_live = face_engine.check_liveness(img, face["bbox"])
|
| 62 |
+
# In enrollment we want to warn or prevent spoofing. Let's allow it but log a warning if is_live is False,
|
| 63 |
+
# or block. For highest security, we enforce liveness on enrollment!
|
| 64 |
+
if not is_live and not face_engine.mock_mode:
|
| 65 |
+
raise HTTPException(status_code=400, detail=f"Liveness detection failed ({liveness_score:.2f}). Please upload a real, non-spoofed image.")
|
| 66 |
+
|
| 67 |
+
# Align face (112x112)
|
| 68 |
+
aligned_face = face_engine.align_face(img, face["landmarks"])
|
| 69 |
+
|
| 70 |
+
# Generate 512-D embedding
|
| 71 |
+
embedding = face_engine.extract_embedding(aligned_face)
|
| 72 |
+
|
| 73 |
+
# Save image to disk
|
| 74 |
+
emp_upload_dir = os.path.join(settings.UPLOAD_DIR, str(employee.employee_id))
|
| 75 |
+
os.makedirs(emp_upload_dir, exist_ok=True)
|
| 76 |
+
|
| 77 |
+
# Save the raw uploaded photo (or aligned photo, raw photo is better for archive)
|
| 78 |
+
filename = f"{pose_type.replace(' ', '_').lower()}.jpg"
|
| 79 |
+
dest_path = os.path.join(emp_upload_dir, filename)
|
| 80 |
+
|
| 81 |
+
# Save the file (we compress/save as JPG)
|
| 82 |
+
cv2.imwrite(dest_path, img)
|
| 83 |
+
|
| 84 |
+
# Check if this pose already exists for the employee, delete it if it does
|
| 85 |
+
# (to allow re-enrolling a specific pose)
|
| 86 |
+
for existing_img in employee.images:
|
| 87 |
+
if existing_img.pose_type == pose_type:
|
| 88 |
+
db.delete(existing_img)
|
| 89 |
+
|
| 90 |
+
db.commit()
|
| 91 |
+
|
| 92 |
+
# Save EmployeeImage
|
| 93 |
+
db_img = crud.save_employee_image(
|
| 94 |
+
db=db,
|
| 95 |
+
employee_id=employee.id,
|
| 96 |
+
file_path=dest_path,
|
| 97 |
+
pose_type=pose_type
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Save FaceEmbedding (convert numpy array to python list)
|
| 101 |
+
embedding_list = embedding.tolist()
|
| 102 |
+
db_emb = crud.save_face_embedding(
|
| 103 |
+
db=db,
|
| 104 |
+
employee_id=employee.id,
|
| 105 |
+
image_id=db_img.id,
|
| 106 |
+
embedding=embedding_list
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Invalidate face engine embeddings cache
|
| 110 |
+
face_engine.invalidate_cache()
|
| 111 |
+
|
| 112 |
+
# Log Audit
|
| 113 |
+
crud.create_audit_log(
|
| 114 |
+
db=db,
|
| 115 |
+
user_id=current_user.id,
|
| 116 |
+
action="Enroll Face Pose",
|
| 117 |
+
ip_address=request.client.host if request.client else None,
|
| 118 |
+
user_agent=request.headers.get("user-agent"),
|
| 119 |
+
details=f"Enrolled pose '{pose_type}' for employee ID: {employee.employee_id}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"message": f"Successfully enrolled pose '{pose_type}' for employee {employee.name}",
|
| 124 |
+
"pose_type": pose_type,
|
| 125 |
+
"confidence": confidence,
|
| 126 |
+
"liveness_score": liveness_score,
|
| 127 |
+
"image_id": db_img.id
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
@router.get("/status/{employee_id}")
|
| 131 |
+
def get_enrollment_status(
|
| 132 |
+
employee_id: int,
|
| 133 |
+
db: Session = Depends(get_db),
|
| 134 |
+
current_user: models.User = Depends(checker_manage)
|
| 135 |
+
):
|
| 136 |
+
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 137 |
+
if not employee:
|
| 138 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 139 |
+
|
| 140 |
+
poses = [img.pose_type for img in employee.images]
|
| 141 |
+
|
| 142 |
+
# Required poses list
|
| 143 |
+
required_poses = [
|
| 144 |
+
"front", "left", "right", "up", "down",
|
| 145 |
+
"smile", "neutral", "indoor", "outdoor"
|
| 146 |
+
]
|
| 147 |
+
|
| 148 |
+
missing_poses = [p for p in required_poses if p not in [x.lower() for x in poses]]
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
"employee_id": employee.employee_id,
|
| 152 |
+
"name": employee.name,
|
| 153 |
+
"total_enrolled": len(poses),
|
| 154 |
+
"enrolled_poses": poses,
|
| 155 |
+
"required_poses": required_poses,
|
| 156 |
+
"missing_poses": missing_poses,
|
| 157 |
+
"is_complete": len(poses) >= 9 # 9 standard, 10th optional glasses
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
@router.delete("/{employee_id}")
|
| 161 |
+
def delete_all_enrollments(
|
| 162 |
+
request: Request,
|
| 163 |
+
employee_id: int,
|
| 164 |
+
db: Session = Depends(get_db),
|
| 165 |
+
current_user: models.User = Depends(checker_manage)
|
| 166 |
+
):
|
| 167 |
+
employee = crud.get_employee_by_id(db, id=employee_id)
|
| 168 |
+
if not employee:
|
| 169 |
+
raise HTTPException(status_code=404, detail="Employee not found")
|
| 170 |
+
|
| 171 |
+
crud.delete_face_embeddings(db, employee_id=employee.id)
|
| 172 |
+
face_engine.invalidate_cache()
|
| 173 |
+
|
| 174 |
+
crud.create_audit_log(
|
| 175 |
+
db=db,
|
| 176 |
+
user_id=current_user.id,
|
| 177 |
+
action="Clear Face Enrollments",
|
| 178 |
+
ip_address=request.client.host if request.client else None,
|
| 179 |
+
user_agent=request.headers.get("user-agent"),
|
| 180 |
+
details=f"Cleared all face data for employee ID: {employee.employee_id}"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
return {"message": "All face enrollments and images cleared successfully"}
|
backend/app/api/v1/kiosk.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
import base64
|
| 6 |
+
import cv2
|
| 7 |
+
import numpy as np
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime, time
|
| 10 |
+
import urllib.parse
|
| 11 |
+
|
| 12 |
+
from app.core.database import get_db
|
| 13 |
+
from app.core.config import settings
|
| 14 |
+
from app.core import event_bus
|
| 15 |
+
from app.crud import crud
|
| 16 |
+
from app.schemas import schemas
|
| 17 |
+
from app.models import models
|
| 18 |
+
from app.services.singletons import face_engine, voice_assistant
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("Kiosk")
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
# Global variable to prevent consecutive voice greetings for the same employee
|
| 24 |
+
_last_greeted_employee_id = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _publish_log(log_obj, employee=None):
|
| 28 |
+
"""Serialize an AttendanceLog ORM object and publish it to the SSE bus."""
|
| 29 |
+
try:
|
| 30 |
+
emp_data = None
|
| 31 |
+
if employee:
|
| 32 |
+
emp_data = {
|
| 33 |
+
"id": employee.id,
|
| 34 |
+
"name": employee.name,
|
| 35 |
+
"employee_id": employee.employee_id,
|
| 36 |
+
"designation": employee.designation,
|
| 37 |
+
"department": employee.department.name if employee.department else "General",
|
| 38 |
+
}
|
| 39 |
+
payload = {
|
| 40 |
+
"id": log_obj.id,
|
| 41 |
+
"timestamp": log_obj.timestamp.isoformat() if log_obj.timestamp else None,
|
| 42 |
+
"camera": log_obj.camera,
|
| 43 |
+
"confidence": log_obj.confidence,
|
| 44 |
+
"liveness_score": log_obj.liveness_score,
|
| 45 |
+
"is_spoof": log_obj.is_spoof,
|
| 46 |
+
"status": log_obj.status,
|
| 47 |
+
"employee": emp_data,
|
| 48 |
+
}
|
| 49 |
+
event_bus.publish_scan_event(payload)
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
logger.warning(f"Failed to publish scan event: {exc}")
|
| 52 |
+
|
| 53 |
+
class KioskScanRequest(BaseModel):
|
| 54 |
+
image: str = Field(..., description="Base64 encoded image frame (JPEG/PNG data URL)")
|
| 55 |
+
camera: str = Field("Main Kiosk", description="Identifier of the kiosk scanner device")
|
| 56 |
+
|
| 57 |
+
@router.post("/scan")
|
| 58 |
+
def scan_face(
|
| 59 |
+
request: Request,
|
| 60 |
+
payload: KioskScanRequest,
|
| 61 |
+
db: Session = Depends(get_db)
|
| 62 |
+
):
|
| 63 |
+
now = datetime.now()
|
| 64 |
+
# Retrieve dynamic thresholds from database settings
|
| 65 |
+
face_threshold_setting = crud.get_setting_by_key(db, "KIOSK_FACE_THRESHOLD")
|
| 66 |
+
liveness_threshold_setting = crud.get_setting_by_key(db, "KIOSK_LIVENESS_THRESHOLD")
|
| 67 |
+
voice_greeting_setting = crud.get_setting_by_key(db, "VOICE_GREETING_ENABLED")
|
| 68 |
+
maintenance_setting = crud.get_setting_by_key(db, "SYSTEM_MAINTENANCE_MODE")
|
| 69 |
+
|
| 70 |
+
if maintenance_setting and maintenance_setting.value.lower() == "true":
|
| 71 |
+
return {
|
| 72 |
+
"status": "maintenance",
|
| 73 |
+
"message": "System is currently undergoing scheduled maintenance. Biometric logs are temporarily suspended.",
|
| 74 |
+
"should_retry": False
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
# Check if there are any employees registered
|
| 78 |
+
if db.query(models.Employee).count() == 0:
|
| 79 |
+
return {
|
| 80 |
+
"status": "no_employees",
|
| 81 |
+
"message": "Please add the employee",
|
| 82 |
+
"should_retry": False
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
face_threshold = float(face_threshold_setting.value) if face_threshold_setting else settings.KIOSK_FACE_THRESHOLD
|
| 86 |
+
liveness_threshold = float(liveness_threshold_setting.value) if liveness_threshold_setting else settings.KIOSK_LIVENESS_THRESHOLD
|
| 87 |
+
voice_enabled = voice_greeting_setting.value.lower() == "true" if voice_greeting_setting else True
|
| 88 |
+
|
| 89 |
+
# 1. Parse base64 image
|
| 90 |
+
try:
|
| 91 |
+
header, encoded = payload.image.split(",", 1) if "," in payload.image else ("", payload.image)
|
| 92 |
+
img_bytes = base64.b64decode(encoded)
|
| 93 |
+
nparr = np.frombuffer(img_bytes, np.uint8)
|
| 94 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 95 |
+
if img is None:
|
| 96 |
+
raise ValueError()
|
| 97 |
+
except Exception:
|
| 98 |
+
raise HTTPException(status_code=400, detail="Invalid Base64 image data")
|
| 99 |
+
|
| 100 |
+
# 2. Detect face
|
| 101 |
+
faces = face_engine.detect_faces(img)
|
| 102 |
+
if not faces:
|
| 103 |
+
return {
|
| 104 |
+
"status": "no_face",
|
| 105 |
+
"message": "No face detected. Frame your face within the scanner.",
|
| 106 |
+
"should_retry": True
|
| 107 |
+
}
|
| 108 |
+
if len(faces) > 1:
|
| 109 |
+
return {
|
| 110 |
+
"status": "multiple_faces",
|
| 111 |
+
"message": "Multiple faces detected. Please scan one person at a time.",
|
| 112 |
+
"should_retry": True
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
face = faces[0]
|
| 116 |
+
bbox = face["bbox"]
|
| 117 |
+
confidence = face["confidence"]
|
| 118 |
+
landmarks = face["landmarks"]
|
| 119 |
+
|
| 120 |
+
# 3. Liveness Check
|
| 121 |
+
liveness_score, is_live = face_engine.check_liveness(img, bbox)
|
| 122 |
+
if not is_live and not face_engine.mock_mode:
|
| 123 |
+
# Save spoof log
|
| 124 |
+
log_entry = crud.create_attendance_log(
|
| 125 |
+
db=db,
|
| 126 |
+
employee_id=None,
|
| 127 |
+
camera=payload.camera,
|
| 128 |
+
confidence=confidence,
|
| 129 |
+
liveness_score=liveness_score,
|
| 130 |
+
is_spoof=True,
|
| 131 |
+
status="Spoof Rejected",
|
| 132 |
+
timestamp=now
|
| 133 |
+
)
|
| 134 |
+
_publish_log(log_entry)
|
| 135 |
+
return {
|
| 136 |
+
"status": "spoof_detected",
|
| 137 |
+
"message": "Spoofing attack detected! Verification denied.",
|
| 138 |
+
"confidence": float(confidence),
|
| 139 |
+
"liveness_score": float(liveness_score),
|
| 140 |
+
"should_retry": False
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
# 4. Extract Embedding
|
| 144 |
+
aligned = face_engine.align_face(img, landmarks)
|
| 145 |
+
embedding = face_engine.extract_embedding(aligned)
|
| 146 |
+
|
| 147 |
+
# 5. DB Matching using pgvector / SQLite in-memory fallback
|
| 148 |
+
embedding_list = embedding.tolist()
|
| 149 |
+
|
| 150 |
+
dialect_name = db.bind.dialect.name
|
| 151 |
+
if dialect_name == "postgresql":
|
| 152 |
+
# We query face_embeddings sorting by distance (cosine_distance operator: <=> in SQL, cosine_distance() in SQLAlchemy)
|
| 153 |
+
# Note: pgvector cosine distance returns (1.0 - cosine_similarity). Closer matches have lower distance.
|
| 154 |
+
match_query = (
|
| 155 |
+
db.query(models.FaceEmbedding, models.FaceEmbedding.embedding.cosine_distance(embedding_list).label("distance"))
|
| 156 |
+
.order_by("distance")
|
| 157 |
+
.limit(1)
|
| 158 |
+
)
|
| 159 |
+
match_result = match_query.first()
|
| 160 |
+
else:
|
| 161 |
+
# SQLite / local development fallback: fetch all vectors and compute similarity in memory
|
| 162 |
+
if face_engine.embeddings_cache is None:
|
| 163 |
+
face_engine.load_embeddings_cache(db)
|
| 164 |
+
|
| 165 |
+
all_embeddings = face_engine.embeddings_cache
|
| 166 |
+
if not all_embeddings:
|
| 167 |
+
match_result = None
|
| 168 |
+
else:
|
| 169 |
+
best_emb = None
|
| 170 |
+
best_dist = 10.0
|
| 171 |
+
|
| 172 |
+
for emb_record in all_embeddings:
|
| 173 |
+
try:
|
| 174 |
+
# cached arrays are already numpy arrays
|
| 175 |
+
db_vec = emb_record["embedding"]
|
| 176 |
+
sim = face_engine.cosine_similarity(embedding, db_vec)
|
| 177 |
+
dist = 1.0 - sim
|
| 178 |
+
if dist < best_dist:
|
| 179 |
+
best_dist = dist
|
| 180 |
+
class MockEmb:
|
| 181 |
+
id = emb_record["id"]
|
| 182 |
+
employee_id = emb_record["employee_id"]
|
| 183 |
+
best_emb = MockEmb()
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger.error(f"Error comparing local vector: {e}")
|
| 186 |
+
continue
|
| 187 |
+
|
| 188 |
+
if best_emb is not None:
|
| 189 |
+
match_result = (best_emb, best_dist)
|
| 190 |
+
else:
|
| 191 |
+
match_result = None
|
| 192 |
+
|
| 193 |
+
if not match_result:
|
| 194 |
+
# Database has no enrolled embeddings
|
| 195 |
+
log_entry = crud.create_attendance_log(
|
| 196 |
+
db=db,
|
| 197 |
+
employee_id=None,
|
| 198 |
+
camera=payload.camera,
|
| 199 |
+
confidence=confidence,
|
| 200 |
+
liveness_score=liveness_score,
|
| 201 |
+
is_spoof=False,
|
| 202 |
+
status="Empty Vector Index",
|
| 203 |
+
timestamp=now
|
| 204 |
+
)
|
| 205 |
+
_publish_log(log_entry)
|
| 206 |
+
return {
|
| 207 |
+
"status": "unknown",
|
| 208 |
+
"message": "No employees registered in the system. Please register first.",
|
| 209 |
+
"should_retry": False
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
db_emb, distance = match_result
|
| 213 |
+
# Similarity = 1 - Distance
|
| 214 |
+
similarity = 1.0 - float(distance)
|
| 215 |
+
|
| 216 |
+
if similarity < face_threshold:
|
| 217 |
+
# Low confidence match -> Unknown
|
| 218 |
+
log_entry = crud.create_attendance_log(
|
| 219 |
+
db=db,
|
| 220 |
+
employee_id=None,
|
| 221 |
+
camera=payload.camera,
|
| 222 |
+
confidence=similarity,
|
| 223 |
+
liveness_score=liveness_score,
|
| 224 |
+
is_spoof=False,
|
| 225 |
+
status="Unknown Person",
|
| 226 |
+
timestamp=now
|
| 227 |
+
)
|
| 228 |
+
_publish_log(log_entry)
|
| 229 |
+
return {
|
| 230 |
+
"status": "unknown",
|
| 231 |
+
"message": "Face not recognized. Please try again or contact HR.",
|
| 232 |
+
"confidence": similarity,
|
| 233 |
+
"liveness_score": liveness_score,
|
| 234 |
+
"should_retry": True
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# Face Matched!
|
| 238 |
+
employee = crud.get_employee_by_id(db, db_emb.employee_id)
|
| 239 |
+
if not employee or employee.status != "Active":
|
| 240 |
+
# Inactive employee
|
| 241 |
+
log_entry = crud.create_attendance_log(
|
| 242 |
+
db=db,
|
| 243 |
+
employee_id=db_emb.employee_id,
|
| 244 |
+
camera=payload.camera,
|
| 245 |
+
confidence=similarity,
|
| 246 |
+
liveness_score=liveness_score,
|
| 247 |
+
is_spoof=False,
|
| 248 |
+
status="Inactive Employee Swiped",
|
| 249 |
+
timestamp=now
|
| 250 |
+
)
|
| 251 |
+
_publish_log(log_entry, employee)
|
| 252 |
+
return {
|
| 253 |
+
"status": "inactive",
|
| 254 |
+
"message": "Employee account is deactivated. Access denied.",
|
| 255 |
+
"should_retry": False
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
global _last_greeted_employee_id
|
| 259 |
+
should_greet = True
|
| 260 |
+
if _last_greeted_employee_id == employee.id:
|
| 261 |
+
should_greet = False
|
| 262 |
+
else:
|
| 263 |
+
_last_greeted_employee_id = employee.id
|
| 264 |
+
|
| 265 |
+
# 6. Mark Attendance
|
| 266 |
+
attendance_record = crud.mark_kiosk_attendance(
|
| 267 |
+
db=db,
|
| 268 |
+
employee_id=employee.id,
|
| 269 |
+
timestamp=now,
|
| 270 |
+
camera=payload.camera,
|
| 271 |
+
confidence=similarity
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# Save log
|
| 275 |
+
log_entry = crud.create_attendance_log(
|
| 276 |
+
db=db,
|
| 277 |
+
employee_id=employee.id,
|
| 278 |
+
camera=payload.camera,
|
| 279 |
+
confidence=similarity,
|
| 280 |
+
liveness_score=liveness_score,
|
| 281 |
+
is_spoof=False,
|
| 282 |
+
status="Match Success",
|
| 283 |
+
timestamp=now
|
| 284 |
+
)
|
| 285 |
+
_publish_log(log_entry, employee)
|
| 286 |
+
|
| 287 |
+
# 7. Generate Greeting Text
|
| 288 |
+
current_hour = now.hour
|
| 289 |
+
if 5 <= current_hour < 12:
|
| 290 |
+
salutation = "Good Morning"
|
| 291 |
+
icon = "☀️"
|
| 292 |
+
elif 12 <= current_hour < 17:
|
| 293 |
+
salutation = "Good Afternoon"
|
| 294 |
+
icon = "🌤️"
|
| 295 |
+
else:
|
| 296 |
+
salutation = "Good Evening"
|
| 297 |
+
icon = "🌙"
|
| 298 |
+
|
| 299 |
+
is_checkout = attendance_record.check_out is not None
|
| 300 |
+
action_text = "Checkout Recorded Successfully" if is_checkout else "Attendance Recorded Successfully"
|
| 301 |
+
closing_text = "Have a Great Day" if not is_checkout else "Have a Relaxing Evening"
|
| 302 |
+
|
| 303 |
+
greeting_text = f"Welcome {employee.name}. {salutation}. {action_text}. {closing_text}."
|
| 304 |
+
|
| 305 |
+
# URL for speech audio download
|
| 306 |
+
tts_url = f"{settings.API_V1_STR}/kiosk/tts?text={urllib.parse.quote(greeting_text)}" if (voice_enabled and should_greet) else None
|
| 307 |
+
|
| 308 |
+
return {
|
| 309 |
+
"status": "success",
|
| 310 |
+
"employee": {
|
| 311 |
+
"id": employee.id,
|
| 312 |
+
"employee_id": employee.employee_id,
|
| 313 |
+
"name": employee.name,
|
| 314 |
+
"designation": employee.designation,
|
| 315 |
+
"department": employee.department.name if employee.department else "General"
|
| 316 |
+
},
|
| 317 |
+
"attendance": {
|
| 318 |
+
"date": str(attendance_record.date),
|
| 319 |
+
"check_in": str(attendance_record.check_in.time().strftime("%H:%M:%S")) if attendance_record.check_in else None,
|
| 320 |
+
"check_out": str(attendance_record.check_out.time().strftime("%H:%M:%S")) if attendance_record.check_out else None,
|
| 321 |
+
"status": attendance_record.status
|
| 322 |
+
},
|
| 323 |
+
"confidence": similarity,
|
| 324 |
+
"liveness_score": liveness_score,
|
| 325 |
+
"greeting": {
|
| 326 |
+
"title": f"Welcome, {employee.name}",
|
| 327 |
+
"subtitle": f"{salutation} {icon}",
|
| 328 |
+
"detail": action_text,
|
| 329 |
+
"closing": closing_text
|
| 330 |
+
},
|
| 331 |
+
"tts_url": tts_url
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
@router.get("/tts")
|
| 335 |
+
def play_tts(text: str):
|
| 336 |
+
"""
|
| 337 |
+
Synthesizes greeting text and returns it as a playable WAV stream file.
|
| 338 |
+
"""
|
| 339 |
+
try:
|
| 340 |
+
wav_path = voice_assistant.generate_speech_file(text)
|
| 341 |
+
return FileResponse(
|
| 342 |
+
path=wav_path,
|
| 343 |
+
media_type="audio/wav",
|
| 344 |
+
filename="greeting.wav"
|
| 345 |
+
)
|
| 346 |
+
except Exception as e:
|
| 347 |
+
logger.error(f"Error serving TTS endpoint: {e}")
|
| 348 |
+
raise HTTPException(status_code=500, detail="Voice generation failed")
|
backend/app/api/v1/reports.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query, Response, Request
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from sqlalchemy import select, and_, or_
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from datetime import date, datetime, timedelta
|
| 7 |
+
import io
|
| 8 |
+
|
| 9 |
+
from app.core.database import get_db
|
| 10 |
+
from app.core import security
|
| 11 |
+
from app.crud import crud
|
| 12 |
+
from app.schemas import schemas
|
| 13 |
+
from app.models import models
|
| 14 |
+
from app.services.reports import ReportGenerator
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
checker_view = security.RoleChecker(["Super Admin", "Admin", "HR"])
|
| 19 |
+
|
| 20 |
+
@router.get("/export")
|
| 21 |
+
def export_report(
|
| 22 |
+
request: Request,
|
| 23 |
+
report_type: str = Query(..., description="daily, weekly, monthly, employee, department"),
|
| 24 |
+
format: str = Query("csv", description="csv, xlsx, pdf"),
|
| 25 |
+
start_date: Optional[date] = Query(None),
|
| 26 |
+
end_date: Optional[date] = Query(None),
|
| 27 |
+
employee_id: Optional[int] = Query(None),
|
| 28 |
+
department_id: Optional[int] = Query(None),
|
| 29 |
+
db: Session = Depends(get_db),
|
| 30 |
+
current_user: models.User = Depends(checker_view)
|
| 31 |
+
):
|
| 32 |
+
# Establish defaults for dates
|
| 33 |
+
if not start_date:
|
| 34 |
+
start_date = date.today() - timedelta(days=30)
|
| 35 |
+
if not end_date:
|
| 36 |
+
end_date = date.today()
|
| 37 |
+
|
| 38 |
+
# Query attendance records
|
| 39 |
+
query = db.query(models.Attendance).join(models.Employee)
|
| 40 |
+
filters = [models.Attendance.date.between(start_date, end_date)]
|
| 41 |
+
|
| 42 |
+
if employee_id:
|
| 43 |
+
filters.append(models.Attendance.employee_id == employee_id)
|
| 44 |
+
if department_id:
|
| 45 |
+
filters.append(models.Employee.department_id == department_id)
|
| 46 |
+
|
| 47 |
+
query = query.filter(and_(*filters)).order_by(models.Attendance.date.desc())
|
| 48 |
+
records = query.all()
|
| 49 |
+
|
| 50 |
+
# Format the data into lists of dicts (for CSV/Excel) and nested lists (for PDF)
|
| 51 |
+
data = []
|
| 52 |
+
pdf_rows = []
|
| 53 |
+
|
| 54 |
+
headers = [
|
| 55 |
+
"Date", "Employee ID", "Name", "Department",
|
| 56 |
+
"Check In", "Check Out", "Hours", "Overtime", "Status"
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
for r in records:
|
| 60 |
+
dept_name = r.employee.department.name if r.employee.department else "General"
|
| 61 |
+
ci_str = r.check_in.strftime("%H:%M:%S") if r.check_in else "-"
|
| 62 |
+
co_str = r.check_out.strftime("%H:%M:%S") if r.check_out else "-"
|
| 63 |
+
|
| 64 |
+
row_dict = {
|
| 65 |
+
"Date": str(r.date),
|
| 66 |
+
"Employee ID": r.employee.employee_id,
|
| 67 |
+
"Name": r.employee.name,
|
| 68 |
+
"Department": dept_name,
|
| 69 |
+
"Check In": ci_str,
|
| 70 |
+
"Check Out": co_str,
|
| 71 |
+
"Hours Worked": r.working_hours,
|
| 72 |
+
"Overtime": r.overtime,
|
| 73 |
+
"Status": r.status
|
| 74 |
+
}
|
| 75 |
+
data.append(row_dict)
|
| 76 |
+
|
| 77 |
+
pdf_rows.append([
|
| 78 |
+
str(r.date),
|
| 79 |
+
r.employee.employee_id,
|
| 80 |
+
r.employee.name[:15], # Limit length for PDF wrapping
|
| 81 |
+
dept_name[:12],
|
| 82 |
+
ci_str,
|
| 83 |
+
co_str,
|
| 84 |
+
str(r.working_hours),
|
| 85 |
+
str(r.overtime),
|
| 86 |
+
r.status
|
| 87 |
+
])
|
| 88 |
+
|
| 89 |
+
title = f"NetraID Attendance Report ({report_type.capitalize()})"
|
| 90 |
+
metadata = {
|
| 91 |
+
"Report Type": report_type.capitalize(),
|
| 92 |
+
"Exported By": current_user.email,
|
| 93 |
+
"Start Date": str(start_date),
|
| 94 |
+
"End Date": str(end_date),
|
| 95 |
+
"Total Records": str(len(records))
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# Audit download
|
| 99 |
+
crud.create_audit_log(
|
| 100 |
+
db=db,
|
| 101 |
+
user_id=current_user.id,
|
| 102 |
+
action="Export Attendance Report",
|
| 103 |
+
ip_address=request.client.host if request.client else None,
|
| 104 |
+
user_agent=request.headers.get("user-agent"),
|
| 105 |
+
details=f"Exported {report_type} report in {format} format."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
if format.lower() == "csv":
|
| 109 |
+
csv_data = ReportGenerator.to_csv(data)
|
| 110 |
+
response = Response(content=csv_data, media_type="text/csv")
|
| 111 |
+
response.headers["Content-Disposition"] = f"attachment; filename=attendance_{report_type}_{start_date}_to_{end_date}.csv"
|
| 112 |
+
return response
|
| 113 |
+
|
| 114 |
+
elif format.lower() == "xlsx":
|
| 115 |
+
xlsx_data = ReportGenerator.to_xlsx(data, sheet_name="Attendance")
|
| 116 |
+
response = Response(content=xlsx_data, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
| 117 |
+
response.headers["Content-Disposition"] = f"attachment; filename=attendance_{report_type}_{start_date}_to_{end_date}.xlsx"
|
| 118 |
+
return response
|
| 119 |
+
|
| 120 |
+
elif format.lower() == "pdf":
|
| 121 |
+
pdf_data = ReportGenerator.to_pdf(title, headers, pdf_rows, metadata)
|
| 122 |
+
response = Response(content=pdf_data, media_type="application/pdf")
|
| 123 |
+
response.headers["Content-Disposition"] = f"attachment; filename=attendance_{report_type}_{start_date}_to_{end_date}.pdf"
|
| 124 |
+
return response
|
| 125 |
+
|
| 126 |
+
else:
|
| 127 |
+
raise HTTPException(status_code=400, detail="Invalid format. Supported: csv, xlsx, pdf")
|
backend/app/api/v1/settings.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core import security
|
| 7 |
+
from app.crud import crud
|
| 8 |
+
from app.schemas import schemas
|
| 9 |
+
from app.models import models
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
# Restrict setting adjustments to Super Admins & Admins
|
| 14 |
+
checker_manage = security.RoleChecker(["Super Admin", "Admin"])
|
| 15 |
+
|
| 16 |
+
@router.get("/", response_model=List[schemas.SettingOut])
|
| 17 |
+
def read_settings(
|
| 18 |
+
db: Session = Depends(get_db),
|
| 19 |
+
current_user: models.User = Depends(checker_manage)
|
| 20 |
+
):
|
| 21 |
+
return crud.get_settings(db)
|
| 22 |
+
|
| 23 |
+
@router.put("/{key}", response_model=schemas.SettingOut)
|
| 24 |
+
def update_setting(
|
| 25 |
+
request: Request,
|
| 26 |
+
key: str,
|
| 27 |
+
payload: schemas.SettingUpdate,
|
| 28 |
+
db: Session = Depends(get_db),
|
| 29 |
+
current_user: models.User = Depends(checker_manage)
|
| 30 |
+
):
|
| 31 |
+
setting = crud.get_setting_by_key(db, key=key)
|
| 32 |
+
if not setting:
|
| 33 |
+
raise HTTPException(status_code=404, detail="Setting not found")
|
| 34 |
+
|
| 35 |
+
old_value = setting.value
|
| 36 |
+
updated = crud.set_setting(db, key=key, value=payload.value)
|
| 37 |
+
|
| 38 |
+
# Audit log setting change
|
| 39 |
+
crud.create_audit_log(
|
| 40 |
+
db=db,
|
| 41 |
+
user_id=current_user.id,
|
| 42 |
+
action="Update Setting",
|
| 43 |
+
ip_address=request.client.host if request.client else None,
|
| 44 |
+
user_agent=request.headers.get("user-agent"),
|
| 45 |
+
details=f"Updated setting '{key}' from '{old_value}' to '{payload.value}'"
|
| 46 |
+
)
|
| 47 |
+
return updated
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import List, Union
|
| 3 |
+
from pydantic import AnyHttpUrl, validator
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
PROJECT_NAME: str = "NetraID AI Face Attendance"
|
| 8 |
+
API_V1_STR: str = "/api/v1"
|
| 9 |
+
|
| 10 |
+
# Database Configuration
|
| 11 |
+
DATABASE_URL: str
|
| 12 |
+
|
| 13 |
+
# JWT & Security
|
| 14 |
+
JWT_SECRET_KEY: str
|
| 15 |
+
JWT_ALGORITHM: str = "HS256"
|
| 16 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
| 17 |
+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
| 18 |
+
|
| 19 |
+
# Seeding
|
| 20 |
+
INITIAL_ADMIN_EMAIL: str = "admin@netraid.ai"
|
| 21 |
+
INITIAL_ADMIN_PASSWORD: str = "Admin@NetraID2026"
|
| 22 |
+
|
| 23 |
+
# Face recognition & liveness detection parameters
|
| 24 |
+
KIOSK_FACE_THRESHOLD: float = 0.60
|
| 25 |
+
KIOSK_LIVENESS_THRESHOLD: float = 0.75
|
| 26 |
+
|
| 27 |
+
# Paths
|
| 28 |
+
UPLOAD_DIR: str = "./uploads"
|
| 29 |
+
MODELS_DIR: str = "./models"
|
| 30 |
+
|
| 31 |
+
# Rate Limiting
|
| 32 |
+
RATE_LIMIT_PER_MINUTE: int = 100
|
| 33 |
+
|
| 34 |
+
# CORS Origins
|
| 35 |
+
# We load them as a list of strings
|
| 36 |
+
ALLOWED_HOSTS: str = "*"
|
| 37 |
+
|
| 38 |
+
model_config = SettingsConfigDict(
|
| 39 |
+
env_file=os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"),
|
| 40 |
+
env_file_encoding="utf-8",
|
| 41 |
+
extra="ignore"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def cors_origins(self) -> List[str]:
|
| 46 |
+
if not self.ALLOWED_HOSTS:
|
| 47 |
+
return ["*"]
|
| 48 |
+
return [origin.strip() for origin in self.ALLOWED_HOSTS.split(",")]
|
| 49 |
+
|
| 50 |
+
settings = Settings()
|
backend/app/core/database.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine, event
|
| 2 |
+
from sqlalchemy.engine import Engine
|
| 3 |
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
# Determine if using SQLite or Postgres
|
| 7 |
+
is_sqlite = settings.DATABASE_URL.startswith("sqlite")
|
| 8 |
+
|
| 9 |
+
connect_args = {}
|
| 10 |
+
if is_sqlite:
|
| 11 |
+
# SQLite requires disabling same thread checks for concurrent FastAPI endpoints
|
| 12 |
+
connect_args = {"check_same_thread": False}
|
| 13 |
+
|
| 14 |
+
# Engine setup
|
| 15 |
+
engine = create_engine(
|
| 16 |
+
settings.DATABASE_URL,
|
| 17 |
+
connect_args=connect_args,
|
| 18 |
+
# Standard pool config for Postgres (SQLite doesn't support pool pre-ping/size attributes)
|
| 19 |
+
**({
|
| 20 |
+
"pool_size": 20,
|
| 21 |
+
"max_overflow": 10,
|
| 22 |
+
"pool_recycle": 1800,
|
| 23 |
+
"pool_pre_ping": True
|
| 24 |
+
} if not is_sqlite else {})
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
@event.listens_for(Engine, "connect")
|
| 28 |
+
def set_sqlite_pragma(dbapi_connection, connection_record):
|
| 29 |
+
if is_sqlite:
|
| 30 |
+
cursor = dbapi_connection.cursor()
|
| 31 |
+
cursor.execute("PRAGMA foreign_keys=ON")
|
| 32 |
+
cursor.close()
|
| 33 |
+
|
| 34 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 35 |
+
Base = declarative_base()
|
| 36 |
+
|
| 37 |
+
def get_db():
|
| 38 |
+
db = SessionLocal()
|
| 39 |
+
try:
|
| 40 |
+
yield db
|
| 41 |
+
finally:
|
| 42 |
+
db.close()
|
| 43 |
+
|
backend/app/core/download_models.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import urllib.request
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
| 6 |
+
logger = logging.getLogger("ModelDownloader")
|
| 7 |
+
|
| 8 |
+
MODELS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "models"))
|
| 9 |
+
|
| 10 |
+
MODEL_URLS = {
|
| 11 |
+
# SCRFD Face Detector (2.5G is fast and accurate enough for VPS)
|
| 12 |
+
"det_2.5g.onnx": "https://huggingface.co/immich-app/buffalo_m/resolve/main/detection/model.onnx",
|
| 13 |
+
# ArcFace Face Recognition (w600k_r50)
|
| 14 |
+
"w600k_r50.onnx": "https://huggingface.co/immich-app/buffalo_m/resolve/main/recognition/model.onnx",
|
| 15 |
+
# Silent-Face-Anti-Spoofing ONNX models (2.7k_80x80)
|
| 16 |
+
"2.7k_80x80.onnx": "https://raw.githubusercontent.com/QingHeYang/Silent-Face-Anti-Spoofing-onnx/main/onnx/2.7_80x80_MiniFASNetV2.onnx"
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
def download_file(url: str, dest_path: str):
|
| 20 |
+
logger.info(f"Downloading {url} to {dest_path}...")
|
| 21 |
+
try:
|
| 22 |
+
def progress(count, block_size, total_size):
|
| 23 |
+
percent = int(count * block_size * 100 / total_size)
|
| 24 |
+
if percent % 10 == 0:
|
| 25 |
+
logger.info(f"Progress: {percent}%")
|
| 26 |
+
|
| 27 |
+
urllib.request.urlretrieve(url, dest_path, reporthook=progress)
|
| 28 |
+
logger.info(f"Successfully downloaded {dest_path}")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"Failed to download {url}: {e}")
|
| 31 |
+
# If it fails, we touch/create a small file so the backend can run in mock mode
|
| 32 |
+
# or we throw an exception. For production resilience, we log it.
|
| 33 |
+
raise e
|
| 34 |
+
|
| 35 |
+
def download_all_models():
|
| 36 |
+
os.makedirs(MODELS_DIR, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
for filename, url in MODEL_URLS.items():
|
| 39 |
+
dest_path = os.path.join(MODELS_DIR, filename)
|
| 40 |
+
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 1024 * 1024:
|
| 41 |
+
logger.info(f"{filename} already exists and is valid. Skipping download.")
|
| 42 |
+
else:
|
| 43 |
+
try:
|
| 44 |
+
download_file(url, dest_path)
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.warning(f"Could not download {filename} from remote. Ensure you place it in {MODELS_DIR} manually. Error: {e}")
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
download_all_models()
|
backend/app/core/event_bus.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
event_bus.py — In-process broadcast bus for real-time scan events.
|
| 3 |
+
|
| 4 |
+
Any code path that creates an AttendanceLog (kiosk scan) calls
|
| 5 |
+
`publish_scan_event(payload_dict)`. The SSE endpoint in analytics.py
|
| 6 |
+
subscribes a new asyncio.Queue per connected client and streams the events.
|
| 7 |
+
"""
|
| 8 |
+
import asyncio
|
| 9 |
+
from typing import List
|
| 10 |
+
|
| 11 |
+
_subscribers: List[asyncio.Queue] = []
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def subscribe() -> asyncio.Queue:
|
| 15 |
+
"""Register a new SSE client and return its dedicated queue."""
|
| 16 |
+
q: asyncio.Queue = asyncio.Queue(maxsize=50)
|
| 17 |
+
_subscribers.append(q)
|
| 18 |
+
return q
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def unsubscribe(q: asyncio.Queue) -> None:
|
| 22 |
+
"""Remove a client queue when the SSE connection closes."""
|
| 23 |
+
try:
|
| 24 |
+
_subscribers.remove(q)
|
| 25 |
+
except ValueError:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def publish_scan_event(payload: dict) -> None:
|
| 30 |
+
"""
|
| 31 |
+
Broadcast a scan-event dict to all connected SSE clients.
|
| 32 |
+
Called from sync code (kiosk endpoint) — safe because Queue.put_nowait
|
| 33 |
+
is thread-safe in CPython and does not need an event loop reference.
|
| 34 |
+
"""
|
| 35 |
+
dead: List[asyncio.Queue] = []
|
| 36 |
+
for q in _subscribers:
|
| 37 |
+
try:
|
| 38 |
+
q.put_nowait(payload)
|
| 39 |
+
except asyncio.QueueFull:
|
| 40 |
+
dead.append(q)
|
| 41 |
+
for q in dead:
|
| 42 |
+
unsubscribe(q)
|
backend/app/core/init_db.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import text
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.core.database import Base, engine
|
| 4 |
+
from app.models import models
|
| 5 |
+
from app.crud import crud
|
| 6 |
+
from app.schemas import schemas
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("InitDB")
|
| 11 |
+
|
| 12 |
+
def init_db(db: Session):
|
| 13 |
+
# Enable vector extension for PostgreSQL
|
| 14 |
+
if db.bind.dialect.name == "postgresql":
|
| 15 |
+
logger.info("Enabling pgvector extension on PostgreSQL...")
|
| 16 |
+
db.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
|
| 17 |
+
db.commit()
|
| 18 |
+
|
| 19 |
+
# Create all tables if they don't exist
|
| 20 |
+
logger.info("Creating all database tables if they do not exist...")
|
| 21 |
+
Base.metadata.create_all(bind=engine)
|
| 22 |
+
|
| 23 |
+
# 1. Seed Roles
|
| 24 |
+
roles = [
|
| 25 |
+
{"name": "Super Admin", "description": "Full access to all system features and settings"},
|
| 26 |
+
{"name": "Admin", "description": "Administrative access to employees, analytics, and reports"},
|
| 27 |
+
{"name": "HR", "description": "Manage attendance records, leaves, and employee profiles"},
|
| 28 |
+
{"name": "Employee", "description": "Read-only access to own attendance logs and leave requests"}
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
db_roles = {}
|
| 32 |
+
for r in roles:
|
| 33 |
+
role = crud.get_role_by_name(db, r["name"])
|
| 34 |
+
if not role:
|
| 35 |
+
logger.info(f"Seeding role: {r['name']}")
|
| 36 |
+
role = crud.create_role(db, r["name"], r["description"])
|
| 37 |
+
db_roles[r["name"]] = role
|
| 38 |
+
|
| 39 |
+
# 2. Seed Corporate Departments
|
| 40 |
+
departments = [
|
| 41 |
+
{"name": "Engineering", "code": "ENG", "description": "Software development, DevOps, QA, and IT systems"},
|
| 42 |
+
{"name": "Human Resources", "code": "HR", "description": "Recruitment, payroll, and staff relations"},
|
| 43 |
+
{"name": "Marketing & Sales", "code": "MKT", "description": "Product branding, marketing campaigns, and client sales"},
|
| 44 |
+
{"name": "Finance & Accounts", "code": "FIN", "description": "Financial planning, accounting, and budgeting"},
|
| 45 |
+
{"name": "Operations", "code": "OPS", "description": "Office administration and business facilities"}
|
| 46 |
+
]
|
| 47 |
+
for d in departments:
|
| 48 |
+
dept = crud.get_department_by_code(db, d["code"])
|
| 49 |
+
if not dept:
|
| 50 |
+
logger.info(f"Seeding department: {d['name']} ({d['code']})")
|
| 51 |
+
crud.create_department(db, schemas.DepartmentCreate(**d))
|
| 52 |
+
|
| 53 |
+
# 3. Seed Default Settings
|
| 54 |
+
default_settings = [
|
| 55 |
+
{"key": "CHECK_IN_START", "value": "11:00", "description": "Official start of work day (HH:MM)"},
|
| 56 |
+
{"key": "CHECK_OUT_END", "value": "19:00", "description": "Official end of work day (HH:MM)"},
|
| 57 |
+
{"key": "GRACE_PERIOD_MINUTES", "value": "0", "description": "Grace period for check-ins in minutes"},
|
| 58 |
+
{"key": "KIOSK_FACE_THRESHOLD", "value": str(settings.KIOSK_FACE_THRESHOLD), "description": "Cosine similarity threshold for face match"},
|
| 59 |
+
{"key": "KIOSK_LIVENESS_THRESHOLD", "value": str(settings.KIOSK_LIVENESS_THRESHOLD), "description": "Softmax probability threshold for face liveness"},
|
| 60 |
+
{"key": "VOICE_GREETING_ENABLED", "value": "true", "description": "Enable voice greeting on successful attendance"},
|
| 61 |
+
{"key": "SYSTEM_MAINTENANCE_MODE", "value": "false", "description": "Toggle maintenance mode to suspend active check-ins"},
|
| 62 |
+
{"key": "MAX_ENROLLMENT_IMAGES", "value": "5", "description": "Maximum face images captured during registration"},
|
| 63 |
+
{"key": "KIOSK_AUTO_RESET_SECONDS", "value": "5", "description": "Duration in seconds the success screen stays visible before scanning again"}
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
for s in default_settings:
|
| 67 |
+
setting = crud.get_setting_by_key(db, s["key"])
|
| 68 |
+
if not setting:
|
| 69 |
+
logger.info(f"Seeding setting: {s['key']} = {s['value']}")
|
| 70 |
+
crud.set_setting(db, s["key"], s["value"], s["description"])
|
| 71 |
+
|
| 72 |
+
# 3. Seed Initial Super Admin User
|
| 73 |
+
admin_email = settings.INITIAL_ADMIN_EMAIL
|
| 74 |
+
admin_user = crud.get_user_by_email(db, admin_email)
|
| 75 |
+
if not admin_user:
|
| 76 |
+
logger.info(f"Seeding Super Admin user: {admin_email}")
|
| 77 |
+
admin_create = schemas.UserCreate(
|
| 78 |
+
email=admin_email,
|
| 79 |
+
password=settings.INITIAL_ADMIN_PASSWORD,
|
| 80 |
+
role_id=db_roles["Super Admin"].id
|
| 81 |
+
)
|
| 82 |
+
crud.create_user(db, admin_create)
|
| 83 |
+
else:
|
| 84 |
+
logger.info(f"Super Admin user {admin_email} already exists.")
|
| 85 |
+
|
| 86 |
+
logger.info("Database initialization and seeding completed successfully.")
|
backend/app/core/security.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Union, Any, List
|
| 3 |
+
from jose import jwt, JWTError
|
| 4 |
+
from fastapi import Depends, HTTPException, status, Request, Query
|
| 5 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
from app.core.database import get_db
|
| 10 |
+
from app.models import models
|
| 11 |
+
from app.crud import crud
|
| 12 |
+
|
| 13 |
+
oauth2_scheme = OAuth2PasswordBearer(
|
| 14 |
+
tokenUrl=f"{settings.API_V1_STR}/auth/login"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
def create_access_token(subject: Union[str, Any], role: str, expires_delta: timedelta = None) -> str:
|
| 18 |
+
if expires_delta:
|
| 19 |
+
expire = datetime.utcnow() + expires_delta
|
| 20 |
+
else:
|
| 21 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 22 |
+
|
| 23 |
+
to_encode = {
|
| 24 |
+
"exp": expire,
|
| 25 |
+
"sub": str(subject),
|
| 26 |
+
"role": role,
|
| 27 |
+
"type": "access"
|
| 28 |
+
}
|
| 29 |
+
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 30 |
+
return encoded_jwt
|
| 31 |
+
|
| 32 |
+
def create_refresh_token(subject: Union[str, Any], role: str, expires_delta: timedelta = None) -> str:
|
| 33 |
+
if expires_delta:
|
| 34 |
+
expire = datetime.utcnow() + expires_delta
|
| 35 |
+
else:
|
| 36 |
+
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
| 37 |
+
|
| 38 |
+
to_encode = {
|
| 39 |
+
"exp": expire,
|
| 40 |
+
"sub": str(subject),
|
| 41 |
+
"role": role,
|
| 42 |
+
"type": "refresh"
|
| 43 |
+
}
|
| 44 |
+
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
| 45 |
+
return encoded_jwt
|
| 46 |
+
|
| 47 |
+
def get_current_user(
|
| 48 |
+
db: Session = Depends(get_db),
|
| 49 |
+
token: str = Depends(oauth2_scheme)
|
| 50 |
+
) -> models.User:
|
| 51 |
+
credentials_exception = HTTPException(
|
| 52 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 53 |
+
detail="Could not validate credentials",
|
| 54 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 55 |
+
)
|
| 56 |
+
try:
|
| 57 |
+
payload = jwt.decode(
|
| 58 |
+
token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
|
| 59 |
+
)
|
| 60 |
+
email: str = payload.get("sub")
|
| 61 |
+
token_type: str = payload.get("type")
|
| 62 |
+
|
| 63 |
+
if email is None or token_type != "access":
|
| 64 |
+
raise credentials_exception
|
| 65 |
+
except JWTError:
|
| 66 |
+
raise credentials_exception
|
| 67 |
+
|
| 68 |
+
user = crud.get_user_by_email(db, email=email)
|
| 69 |
+
if user is None:
|
| 70 |
+
raise credentials_exception
|
| 71 |
+
if not user.is_active:
|
| 72 |
+
raise HTTPException(status_code=400, detail="Inactive user")
|
| 73 |
+
|
| 74 |
+
return user
|
| 75 |
+
|
| 76 |
+
class RoleChecker:
|
| 77 |
+
def __init__(self, allowed_roles: List[str]):
|
| 78 |
+
self.allowed_roles = allowed_roles
|
| 79 |
+
|
| 80 |
+
def __call__(
|
| 81 |
+
self,
|
| 82 |
+
current_user: models.User = Depends(get_current_user)
|
| 83 |
+
) -> models.User:
|
| 84 |
+
if current_user.role.name not in self.allowed_roles:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 87 |
+
detail=f"User role '{current_user.role.name}' does not have permission to access this resource. Allowed: {self.allowed_roles}"
|
| 88 |
+
)
|
| 89 |
+
return current_user
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_current_user_from_token(token: str, db: Session) -> models.User:
|
| 93 |
+
credentials_exception = HTTPException(
|
| 94 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 95 |
+
detail="Could not validate credentials",
|
| 96 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 97 |
+
)
|
| 98 |
+
try:
|
| 99 |
+
payload = jwt.decode(
|
| 100 |
+
token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
|
| 101 |
+
)
|
| 102 |
+
email: str = payload.get("sub")
|
| 103 |
+
token_type: str = payload.get("type")
|
| 104 |
+
|
| 105 |
+
if email is None or token_type != "access":
|
| 106 |
+
raise credentials_exception
|
| 107 |
+
except JWTError:
|
| 108 |
+
raise credentials_exception
|
| 109 |
+
|
| 110 |
+
user = crud.get_user_by_email(db, email=email)
|
| 111 |
+
if user is None:
|
| 112 |
+
raise credentials_exception
|
| 113 |
+
if not user.is_active:
|
| 114 |
+
raise HTTPException(status_code=400, detail="Inactive user")
|
| 115 |
+
|
| 116 |
+
return user
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def get_current_user_sse(
|
| 120 |
+
request: Request,
|
| 121 |
+
token: str = Query(None),
|
| 122 |
+
db: Session = Depends(get_db)
|
| 123 |
+
) -> models.User:
|
| 124 |
+
# 1. Try to get token from query parameter
|
| 125 |
+
if token:
|
| 126 |
+
return get_current_user_from_token(token, db)
|
| 127 |
+
|
| 128 |
+
# 2. Try to get token from Authorization header
|
| 129 |
+
auth_header = request.headers.get("Authorization")
|
| 130 |
+
if auth_header and auth_header.startswith("Bearer "):
|
| 131 |
+
actual_token = auth_header.split(" ")[1]
|
| 132 |
+
return get_current_user_from_token(actual_token, db)
|
| 133 |
+
|
| 134 |
+
raise HTTPException(
|
| 135 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 136 |
+
detail="Not authenticated",
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class RoleCheckerSSE:
|
| 141 |
+
def __init__(self, allowed_roles: List[str]):
|
| 142 |
+
self.allowed_roles = allowed_roles
|
| 143 |
+
|
| 144 |
+
def __call__(
|
| 145 |
+
self,
|
| 146 |
+
current_user: models.User = Depends(get_current_user_sse)
|
| 147 |
+
) -> models.User:
|
| 148 |
+
if current_user.role.name not in self.allowed_roles:
|
| 149 |
+
raise HTTPException(
|
| 150 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 151 |
+
detail=f"User role '{current_user.role.name}' does not have permission to access this resource. Allowed: {self.allowed_roles}"
|
| 152 |
+
)
|
| 153 |
+
return current_user
|
backend/app/crud/crud.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import Session
|
| 2 |
+
from sqlalchemy import select, and_, or_, func
|
| 3 |
+
from app.models import models
|
| 4 |
+
from app.schemas import schemas
|
| 5 |
+
from datetime import datetime, date, time, timedelta
|
| 6 |
+
import logging
|
| 7 |
+
import bcrypt
|
| 8 |
+
import os
|
| 9 |
+
import shutil
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("CRUD")
|
| 12 |
+
|
| 13 |
+
# --- Security & Password Hashing ---
|
| 14 |
+
def get_password_hash(password: str) -> str:
|
| 15 |
+
pwd_bytes = password.encode('utf-8')
|
| 16 |
+
salt = bcrypt.gensalt()
|
| 17 |
+
hashed = bcrypt.hashpw(pwd_bytes, salt)
|
| 18 |
+
return hashed.decode('utf-8')
|
| 19 |
+
|
| 20 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 21 |
+
if not hashed_password or not plain_password:
|
| 22 |
+
return False
|
| 23 |
+
try:
|
| 24 |
+
pwd_bytes = plain_password.encode('utf-8')
|
| 25 |
+
hashed_bytes = hashed_password.encode('utf-8')
|
| 26 |
+
return bcrypt.checkpw(pwd_bytes, hashed_bytes)
|
| 27 |
+
except Exception:
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
# --- Role CRUD ---
|
| 31 |
+
def get_role_by_name(db: Session, name: str):
|
| 32 |
+
return db.execute(select(models.Role).where(models.Role.name == name)).scalar_one_or_none()
|
| 33 |
+
|
| 34 |
+
def create_role(db: Session, name: str, description: str = None):
|
| 35 |
+
db_role = models.Role(name=name, description=description)
|
| 36 |
+
db.add(db_role)
|
| 37 |
+
db.commit()
|
| 38 |
+
db.refresh(db_role)
|
| 39 |
+
return db_role
|
| 40 |
+
|
| 41 |
+
# --- User CRUD ---
|
| 42 |
+
def get_user_by_email(db: Session, email: str):
|
| 43 |
+
return db.execute(select(models.User).where(models.User.email == email)).scalar_one_or_none()
|
| 44 |
+
|
| 45 |
+
def create_user(db: Session, user: schemas.UserCreate):
|
| 46 |
+
hashed_pwd = get_password_hash(user.password)
|
| 47 |
+
db_user = models.User(
|
| 48 |
+
email=user.email,
|
| 49 |
+
hashed_password=hashed_pwd,
|
| 50 |
+
role_id=user.role_id,
|
| 51 |
+
is_active=True
|
| 52 |
+
)
|
| 53 |
+
db.add(db_user)
|
| 54 |
+
db.commit()
|
| 55 |
+
db.refresh(db_user)
|
| 56 |
+
return db_user
|
| 57 |
+
|
| 58 |
+
# --- Department CRUD ---
|
| 59 |
+
def get_departments(db: Session, skip: int = 0, limit: int = 100):
|
| 60 |
+
return db.execute(select(models.Department).offset(skip).limit(limit)).scalars().all()
|
| 61 |
+
|
| 62 |
+
def get_department_by_code(db: Session, code: str):
|
| 63 |
+
return db.execute(select(models.Department).where(models.Department.code == code)).scalar_one_or_none()
|
| 64 |
+
|
| 65 |
+
def get_department_by_id(db: Session, department_id: int):
|
| 66 |
+
return db.get(models.Department, department_id)
|
| 67 |
+
|
| 68 |
+
def create_department(db: Session, dept: schemas.DepartmentCreate):
|
| 69 |
+
db_dept = models.Department(**dept.model_dump())
|
| 70 |
+
db.add(db_dept)
|
| 71 |
+
db.commit()
|
| 72 |
+
db.refresh(db_dept)
|
| 73 |
+
return db_dept
|
| 74 |
+
|
| 75 |
+
def update_department(db: Session, department_id: int, dept: schemas.DepartmentUpdate):
|
| 76 |
+
db_dept = get_department_by_id(db, department_id)
|
| 77 |
+
if not db_dept:
|
| 78 |
+
return None
|
| 79 |
+
for key, value in dept.model_dump(exclude_unset=True).items():
|
| 80 |
+
setattr(db_dept, key, value)
|
| 81 |
+
db.commit()
|
| 82 |
+
db.refresh(db_dept)
|
| 83 |
+
return db_dept
|
| 84 |
+
|
| 85 |
+
def delete_department(db: Session, department_id: int) -> bool:
|
| 86 |
+
db_dept = get_department_by_id(db, department_id)
|
| 87 |
+
if not db_dept:
|
| 88 |
+
return False
|
| 89 |
+
db.delete(db_dept)
|
| 90 |
+
db.commit()
|
| 91 |
+
return True
|
| 92 |
+
|
| 93 |
+
# --- Employee CRUD ---
|
| 94 |
+
def get_employee_by_id(db: Session, id: int):
|
| 95 |
+
return db.get(models.Employee, id)
|
| 96 |
+
|
| 97 |
+
def get_employee_by_uuid(db: Session, employee_id: str):
|
| 98 |
+
return db.execute(select(models.Employee).where(models.Employee.employee_id == employee_id)).scalar_one_or_none()
|
| 99 |
+
|
| 100 |
+
def get_employee_by_email(db: Session, email: str):
|
| 101 |
+
return db.execute(select(models.Employee).where(models.Employee.email == email)).scalar_one_or_none()
|
| 102 |
+
|
| 103 |
+
def get_employees(
|
| 104 |
+
db: Session,
|
| 105 |
+
skip: int = 0,
|
| 106 |
+
limit: int = 100,
|
| 107 |
+
search: str = None,
|
| 108 |
+
department_id: int = None,
|
| 109 |
+
status: str = None
|
| 110 |
+
):
|
| 111 |
+
query = select(models.Employee)
|
| 112 |
+
filters = []
|
| 113 |
+
|
| 114 |
+
if search:
|
| 115 |
+
filters.append(or_(
|
| 116 |
+
models.Employee.name.ilike(f"%{search}%"),
|
| 117 |
+
models.Employee.employee_id.ilike(f"%{search}%"),
|
| 118 |
+
models.Employee.email.ilike(f"%{search}%")
|
| 119 |
+
))
|
| 120 |
+
if department_id:
|
| 121 |
+
filters.append(models.Employee.department_id == department_id)
|
| 122 |
+
if status:
|
| 123 |
+
filters.append(models.Employee.status == status)
|
| 124 |
+
|
| 125 |
+
if filters:
|
| 126 |
+
query = query.where(and_(*filters))
|
| 127 |
+
|
| 128 |
+
query = query.offset(skip).limit(limit)
|
| 129 |
+
return db.execute(query).scalars().all()
|
| 130 |
+
|
| 131 |
+
def count_employees(db: Session, search: str = None, department_id: int = None, status: str = None) -> int:
|
| 132 |
+
query = select(func.count(models.Employee.id))
|
| 133 |
+
filters = []
|
| 134 |
+
|
| 135 |
+
if search:
|
| 136 |
+
filters.append(or_(
|
| 137 |
+
models.Employee.name.ilike(f"%{search}%"),
|
| 138 |
+
models.Employee.employee_id.ilike(f"%{search}%"),
|
| 139 |
+
models.Employee.email.ilike(f"%{search}%")
|
| 140 |
+
))
|
| 141 |
+
if department_id:
|
| 142 |
+
filters.append(models.Employee.department_id == department_id)
|
| 143 |
+
if status:
|
| 144 |
+
filters.append(models.Employee.status == status)
|
| 145 |
+
|
| 146 |
+
if filters:
|
| 147 |
+
query = query.where(and_(*filters))
|
| 148 |
+
|
| 149 |
+
return db.execute(query).scalar() or 0
|
| 150 |
+
|
| 151 |
+
def create_employee(db: Session, emp: schemas.EmployeeCreate, user_id: int = None):
|
| 152 |
+
db_emp = models.Employee(
|
| 153 |
+
employee_id=emp.employee_id,
|
| 154 |
+
name=emp.name,
|
| 155 |
+
email=emp.email,
|
| 156 |
+
phone=emp.phone,
|
| 157 |
+
designation=emp.designation,
|
| 158 |
+
joining_date=emp.joining_date,
|
| 159 |
+
status=emp.status,
|
| 160 |
+
department_id=emp.department_id,
|
| 161 |
+
user_id=user_id
|
| 162 |
+
)
|
| 163 |
+
db.add(db_emp)
|
| 164 |
+
db.commit()
|
| 165 |
+
db.refresh(db_emp)
|
| 166 |
+
return db_emp
|
| 167 |
+
|
| 168 |
+
def update_employee(db: Session, id: int, emp: schemas.EmployeeUpdate):
|
| 169 |
+
db_emp = get_employee_by_id(db, id)
|
| 170 |
+
if not db_emp:
|
| 171 |
+
return None
|
| 172 |
+
for key, value in emp.model_dump(exclude_unset=True).items():
|
| 173 |
+
setattr(db_emp, key, value)
|
| 174 |
+
db.commit()
|
| 175 |
+
db.refresh(db_emp)
|
| 176 |
+
return db_emp
|
| 177 |
+
|
| 178 |
+
def delete_employee(db: Session, id: int) -> bool:
|
| 179 |
+
db_emp = get_employee_by_id(db, id)
|
| 180 |
+
if not db_emp:
|
| 181 |
+
return False
|
| 182 |
+
# If the employee has an associated user, delete that user as well
|
| 183 |
+
if db_emp.user_id:
|
| 184 |
+
db_user = db.get(models.User, db_emp.user_id)
|
| 185 |
+
if db_user:
|
| 186 |
+
db.delete(db_user)
|
| 187 |
+
db.delete(db_emp)
|
| 188 |
+
db.commit()
|
| 189 |
+
return True
|
| 190 |
+
|
| 191 |
+
# --- Face Enrollment & Embeddings CRUD ---
|
| 192 |
+
def save_employee_image(db: Session, employee_id: int, file_path: str, pose_type: str):
|
| 193 |
+
db_img = models.EmployeeImage(
|
| 194 |
+
employee_id=employee_id,
|
| 195 |
+
file_path=file_path,
|
| 196 |
+
pose_type=pose_type
|
| 197 |
+
)
|
| 198 |
+
db.add(db_img)
|
| 199 |
+
db.commit()
|
| 200 |
+
db.refresh(db_img)
|
| 201 |
+
return db_img
|
| 202 |
+
|
| 203 |
+
def save_face_embedding(db: Session, employee_id: int, image_id: int, embedding: list):
|
| 204 |
+
db_emb = models.FaceEmbedding(
|
| 205 |
+
employee_id=employee_id,
|
| 206 |
+
image_id=image_id,
|
| 207 |
+
embedding=embedding
|
| 208 |
+
)
|
| 209 |
+
db.add(db_emb)
|
| 210 |
+
db.commit()
|
| 211 |
+
db.refresh(db_emb)
|
| 212 |
+
return db_emb
|
| 213 |
+
|
| 214 |
+
def delete_face_embeddings(db: Session, employee_id: int):
|
| 215 |
+
# Deletes all embeddings and reference images for a user
|
| 216 |
+
from app.core.config import settings
|
| 217 |
+
|
| 218 |
+
emp = db.get(models.Employee, employee_id)
|
| 219 |
+
if emp and emp.employee_id:
|
| 220 |
+
emp_upload_dir = os.path.join(settings.UPLOAD_DIR, str(emp.employee_id))
|
| 221 |
+
if os.path.exists(emp_upload_dir):
|
| 222 |
+
try:
|
| 223 |
+
shutil.rmtree(emp_upload_dir)
|
| 224 |
+
except Exception as e:
|
| 225 |
+
logger.error(f"Failed to delete directory {emp_upload_dir}: {e}")
|
| 226 |
+
|
| 227 |
+
embeddings = db.execute(select(models.FaceEmbedding).where(models.FaceEmbedding.employee_id == employee_id)).scalars().all()
|
| 228 |
+
for emb in embeddings:
|
| 229 |
+
db.delete(emb)
|
| 230 |
+
|
| 231 |
+
images = db.execute(select(models.EmployeeImage).where(models.EmployeeImage.employee_id == employee_id)).scalars().all()
|
| 232 |
+
for img in images:
|
| 233 |
+
if os.path.exists(img.file_path):
|
| 234 |
+
try:
|
| 235 |
+
os.remove(img.file_path)
|
| 236 |
+
except Exception:
|
| 237 |
+
pass
|
| 238 |
+
db.delete(img)
|
| 239 |
+
db.commit()
|
| 240 |
+
|
| 241 |
+
# --- System Settings CRUD ---
|
| 242 |
+
def get_setting_by_key(db: Session, key: str):
|
| 243 |
+
return db.execute(select(models.Setting).where(models.Setting.key == key)).scalar_one_or_none()
|
| 244 |
+
|
| 245 |
+
def get_settings(db: Session):
|
| 246 |
+
return db.execute(select(models.Setting)).scalars().all()
|
| 247 |
+
|
| 248 |
+
def set_setting(db: Session, key: str, value: str, description: str = None):
|
| 249 |
+
db_setting = get_setting_by_key(db, key)
|
| 250 |
+
if db_setting:
|
| 251 |
+
db_setting.value = value
|
| 252 |
+
if description:
|
| 253 |
+
db_setting.description = description
|
| 254 |
+
else:
|
| 255 |
+
db_setting = models.Setting(key=key, value=value, description=description)
|
| 256 |
+
db.add(db_setting)
|
| 257 |
+
db.commit()
|
| 258 |
+
db.refresh(db_setting)
|
| 259 |
+
return db_setting
|
| 260 |
+
|
| 261 |
+
# --- Attendance Logs CRUD ---
|
| 262 |
+
def create_attendance_log(db: Session, employee_id: int, camera: str, confidence: float, liveness_score: float, is_spoof: bool, status: str, timestamp: datetime = None):
|
| 263 |
+
if not timestamp:
|
| 264 |
+
timestamp = datetime.now()
|
| 265 |
+
log = models.AttendanceLog(
|
| 266 |
+
employee_id=employee_id,
|
| 267 |
+
camera=camera,
|
| 268 |
+
confidence=confidence,
|
| 269 |
+
liveness_score=liveness_score,
|
| 270 |
+
is_spoof=is_spoof,
|
| 271 |
+
status=status,
|
| 272 |
+
timestamp=timestamp
|
| 273 |
+
)
|
| 274 |
+
db.add(log)
|
| 275 |
+
db.commit()
|
| 276 |
+
db.refresh(log)
|
| 277 |
+
return log
|
| 278 |
+
|
| 279 |
+
def get_attendance_logs(db: Session, skip: int = 0, limit: int = 100, employee_id: int = None, date_str: str = None):
|
| 280 |
+
query = select(models.AttendanceLog)
|
| 281 |
+
filters = []
|
| 282 |
+
|
| 283 |
+
if employee_id:
|
| 284 |
+
filters.append(models.AttendanceLog.employee_id == employee_id)
|
| 285 |
+
|
| 286 |
+
if date_str:
|
| 287 |
+
try:
|
| 288 |
+
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
| 289 |
+
filters.append(func.date(models.AttendanceLog.timestamp) == target_date)
|
| 290 |
+
except ValueError:
|
| 291 |
+
pass
|
| 292 |
+
|
| 293 |
+
if filters:
|
| 294 |
+
query = query.where(and_(*filters))
|
| 295 |
+
|
| 296 |
+
query = query.order_by(models.AttendanceLog.timestamp.desc()).offset(skip).limit(limit)
|
| 297 |
+
return db.execute(query).scalars().all()
|
| 298 |
+
|
| 299 |
+
# --- Attendance Logic & Processing ---
|
| 300 |
+
def get_daily_attendance(db: Session, date_val: date, employee_id: int = None, department_id: int = None):
|
| 301 |
+
query = select(models.Attendance)
|
| 302 |
+
filters = [models.Attendance.date == date_val]
|
| 303 |
+
|
| 304 |
+
if employee_id:
|
| 305 |
+
filters.append(models.Attendance.employee_id == employee_id)
|
| 306 |
+
|
| 307 |
+
if department_id:
|
| 308 |
+
query = query.join(models.Employee).where(models.Employee.department_id == department_id)
|
| 309 |
+
|
| 310 |
+
query = query.where(and_(*filters))
|
| 311 |
+
return db.execute(query).scalars().all()
|
| 312 |
+
|
| 313 |
+
def get_attendance_by_id(db: Session, id: int):
|
| 314 |
+
return db.get(models.Attendance, id)
|
| 315 |
+
|
| 316 |
+
def update_attendance(db: Session, id: int, data: schemas.AttendanceUpdate):
|
| 317 |
+
db_att = get_attendance_by_id(db, id)
|
| 318 |
+
if not db_att:
|
| 319 |
+
return None
|
| 320 |
+
|
| 321 |
+
for key, value in data.model_dump(exclude_unset=True).items():
|
| 322 |
+
setattr(db_att, key, value)
|
| 323 |
+
|
| 324 |
+
# Recalculate working hours if both check_in and check_out exist
|
| 325 |
+
if db_att.check_in and db_att.check_out:
|
| 326 |
+
diff = db_att.check_out - db_att.check_in
|
| 327 |
+
db_att.working_hours = round(diff.total_seconds() / 3600.0, 2)
|
| 328 |
+
# Standard work hours = 8. Overtime is above 8
|
| 329 |
+
db_att.overtime = max(0.0, round(db_att.working_hours - 8.0, 2))
|
| 330 |
+
|
| 331 |
+
db.commit()
|
| 332 |
+
db.refresh(db_att)
|
| 333 |
+
return db_att
|
| 334 |
+
|
| 335 |
+
def mark_kiosk_attendance(db: Session, employee_id: int, timestamp: datetime, camera: str, confidence: float) -> models.Attendance:
|
| 336 |
+
"""
|
| 337 |
+
Core attendance state machine:
|
| 338 |
+
- Check if there's already an attendance record for this employee and date.
|
| 339 |
+
- If none exists, create a check-in record.
|
| 340 |
+
- If one exists and check_out is empty, mark check-out and calculate hours.
|
| 341 |
+
- If both exist, do nothing or update checkout to a later timestamp.
|
| 342 |
+
"""
|
| 343 |
+
local_now = datetime.now()
|
| 344 |
+
today = local_now.date()
|
| 345 |
+
|
| 346 |
+
# Check if a record exists
|
| 347 |
+
stmt = select(models.Attendance).where(
|
| 348 |
+
and_(
|
| 349 |
+
models.Attendance.employee_id == employee_id,
|
| 350 |
+
models.Attendance.date == today
|
| 351 |
+
)
|
| 352 |
+
)
|
| 353 |
+
db_attendance = db.execute(stmt).scalar_one_or_none()
|
| 354 |
+
|
| 355 |
+
# Check system configurations for status mapping
|
| 356 |
+
start_time_setting = get_setting_by_key(db, "CHECK_IN_START")
|
| 357 |
+
grace_period_setting = get_setting_by_key(db, "GRACE_PERIOD_MINUTES")
|
| 358 |
+
|
| 359 |
+
start_str = start_time_setting.value if start_time_setting else "09:00"
|
| 360 |
+
grace_mins = int(grace_period_setting.value) if grace_period_setting else 15
|
| 361 |
+
|
| 362 |
+
try:
|
| 363 |
+
hr, mn = map(int, start_str.split(":"))
|
| 364 |
+
check_in_deadline = datetime.combine(today, time(hr, mn)) + timedelta(minutes=grace_mins)
|
| 365 |
+
except Exception:
|
| 366 |
+
check_in_deadline = datetime.combine(today, time(9, 15))
|
| 367 |
+
|
| 368 |
+
if not db_attendance:
|
| 369 |
+
# First scan of the day -> CHECK-IN
|
| 370 |
+
is_late = local_now > check_in_deadline
|
| 371 |
+
status = "Late" if is_late else "Present"
|
| 372 |
+
|
| 373 |
+
db_attendance = models.Attendance(
|
| 374 |
+
employee_id=employee_id,
|
| 375 |
+
date=today,
|
| 376 |
+
check_in=timestamp,
|
| 377 |
+
late_arrival=is_late,
|
| 378 |
+
status=status
|
| 379 |
+
)
|
| 380 |
+
db.add(db_attendance)
|
| 381 |
+
logger.info(f"Marked Check-In for employee {employee_id} at {timestamp}. Status: {status}")
|
| 382 |
+
else:
|
| 383 |
+
# Second scan of the day -> CHECK-OUT
|
| 384 |
+
# If it's a scan that happens at least 1 minute after check_in, update checkout
|
| 385 |
+
if db_attendance.check_in and (timestamp - db_attendance.check_in).total_seconds() > 60:
|
| 386 |
+
db_attendance.check_out = timestamp
|
| 387 |
+
|
| 388 |
+
# Calculate working hours
|
| 389 |
+
diff = timestamp - db_attendance.check_in
|
| 390 |
+
hours = round(diff.total_seconds() / 3600.0, 2)
|
| 391 |
+
db_attendance.working_hours = hours
|
| 392 |
+
|
| 393 |
+
# Early departure: Check if checkout is before e.g., 5:00 PM
|
| 394 |
+
end_time_setting = get_setting_by_key(db, "CHECK_OUT_END")
|
| 395 |
+
end_str = end_time_setting.value if end_time_setting else "17:00"
|
| 396 |
+
try:
|
| 397 |
+
ehr, emn = map(int, end_str.split(":"))
|
| 398 |
+
departure_deadline = datetime.combine(today, time(ehr, emn))
|
| 399 |
+
except Exception:
|
| 400 |
+
departure_deadline = datetime.combine(today, time(17, 0))
|
| 401 |
+
|
| 402 |
+
db_attendance.early_departure = local_now < departure_deadline
|
| 403 |
+
|
| 404 |
+
# Overtime: Hours worked beyond 8 hours
|
| 405 |
+
db_attendance.overtime = max(0.0, round(hours - 8.0, 2))
|
| 406 |
+
|
| 407 |
+
# Half Day check: If total working hours is less than 8 hours
|
| 408 |
+
if hours < 8.0:
|
| 409 |
+
db_attendance.status = "Half Day"
|
| 410 |
+
else:
|
| 411 |
+
if db_attendance.status == "Half Day" or db_attendance.status == "Absent":
|
| 412 |
+
db_attendance.status = "Present"
|
| 413 |
+
|
| 414 |
+
logger.info(f"Marked Check-Out for employee {employee_id} at {timestamp}. Worked: {hours}h. Status: {db_attendance.status}")
|
| 415 |
+
|
| 416 |
+
db.commit()
|
| 417 |
+
db.refresh(db_attendance)
|
| 418 |
+
return db_attendance
|
| 419 |
+
|
| 420 |
+
# --- Leave Requests CRUD ---
|
| 421 |
+
def create_leave_request(db: Session, req: schemas.LeaveRequestCreate):
|
| 422 |
+
db_req = models.LeaveRequest(
|
| 423 |
+
employee_id=req.employee_id,
|
| 424 |
+
start_date=req.start_date,
|
| 425 |
+
end_date=req.end_date,
|
| 426 |
+
leave_type=req.leave_type,
|
| 427 |
+
reason=req.reason,
|
| 428 |
+
status="Pending"
|
| 429 |
+
)
|
| 430 |
+
db.add(db_req)
|
| 431 |
+
db.commit()
|
| 432 |
+
db.refresh(db_req)
|
| 433 |
+
return db_req
|
| 434 |
+
|
| 435 |
+
def get_leave_requests(db: Session, skip: int = 0, limit: int = 100, employee_id: int = None, status: str = None):
|
| 436 |
+
query = select(models.LeaveRequest)
|
| 437 |
+
filters = []
|
| 438 |
+
if employee_id:
|
| 439 |
+
filters.append(models.LeaveRequest.employee_id == employee_id)
|
| 440 |
+
if status:
|
| 441 |
+
filters.append(models.LeaveRequest.status == status)
|
| 442 |
+
|
| 443 |
+
if filters:
|
| 444 |
+
query = query.where(and_(*filters))
|
| 445 |
+
|
| 446 |
+
query = query.order_by(models.LeaveRequest.created_at.desc()).offset(skip).limit(limit)
|
| 447 |
+
return db.execute(query).scalars().all()
|
| 448 |
+
|
| 449 |
+
def update_leave_request(db: Session, id: int, status: str, admin_user_id: int):
|
| 450 |
+
db_req = db.get(models.LeaveRequest, id)
|
| 451 |
+
if not db_req:
|
| 452 |
+
return None
|
| 453 |
+
db_req.status = status
|
| 454 |
+
db_req.approved_by = admin_user_id
|
| 455 |
+
|
| 456 |
+
# If approved, update attendance status for those dates
|
| 457 |
+
if status.lower() == "approved":
|
| 458 |
+
current_date = db_req.start_date
|
| 459 |
+
while current_date <= db_req.end_date:
|
| 460 |
+
# Check if an attendance record exists
|
| 461 |
+
stmt = select(models.Attendance).where(
|
| 462 |
+
and_(
|
| 463 |
+
models.Attendance.employee_id == db_req.employee_id,
|
| 464 |
+
models.Attendance.date == current_date
|
| 465 |
+
)
|
| 466 |
+
)
|
| 467 |
+
att = db.execute(stmt).scalar_one_or_none()
|
| 468 |
+
if att:
|
| 469 |
+
att.status = "On Leave"
|
| 470 |
+
else:
|
| 471 |
+
att = models.Attendance(
|
| 472 |
+
employee_id=db_req.employee_id,
|
| 473 |
+
date=current_date,
|
| 474 |
+
status="On Leave"
|
| 475 |
+
)
|
| 476 |
+
db.add(att)
|
| 477 |
+
current_date += timedelta(days=1)
|
| 478 |
+
|
| 479 |
+
db.commit()
|
| 480 |
+
db.refresh(db_req)
|
| 481 |
+
return db_req
|
| 482 |
+
|
| 483 |
+
# --- Holiday CRUD ---
|
| 484 |
+
def get_holidays(db: Session):
|
| 485 |
+
return db.execute(select(models.Holiday).order_by(models.Holiday.date)).scalars().all()
|
| 486 |
+
|
| 487 |
+
def get_holiday_by_date(db: Session, date_val: date):
|
| 488 |
+
return db.execute(select(models.Holiday).where(models.Holiday.date == date_val)).scalar_one_or_none()
|
| 489 |
+
|
| 490 |
+
def create_holiday(db: Session, hol: schemas.HolidayCreate):
|
| 491 |
+
db_hol = models.Holiday(**hol.model_dump())
|
| 492 |
+
db.add(db_hol)
|
| 493 |
+
db.commit()
|
| 494 |
+
db.refresh(db_hol)
|
| 495 |
+
return db_hol
|
| 496 |
+
|
| 497 |
+
def delete_holiday(db: Session, id: int) -> bool:
|
| 498 |
+
db_hol = db.get(models.Holiday, id)
|
| 499 |
+
if not db_hol:
|
| 500 |
+
return False
|
| 501 |
+
db.delete(db_hol)
|
| 502 |
+
db.commit()
|
| 503 |
+
return True
|
| 504 |
+
|
| 505 |
+
# --- Audit Logs CRUD ---
|
| 506 |
+
def create_audit_log(db: Session, user_id: int, action: str, ip_address: str = None, user_agent: str = None, details: str = None):
|
| 507 |
+
log = models.AuditLog(
|
| 508 |
+
user_id=user_id,
|
| 509 |
+
action=action,
|
| 510 |
+
ip_address=ip_address,
|
| 511 |
+
user_agent=user_agent,
|
| 512 |
+
details=details
|
| 513 |
+
)
|
| 514 |
+
db.add(log)
|
| 515 |
+
db.commit()
|
| 516 |
+
db.refresh(log)
|
| 517 |
+
return log
|
| 518 |
+
|
| 519 |
+
def get_audit_logs(db: Session, skip: int = 0, limit: int = 100):
|
| 520 |
+
query = select(models.AuditLog).order_by(models.AuditLog.timestamp.desc()).offset(skip).limit(limit)
|
| 521 |
+
return db.execute(query).scalars().all()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import FastAPI, Depends
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.core.database import SessionLocal
|
| 8 |
+
from app.core.init_db import init_db
|
| 9 |
+
from app.api.v1 import auth, employees, departments, enrollment, kiosk, attendance, reports, analytics, settings as settings_api, audit
|
| 10 |
+
|
| 11 |
+
# Logging configuration
|
| 12 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
| 13 |
+
logger = logging.getLogger("NetraID")
|
| 14 |
+
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
| 16 |
+
|
| 17 |
+
app = FastAPI(
|
| 18 |
+
title=settings.PROJECT_NAME,
|
| 19 |
+
version="1.0.0",
|
| 20 |
+
docs_url="/docs",
|
| 21 |
+
redoc_url="/redoc"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Mount uploads directory as static files
|
| 25 |
+
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
|
| 26 |
+
|
| 27 |
+
# CORS configuration
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=settings.cors_origins,
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["*"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Create folders on startup
|
| 37 |
+
@app.on_event("startup")
|
| 38 |
+
def startup_event():
|
| 39 |
+
logger.info("Starting NetraID Backend...")
|
| 40 |
+
|
| 41 |
+
# Create upload directory
|
| 42 |
+
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
| 43 |
+
logger.info(f"Uploads directory verified: {settings.UPLOAD_DIR}")
|
| 44 |
+
|
| 45 |
+
# Create models directory
|
| 46 |
+
os.makedirs(settings.MODELS_DIR, exist_ok=True)
|
| 47 |
+
logger.info(f"Models directory verified: {settings.MODELS_DIR}")
|
| 48 |
+
|
| 49 |
+
# Initialize and Seed database
|
| 50 |
+
db = SessionLocal()
|
| 51 |
+
try:
|
| 52 |
+
init_db(db)
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"Error seeding database: {e}")
|
| 55 |
+
finally:
|
| 56 |
+
db.close()
|
| 57 |
+
|
| 58 |
+
# Health check
|
| 59 |
+
@app.get("/health", tags=["Status"])
|
| 60 |
+
def health_check():
|
| 61 |
+
return {
|
| 62 |
+
"status": "healthy",
|
| 63 |
+
"project": settings.PROJECT_NAME,
|
| 64 |
+
"version": "1.0.0"
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# Include API Routers
|
| 68 |
+
app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["Authentication"])
|
| 69 |
+
app.include_router(employees.router, prefix=f"{settings.API_V1_STR}/employees", tags=["Employee Management"])
|
| 70 |
+
app.include_router(departments.router, prefix=f"{settings.API_V1_STR}/departments", tags=["Department Management"])
|
| 71 |
+
app.include_router(enrollment.router, prefix=f"{settings.API_V1_STR}/enrollment", tags=["Face Enrollment"])
|
| 72 |
+
app.include_router(kiosk.router, prefix=f"{settings.API_V1_STR}/kiosk", tags=["Kiosk Attendance Screen"])
|
| 73 |
+
app.include_router(attendance.router, prefix=f"{settings.API_V1_STR}/attendance", tags=["Attendance Logs & Feeds"])
|
| 74 |
+
app.include_router(reports.router, prefix=f"{settings.API_V1_STR}/reports", tags=["Reporting & Exports"])
|
| 75 |
+
app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["Dashboard Analytics"])
|
| 76 |
+
app.include_router(settings_api.router, prefix=f"{settings.API_V1_STR}/settings", tags=["System Settings"])
|
| 77 |
+
app.include_router(audit.router, prefix=f"{settings.API_V1_STR}/audit", tags=["System Audit Logs"])
|
backend/app/models/models.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
import json
|
| 3 |
+
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, ForeignKey, Float, Text, Time, Interval, Enum
|
| 4 |
+
from sqlalchemy.types import TypeDecorator
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
from app.core.database import Base
|
| 7 |
+
|
| 8 |
+
class SafeVector(TypeDecorator):
|
| 9 |
+
impl = Text
|
| 10 |
+
cache_ok = True
|
| 11 |
+
|
| 12 |
+
def __init__(self, dimensions):
|
| 13 |
+
self.dimensions = dimensions
|
| 14 |
+
super().__init__()
|
| 15 |
+
|
| 16 |
+
def load_dialect_impl(self, dialect):
|
| 17 |
+
if dialect.name == 'postgresql':
|
| 18 |
+
try:
|
| 19 |
+
from pgvector.sqlalchemy import Vector
|
| 20 |
+
return dialect.type_descriptor(Vector(self.dimensions))
|
| 21 |
+
except ImportError:
|
| 22 |
+
pass
|
| 23 |
+
return dialect.type_descriptor(Text())
|
| 24 |
+
|
| 25 |
+
def process_bind_param(self, value, dialect):
|
| 26 |
+
if value is None:
|
| 27 |
+
return None
|
| 28 |
+
if dialect.name == 'postgresql':
|
| 29 |
+
return value
|
| 30 |
+
return json.dumps(value)
|
| 31 |
+
|
| 32 |
+
def process_result_value(self, value, dialect):
|
| 33 |
+
if value is None:
|
| 34 |
+
return None
|
| 35 |
+
if dialect.name == 'postgresql':
|
| 36 |
+
return value
|
| 37 |
+
try:
|
| 38 |
+
return json.loads(value)
|
| 39 |
+
except Exception:
|
| 40 |
+
return value
|
| 41 |
+
|
| 42 |
+
class Role(Base):
|
| 43 |
+
__tablename__ = "roles"
|
| 44 |
+
|
| 45 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 46 |
+
name = Column(String(50), unique=True, nullable=False) # Super Admin, Admin, HR, Employee
|
| 47 |
+
description = Column(String(255), nullable=True)
|
| 48 |
+
|
| 49 |
+
users = relationship("User", back_populates="role")
|
| 50 |
+
|
| 51 |
+
class User(Base):
|
| 52 |
+
__tablename__ = "users"
|
| 53 |
+
|
| 54 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 55 |
+
email = Column(String(255), unique=True, index=True, nullable=False)
|
| 56 |
+
hashed_password = Column(String(255), nullable=False)
|
| 57 |
+
is_active = Column(Boolean, default=True)
|
| 58 |
+
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
|
| 59 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 60 |
+
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
| 61 |
+
|
| 62 |
+
role = relationship("Role", back_populates="users")
|
| 63 |
+
employee = relationship("Employee", back_populates="user", uselist=False)
|
| 64 |
+
audit_logs = relationship("AuditLog", back_populates="user")
|
| 65 |
+
|
| 66 |
+
class Department(Base):
|
| 67 |
+
__tablename__ = "departments"
|
| 68 |
+
|
| 69 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 70 |
+
name = Column(String(100), unique=True, nullable=False)
|
| 71 |
+
code = Column(String(20), unique=True, nullable=False)
|
| 72 |
+
description = Column(String(255), nullable=True)
|
| 73 |
+
|
| 74 |
+
employees = relationship("Employee", back_populates="department")
|
| 75 |
+
|
| 76 |
+
class Employee(Base):
|
| 77 |
+
__tablename__ = "employees"
|
| 78 |
+
|
| 79 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 80 |
+
employee_id = Column(String(50), unique=True, index=True, nullable=False)
|
| 81 |
+
name = Column(String(100), nullable=False)
|
| 82 |
+
email = Column(String(255), unique=True, index=True, nullable=False)
|
| 83 |
+
phone = Column(String(20), nullable=True)
|
| 84 |
+
designation = Column(String(100), nullable=True)
|
| 85 |
+
joining_date = Column(Date, nullable=False, default=datetime.date.today)
|
| 86 |
+
status = Column(String(20), default="Active") # Active, Inactive, Suspended
|
| 87 |
+
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True)
|
| 88 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, unique=True)
|
| 89 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 90 |
+
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
| 91 |
+
|
| 92 |
+
department = relationship("Department", back_populates="employees")
|
| 93 |
+
user = relationship("User", back_populates="employee")
|
| 94 |
+
images = relationship("EmployeeImage", back_populates="employee", cascade="all, delete-orphan")
|
| 95 |
+
embeddings = relationship("FaceEmbedding", back_populates="employee", cascade="all, delete-orphan")
|
| 96 |
+
attendance_records = relationship("Attendance", back_populates="employee", cascade="all, delete-orphan")
|
| 97 |
+
attendance_logs = relationship("AttendanceLog", back_populates="employee", cascade="all, delete-orphan")
|
| 98 |
+
leave_requests = relationship("LeaveRequest", back_populates="employee", cascade="all, delete-orphan")
|
| 99 |
+
|
| 100 |
+
class EmployeeImage(Base):
|
| 101 |
+
__tablename__ = "employee_images"
|
| 102 |
+
|
| 103 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 104 |
+
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
| 105 |
+
file_path = Column(String(255), nullable=False)
|
| 106 |
+
pose_type = Column(String(50), nullable=False) # Front, Left, Right, Up, Down, Smile, Neutral, etc.
|
| 107 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 108 |
+
|
| 109 |
+
employee = relationship("Employee", back_populates="images")
|
| 110 |
+
embeddings = relationship("FaceEmbedding", back_populates="image", cascade="all, delete-orphan")
|
| 111 |
+
|
| 112 |
+
class FaceEmbedding(Base):
|
| 113 |
+
__tablename__ = "face_embeddings"
|
| 114 |
+
|
| 115 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 116 |
+
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
| 117 |
+
image_id = Column(Integer, ForeignKey("employee_images.id", ondelete="CASCADE"), nullable=True)
|
| 118 |
+
embedding = Column(SafeVector(512), nullable=False) # pgvector field for 512 dimensions (ArcFace) or JSON text on SQLite
|
| 119 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 120 |
+
|
| 121 |
+
employee = relationship("Employee", back_populates="embeddings")
|
| 122 |
+
image = relationship("EmployeeImage", back_populates="embeddings")
|
| 123 |
+
|
| 124 |
+
class Attendance(Base):
|
| 125 |
+
__tablename__ = "attendance"
|
| 126 |
+
|
| 127 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 128 |
+
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
| 129 |
+
date = Column(Date, nullable=False)
|
| 130 |
+
check_in = Column(DateTime, nullable=True)
|
| 131 |
+
check_out = Column(DateTime, nullable=True)
|
| 132 |
+
working_hours = Column(Float, default=0.0) # Calculated in hours
|
| 133 |
+
late_arrival = Column(Boolean, default=False)
|
| 134 |
+
early_departure = Column(Boolean, default=False)
|
| 135 |
+
overtime = Column(Float, default=0.0) # Calculated in hours
|
| 136 |
+
status = Column(String(20), default="Absent") # Present, Absent, Late, Half Day, Leave, Holiday, WFH
|
| 137 |
+
|
| 138 |
+
employee = relationship("Employee", back_populates="attendance_records")
|
| 139 |
+
|
| 140 |
+
class AttendanceLog(Base):
|
| 141 |
+
__tablename__ = "attendance_logs"
|
| 142 |
+
|
| 143 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 144 |
+
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True) # Null if not recognized
|
| 145 |
+
timestamp = Column(DateTime, default=datetime.datetime.now, nullable=False)
|
| 146 |
+
camera = Column(String(100), default="Kiosk")
|
| 147 |
+
confidence = Column(Float, nullable=True)
|
| 148 |
+
liveness_score = Column(Float, nullable=True)
|
| 149 |
+
is_spoof = Column(Boolean, default=False)
|
| 150 |
+
status = Column(String(50), nullable=False) # Match Success, Spoof Rejected, Unknown Person, Low Confidence
|
| 151 |
+
|
| 152 |
+
employee = relationship("Employee", back_populates="attendance_logs")
|
| 153 |
+
|
| 154 |
+
class LeaveRequest(Base):
|
| 155 |
+
__tablename__ = "leave_requests"
|
| 156 |
+
|
| 157 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 158 |
+
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
| 159 |
+
start_date = Column(Date, nullable=False)
|
| 160 |
+
end_date = Column(Date, nullable=False)
|
| 161 |
+
leave_type = Column(String(50), nullable=False) # Sick, Casual, Annual, unpaid
|
| 162 |
+
reason = Column(Text, nullable=True)
|
| 163 |
+
status = Column(String(20), default="Pending") # Pending, Approved, Rejected
|
| 164 |
+
approved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
| 165 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 166 |
+
|
| 167 |
+
employee = relationship("Employee", back_populates="leave_requests")
|
| 168 |
+
|
| 169 |
+
class Holiday(Base):
|
| 170 |
+
__tablename__ = "holidays"
|
| 171 |
+
|
| 172 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 173 |
+
name = Column(String(100), nullable=False)
|
| 174 |
+
date = Column(Date, unique=True, nullable=False)
|
| 175 |
+
description = Column(String(255), nullable=True)
|
| 176 |
+
|
| 177 |
+
class Setting(Base):
|
| 178 |
+
__tablename__ = "settings"
|
| 179 |
+
|
| 180 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 181 |
+
key = Column(String(100), unique=True, nullable=False)
|
| 182 |
+
value = Column(Text, nullable=False)
|
| 183 |
+
description = Column(String(255), nullable=True)
|
| 184 |
+
|
| 185 |
+
class AuditLog(Base):
|
| 186 |
+
__tablename__ = "audit_logs"
|
| 187 |
+
|
| 188 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 189 |
+
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
| 190 |
+
action = Column(String(100), nullable=False) # Login, Logout, Create Employee, Mark Attendance, etc.
|
| 191 |
+
timestamp = Column(DateTime, default=datetime.datetime.now)
|
| 192 |
+
ip_address = Column(String(50), nullable=True)
|
| 193 |
+
user_agent = Column(String(255), nullable=True)
|
| 194 |
+
details = Column(Text, nullable=True)
|
| 195 |
+
|
| 196 |
+
user = relationship("User", back_populates="audit_logs")
|
backend/app/schemas/schemas.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime, date, time, timedelta
|
| 4 |
+
|
| 5 |
+
# Role Schemas
|
| 6 |
+
class RoleBase(BaseModel):
|
| 7 |
+
name: str
|
| 8 |
+
description: Optional[str] = None
|
| 9 |
+
|
| 10 |
+
class RoleOut(RoleBase):
|
| 11 |
+
id: int
|
| 12 |
+
model_config = ConfigDict(from_attributes=True)
|
| 13 |
+
|
| 14 |
+
# User Schemas
|
| 15 |
+
class UserBase(BaseModel):
|
| 16 |
+
email: EmailStr
|
| 17 |
+
|
| 18 |
+
class UserCreate(UserBase):
|
| 19 |
+
password: str
|
| 20 |
+
role_id: int
|
| 21 |
+
|
| 22 |
+
class UserUpdate(BaseModel):
|
| 23 |
+
email: Optional[EmailStr] = None
|
| 24 |
+
password: Optional[str] = None
|
| 25 |
+
role_id: Optional[int] = None
|
| 26 |
+
is_active: Optional[bool] = None
|
| 27 |
+
|
| 28 |
+
class UserOut(UserBase):
|
| 29 |
+
id: int
|
| 30 |
+
is_active: bool
|
| 31 |
+
role_id: int
|
| 32 |
+
role: RoleOut
|
| 33 |
+
created_at: Optional[datetime] = None
|
| 34 |
+
model_config = ConfigDict(from_attributes=True)
|
| 35 |
+
|
| 36 |
+
# Token Schemas
|
| 37 |
+
class Token(BaseModel):
|
| 38 |
+
access_token: str
|
| 39 |
+
refresh_token: str
|
| 40 |
+
token_type: str
|
| 41 |
+
|
| 42 |
+
class TokenData(BaseModel):
|
| 43 |
+
email: Optional[str] = None
|
| 44 |
+
role: Optional[str] = None
|
| 45 |
+
|
| 46 |
+
# Department Schemas
|
| 47 |
+
class DepartmentBase(BaseModel):
|
| 48 |
+
name: str = Field(..., max_length=100)
|
| 49 |
+
code: str = Field(..., max_length=20)
|
| 50 |
+
description: Optional[str] = None
|
| 51 |
+
|
| 52 |
+
class DepartmentCreate(DepartmentBase):
|
| 53 |
+
pass
|
| 54 |
+
|
| 55 |
+
class DepartmentUpdate(BaseModel):
|
| 56 |
+
name: Optional[str] = None
|
| 57 |
+
code: Optional[str] = None
|
| 58 |
+
description: Optional[str] = None
|
| 59 |
+
|
| 60 |
+
class DepartmentOut(DepartmentBase):
|
| 61 |
+
id: int
|
| 62 |
+
model_config = ConfigDict(from_attributes=True)
|
| 63 |
+
|
| 64 |
+
# Employee Schemas
|
| 65 |
+
class EmployeeBase(BaseModel):
|
| 66 |
+
employee_id: str
|
| 67 |
+
name: str
|
| 68 |
+
email: EmailStr
|
| 69 |
+
phone: Optional[str] = None
|
| 70 |
+
designation: Optional[str] = None
|
| 71 |
+
joining_date: date
|
| 72 |
+
status: str = "Active"
|
| 73 |
+
department_id: Optional[int] = None
|
| 74 |
+
|
| 75 |
+
class EmployeeCreate(EmployeeBase):
|
| 76 |
+
create_user_login: bool = False
|
| 77 |
+
password: Optional[str] = None # Required if create_user_login is True
|
| 78 |
+
|
| 79 |
+
class EmployeeUpdate(BaseModel):
|
| 80 |
+
name: Optional[str] = None
|
| 81 |
+
email: Optional[EmailStr] = None
|
| 82 |
+
phone: Optional[str] = None
|
| 83 |
+
designation: Optional[str] = None
|
| 84 |
+
joining_date: Optional[date] = None
|
| 85 |
+
status: Optional[str] = None
|
| 86 |
+
department_id: Optional[int] = None
|
| 87 |
+
|
| 88 |
+
class EmployeeImageOut(BaseModel):
|
| 89 |
+
id: int
|
| 90 |
+
file_path: str
|
| 91 |
+
pose_type: str
|
| 92 |
+
created_at: datetime
|
| 93 |
+
model_config = ConfigDict(from_attributes=True)
|
| 94 |
+
|
| 95 |
+
class EmployeeOut(EmployeeBase):
|
| 96 |
+
id: int
|
| 97 |
+
user_id: Optional[int] = None
|
| 98 |
+
department: Optional[DepartmentOut] = None
|
| 99 |
+
images: List[EmployeeImageOut] = []
|
| 100 |
+
created_at: Optional[datetime] = None
|
| 101 |
+
model_config = ConfigDict(from_attributes=True)
|
| 102 |
+
|
| 103 |
+
# Face Embedding Schemas
|
| 104 |
+
class FaceEmbeddingBase(BaseModel):
|
| 105 |
+
employee_id: int
|
| 106 |
+
|
| 107 |
+
class FaceEmbeddingOut(FaceEmbeddingBase):
|
| 108 |
+
id: int
|
| 109 |
+
image_id: Optional[int] = None
|
| 110 |
+
created_at: Optional[datetime] = None
|
| 111 |
+
model_config = ConfigDict(from_attributes=True)
|
| 112 |
+
|
| 113 |
+
# Attendance Schemas
|
| 114 |
+
class AttendanceBase(BaseModel):
|
| 115 |
+
employee_id: int
|
| 116 |
+
date: date
|
| 117 |
+
check_in: Optional[datetime] = None
|
| 118 |
+
check_out: Optional[datetime] = None
|
| 119 |
+
status: str
|
| 120 |
+
|
| 121 |
+
class AttendanceOut(AttendanceBase):
|
| 122 |
+
id: int
|
| 123 |
+
working_hours: float
|
| 124 |
+
late_arrival: bool
|
| 125 |
+
early_departure: bool
|
| 126 |
+
overtime: float
|
| 127 |
+
employee: Optional[EmployeeOut] = None
|
| 128 |
+
model_config = ConfigDict(from_attributes=True)
|
| 129 |
+
|
| 130 |
+
class AttendanceUpdate(BaseModel):
|
| 131 |
+
check_in: Optional[datetime] = None
|
| 132 |
+
check_out: Optional[datetime] = None
|
| 133 |
+
status: Optional[str] = None
|
| 134 |
+
|
| 135 |
+
# Attendance Log Schemas
|
| 136 |
+
class AttendanceLogOut(BaseModel):
|
| 137 |
+
id: int
|
| 138 |
+
employee_id: Optional[int] = None
|
| 139 |
+
timestamp: Optional[datetime] = None
|
| 140 |
+
camera: str
|
| 141 |
+
confidence: Optional[float] = None
|
| 142 |
+
liveness_score: Optional[float] = None
|
| 143 |
+
is_spoof: bool
|
| 144 |
+
status: str
|
| 145 |
+
employee: Optional[EmployeeOut] = None
|
| 146 |
+
model_config = ConfigDict(from_attributes=True)
|
| 147 |
+
|
| 148 |
+
# Leave Request Schemas
|
| 149 |
+
class LeaveRequestBase(BaseModel):
|
| 150 |
+
start_date: date
|
| 151 |
+
end_date: date
|
| 152 |
+
leave_type: str
|
| 153 |
+
reason: Optional[str] = None
|
| 154 |
+
|
| 155 |
+
class LeaveRequestCreate(LeaveRequestBase):
|
| 156 |
+
employee_id: int
|
| 157 |
+
|
| 158 |
+
class LeaveRequestUpdate(BaseModel):
|
| 159 |
+
status: str # Approved, Rejected
|
| 160 |
+
|
| 161 |
+
class LeaveRequestOut(LeaveRequestBase):
|
| 162 |
+
id: int
|
| 163 |
+
employee_id: int
|
| 164 |
+
status: str
|
| 165 |
+
approved_by: Optional[int] = None
|
| 166 |
+
created_at: Optional[datetime] = None
|
| 167 |
+
employee: Optional[EmployeeOut] = None
|
| 168 |
+
model_config = ConfigDict(from_attributes=True)
|
| 169 |
+
|
| 170 |
+
# Holiday Schemas
|
| 171 |
+
class HolidayBase(BaseModel):
|
| 172 |
+
name: str
|
| 173 |
+
date: date
|
| 174 |
+
description: Optional[str] = None
|
| 175 |
+
|
| 176 |
+
class HolidayCreate(HolidayBase):
|
| 177 |
+
pass
|
| 178 |
+
|
| 179 |
+
class HolidayOut(HolidayBase):
|
| 180 |
+
id: int
|
| 181 |
+
model_config = ConfigDict(from_attributes=True)
|
| 182 |
+
|
| 183 |
+
# Setting Schemas
|
| 184 |
+
class SettingBase(BaseModel):
|
| 185 |
+
key: str
|
| 186 |
+
value: str
|
| 187 |
+
description: Optional[str] = None
|
| 188 |
+
|
| 189 |
+
class SettingUpdate(BaseModel):
|
| 190 |
+
value: str
|
| 191 |
+
|
| 192 |
+
class SettingOut(SettingBase):
|
| 193 |
+
id: int
|
| 194 |
+
model_config = ConfigDict(from_attributes=True)
|
| 195 |
+
|
| 196 |
+
# Audit Log Schemas
|
| 197 |
+
class UserEmailOut(BaseModel):
|
| 198 |
+
email: EmailStr
|
| 199 |
+
model_config = ConfigDict(from_attributes=True)
|
| 200 |
+
|
| 201 |
+
class AuditLogOut(BaseModel):
|
| 202 |
+
id: int
|
| 203 |
+
user_id: Optional[int] = None
|
| 204 |
+
user: Optional[UserEmailOut] = None
|
| 205 |
+
action: str
|
| 206 |
+
timestamp: Optional[datetime] = None
|
| 207 |
+
ip_address: Optional[str] = None
|
| 208 |
+
user_agent: Optional[str] = None
|
| 209 |
+
details: Optional[str] = None
|
| 210 |
+
model_config = ConfigDict(from_attributes=True)
|
backend/app/services/face_engine.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import cv2
|
| 3 |
+
import numpy as np
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
import onnxruntime as ort
|
| 8 |
+
except ImportError:
|
| 9 |
+
ort = None
|
| 10 |
+
|
| 11 |
+
from app.core.config import settings
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger("FaceEngine")
|
| 14 |
+
|
| 15 |
+
class FaceEngine:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.models_dir = settings.MODELS_DIR
|
| 18 |
+
self.mock_mode = False
|
| 19 |
+
self.embeddings_cache = None
|
| 20 |
+
|
| 21 |
+
# Paths to models
|
| 22 |
+
self.det_model_path = os.path.join(self.models_dir, "det_2.5g.onnx")
|
| 23 |
+
self.rec_model_path = os.path.join(self.models_dir, "w600k_r50.onnx")
|
| 24 |
+
self.liveness_model_27 = os.path.join(self.models_dir, "2.7k_80x80.onnx")
|
| 25 |
+
self.liveness_model_18 = os.path.join(self.models_dir, "1.8k_128x128.onnx")
|
| 26 |
+
|
| 27 |
+
# Check if ORT is available
|
| 28 |
+
if ort is None:
|
| 29 |
+
logger.warning("onnxruntime is not installed. Running in MOCK MODE.")
|
| 30 |
+
self.mock_mode = True
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
# Check if all models are present
|
| 34 |
+
# Check if all models are present (1.8 liveness is optional)
|
| 35 |
+
required_models = [self.det_model_path, self.rec_model_path, self.liveness_model_27]
|
| 36 |
+
missing = [m for m in required_models if not os.path.exists(m)]
|
| 37 |
+
|
| 38 |
+
if missing:
|
| 39 |
+
logger.warning(f"The following models are missing: {missing}. Running in MOCK MODE.")
|
| 40 |
+
logger.warning("To run in production mode, please execute the download_models.py script.")
|
| 41 |
+
self.mock_mode = True
|
| 42 |
+
return
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# Initialize ONNX Runtime Inference Sessions
|
| 46 |
+
# CPU Execution Provider is used by default for cross-platform compatibility
|
| 47 |
+
opts = ort.SessionOptions()
|
| 48 |
+
opts.intra_op_num_threads = 4
|
| 49 |
+
|
| 50 |
+
providers = ['CPUExecutionProvider']
|
| 51 |
+
# If GPU is available (optional setup)
|
| 52 |
+
if 'CUDAExecutionProvider' in ort.get_available_providers():
|
| 53 |
+
providers = ['CUDAExecutionProvider'] + providers
|
| 54 |
+
|
| 55 |
+
logger.info(f"Initializing ONNX sessions with providers: {providers}")
|
| 56 |
+
|
| 57 |
+
self.det_session = ort.InferenceSession(self.det_model_path, opts, providers=providers)
|
| 58 |
+
self.rec_session = ort.InferenceSession(self.rec_model_path, opts, providers=providers)
|
| 59 |
+
self.live_session_27 = ort.InferenceSession(self.liveness_model_27, opts, providers=providers)
|
| 60 |
+
|
| 61 |
+
# Optional 1.8 liveness model
|
| 62 |
+
if os.path.exists(self.liveness_model_18):
|
| 63 |
+
self.live_session_18 = ort.InferenceSession(self.liveness_model_18, opts, providers=providers)
|
| 64 |
+
else:
|
| 65 |
+
self.live_session_18 = None
|
| 66 |
+
|
| 67 |
+
logger.info("FaceEngine initialized successfully with all required AI models.")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"Error initializing ONNX sessions: {e}. Falling back to MOCK MODE.")
|
| 70 |
+
self.mock_mode = True
|
| 71 |
+
|
| 72 |
+
def detect_faces(self, image_np, conf_threshold=0.5):
|
| 73 |
+
"""
|
| 74 |
+
Detects faces using SCRFD detector.
|
| 75 |
+
Returns: list of dicts [{"bbox": [x1, y1, x2, y2], "confidence": score, "landmarks": [[x,y], ...]}]
|
| 76 |
+
"""
|
| 77 |
+
if self.mock_mode:
|
| 78 |
+
# Mock face detection: assume one face in the center of the image
|
| 79 |
+
h, w = image_np.shape[:2]
|
| 80 |
+
cx, cy = w // 2, h // 2
|
| 81 |
+
bw, bh = int(w * 0.4), int(h * 0.5)
|
| 82 |
+
x1, y1 = max(0, cx - bw // 2), max(0, cy - bh // 2)
|
| 83 |
+
x2, y2 = min(w, cx + bw // 2), min(h, cy + bh // 2)
|
| 84 |
+
|
| 85 |
+
mock_landmarks = [
|
| 86 |
+
[cx - bw // 6, cy - bh // 8], # Left Eye
|
| 87 |
+
[cx + bw // 6, cy - bh // 8], # Right Eye
|
| 88 |
+
[cx, cy], # Nose
|
| 89 |
+
[cx - bw // 8, cy + bh // 6], # Left Mouth
|
| 90 |
+
[cx + bw // 8, cy + bh // 6] # Right Mouth
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
return [{
|
| 94 |
+
"bbox": [float(x1), float(y1), float(x2), float(y2)],
|
| 95 |
+
"confidence": 0.99,
|
| 96 |
+
"landmarks": mock_landmarks
|
| 97 |
+
}]
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
# SCRFD Input preparation
|
| 101 |
+
h, w = image_np.shape[:2]
|
| 102 |
+
# Resizing image for SCRFD (usually fits within 640x640)
|
| 103 |
+
target_size = 640
|
| 104 |
+
scale = target_size / max(h, w)
|
| 105 |
+
nh, nw = int(h * scale), int(w * scale)
|
| 106 |
+
resized = cv2.resize(image_np, (nw, nh))
|
| 107 |
+
|
| 108 |
+
# Pad to square 640x640
|
| 109 |
+
padded = np.zeros((target_size, target_size, 3), dtype=np.uint8)
|
| 110 |
+
padded[:nh, :nw, :] = resized
|
| 111 |
+
|
| 112 |
+
# BGR to RGB, normalize, batch/channel layout
|
| 113 |
+
blob = padded.astype(np.float32)
|
| 114 |
+
blob = (blob - 127.5) / 128.0
|
| 115 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 116 |
+
blob = np.expand_dims(blob, axis=0)
|
| 117 |
+
|
| 118 |
+
# SCRFD forward pass
|
| 119 |
+
outputs = self.det_session.run(None, {self.det_session.get_inputs()[0].name: blob})
|
| 120 |
+
|
| 121 |
+
# Postprocessing outputs (SCRFD outputs scores, bbox, and landmarks at 3 scales)
|
| 122 |
+
# For a simpler compile-free approach, we will extract face boxes using a standard heuristic
|
| 123 |
+
# Or simplified processing. Since SCRFD outputs are complex (strides 8, 16, 32),
|
| 124 |
+
# let's map them.
|
| 125 |
+
# In mock mode / fallback, if full parsing fails, we fallback to a simpler detector or mock.
|
| 126 |
+
# Let's write the parsing logic for SCRFD.
|
| 127 |
+
# However, to avoid bugs in complex anchor generation, we can implement it robustly:
|
| 128 |
+
faces = self._parse_scrfd(outputs, scale, w, h, conf_threshold)
|
| 129 |
+
|
| 130 |
+
# If SCRFD returns nothing, we attempt OpenCV Haar Cascade as a secondary fallback,
|
| 131 |
+
# but standard is SCRFD.
|
| 132 |
+
return faces
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Error in detect_faces: {e}")
|
| 135 |
+
return []
|
| 136 |
+
|
| 137 |
+
def _parse_scrfd(self, outputs, scale, orig_w, orig_h, conf_threshold):
|
| 138 |
+
"""
|
| 139 |
+
Parse SCRFD ONNX model outputs into face detections.
|
| 140 |
+
|
| 141 |
+
The det_2.5g.onnx model outputs 9 tensors (3 strides x 3 types):
|
| 142 |
+
outputs[0,1,2]: scores shape (N_anchors_at_stride, 1) -- strides 8,16,32
|
| 143 |
+
outputs[3,4,5]: bbox_pred shape (N_anchors_at_stride, 4) -- strides 8,16,32
|
| 144 |
+
outputs[6,7,8]: kps_pred shape (N_anchors_at_stride, 10) -- strides 8,16,32
|
| 145 |
+
|
| 146 |
+
Anchors per stride = (640/stride)^2 * num_anchors_per_cell (typically 2)
|
| 147 |
+
"""
|
| 148 |
+
input_h = input_w = 640 # The padded input size used during preprocessing
|
| 149 |
+
strides = [8, 16, 32]
|
| 150 |
+
num_anchors = 2 # SCRFD 2.5G uses 2 anchors per cell
|
| 151 |
+
faces = []
|
| 152 |
+
|
| 153 |
+
for idx, stride in enumerate(strides):
|
| 154 |
+
scores_raw = outputs[idx] # (N, 1)
|
| 155 |
+
bbox_raw = outputs[idx + 3] # (N, 4)
|
| 156 |
+
kps_raw = outputs[idx + 6] # (N, 10)
|
| 157 |
+
|
| 158 |
+
# Generate anchor center points for this stride
|
| 159 |
+
feat_h = input_h // stride
|
| 160 |
+
feat_w = input_w // stride
|
| 161 |
+
|
| 162 |
+
# Create grid of anchor centers: (feat_h * feat_w * num_anchors, 2)
|
| 163 |
+
anchor_centers = []
|
| 164 |
+
for ay in range(feat_h):
|
| 165 |
+
for ax in range(feat_w):
|
| 166 |
+
for _ in range(num_anchors):
|
| 167 |
+
# Center is (ax + 0.5) * stride, (ay + 0.5) * stride
|
| 168 |
+
cx = (ax + 0.5) * stride
|
| 169 |
+
cy = (ay + 0.5) * stride
|
| 170 |
+
anchor_centers.append([cx, cy])
|
| 171 |
+
anchor_centers = np.array(anchor_centers, dtype=np.float32) # (N, 2)
|
| 172 |
+
|
| 173 |
+
# Filter by confidence
|
| 174 |
+
scores = scores_raw[:, 0] # (N,)
|
| 175 |
+
valid_mask = scores >= conf_threshold
|
| 176 |
+
valid_indices = np.where(valid_mask)[0]
|
| 177 |
+
|
| 178 |
+
if len(valid_indices) == 0:
|
| 179 |
+
continue
|
| 180 |
+
|
| 181 |
+
valid_scores = scores[valid_indices]
|
| 182 |
+
valid_bbox = bbox_raw[valid_indices] # (K, 4)
|
| 183 |
+
valid_kps = kps_raw[valid_indices] # (K, 10)
|
| 184 |
+
valid_anchors = anchor_centers[valid_indices] # (K, 2)
|
| 185 |
+
|
| 186 |
+
# Decode bounding boxes: distance from anchor center
|
| 187 |
+
# SCRFD predicts [left, top, right, bottom] distances, scaled by stride
|
| 188 |
+
x1 = valid_anchors[:, 0] - valid_bbox[:, 0] * stride
|
| 189 |
+
y1 = valid_anchors[:, 1] - valid_bbox[:, 1] * stride
|
| 190 |
+
x2 = valid_anchors[:, 0] + valid_bbox[:, 2] * stride
|
| 191 |
+
y2 = valid_anchors[:, 1] + valid_bbox[:, 3] * stride
|
| 192 |
+
|
| 193 |
+
# Decode landmarks: 5 keypoints, each (dx, dy) from anchor center
|
| 194 |
+
# kps shape is (K, 10) where [0::2] are x offsets, [1::2] are y offsets
|
| 195 |
+
|
| 196 |
+
for i in range(len(valid_indices)):
|
| 197 |
+
# Rescale back to original image coordinates
|
| 198 |
+
rx1 = float(max(0, x1[i] / scale))
|
| 199 |
+
ry1 = float(max(0, y1[i] / scale))
|
| 200 |
+
rx2 = float(min(orig_w, x2[i] / scale))
|
| 201 |
+
ry2 = float(min(orig_h, y2[i] / scale))
|
| 202 |
+
|
| 203 |
+
landmarks = []
|
| 204 |
+
for k in range(5):
|
| 205 |
+
# kps layout: [kp0_x, kp0_y, kp1_x, kp1_y, ...]
|
| 206 |
+
kx = float((valid_anchors[i, 0] + valid_kps[i, k*2] * stride) / scale)
|
| 207 |
+
ky = float((valid_anchors[i, 1] + valid_kps[i, k*2+1] * stride) / scale)
|
| 208 |
+
kx = max(0, min(orig_w, kx))
|
| 209 |
+
ky = max(0, min(orig_h, ky))
|
| 210 |
+
landmarks.append([kx, ky])
|
| 211 |
+
|
| 212 |
+
faces.append({
|
| 213 |
+
"bbox": [rx1, ry1, rx2, ry2],
|
| 214 |
+
"confidence": float(valid_scores[i]),
|
| 215 |
+
"landmarks": landmarks
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
# Non-Maximum Suppression
|
| 219 |
+
faces = self._nms(faces, iou_threshold=0.4)
|
| 220 |
+
return faces
|
| 221 |
+
|
| 222 |
+
def _nms(self, faces, iou_threshold):
|
| 223 |
+
if not faces:
|
| 224 |
+
return []
|
| 225 |
+
|
| 226 |
+
# Sort by confidence descending
|
| 227 |
+
faces = sorted(faces, key=lambda x: x["confidence"], reverse=True)
|
| 228 |
+
keep = []
|
| 229 |
+
|
| 230 |
+
while faces:
|
| 231 |
+
best = faces.pop(0)
|
| 232 |
+
keep.append(best)
|
| 233 |
+
|
| 234 |
+
# Compare with remaining
|
| 235 |
+
remaining = []
|
| 236 |
+
for f in faces:
|
| 237 |
+
iou = self._iou(best["bbox"], f["bbox"])
|
| 238 |
+
if iou < iou_threshold:
|
| 239 |
+
remaining.append(f)
|
| 240 |
+
faces = remaining
|
| 241 |
+
|
| 242 |
+
return keep
|
| 243 |
+
|
| 244 |
+
def _iou(self, box1, box2):
|
| 245 |
+
x1_1, y1_1, x2_1, y2_1 = box1
|
| 246 |
+
x1_2, y1_2, x2_2, y2_2 = box2
|
| 247 |
+
|
| 248 |
+
xi1 = max(x1_1, x1_2)
|
| 249 |
+
yi1 = max(y1_1, y1_2)
|
| 250 |
+
xi2 = min(x2_1, x2_2)
|
| 251 |
+
yi2 = min(y2_1, y2_2)
|
| 252 |
+
|
| 253 |
+
inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1)
|
| 254 |
+
box1_area = (x2_1 - x1_1) * (y2_1 - y1_1)
|
| 255 |
+
box2_area = (x2_2 - x1_2) * (y2_2 - y1_2)
|
| 256 |
+
union_area = box1_area + box2_area - inter_area
|
| 257 |
+
|
| 258 |
+
return inter_area / union_area if union_area > 0 else 0
|
| 259 |
+
|
| 260 |
+
def align_face(self, image_np, landmarks):
|
| 261 |
+
"""
|
| 262 |
+
Aligns the face using the 5 landmarks using standard similarity transformation.
|
| 263 |
+
Output is 112x112 image, standard for ArcFace.
|
| 264 |
+
"""
|
| 265 |
+
if not landmarks or len(landmarks) < 5:
|
| 266 |
+
# Fallback to simple center crop if landmarks are missing
|
| 267 |
+
return cv2.resize(image_np, (112, 112))
|
| 268 |
+
|
| 269 |
+
# Standard ArcFace reference points
|
| 270 |
+
reference_landmarks = np.array([
|
| 271 |
+
[38.2946, 51.6963], # Left Eye
|
| 272 |
+
[73.5318, 51.6963], # Right Eye
|
| 273 |
+
[56.0252, 71.7366], # Nose
|
| 274 |
+
[41.5493, 92.3655], # Left Mouth Corner
|
| 275 |
+
[70.7299, 92.3655] # Right Mouth Corner
|
| 276 |
+
], dtype=np.float32)
|
| 277 |
+
|
| 278 |
+
src = np.array(landmarks, dtype=np.float32)
|
| 279 |
+
|
| 280 |
+
# Estimate similarity transform matrix
|
| 281 |
+
# cv2.estimateAffinePartial2D finds a similarity transform (rotation, translation, scaling)
|
| 282 |
+
M, inliers = cv2.estimateAffinePartial2D(src, reference_landmarks)
|
| 283 |
+
if M is None:
|
| 284 |
+
# Fallback
|
| 285 |
+
return cv2.resize(image_np, (112, 112))
|
| 286 |
+
|
| 287 |
+
# Warp image
|
| 288 |
+
aligned = cv2.warpAffine(image_np, M, (112, 112))
|
| 289 |
+
return aligned
|
| 290 |
+
|
| 291 |
+
def extract_embedding(self, aligned_face):
|
| 292 |
+
"""
|
| 293 |
+
Extracts 512-D face embedding vector using ArcFace model.
|
| 294 |
+
Returns a normalized 512-D numpy array.
|
| 295 |
+
"""
|
| 296 |
+
if self.mock_mode:
|
| 297 |
+
# MOCK MODE: Generate a stable embedding that is consistent across frames
|
| 298 |
+
# for the same person by downsampling + quantizing the face image.
|
| 299 |
+
# Raw pixel sum was too sensitive to lighting changes - every frame got a
|
| 300 |
+
# different random seed, making enrollment and kiosk scan embeddings never match.
|
| 301 |
+
#
|
| 302 |
+
# New approach: downsample to 4x4 (16 pixels), quantize to 8 levels (0-7),
|
| 303 |
+
# and create a 16-digit seed string -> same face = same seed across sessions.
|
| 304 |
+
try:
|
| 305 |
+
tiny = cv2.resize(aligned_face, (4, 4), interpolation=cv2.INTER_AREA)
|
| 306 |
+
# Convert to grayscale for robustness to minor color/lighting shifts
|
| 307 |
+
if len(tiny.shape) == 3:
|
| 308 |
+
tiny_gray = cv2.cvtColor(tiny, cv2.COLOR_BGR2GRAY)
|
| 309 |
+
else:
|
| 310 |
+
tiny_gray = tiny
|
| 311 |
+
# Quantize to 8 levels (0-7) - tolerant of minor lighting variation
|
| 312 |
+
quantized = (tiny_gray // 32).flatten() # values 0-7
|
| 313 |
+
seed_str = ''.join([str(v) for v in quantized])
|
| 314 |
+
seed_val = int(seed_str, 8) % 2147483647 # convert octal string to int
|
| 315 |
+
except Exception:
|
| 316 |
+
# Ultimate fallback: any stable value
|
| 317 |
+
seed_val = 42
|
| 318 |
+
|
| 319 |
+
np.random.seed(seed_val)
|
| 320 |
+
vec = np.random.randn(512).astype(np.float32)
|
| 321 |
+
# Normalize to unit vector
|
| 322 |
+
norm = np.linalg.norm(vec)
|
| 323 |
+
return vec / norm if norm > 0 else vec
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
# ArcFace input preprocessing:
|
| 327 |
+
# Face is 112x112, channel layout is BGR.
|
| 328 |
+
# Model expects RGB or BGR depending on export. w600k_r50 expects BGR or RGB (usually (pixel - 127.5) / 128.0)
|
| 329 |
+
# Let's process: (image - 127.5) / 128.0
|
| 330 |
+
# w600k_r50 ONNX from Insightface expects float32 input [1, 3, 112, 112]
|
| 331 |
+
blob = aligned_face.astype(np.float32)
|
| 332 |
+
# w600k_r50 usually expects BGR representation but normalized
|
| 333 |
+
blob = (blob - 127.5) / 128.0
|
| 334 |
+
blob = np.transpose(blob, (2, 0, 1))
|
| 335 |
+
blob = np.expand_dims(blob, axis=0)
|
| 336 |
+
|
| 337 |
+
# ArcFace forward pass
|
| 338 |
+
outputs = self.rec_session.run(None, {self.rec_session.get_inputs()[0].name: blob})
|
| 339 |
+
embedding = outputs[0][0]
|
| 340 |
+
|
| 341 |
+
# Normalize vector to unit length (L2 norm)
|
| 342 |
+
norm = np.linalg.norm(embedding)
|
| 343 |
+
if norm > 0:
|
| 344 |
+
embedding = embedding / norm
|
| 345 |
+
|
| 346 |
+
return embedding
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.error(f"Error in extract_embedding: {e}")
|
| 349 |
+
# Return random unit vector on failure
|
| 350 |
+
vec = np.random.randn(512).astype(np.float32)
|
| 351 |
+
return vec / np.linalg.norm(vec)
|
| 352 |
+
|
| 353 |
+
def check_liveness(self, image_np, bbox):
|
| 354 |
+
"""
|
| 355 |
+
Silent Face Anti-Spoofing MiniFASNet model.
|
| 356 |
+
Crops face, resizes, runs liveness model.
|
| 357 |
+
Returns: liveness_score (float), is_live (bool)
|
| 358 |
+
"""
|
| 359 |
+
if self.mock_mode:
|
| 360 |
+
# Default mock liveness: Check if the photo is in color and average variance is high
|
| 361 |
+
# We return True for mock testing, with high liveness score (0.95)
|
| 362 |
+
# If the image filename/source contains "spoof" we return False
|
| 363 |
+
return 0.92, True
|
| 364 |
+
|
| 365 |
+
try:
|
| 366 |
+
x1, y1, x2, y2 = bbox
|
| 367 |
+
w, h = x2 - x1, y2 - y1
|
| 368 |
+
|
| 369 |
+
# MiniFASNet uses scaled crops. Let's crop with scale=2.7 for 80x80 model
|
| 370 |
+
scale_27 = 2.7
|
| 371 |
+
cx, cy = x1 + w/2, y1 + h/2
|
| 372 |
+
|
| 373 |
+
# Crop 2.7x bounding box
|
| 374 |
+
w_new, h_new = w * scale_27, h * scale_27
|
| 375 |
+
x1_new = int(max(0, cx - w_new/2))
|
| 376 |
+
y1_new = int(max(0, cy - h_new/2))
|
| 377 |
+
x2_new = int(min(image_np.shape[1], cx + w_new/2))
|
| 378 |
+
y2_new = int(min(image_np.shape[0], cy + h_new/2))
|
| 379 |
+
|
| 380 |
+
crop_27 = image_np[y1_new:y2_new, x1_new:x2_new]
|
| 381 |
+
if crop_27.size == 0:
|
| 382 |
+
return 0.0, False
|
| 383 |
+
|
| 384 |
+
# Resize to 80x80
|
| 385 |
+
resized_27 = cv2.resize(crop_27, (80, 80))
|
| 386 |
+
# Preprocess: Transpose and batch
|
| 387 |
+
blob_27 = np.transpose(resized_27, (2, 0, 1)).astype(np.float32)
|
| 388 |
+
blob_27 = np.expand_dims(blob_27, axis=0)
|
| 389 |
+
|
| 390 |
+
# Run 2.7 model
|
| 391 |
+
output_27 = self.live_session_27.run(None, {self.live_session_27.get_inputs()[0].name: blob_27})[0][0]
|
| 392 |
+
|
| 393 |
+
# Softmax calculation for score
|
| 394 |
+
def softmax(x):
|
| 395 |
+
e_x = np.exp(x - np.max(x))
|
| 396 |
+
return e_x / e_x.sum(axis=0)
|
| 397 |
+
|
| 398 |
+
prob_27 = softmax(output_27)
|
| 399 |
+
score_27 = float(prob_27[1])
|
| 400 |
+
|
| 401 |
+
# If 1.8 model is loaded, average the scores
|
| 402 |
+
if self.live_session_18 is not None:
|
| 403 |
+
# MiniFASNet uses scaled crops. Let's crop with scale=1.8 for 128x128 model
|
| 404 |
+
scale_18 = 1.8
|
| 405 |
+
w_new_18, h_new_18 = w * scale_18, h * scale_18
|
| 406 |
+
x1_new_18 = int(max(0, cx - w_new_18/2))
|
| 407 |
+
y1_new_18 = int(max(0, cy - h_new_18/2))
|
| 408 |
+
x2_new_18 = int(min(image_np.shape[1], cx + w_new_18/2))
|
| 409 |
+
y2_new_18 = int(min(image_np.shape[0], cy + h_new_18/2))
|
| 410 |
+
|
| 411 |
+
crop_18 = image_np[y1_new_18:y2_new_18, x1_new_18:x2_new_18]
|
| 412 |
+
if crop_18.size > 0:
|
| 413 |
+
# Resize to 128x128
|
| 414 |
+
resized_18 = cv2.resize(crop_18, (128, 128))
|
| 415 |
+
# Preprocess: Transpose and batch
|
| 416 |
+
blob_18 = np.transpose(resized_18, (2, 0, 1)).astype(np.float32)
|
| 417 |
+
blob_18 = np.expand_dims(blob_18, axis=0)
|
| 418 |
+
|
| 419 |
+
output_18 = self.live_session_18.run(None, {self.live_session_18.get_inputs()[0].name: blob_18})[0][0]
|
| 420 |
+
prob_18 = softmax(output_18)
|
| 421 |
+
score_18 = float(prob_18[1])
|
| 422 |
+
avg_score = (score_27 + score_18) / 2.0
|
| 423 |
+
else:
|
| 424 |
+
avg_score = score_27
|
| 425 |
+
else:
|
| 426 |
+
avg_score = score_27
|
| 427 |
+
|
| 428 |
+
is_live = avg_score >= settings.KIOSK_LIVENESS_THRESHOLD
|
| 429 |
+
return avg_score, is_live
|
| 430 |
+
|
| 431 |
+
except Exception as e:
|
| 432 |
+
logger.error(f"Error in check_liveness: {e}")
|
| 433 |
+
return 0.0, False
|
| 434 |
+
|
| 435 |
+
def cosine_similarity(self, embedding1, embedding2):
|
| 436 |
+
"""
|
| 437 |
+
Computes cosine similarity between two 512-D embeddings.
|
| 438 |
+
Since they are L2-normalized, cosine similarity is just the dot product.
|
| 439 |
+
"""
|
| 440 |
+
return float(np.dot(embedding1, embedding2))
|
| 441 |
+
|
| 442 |
+
def load_embeddings_cache(self, db_session):
|
| 443 |
+
from app.models import models
|
| 444 |
+
try:
|
| 445 |
+
records = db_session.query(models.FaceEmbedding).all()
|
| 446 |
+
cache = []
|
| 447 |
+
for r in records:
|
| 448 |
+
# SQLite stores vectors as JSON text, while postgres returns native lists
|
| 449 |
+
if isinstance(r.embedding, str):
|
| 450 |
+
import json
|
| 451 |
+
vec = np.array(json.loads(r.embedding), dtype=np.float32)
|
| 452 |
+
else:
|
| 453 |
+
vec = np.array(r.embedding, dtype=np.float32)
|
| 454 |
+
cache.append({
|
| 455 |
+
"id": r.id,
|
| 456 |
+
"employee_id": r.employee_id,
|
| 457 |
+
"embedding": vec
|
| 458 |
+
})
|
| 459 |
+
self.embeddings_cache = cache
|
| 460 |
+
logger.info(f"Loaded {len(cache)} face embeddings into local memory cache.")
|
| 461 |
+
except Exception as e:
|
| 462 |
+
logger.error(f"Failed to load embeddings cache: {e}")
|
| 463 |
+
self.embeddings_cache = []
|
| 464 |
+
|
| 465 |
+
def invalidate_cache(self):
|
| 466 |
+
self.embeddings_cache = None
|
| 467 |
+
logger.info("FaceEngine memory cache invalidated.")
|
backend/app/services/reports.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from datetime import datetime, date
|
| 5 |
+
|
| 6 |
+
# ReportLab imports for PDF generation
|
| 7 |
+
from reportlab.lib.pagesizes import letter
|
| 8 |
+
from reportlab.lib import colors
|
| 9 |
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
| 10 |
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| 11 |
+
|
| 12 |
+
class ReportGenerator:
|
| 13 |
+
@staticmethod
|
| 14 |
+
def to_csv(data: List[Dict[str, Any]]) -> str:
|
| 15 |
+
"""
|
| 16 |
+
Converts list of dicts to CSV string.
|
| 17 |
+
"""
|
| 18 |
+
if not data:
|
| 19 |
+
return ""
|
| 20 |
+
df = pd.DataFrame(data)
|
| 21 |
+
return df.to_csv(index=False)
|
| 22 |
+
|
| 23 |
+
@staticmethod
|
| 24 |
+
def to_xlsx(data: List[Dict[str, Any]], sheet_name: str = "Report") -> bytes:
|
| 25 |
+
"""
|
| 26 |
+
Converts list of dicts to Excel binary stream.
|
| 27 |
+
"""
|
| 28 |
+
output = io.BytesIO()
|
| 29 |
+
if not data:
|
| 30 |
+
# Create an empty excel file
|
| 31 |
+
df = pd.DataFrame([{"Message": "No data available"}])
|
| 32 |
+
else:
|
| 33 |
+
df = pd.DataFrame(data)
|
| 34 |
+
|
| 35 |
+
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
| 36 |
+
df.to_excel(writer, index=False, sheet_name=sheet_name)
|
| 37 |
+
|
| 38 |
+
# Format sheet with openpyxl
|
| 39 |
+
workbook = writer.book
|
| 40 |
+
worksheet = writer.sheets[sheet_name]
|
| 41 |
+
|
| 42 |
+
# Auto-fit columns
|
| 43 |
+
for col in worksheet.columns:
|
| 44 |
+
max_len = max(len(str(cell.value or '')) for cell in col)
|
| 45 |
+
col_letter = col[0].column_letter
|
| 46 |
+
worksheet.column_dimensions[col_letter].width = max(max_len + 3, 10)
|
| 47 |
+
|
| 48 |
+
output.seek(0)
|
| 49 |
+
return output.getvalue()
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
def to_pdf(title: str, headers: List[str], rows: List[List[Any]], metadata: Dict[str, str] = None) -> bytes:
|
| 53 |
+
"""
|
| 54 |
+
Generates a premium, clean PDF report using ReportLab.
|
| 55 |
+
"""
|
| 56 |
+
buffer = io.BytesIO()
|
| 57 |
+
doc = SimpleDocTemplate(
|
| 58 |
+
buffer,
|
| 59 |
+
pagesize=letter,
|
| 60 |
+
rightMargin=36,
|
| 61 |
+
leftMargin=36,
|
| 62 |
+
topMargin=36,
|
| 63 |
+
bottomMargin=36
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
styles = getSampleStyleSheet()
|
| 67 |
+
|
| 68 |
+
# Custom styles
|
| 69 |
+
title_style = ParagraphStyle(
|
| 70 |
+
name='ReportTitle',
|
| 71 |
+
parent=styles['Heading1'],
|
| 72 |
+
fontName='Helvetica-Bold',
|
| 73 |
+
fontSize=22,
|
| 74 |
+
leading=26,
|
| 75 |
+
textColor=colors.HexColor('#0F172A'), # Slate 900
|
| 76 |
+
spaceAfter=15
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
meta_label_style = ParagraphStyle(
|
| 80 |
+
name='MetaLabel',
|
| 81 |
+
parent=styles['Normal'],
|
| 82 |
+
fontName='Helvetica-Bold',
|
| 83 |
+
fontSize=10,
|
| 84 |
+
leading=14,
|
| 85 |
+
textColor=colors.HexColor('#475569') # Slate 600
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
meta_val_style = ParagraphStyle(
|
| 89 |
+
name='MetaVal',
|
| 90 |
+
parent=styles['Normal'],
|
| 91 |
+
fontName='Helvetica',
|
| 92 |
+
fontSize=10,
|
| 93 |
+
leading=14,
|
| 94 |
+
textColor=colors.HexColor('#0F172A')
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
table_header_style = ParagraphStyle(
|
| 98 |
+
name='TableHeader',
|
| 99 |
+
parent=styles['Normal'],
|
| 100 |
+
fontName='Helvetica-Bold',
|
| 101 |
+
fontSize=9,
|
| 102 |
+
leading=12,
|
| 103 |
+
textColor=colors.white
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
table_cell_style = ParagraphStyle(
|
| 107 |
+
name='TableCell',
|
| 108 |
+
parent=styles['Normal'],
|
| 109 |
+
fontName='Helvetica',
|
| 110 |
+
fontSize=9,
|
| 111 |
+
leading=12,
|
| 112 |
+
textColor=colors.HexColor('#334155') # Slate 700
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
elements = []
|
| 116 |
+
|
| 117 |
+
# Add Title
|
| 118 |
+
elements.append(Paragraph(title, title_style))
|
| 119 |
+
elements.append(Spacer(1, 10))
|
| 120 |
+
|
| 121 |
+
# Add Metadata Block
|
| 122 |
+
if metadata:
|
| 123 |
+
meta_data = []
|
| 124 |
+
keys = list(metadata.keys())
|
| 125 |
+
for i in range(0, len(keys), 2):
|
| 126 |
+
row = []
|
| 127 |
+
# First col
|
| 128 |
+
k1 = keys[i]
|
| 129 |
+
row.extend([Paragraph(k1, meta_label_style), Paragraph(metadata[k1], meta_val_style)])
|
| 130 |
+
# Second col
|
| 131 |
+
if i + 1 < len(keys):
|
| 132 |
+
k2 = keys[i+1]
|
| 133 |
+
row.extend([Paragraph(k2, meta_label_style), Paragraph(metadata[k2], meta_val_style)])
|
| 134 |
+
else:
|
| 135 |
+
row.extend([Paragraph("", meta_label_style), Paragraph("", meta_val_style)])
|
| 136 |
+
meta_data.append(row)
|
| 137 |
+
|
| 138 |
+
meta_table = Table(meta_data, colWidths=[100, 170, 100, 170])
|
| 139 |
+
meta_table.setStyle(TableStyle([
|
| 140 |
+
('ALIGN', (0,0), (-1,-1), 'LEFT'),
|
| 141 |
+
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
| 142 |
+
('BOTTOMPADDING', (0,0), (-1,-1), 4),
|
| 143 |
+
('TOPPADDING', (0,0), (-1,-1), 4),
|
| 144 |
+
]))
|
| 145 |
+
elements.append(meta_table)
|
| 146 |
+
elements.append(Spacer(1, 20))
|
| 147 |
+
|
| 148 |
+
# Draw a line
|
| 149 |
+
line_table = Table([[""]], colWidths=[540], rowHeights=[1])
|
| 150 |
+
line_table.setStyle(TableStyle([
|
| 151 |
+
('LINEABOVE', (0,0), (-1,-1), 1, colors.HexColor('#E2E8F0')),
|
| 152 |
+
]))
|
| 153 |
+
elements.append(line_table)
|
| 154 |
+
elements.append(Spacer(1, 15))
|
| 155 |
+
|
| 156 |
+
# Prepare Data Table
|
| 157 |
+
table_data = []
|
| 158 |
+
# Header row
|
| 159 |
+
table_data.append([Paragraph(h, table_header_style) for h in headers])
|
| 160 |
+
|
| 161 |
+
# Data rows
|
| 162 |
+
for r in rows:
|
| 163 |
+
table_data.append([Paragraph(str(cell), table_cell_style) for cell in r])
|
| 164 |
+
|
| 165 |
+
# Calculate widths dynamically
|
| 166 |
+
col_width = 540 / len(headers)
|
| 167 |
+
data_table = Table(table_data, colWidths=[col_width] * len(headers))
|
| 168 |
+
|
| 169 |
+
# Table Styling
|
| 170 |
+
grid_style = TableStyle([
|
| 171 |
+
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#0F172A')), # Dark Header
|
| 172 |
+
('ALIGN', (0,0), (-1,-1), 'LEFT'),
|
| 173 |
+
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
| 174 |
+
('TOPPADDING', (0,0), (-1,-1), 6),
|
| 175 |
+
('BOTTOMPADDING', (0,0), (-1,-1), 6),
|
| 176 |
+
('LEFTPADDING', (0,0), (-1,-1), 8),
|
| 177 |
+
('RIGHTPADDING', (0,0), (-1,-1), 8),
|
| 178 |
+
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#F1F5F9')),
|
| 179 |
+
])
|
| 180 |
+
|
| 181 |
+
# Alternating row colors
|
| 182 |
+
for i in range(1, len(table_data)):
|
| 183 |
+
if i % 2 == 0:
|
| 184 |
+
grid_style.add('BACKGROUND', (0, i), (-1, i), colors.HexColor('#F8FAFC'))
|
| 185 |
+
|
| 186 |
+
data_table.setStyle(grid_style)
|
| 187 |
+
elements.append(data_table)
|
| 188 |
+
|
| 189 |
+
# Build Document
|
| 190 |
+
doc.build(elements)
|
| 191 |
+
buffer.seek(0)
|
| 192 |
+
return buffer.getvalue()
|
backend/app/services/singletons.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.face_engine import FaceEngine
|
| 2 |
+
from app.services.voice_assistant import VoiceAssistant
|
| 3 |
+
|
| 4 |
+
# Singletons to prevent reloading ONNX models on every request
|
| 5 |
+
face_engine = FaceEngine()
|
| 6 |
+
voice_assistant = VoiceAssistant()
|
backend/app/services/voice_assistant.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
import threading
|
| 4 |
+
import logging
|
| 5 |
+
import wave
|
| 6 |
+
import struct
|
| 7 |
+
import math
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger("VoiceAssistant")
|
| 10 |
+
|
| 11 |
+
# We use pyttsx3 for offline TTS
|
| 12 |
+
try:
|
| 13 |
+
import pyttsx3
|
| 14 |
+
except ImportError:
|
| 15 |
+
pyttsx3 = None
|
| 16 |
+
|
| 17 |
+
class VoiceAssistant:
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self.engine_available = False
|
| 20 |
+
self.lock = threading.Lock()
|
| 21 |
+
|
| 22 |
+
if pyttsx3 is not None:
|
| 23 |
+
# We initialize pyttsx3 inside a lock to prevent multi-threading issues.
|
| 24 |
+
try:
|
| 25 |
+
# Test initialization
|
| 26 |
+
engine = pyttsx3.init()
|
| 27 |
+
# Set voice properties
|
| 28 |
+
engine.setProperty('rate', 150) # Speed percent
|
| 29 |
+
engine.setProperty('volume', 0.9) # Volume 0-1
|
| 30 |
+
self.engine_available = True
|
| 31 |
+
logger.info("pyttsx3 Voice Assistant initialized successfully.")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.warning(f"Failed to initialize pyttsx3 (common on headless servers): {e}. Using pure-python WAV fallback.")
|
| 34 |
+
|
| 35 |
+
def generate_speech_file(self, text: str) -> str:
|
| 36 |
+
"""
|
| 37 |
+
Synthesizes speech to a WAV file and returns the path.
|
| 38 |
+
If pyttsx3 is not available, generates a fallback WAV file with audio.
|
| 39 |
+
"""
|
| 40 |
+
temp_dir = tempfile.gettempdir()
|
| 41 |
+
file_path = os.path.join(temp_dir, f"speech_{hash(text)}.wav")
|
| 42 |
+
|
| 43 |
+
# If file already exists, return it
|
| 44 |
+
if os.path.exists(file_path):
|
| 45 |
+
return file_path
|
| 46 |
+
|
| 47 |
+
if self.engine_available:
|
| 48 |
+
with self.lock:
|
| 49 |
+
try:
|
| 50 |
+
# Initialize locally in the thread to avoid COM apartment errors on Windows
|
| 51 |
+
engine = pyttsx3.init()
|
| 52 |
+
engine.setProperty('rate', 155)
|
| 53 |
+
engine.setProperty('volume', 0.9)
|
| 54 |
+
|
| 55 |
+
# Try to select a female voice if available
|
| 56 |
+
voices = engine.getProperty('voices')
|
| 57 |
+
for voice in voices:
|
| 58 |
+
if "female" in voice.name.lower() or "zira" in voice.name.lower():
|
| 59 |
+
engine.setProperty('voice', voice.id)
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
engine.save_to_file(text, file_path)
|
| 63 |
+
engine.runAndWait()
|
| 64 |
+
|
| 65 |
+
# Verify file was written
|
| 66 |
+
if os.path.exists(file_path) and os.path.getsize(file_path) > 0:
|
| 67 |
+
return file_path
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"pyttsx3 synthesis failed: {e}. Falling back to WAV generator.")
|
| 70 |
+
|
| 71 |
+
# Bulletproof Fallback: Generate a simple double-beep/tone WAV file using pure Python
|
| 72 |
+
# so the application never fails or hangs.
|
| 73 |
+
self._generate_fallback_wav(file_path)
|
| 74 |
+
return file_path
|
| 75 |
+
|
| 76 |
+
def _generate_fallback_wav(self, file_path: str):
|
| 77 |
+
"""
|
| 78 |
+
Generates a 0.5s double-tone beep in a pure python wave file.
|
| 79 |
+
This provides a reliable offline alternative if espeak/SAPI5 is missing.
|
| 80 |
+
"""
|
| 81 |
+
sample_rate = 8000
|
| 82 |
+
duration = 0.4 # seconds
|
| 83 |
+
num_samples = int(duration * sample_rate)
|
| 84 |
+
|
| 85 |
+
# 440 Hz (A4 note) tone
|
| 86 |
+
freq = 440.0
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
with wave.open(file_path, 'wb') as wav_file:
|
| 90 |
+
wav_file.setnchannels(1) # Mono
|
| 91 |
+
wav_file.setsampwidth(2) # 16-bit
|
| 92 |
+
wav_file.setframerate(sample_rate)
|
| 93 |
+
|
| 94 |
+
for i in range(num_samples):
|
| 95 |
+
# Introduce a small gap in the middle to make it sound like a double-beep
|
| 96 |
+
if num_samples // 3 < i < (num_samples // 3 + num_samples // 10):
|
| 97 |
+
val = 0
|
| 98 |
+
else:
|
| 99 |
+
t = float(i) / sample_rate
|
| 100 |
+
# Sine wave
|
| 101 |
+
val = int(32767.0 * 0.5 * math.sin(2.0 * math.pi * freq * t))
|
| 102 |
+
|
| 103 |
+
data = struct.pack('<h', val)
|
| 104 |
+
wav_file.writeframesraw(data)
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Failed to generate fallback wav file: {e}")
|
| 107 |
+
# Write a completely blank file as a last resort
|
| 108 |
+
with open(file_path, 'wb') as f:
|
| 109 |
+
f.write(b'')
|
backend/app/tests/conftest.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from sqlalchemy import create_engine
|
| 4 |
+
from sqlalchemy.orm import sessionmaker
|
| 5 |
+
from unittest.mock import MagicMock
|
| 6 |
+
|
| 7 |
+
from app.main import app
|
| 8 |
+
from app.core.database import get_db, Base
|
| 9 |
+
from app.core.security import get_current_user
|
| 10 |
+
from app.models import models
|
| 11 |
+
|
| 12 |
+
# Create a mock database session
|
| 13 |
+
@pytest.fixture(scope="session")
|
| 14 |
+
def db_session():
|
| 15 |
+
mock_session = MagicMock()
|
| 16 |
+
return mock_session
|
| 17 |
+
|
| 18 |
+
# Override the database dependency in FastAPI
|
| 19 |
+
@pytest.fixture(autouse=True)
|
| 20 |
+
def override_db(db_session):
|
| 21 |
+
def _get_db_override():
|
| 22 |
+
yield db_session
|
| 23 |
+
app.dependency_overrides[get_db] = _get_db_override
|
| 24 |
+
yield
|
| 25 |
+
app.dependency_overrides.pop(get_db, None)
|
| 26 |
+
|
| 27 |
+
# Client fixture for requests
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def client():
|
| 30 |
+
with TestClient(app) as test_client:
|
| 31 |
+
yield test_client
|
| 32 |
+
|
| 33 |
+
# Mock Current User to bypass JWT auth in CRUD endpoint tests
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
def mock_admin_user():
|
| 36 |
+
user = models.User(
|
| 37 |
+
id=1,
|
| 38 |
+
email="admin@netraid.ai",
|
| 39 |
+
hashed_password="hashed_password",
|
| 40 |
+
is_active=True,
|
| 41 |
+
role_id=1
|
| 42 |
+
)
|
| 43 |
+
role = models.Role(id=1, name="Super Admin")
|
| 44 |
+
user.role = role
|
| 45 |
+
return user
|
| 46 |
+
|
| 47 |
+
@pytest.fixture
|
| 48 |
+
def authenticated_client(mock_admin_user):
|
| 49 |
+
def _get_current_user_override():
|
| 50 |
+
return mock_admin_user
|
| 51 |
+
app.dependency_overrides[get_current_user] = _get_current_user_override
|
| 52 |
+
with TestClient(app) as test_client:
|
| 53 |
+
yield test_client
|
| 54 |
+
app.dependency_overrides.pop(get_current_user, None)
|
backend/app/tests/test_auth.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unittest.mock import patch
|
| 2 |
+
from app.models import models
|
| 3 |
+
|
| 4 |
+
def test_health_check(client):
|
| 5 |
+
response = client.get("/health")
|
| 6 |
+
assert response.status_code == 200
|
| 7 |
+
assert response.json()["status"] == "healthy"
|
| 8 |
+
|
| 9 |
+
@patch("app.api.v1.auth.crud.get_user_by_email")
|
| 10 |
+
@patch("app.api.v1.auth.crud.verify_password")
|
| 11 |
+
@patch("app.api.v1.auth.crud.create_audit_log")
|
| 12 |
+
def test_login_success(mock_audit, mock_verify, mock_get_user, client):
|
| 13 |
+
# Mock data
|
| 14 |
+
mock_role = models.Role(id=1, name="Super Admin")
|
| 15 |
+
mock_user = models.User(
|
| 16 |
+
id=1,
|
| 17 |
+
email="admin@netraid.ai",
|
| 18 |
+
hashed_password="hashed_password",
|
| 19 |
+
is_active=True,
|
| 20 |
+
role_id=1,
|
| 21 |
+
role=mock_role
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
mock_get_user.return_value = mock_user
|
| 25 |
+
mock_verify.return_value = True
|
| 26 |
+
|
| 27 |
+
response = client.post(
|
| 28 |
+
"/api/v1/auth/login",
|
| 29 |
+
data={"username": "admin@netraid.ai", "password": "Admin@NetraID2026"}
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
assert response.status_code == 200
|
| 33 |
+
json_data = response.json()
|
| 34 |
+
assert "access_token" in json_data
|
| 35 |
+
assert "refresh_token" in json_data
|
| 36 |
+
assert json_data["token_type"] == "bearer"
|
| 37 |
+
|
| 38 |
+
@patch("app.api.v1.auth.crud.get_user_by_email")
|
| 39 |
+
@patch("app.api.v1.auth.crud.verify_password")
|
| 40 |
+
@patch("app.api.v1.auth.crud.create_audit_log")
|
| 41 |
+
def test_login_incorrect_password(mock_audit, mock_verify, mock_get_user, client):
|
| 42 |
+
mock_role = models.Role(id=1, name="Super Admin")
|
| 43 |
+
mock_user = models.User(
|
| 44 |
+
id=1,
|
| 45 |
+
email="admin@netraid.ai",
|
| 46 |
+
hashed_password="hashed_password",
|
| 47 |
+
is_active=True,
|
| 48 |
+
role_id=1,
|
| 49 |
+
role=mock_role
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
mock_get_user.return_value = mock_user
|
| 53 |
+
mock_verify.return_value = False
|
| 54 |
+
|
| 55 |
+
response = client.post(
|
| 56 |
+
"/api/v1/auth/login",
|
| 57 |
+
data={"username": "admin@netraid.ai", "password": "wrong_password"}
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
assert response.status_code == 400
|
| 61 |
+
assert response.json()["detail"] == "Incorrect email or password"
|
| 62 |
+
|
| 63 |
+
def test_read_users_me(authenticated_client):
|
| 64 |
+
response = authenticated_client.get("/api/v1/auth/me")
|
| 65 |
+
assert response.status_code == 200
|
| 66 |
+
assert response.json()["email"] == "admin@netraid.ai"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
from app.core.security import create_access_token, get_current_user_sse
|
| 70 |
+
from fastapi import Request
|
| 71 |
+
from unittest.mock import MagicMock
|
| 72 |
+
|
| 73 |
+
def test_get_current_user_sse_query_param(db_session):
|
| 74 |
+
# Mock user in DB
|
| 75 |
+
mock_role = models.Role(id=1, name="Super Admin")
|
| 76 |
+
mock_user = models.User(id=1, email="admin@netraid.ai", is_active=True, role=mock_role)
|
| 77 |
+
|
| 78 |
+
with patch("app.core.security.crud.get_user_by_email", return_value=mock_user):
|
| 79 |
+
token = create_access_token(subject="admin@netraid.ai", role="Super Admin")
|
| 80 |
+
request = MagicMock(spec=Request)
|
| 81 |
+
request.headers = {}
|
| 82 |
+
|
| 83 |
+
user = get_current_user_sse(request=request, token=token, db=db_session)
|
| 84 |
+
assert user.email == "admin@netraid.ai"
|
| 85 |
+
|
| 86 |
+
def test_live_stream_auth_failure(client):
|
| 87 |
+
response = client.get("/api/v1/analytics/live-stream")
|
| 88 |
+
assert response.status_code == 401
|
| 89 |
+
|
| 90 |
+
@patch("app.core.security.crud.get_user_by_email")
|
| 91 |
+
def test_live_stream_auth_success(mock_get_user, client):
|
| 92 |
+
mock_role = models.Role(id=1, name="Super Admin")
|
| 93 |
+
mock_user = models.User(id=1, email="admin@netraid.ai", is_active=True, role=mock_role)
|
| 94 |
+
mock_get_user.return_value = mock_user
|
| 95 |
+
|
| 96 |
+
token = create_access_token(subject="admin@netraid.ai", role="Super Admin")
|
| 97 |
+
|
| 98 |
+
# We can connect to the streaming endpoint using client.stream
|
| 99 |
+
with client.stream("GET", f"/api/v1/analytics/live-stream?token={token}") as response:
|
| 100 |
+
assert response.status_code == 200
|
backend/app/tests/test_employees.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unittest.mock import patch, MagicMock
|
| 2 |
+
from app.models import models
|
| 3 |
+
|
| 4 |
+
@patch("app.api.v1.employees.crud.get_employees")
|
| 5 |
+
def test_read_employees(mock_get, authenticated_client):
|
| 6 |
+
mock_employee = models.Employee(
|
| 7 |
+
id=1,
|
| 8 |
+
employee_id="EMP101",
|
| 9 |
+
name="John Doe",
|
| 10 |
+
email="john@netraid.ai",
|
| 11 |
+
phone="1234567890",
|
| 12 |
+
designation="Software Engineer",
|
| 13 |
+
joining_date="2026-06-10",
|
| 14 |
+
status="Active"
|
| 15 |
+
)
|
| 16 |
+
mock_get.return_value = [mock_employee]
|
| 17 |
+
|
| 18 |
+
response = authenticated_client.get("/api/v1/employees/")
|
| 19 |
+
assert response.status_code == 200
|
| 20 |
+
assert len(response.json()) == 1
|
| 21 |
+
assert response.json()[0]["employee_id"] == "EMP101"
|
| 22 |
+
assert response.json()[0]["name"] == "John Doe"
|
| 23 |
+
|
| 24 |
+
@patch("app.api.v1.employees.crud.get_employee_by_id")
|
| 25 |
+
def test_read_employee_not_found(mock_get_id, authenticated_client):
|
| 26 |
+
mock_get_id.return_value = None
|
| 27 |
+
response = authenticated_client.get("/api/v1/employees/99")
|
| 28 |
+
assert response.status_code == 404
|
| 29 |
+
assert response.json()["detail"] == "Employee not found"
|
| 30 |
+
|
| 31 |
+
@patch("app.api.v1.employees.crud.get_employee_by_uuid")
|
| 32 |
+
@patch("app.api.v1.employees.crud.get_employee_by_email")
|
| 33 |
+
@patch("app.api.v1.employees.crud.create_employee")
|
| 34 |
+
@patch("app.api.v1.employees.crud.create_audit_log")
|
| 35 |
+
def test_create_employee(mock_audit, mock_create, mock_get_email, mock_get_uuid, authenticated_client):
|
| 36 |
+
mock_get_uuid.return_value = None
|
| 37 |
+
mock_get_email.return_value = None
|
| 38 |
+
|
| 39 |
+
mock_employee = models.Employee(
|
| 40 |
+
id=2,
|
| 41 |
+
employee_id="EMP102",
|
| 42 |
+
name="Jane Smith",
|
| 43 |
+
email="jane@netraid.ai",
|
| 44 |
+
joining_date="2026-06-10",
|
| 45 |
+
status="Active"
|
| 46 |
+
)
|
| 47 |
+
mock_create.return_value = mock_employee
|
| 48 |
+
|
| 49 |
+
payload = {
|
| 50 |
+
"employee_id": "EMP102",
|
| 51 |
+
"name": "Jane Smith",
|
| 52 |
+
"email": "jane@netraid.ai",
|
| 53 |
+
"phone": "9876543210",
|
| 54 |
+
"designation": "HR Manager",
|
| 55 |
+
"joining_date": "2026-06-10",
|
| 56 |
+
"status": "Active",
|
| 57 |
+
"create_user_login": False
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
response = authenticated_client.post("/api/v1/employees/", json=payload)
|
| 61 |
+
assert response.status_code == 201
|
| 62 |
+
assert response.json()["employee_id"] == "EMP102"
|
| 63 |
+
assert response.json()["name"] == "Jane Smith"
|
backend/app/tests/test_face_engine.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import numpy as np
|
| 3 |
+
from app.services.face_engine import FaceEngine
|
| 4 |
+
|
| 5 |
+
def test_face_engine_initialization():
|
| 6 |
+
# Verify that engine initializes and can enter mock mode if files are missing
|
| 7 |
+
engine = FaceEngine()
|
| 8 |
+
assert hasattr(engine, 'mock_mode')
|
| 9 |
+
|
| 10 |
+
def test_face_engine_mock_mode_detection():
|
| 11 |
+
engine = FaceEngine()
|
| 12 |
+
engine.mock_mode = True
|
| 13 |
+
|
| 14 |
+
# Create blank dummy image
|
| 15 |
+
dummy_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 16 |
+
|
| 17 |
+
faces = engine.detect_faces(dummy_image)
|
| 18 |
+
assert len(faces) == 1
|
| 19 |
+
assert "bbox" in faces[0]
|
| 20 |
+
assert "confidence" in faces[0]
|
| 21 |
+
assert "landmarks" in faces[0]
|
| 22 |
+
assert len(faces[0]["landmarks"]) == 5
|
| 23 |
+
|
| 24 |
+
def test_face_engine_mock_mode_embedding():
|
| 25 |
+
engine = FaceEngine()
|
| 26 |
+
engine.mock_mode = True
|
| 27 |
+
|
| 28 |
+
dummy_face = np.zeros((112, 112, 3), dtype=np.uint8)
|
| 29 |
+
embedding = engine.extract_embedding(dummy_face)
|
| 30 |
+
|
| 31 |
+
assert embedding.shape == (512,)
|
| 32 |
+
# Verify it is L2 normalized
|
| 33 |
+
norm = np.linalg.norm(embedding)
|
| 34 |
+
assert pytest.approx(norm, abs=1e-5) == 1.0
|
| 35 |
+
|
| 36 |
+
def test_face_engine_liveness():
|
| 37 |
+
engine = FaceEngine()
|
| 38 |
+
engine.mock_mode = True
|
| 39 |
+
|
| 40 |
+
dummy_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 41 |
+
score, is_live = engine.check_liveness(dummy_image, [100, 100, 200, 200])
|
| 42 |
+
|
| 43 |
+
assert score >= 0.0 and score <= 1.0
|
| 44 |
+
assert isinstance(is_live, bool)
|
| 45 |
+
|
| 46 |
+
def test_cosine_similarity():
|
| 47 |
+
engine = FaceEngine()
|
| 48 |
+
|
| 49 |
+
v1 = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
| 50 |
+
v2 = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
| 51 |
+
v3 = np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
| 52 |
+
|
| 53 |
+
assert pytest.approx(engine.cosine_similarity(v1, v2)) == 1.0
|
| 54 |
+
assert pytest.approx(engine.cosine_similarity(v1, v3)) == 0.0
|
backend/check_db.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
conn = sqlite3.connect('netraid.db')
|
| 3 |
+
c = conn.cursor()
|
| 4 |
+
|
| 5 |
+
c.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
| 6 |
+
print('Tables:', c.fetchall())
|
| 7 |
+
|
| 8 |
+
c.execute("SELECT COUNT(*) FROM face_embeddings")
|
| 9 |
+
print('Embeddings:', c.fetchone())
|
| 10 |
+
|
| 11 |
+
c.execute("SELECT * FROM attendance_logs ORDER BY id DESC LIMIT 10")
|
| 12 |
+
print('Attendance logs:', c.fetchall())
|
| 13 |
+
|
| 14 |
+
c.execute("SELECT * FROM attendance ORDER BY id DESC LIMIT 10")
|
| 15 |
+
print('Attendances:', c.fetchall())
|
| 16 |
+
|
| 17 |
+
conn.close()
|
backend/clear_enrollments.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Clear all black/invalid face enrollments from the database"""
|
| 2 |
+
import sys, os
|
| 3 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
import sqlite3
|
| 10 |
+
|
| 11 |
+
# Connect directly to SQLite for cleanup
|
| 12 |
+
conn = sqlite3.connect('netraid.db')
|
| 13 |
+
c = conn.cursor()
|
| 14 |
+
|
| 15 |
+
# Count current state
|
| 16 |
+
c.execute("SELECT COUNT(*) FROM face_embeddings")
|
| 17 |
+
emb_count = c.fetchone()[0]
|
| 18 |
+
c.execute("SELECT COUNT(*) FROM employee_images")
|
| 19 |
+
img_count = c.fetchone()[0]
|
| 20 |
+
print(f"Current: {emb_count} embeddings, {img_count} employee images")
|
| 21 |
+
|
| 22 |
+
# Delete all face embeddings
|
| 23 |
+
c.execute("DELETE FROM face_embeddings")
|
| 24 |
+
print(f"Deleted all face embeddings")
|
| 25 |
+
|
| 26 |
+
# Delete all employee images from DB
|
| 27 |
+
c.execute("DELETE FROM employee_images")
|
| 28 |
+
print(f"Deleted all employee image records from DB")
|
| 29 |
+
|
| 30 |
+
conn.commit()
|
| 31 |
+
conn.close()
|
| 32 |
+
|
| 33 |
+
# Also delete the actual image files
|
| 34 |
+
import shutil
|
| 35 |
+
uploads_dir = "./uploads/EMP01"
|
| 36 |
+
if os.path.exists(uploads_dir):
|
| 37 |
+
for f in os.listdir(uploads_dir):
|
| 38 |
+
fpath = os.path.join(uploads_dir, f)
|
| 39 |
+
if os.path.isfile(fpath):
|
| 40 |
+
os.remove(fpath)
|
| 41 |
+
print(f"Deleted file: {fpath}")
|
| 42 |
+
print("Cleared all uploaded image files")
|
| 43 |
+
|
| 44 |
+
print("\n✅ Cleanup complete! You can now re-enroll fresh from the UI.")
|
| 45 |
+
print("Go to: http://localhost:3001/employees → select employee → Biometric Enroll")
|
backend/re_enroll.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Re-enrollment script: Re-generates face embeddings from existing enrolled images
|
| 3 |
+
using the current face engine. Run this after upgrading the face engine.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
..\.venv\Scripts\python.exe re_enroll.py
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
# Add the backend to path
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 13 |
+
|
| 14 |
+
# Set env vars before importing app modules
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
from app.core.database import SessionLocal
|
| 19 |
+
from app.models import models
|
| 20 |
+
from app.services.singletons import face_engine
|
| 21 |
+
import cv2
|
| 22 |
+
import numpy as np
|
| 23 |
+
import logging
|
| 24 |
+
|
| 25 |
+
logging.basicConfig(level=logging.INFO)
|
| 26 |
+
logger = logging.getLogger("ReEnroll")
|
| 27 |
+
|
| 28 |
+
def re_enroll():
|
| 29 |
+
db = SessionLocal()
|
| 30 |
+
try:
|
| 31 |
+
# Get all employees with images
|
| 32 |
+
employees = db.query(models.Employee).all()
|
| 33 |
+
logger.info(f"Found {len(employees)} employees to re-enroll.")
|
| 34 |
+
|
| 35 |
+
for employee in employees:
|
| 36 |
+
images = db.query(models.EmployeeImage).filter(
|
| 37 |
+
models.EmployeeImage.employee_id == employee.id
|
| 38 |
+
).all()
|
| 39 |
+
|
| 40 |
+
if not images:
|
| 41 |
+
logger.warning(f"No images found for employee {employee.name} ({employee.employee_id})")
|
| 42 |
+
continue
|
| 43 |
+
|
| 44 |
+
logger.info(f"\nRe-enrolling {employee.name} ({employee.employee_id}) - {len(images)} pose(s)...")
|
| 45 |
+
|
| 46 |
+
# Delete existing embeddings for this employee
|
| 47 |
+
db.query(models.FaceEmbedding).filter(
|
| 48 |
+
models.FaceEmbedding.employee_id == employee.id
|
| 49 |
+
).delete()
|
| 50 |
+
db.commit()
|
| 51 |
+
|
| 52 |
+
re_enrolled_count = 0
|
| 53 |
+
for img_record in images:
|
| 54 |
+
# Read image from disk
|
| 55 |
+
if not os.path.exists(img_record.file_path):
|
| 56 |
+
logger.warning(f" Image file not found: {img_record.file_path}")
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
img = cv2.imread(img_record.file_path)
|
| 60 |
+
if img is None:
|
| 61 |
+
logger.warning(f" Failed to read image: {img_record.file_path}")
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
# Detect face
|
| 65 |
+
faces = face_engine.detect_faces(img)
|
| 66 |
+
if not faces:
|
| 67 |
+
logger.warning(f" No face detected in {img_record.file_path}")
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
face = faces[0]
|
| 71 |
+
|
| 72 |
+
# Align and extract embedding
|
| 73 |
+
aligned = face_engine.align_face(img, face["landmarks"])
|
| 74 |
+
embedding = face_engine.extract_embedding(aligned)
|
| 75 |
+
embedding_list = embedding.tolist()
|
| 76 |
+
|
| 77 |
+
# Save new embedding
|
| 78 |
+
new_emb = models.FaceEmbedding(
|
| 79 |
+
employee_id=employee.id,
|
| 80 |
+
image_id=img_record.id,
|
| 81 |
+
embedding=embedding_list
|
| 82 |
+
)
|
| 83 |
+
db.add(new_emb)
|
| 84 |
+
re_enrolled_count += 1
|
| 85 |
+
logger.info(f" ✓ Re-enrolled pose: {img_record.pose_type}")
|
| 86 |
+
|
| 87 |
+
db.commit()
|
| 88 |
+
logger.info(f" Total re-enrolled: {re_enrolled_count}/{len(images)} poses for {employee.name}")
|
| 89 |
+
|
| 90 |
+
# Final count
|
| 91 |
+
total_embs = db.query(models.FaceEmbedding).count()
|
| 92 |
+
logger.info(f"\n✅ Re-enrollment complete! Total embeddings in DB: {total_embs}")
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"Re-enrollment failed: {e}")
|
| 96 |
+
import traceback
|
| 97 |
+
traceback.print_exc()
|
| 98 |
+
db.rollback()
|
| 99 |
+
finally:
|
| 100 |
+
db.close()
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
re_enroll()
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn>=0.28.0
|
| 3 |
+
pydantic[email]>=2.6.4
|
| 4 |
+
pydantic-settings>=2.2.1
|
| 5 |
+
sqlalchemy>=2.0.28
|
| 6 |
+
pgvector>=0.2.5
|
| 7 |
+
psycopg2-binary>=2.9.9
|
| 8 |
+
alembic>=1.13.1
|
| 9 |
+
python-jose[cryptography]>=3.3.0
|
| 10 |
+
passlib[bcrypt]>=1.7.4
|
| 11 |
+
python-multipart>=0.0.9
|
| 12 |
+
numpy>=1.26.4
|
| 13 |
+
opencv-python-headless>=4.9.0.80
|
| 14 |
+
onnxruntime>=1.17.1
|
| 15 |
+
scipy>=1.12.0
|
| 16 |
+
pyttsx3>=2.90
|
| 17 |
+
pandas>=2.2.1
|
| 18 |
+
openpyxl>=3.1.2
|
| 19 |
+
reportlab>=4.1.0
|
| 20 |
+
pytest>=8.1.1
|
| 21 |
+
pytest-cov>=4.1.0
|
| 22 |
+
httpx>=0.27.0
|
| 23 |
+
python-dotenv>=1.0.1
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
db:
|
| 5 |
+
image: pgvector/pgvector:pg16
|
| 6 |
+
container_name: netraid-db
|
| 7 |
+
restart: always
|
| 8 |
+
environment:
|
| 9 |
+
POSTGRES_DB: netraid_db
|
| 10 |
+
POSTGRES_USER: netraid
|
| 11 |
+
POSTGRES_PASSWORD: netraid123
|
| 12 |
+
ports:
|
| 13 |
+
- "5432:5432"
|
| 14 |
+
volumes:
|
| 15 |
+
- pgdata:/var/lib/postgresql/data
|
| 16 |
+
healthcheck:
|
| 17 |
+
test: ["CMD-SHELL", "pg_isready -U netraid -d netraid_db"]
|
| 18 |
+
interval: 5s
|
| 19 |
+
timeout: 5s
|
| 20 |
+
retries: 5
|
| 21 |
+
|
| 22 |
+
backend:
|
| 23 |
+
build:
|
| 24 |
+
context: ./backend
|
| 25 |
+
dockerfile: Dockerfile
|
| 26 |
+
container_name: netraid-backend
|
| 27 |
+
restart: always
|
| 28 |
+
environment:
|
| 29 |
+
- DATABASE_URL=postgresql://netraid:netraid123@db:5432/netraid_db
|
| 30 |
+
- JWT_SECRET_KEY=c01c0be1b621e25e985b96ea6e88e2cbdfcbce52ff6ea96be220f8623eb0b21a
|
| 31 |
+
- JWT_ALGORITHM=HS256
|
| 32 |
+
- ACCESS_TOKEN_EXPIRE_MINUTES=60
|
| 33 |
+
- REFRESH_TOKEN_EXPIRE_DAYS=7
|
| 34 |
+
- INITIAL_ADMIN_EMAIL=admin@netraid.ai
|
| 35 |
+
- INITIAL_ADMIN_PASSWORD=Admin@NetraID2026
|
| 36 |
+
- KIOSK_FACE_THRESHOLD=0.60
|
| 37 |
+
- KIOSK_LIVENESS_THRESHOLD=0.75
|
| 38 |
+
- UPLOAD_DIR=/workspace/uploads
|
| 39 |
+
- MODELS_DIR=/workspace/models
|
| 40 |
+
- ALLOWED_HOSTS=*
|
| 41 |
+
ports:
|
| 42 |
+
- "8000:8000"
|
| 43 |
+
volumes:
|
| 44 |
+
- uploads:/workspace/uploads
|
| 45 |
+
- models:/workspace/models
|
| 46 |
+
depends_on:
|
| 47 |
+
db:
|
| 48 |
+
condition: service_healthy
|
| 49 |
+
|
| 50 |
+
frontend:
|
| 51 |
+
build:
|
| 52 |
+
context: ./frontend
|
| 53 |
+
dockerfile: Dockerfile
|
| 54 |
+
container_name: netraid-frontend
|
| 55 |
+
restart: always
|
| 56 |
+
ports:
|
| 57 |
+
- "3000:3000"
|
| 58 |
+
depends_on:
|
| 59 |
+
- backend
|
| 60 |
+
|
| 61 |
+
nginx:
|
| 62 |
+
image: nginx:alpine
|
| 63 |
+
container_name: netraid-nginx
|
| 64 |
+
restart: always
|
| 65 |
+
ports:
|
| 66 |
+
- "80:80"
|
| 67 |
+
volumes:
|
| 68 |
+
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
| 69 |
+
depends_on:
|
| 70 |
+
- frontend
|
| 71 |
+
- backend
|
| 72 |
+
|
| 73 |
+
volumes:
|
| 74 |
+
pgdata:
|
| 75 |
+
driver: local
|
| 76 |
+
uploads:
|
| 77 |
+
driver: local
|
| 78 |
+
models:
|
| 79 |
+
driver: local
|
docker/nginx.conf
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
server {
|
| 2 |
+
listen 80;
|
| 3 |
+
server_name localhost;
|
| 4 |
+
|
| 5 |
+
# Increase body size limit for large enrollment image uploads
|
| 6 |
+
client_max_body_size 50M;
|
| 7 |
+
|
| 8 |
+
# Proxy API requests to backend
|
| 9 |
+
location /api {
|
| 10 |
+
proxy_pass http://backend:8000;
|
| 11 |
+
proxy_http_version 1.1;
|
| 12 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 13 |
+
proxy_set_header Connection 'upgrade';
|
| 14 |
+
proxy_set_header Host $host;
|
| 15 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 16 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 17 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 18 |
+
proxy_cache_bypass $http_upgrade;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Proxy OpenAPI Documentation to backend
|
| 22 |
+
location /docs {
|
| 23 |
+
proxy_pass http://backend:8000/docs;
|
| 24 |
+
proxy_set_header Host $host;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
location /openapi.json {
|
| 28 |
+
proxy_pass http://backend:8000/openapi.json;
|
| 29 |
+
proxy_set_header Host $host;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
location /redoc {
|
| 33 |
+
proxy_pass http://backend:8000/redoc;
|
| 34 |
+
proxy_set_header Host $host;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# Proxy all other routes to Next.js frontend
|
| 38 |
+
location / {
|
| 39 |
+
proxy_pass http://frontend:3000;
|
| 40 |
+
proxy_http_version 1.1;
|
| 41 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 42 |
+
proxy_set_header Connection 'upgrade';
|
| 43 |
+
proxy_set_header Host $host;
|
| 44 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 45 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 46 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 47 |
+
proxy_cache_bypass $http_upgrade;
|
| 48 |
+
}
|
| 49 |
+
}
|
frontend/Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Install dependencies
|
| 2 |
+
FROM node:20-alpine AS deps
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY package.json ./
|
| 5 |
+
RUN npm install
|
| 6 |
+
|
| 7 |
+
# Stage 2: Build the project
|
| 8 |
+
FROM node:20-alpine AS builder
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 11 |
+
COPY . .
|
| 12 |
+
ENV NEXT_TELEMETRY_DISABLED 1
|
| 13 |
+
RUN npm run build
|
| 14 |
+
|
| 15 |
+
# Stage 3: Runner
|
| 16 |
+
FROM node:20-alpine AS runner
|
| 17 |
+
WORKDIR /app
|
| 18 |
+
ENV NODE_ENV production
|
| 19 |
+
ENV NEXT_TELEMETRY_DISABLED 1
|
| 20 |
+
|
| 21 |
+
# Copy build output and assets
|
| 22 |
+
COPY --from=builder /app/.next ./.next
|
| 23 |
+
COPY --from=builder /app/node_modules ./node_modules
|
| 24 |
+
COPY --from=builder /app/package.json ./package.json
|
| 25 |
+
COPY --from=builder /app/public ./public
|
| 26 |
+
COPY --from=builder /app/next.config.js ./next.config.js
|
| 27 |
+
|
| 28 |
+
EXPOSE 3000
|
| 29 |
+
CMD ["npm", "run", "start"]
|
frontend/app/attendance/page.tsx
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
+
import { fetchApi, parseDateTime, getLocalDateString, getBackendUrl } from "@/app/utils/api";
|
| 7 |
+
import { Clock, Search, Edit3, Calendar, Activity, AlertTriangle, X, CheckCircle2, ChevronDown, Check } from "lucide-react";
|
| 8 |
+
|
| 9 |
+
type Tab = "feed" | "logs";
|
| 10 |
+
|
| 11 |
+
function StatusBadge({ status }: { status: string }) {
|
| 12 |
+
const map: Record<string, string> = {
|
| 13 |
+
Present: "badge-emerald",
|
| 14 |
+
Late: "badge-amber",
|
| 15 |
+
Absent: "badge-rose",
|
| 16 |
+
"Half Day": "badge-indigo",
|
| 17 |
+
"On Leave": "badge-slate",
|
| 18 |
+
"Work From Home": "badge-blue",
|
| 19 |
+
Holiday: "badge-slate",
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const statusColor = map[status] || "badge-slate";
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<span className={`badge ${statusColor} flex items-center gap-1 w-fit`}>
|
| 26 |
+
{status === "Present" && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
| 27 |
+
{status === "Late" && <span className="w-1.5 h-1.5 rounded-full bg-amber-400" />}
|
| 28 |
+
{status}
|
| 29 |
+
</span>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function EmployeeAvatar({ emp, avatarColor, size = "md" }: { emp: any; avatarColor: string; size?: "sm" | "md" }) {
|
| 34 |
+
const [error, setError] = useState(false);
|
| 35 |
+
const hasFrontImage = emp.images?.some((img: any) => img.pose_type.toLowerCase() === "front");
|
| 36 |
+
|
| 37 |
+
const sc = size === "sm" ? "w-7.5 h-7.5 text-[10px]" : "w-8 h-8 text-[11px]";
|
| 38 |
+
|
| 39 |
+
if (hasFrontImage && !error) {
|
| 40 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
| 41 |
+
return (
|
| 42 |
+
<img
|
| 43 |
+
src={`${baseUrl}/uploads/${emp.employee_id}/front.jpg`}
|
| 44 |
+
alt={emp.name}
|
| 45 |
+
className={`${sc.split(" ")[0]} ${sc.split(" ")[1]} rounded-lg object-cover border border-zinc-250 shadow-sm shrink-0`}
|
| 46 |
+
onError={() => setError(true)}
|
| 47 |
+
/>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className={`${sc} rounded-lg bg-gradient-to-br ${avatarColor} flex items-center justify-center shrink-0 border font-bold shadow-sm`}>
|
| 53 |
+
{emp.name.charAt(0).toUpperCase()}
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export default function AttendancePage() {
|
| 59 |
+
const queryClient = useQueryClient();
|
| 60 |
+
const [activeTab, setActiveTab] = useState<Tab>("feed");
|
| 61 |
+
const [selectedDate, setSelectedDate] = useState(getLocalDateString());
|
| 62 |
+
const [search, setSearch] = useState("");
|
| 63 |
+
const [deptFilter, setDeptFilter] = useState("");
|
| 64 |
+
const [showEditDialog, setShowEditDialog] = useState(false);
|
| 65 |
+
const [editingRecord, setEditingRecord] = useState<any>(null);
|
| 66 |
+
const [checkInTime, setCheckInTime] = useState("");
|
| 67 |
+
const [checkOutTime, setCheckOutTime] = useState("");
|
| 68 |
+
const [statusVal, setStatusVal] = useState("Present");
|
| 69 |
+
|
| 70 |
+
const { data: departments } = useQuery({
|
| 71 |
+
queryKey: ["departments"],
|
| 72 |
+
queryFn: () => fetchApi("/departments/")
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
const { data: attendanceFeed, isLoading: loadingFeed } = useQuery({
|
| 76 |
+
queryKey: ["attendance-feed", selectedDate, deptFilter],
|
| 77 |
+
queryFn: () => {
|
| 78 |
+
let params = [`date_val=${selectedDate}`];
|
| 79 |
+
if (deptFilter) params.push(`department_id=${deptFilter}`);
|
| 80 |
+
return fetchApi(`/attendance/daily?${params.join("&")}`);
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
const { data: rawLogs, isLoading: loadingLogs } = useQuery({
|
| 85 |
+
queryKey: ["raw-logs", selectedDate],
|
| 86 |
+
queryFn: () => fetchApi(`/attendance/logs?date_str=${selectedDate}`)
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
const updateMutation = useMutation({
|
| 90 |
+
mutationFn: ({ id, payload }: { id: number; payload: any }) =>
|
| 91 |
+
fetchApi(`/attendance/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
| 92 |
+
onSuccess: () => {
|
| 93 |
+
queryClient.invalidateQueries({ queryKey: ["attendance-feed"] });
|
| 94 |
+
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 95 |
+
setShowEditDialog(false);
|
| 96 |
+
setEditingRecord(null);
|
| 97 |
+
},
|
| 98 |
+
onError: (err: any) => alert(err.message || "Failed to update attendance.")
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
const handleEditClick = (record: any) => {
|
| 102 |
+
setEditingRecord(record);
|
| 103 |
+
const fmt = (d: string | null) => {
|
| 104 |
+
if (!d) return "";
|
| 105 |
+
const dt = parseDateTime(d);
|
| 106 |
+
if (!dt) return "";
|
| 107 |
+
return `${String(dt.getHours()).padStart(2,"0")}:${String(dt.getMinutes()).padStart(2,"0")}`;
|
| 108 |
+
};
|
| 109 |
+
setCheckInTime(fmt(record.check_in));
|
| 110 |
+
setCheckOutTime(fmt(record.check_out));
|
| 111 |
+
setStatusVal(record.status);
|
| 112 |
+
setShowEditDialog(true);
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const handleEditSubmit = (e: React.FormEvent) => {
|
| 116 |
+
e.preventDefault();
|
| 117 |
+
if (!editingRecord) return;
|
| 118 |
+
const combine = (t: string) => {
|
| 119 |
+
if (!t) return null;
|
| 120 |
+
const [yr, mo, dy] = selectedDate.split("-").map(Number);
|
| 121 |
+
const [hr, mn] = t.split(":").map(Number);
|
| 122 |
+
const localDate = new Date(yr, mo - 1, dy, hr, mn, 0);
|
| 123 |
+
return localDate.toISOString();
|
| 124 |
+
};
|
| 125 |
+
updateMutation.mutate({
|
| 126 |
+
id: editingRecord.id,
|
| 127 |
+
payload: { status: statusVal, check_in: combine(checkInTime), check_out: combine(checkOutTime) }
|
| 128 |
+
});
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const filtered = attendanceFeed?.filter((r: any) =>
|
| 132 |
+
r.employee.name.toLowerCase().includes(search.toLowerCase()) ||
|
| 133 |
+
r.employee.employee_id.toLowerCase().includes(search.toLowerCase())
|
| 134 |
+
);
|
| 135 |
+
|
| 136 |
+
const inputCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all w-full";
|
| 137 |
+
const selectCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all appearance-none cursor-pointer w-full pr-8";
|
| 138 |
+
|
| 139 |
+
// Avatar gradient styles
|
| 140 |
+
const avatarColors = [
|
| 141 |
+
"from-blue-500/20 to-indigo-500/20 text-blue-400 border-blue-500/15",
|
| 142 |
+
"from-emerald-500/20 to-teal-500/20 text-emerald-400 border-emerald-500/15",
|
| 143 |
+
"from-rose-500/20 to-orange-500/20 text-rose-400 border-rose-500/15",
|
| 144 |
+
"from-purple-500/20 to-pink-500/20 text-purple-400 border-purple-500/15",
|
| 145 |
+
"from-cyan-500/20 to-blue-500/20 text-cyan-400 border-cyan-500/15",
|
| 146 |
+
];
|
| 147 |
+
|
| 148 |
+
return (
|
| 149 |
+
<SidebarLayout>
|
| 150 |
+
<div className="space-y-6 page-enter">
|
| 151 |
+
{/* Header */}
|
| 152 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
|
| 153 |
+
<div>
|
| 154 |
+
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Attendance Ledger</h1>
|
| 155 |
+
<p className="text-xs text-slate-500 mt-0.5">Audit daily presence ledger records and raw biometric authentication logs.</p>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* Tab switcher */}
|
| 159 |
+
<div className="flex items-center p-1 bg-zinc-100 border border-zinc-200 rounded-2xl self-start sm:self-center shrink-0">
|
| 160 |
+
{(["feed", "logs"] as Tab[]).map((tab) => (
|
| 161 |
+
<button
|
| 162 |
+
key={tab}
|
| 163 |
+
onClick={() => setActiveTab(tab)}
|
| 164 |
+
className={`px-4 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
| 165 |
+
activeTab === tab
|
| 166 |
+
? "bg-zinc-900 text-white shadow-sm"
|
| 167 |
+
: "text-slate-550 hover:text-slate-800"
|
| 168 |
+
}`}
|
| 169 |
+
>
|
| 170 |
+
{tab === "feed" ? (
|
| 171 |
+
<span className="flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" />Daily Ledger</span>
|
| 172 |
+
) : (
|
| 173 |
+
<span className="flex items-center gap-1.5"><Activity className="w-3.5 h-3.5" />Raw Scan Logs</span>
|
| 174 |
+
)}
|
| 175 |
+
</button>
|
| 176 |
+
))}
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{/* Toolbar */}
|
| 181 |
+
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 p-4.5 rounded-2xl bg-white/[0.015] border border-white/5 shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
| 182 |
+
<div className="relative col-span-2">
|
| 183 |
+
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 184 |
+
<input
|
| 185 |
+
type="text"
|
| 186 |
+
placeholder="Search by employee name or ID..."
|
| 187 |
+
value={search}
|
| 188 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 189 |
+
className={`${inputCls} !pl-10 h-10`}
|
| 190 |
+
/>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div className="relative">
|
| 194 |
+
<Calendar className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 195 |
+
<input
|
| 196 |
+
type="date"
|
| 197 |
+
value={selectedDate}
|
| 198 |
+
onChange={(e) => setSelectedDate(e.target.value)}
|
| 199 |
+
className={`${inputCls} !pl-10 h-10`}
|
| 200 |
+
/>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div className="relative">
|
| 204 |
+
<select
|
| 205 |
+
value={deptFilter}
|
| 206 |
+
onChange={(e) => setDeptFilter(e.target.value)}
|
| 207 |
+
className={`${selectCls} h-10`}
|
| 208 |
+
>
|
| 209 |
+
<option value="">All Departments</option>
|
| 210 |
+
{departments?.map((d: any) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
| 211 |
+
</select>
|
| 212 |
+
<ChevronDown className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{/* Table */}
|
| 217 |
+
{activeTab === "feed" ? (
|
| 218 |
+
<div className="glass-card rounded-2xl border border-white/6 overflow-hidden">
|
| 219 |
+
<div className="px-5 py-3.5 border-b border-white/5 bg-white/[0.005]">
|
| 220 |
+
<p className="text-[11px] text-slate-500 font-mono">
|
| 221 |
+
{loadingFeed ? "Fetching..." : `${filtered?.length || 0} ledger records for ${selectedDate}`}
|
| 222 |
+
</p>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
<div className="overflow-x-auto">
|
| 226 |
+
<table className="data-table">
|
| 227 |
+
<thead>
|
| 228 |
+
<tr>
|
| 229 |
+
<th className="text-left py-3.5 px-5">Employee</th>
|
| 230 |
+
<th className="text-left py-3.5 px-5">Check In</th>
|
| 231 |
+
<th className="text-left py-3.5 px-5">Check Out</th>
|
| 232 |
+
<th className="text-left py-3.5 px-5">Work Hours</th>
|
| 233 |
+
<th className="text-left py-3.5 px-5">Status</th>
|
| 234 |
+
<th className="text-center py-3.5 px-5 w-[100px]">Override</th>
|
| 235 |
+
</tr>
|
| 236 |
+
</thead>
|
| 237 |
+
<tbody>
|
| 238 |
+
{loadingFeed ? (
|
| 239 |
+
Array.from({ length: 5 }).map((_, i) => (
|
| 240 |
+
<tr key={i}>{Array.from({ length: 6 }).map((_, j) => (
|
| 241 |
+
<td key={j} className="py-4 px-5"><div className="skeleton h-4 w-20" /></td>
|
| 242 |
+
))}</tr>
|
| 243 |
+
))
|
| 244 |
+
) : filtered?.length === 0 ? (
|
| 245 |
+
<tr>
|
| 246 |
+
<td colSpan={6} className="py-20 text-center">
|
| 247 |
+
<div className="flex flex-col items-center gap-3 max-w-xs mx-auto">
|
| 248 |
+
<div className="w-12 h-12 rounded-2xl bg-white/4 flex items-center justify-center">
|
| 249 |
+
<Clock className="w-5 h-5 text-slate-600" />
|
| 250 |
+
</div>
|
| 251 |
+
<p className="text-slate-400 font-semibold text-xs uppercase tracking-wider">No Records</p>
|
| 252 |
+
<p className="text-[11px] text-slate-600 leading-relaxed">No presence records registered for this date.</p>
|
| 253 |
+
</div>
|
| 254 |
+
</td>
|
| 255 |
+
</tr>
|
| 256 |
+
) : (
|
| 257 |
+
filtered?.map((rec: any) => {
|
| 258 |
+
const avatarColor = avatarColors[rec.employee.id % avatarColors.length];
|
| 259 |
+
return (
|
| 260 |
+
<tr key={rec.id}>
|
| 261 |
+
<td className="py-3.5 px-5">
|
| 262 |
+
<div className="flex items-center gap-3">
|
| 263 |
+
<EmployeeAvatar emp={rec.employee} avatarColor={avatarColor} />
|
| 264 |
+
<div>
|
| 265 |
+
<p className="text-[12.5px] font-semibold text-[var(--text-primary)]">{rec.employee.name}</p>
|
| 266 |
+
<p className="text-[10px] text-slate-500 font-mono mt-0.5">{rec.employee.employee_id}</p>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</td>
|
| 270 |
+
<td className="py-3.5 px-5 font-mono text-[11px] text-[var(--text-secondary)]">
|
| 271 |
+
{rec.check_in ? parseDateTime(rec.check_in)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : <span className="text-slate-400 italic">n/a</span>}
|
| 272 |
+
</td>
|
| 273 |
+
<td className="py-3.5 px-5 font-mono text-[11px] text-[var(--text-secondary)]">
|
| 274 |
+
{rec.check_out ? parseDateTime(rec.check_out)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : <span className="text-slate-400 italic">n/a</span>}
|
| 275 |
+
</td>
|
| 276 |
+
<td className="py-3.5 px-5 font-mono text-[11px] text-[var(--text-secondary)]">
|
| 277 |
+
{rec.working_hours ? (
|
| 278 |
+
<span className="font-semibold text-[var(--text-primary)]">{rec.working_hours.toFixed(1)} hrs</span>
|
| 279 |
+
) : (
|
| 280 |
+
<span className="text-slate-400">—</span>
|
| 281 |
+
)}
|
| 282 |
+
</td>
|
| 283 |
+
<td className="py-3.5 px-5"><StatusBadge status={rec.status} /></td>
|
| 284 |
+
<td className="py-3.5 px-5 text-center">
|
| 285 |
+
<button
|
| 286 |
+
onClick={() => handleEditClick(rec)}
|
| 287 |
+
className="p-2 rounded-xl text-slate-500 hover:text-blue-400 hover:bg-blue-500/8 border border-transparent hover:border-blue-500/15 transition-all cursor-pointer"
|
| 288 |
+
title="Manual override attendance status"
|
| 289 |
+
>
|
| 290 |
+
<Edit3 className="w-3.5 h-3.5" />
|
| 291 |
+
</button>
|
| 292 |
+
</td>
|
| 293 |
+
</tr>
|
| 294 |
+
);
|
| 295 |
+
})
|
| 296 |
+
)}
|
| 297 |
+
</tbody>
|
| 298 |
+
</table>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
) : (
|
| 302 |
+
<div className="glass-card rounded-2xl border border-white/6 overflow-hidden">
|
| 303 |
+
<div className="px-5 py-3.5 border-b border-white/5 bg-white/[0.005]">
|
| 304 |
+
<p className="text-[11px] text-slate-500 font-mono">
|
| 305 |
+
{loadingLogs ? "Fetching..." : `${rawLogs?.length || 0} swipe events for ${selectedDate}`}
|
| 306 |
+
</p>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<div className="overflow-x-auto">
|
| 310 |
+
<table className="data-table">
|
| 311 |
+
<thead>
|
| 312 |
+
<tr>
|
| 313 |
+
<th className="text-left py-3.5 px-5">Timestamp</th>
|
| 314 |
+
<th className="text-left py-3.5 px-5">Identity Profile</th>
|
| 315 |
+
<th className="text-left py-3.5 px-5">Terminal</th>
|
| 316 |
+
<th className="text-left py-3.5 px-5">Confidence</th>
|
| 317 |
+
<th className="text-left py-3.5 px-5">Liveness</th>
|
| 318 |
+
<th className="text-left py-3.5 px-5">Result</th>
|
| 319 |
+
</tr>
|
| 320 |
+
</thead>
|
| 321 |
+
<tbody>
|
| 322 |
+
{loadingLogs ? (
|
| 323 |
+
Array.from({ length: 5 }).map((_, i) => (
|
| 324 |
+
<tr key={i}>{Array.from({ length: 6 }).map((_, j) => (
|
| 325 |
+
<td key={j} className="py-4 px-5"><div className="skeleton h-4 w-20" /></td>
|
| 326 |
+
))}</tr>
|
| 327 |
+
))
|
| 328 |
+
) : rawLogs?.length === 0 ? (
|
| 329 |
+
<tr>
|
| 330 |
+
<td colSpan={6} className="py-20 text-center">
|
| 331 |
+
<div className="flex flex-col items-center gap-3 max-w-xs mx-auto">
|
| 332 |
+
<div className="w-12 h-12 rounded-2xl bg-white/4 flex items-center justify-center">
|
| 333 |
+
<Activity className="w-5 h-5 text-slate-600" />
|
| 334 |
+
</div>
|
| 335 |
+
<p className="text-slate-400 font-semibold text-xs uppercase tracking-wider">No logs</p>
|
| 336 |
+
<p className="text-[11px] text-slate-600 leading-relaxed">No terminal camera logs registered for this date.</p>
|
| 337 |
+
</div>
|
| 338 |
+
</td>
|
| 339 |
+
</tr>
|
| 340 |
+
) : (
|
| 341 |
+
rawLogs?.map((log: any) => {
|
| 342 |
+
const isSuccess = log.status === "Match Success";
|
| 343 |
+
const isSpoof = log.is_spoof;
|
| 344 |
+
const avatarColor = log.employee ? avatarColors[log.employee.id % avatarColors.length] : "";
|
| 345 |
+
return (
|
| 346 |
+
<tr key={log.id}>
|
| 347 |
+
<td className="py-3.5 px-5 font-mono text-[11px] text-zinc-500">
|
| 348 |
+
{parseDateTime(log.timestamp)?.toLocaleDateString([], { month: "short", day: "numeric" })} {parseDateTime(log.timestamp)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"})}
|
| 349 |
+
</td>
|
| 350 |
+
<td className="py-3.5 px-5">
|
| 351 |
+
{log.employee ? (
|
| 352 |
+
<div className="flex items-center gap-2.5">
|
| 353 |
+
<EmployeeAvatar emp={log.employee} avatarColor={avatarColor} size="sm" />
|
| 354 |
+
<div>
|
| 355 |
+
<p className="text-[12.5px] font-semibold text-[var(--text-primary)]">{log.employee.name}</p>
|
| 356 |
+
<p className="text-[10px] text-slate-500 font-mono mt-0.5">{log.employee.employee_id}</p>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
) : (
|
| 360 |
+
<span className="text-slate-500 text-[12.5px] italic">Unknown Identity</span>
|
| 361 |
+
)}
|
| 362 |
+
</td>
|
| 363 |
+
<td className="py-3.5 px-5 text-[11px] text-slate-400 font-mono">{log.camera}</td>
|
| 364 |
+
<td className="py-3.5 px-5 font-mono text-[11.5px]">
|
| 365 |
+
{log.confidence ? (
|
| 366 |
+
<span className={log.confidence >= 0.60 ? "text-[var(--text-primary)]" : "text-amber-500 font-semibold"}>
|
| 367 |
+
{log.confidence.toFixed(3)}
|
| 368 |
+
</span>
|
| 369 |
+
) : (
|
| 370 |
+
<span className="text-slate-600">—</span>
|
| 371 |
+
)}
|
| 372 |
+
</td>
|
| 373 |
+
<td className="py-3.5 px-5 font-mono text-[11.5px]">
|
| 374 |
+
{log.liveness_score ? (
|
| 375 |
+
<span className={log.liveness_score < 0.80 ? "text-rose-400 font-semibold" : "text-emerald-400"}>
|
| 376 |
+
{log.liveness_score.toFixed(3)}
|
| 377 |
+
</span>
|
| 378 |
+
) : (
|
| 379 |
+
<span className="text-slate-600">—</span>
|
| 380 |
+
)}
|
| 381 |
+
</td>
|
| 382 |
+
<td className="py-3.5 px-5">
|
| 383 |
+
<span className={`badge ${isSuccess ? "badge-emerald" : isSpoof ? "badge-rose animate-pulse" : "badge-amber"} flex items-center gap-1.5 w-fit`}>
|
| 384 |
+
{isSpoof && <AlertTriangle className="w-3 h-3 text-rose-400" />}
|
| 385 |
+
{isSuccess ? "Matched" : isSpoof ? "Spoof Block" : log.status}
|
| 386 |
+
</span>
|
| 387 |
+
</td>
|
| 388 |
+
</tr>
|
| 389 |
+
);
|
| 390 |
+
})
|
| 391 |
+
)}
|
| 392 |
+
</tbody>
|
| 393 |
+
</table>
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
)}
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
{/* Override Modal */}
|
| 400 |
+
{showEditDialog && editingRecord && (
|
| 401 |
+
<div className="modal-backdrop">
|
| 402 |
+
<div className="modal-content max-w-sm">
|
| 403 |
+
<div className="flex items-center justify-between mb-5 pb-4 border-b border-white/5">
|
| 404 |
+
<div>
|
| 405 |
+
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Manual Override</h3>
|
| 406 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 407 |
+
{editingRecord.employee.name} · {selectedDate}
|
| 408 |
+
</p>
|
| 409 |
+
</div>
|
| 410 |
+
<button
|
| 411 |
+
onClick={() => setShowEditDialog(false)}
|
| 412 |
+
className="p-2 rounded-xl hover:bg-white/6 text-slate-500 hover:text-slate-300 transition-all cursor-pointer"
|
| 413 |
+
>
|
| 414 |
+
<X className="w-4 h-4" />
|
| 415 |
+
</button>
|
| 416 |
+
</div>
|
| 417 |
+
|
| 418 |
+
<form onSubmit={handleEditSubmit} className="space-y-4">
|
| 419 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 420 |
+
<div className="space-y-1.5">
|
| 421 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Check-In</label>
|
| 422 |
+
<input type="time" value={checkInTime} onChange={(e) => setCheckInTime(e.target.value)} className={inputCls} />
|
| 423 |
+
</div>
|
| 424 |
+
<div className="space-y-1.5">
|
| 425 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Check-Out</label>
|
| 426 |
+
<input type="time" value={checkOutTime} onChange={(e) => setCheckOutTime(e.target.value)} className={inputCls} />
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
|
| 430 |
+
<div className="space-y-1.5">
|
| 431 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">Status Override</label>
|
| 432 |
+
<div className="relative">
|
| 433 |
+
<select value={statusVal} onChange={(e) => setStatusVal(e.target.value)} className={selectCls}>
|
| 434 |
+
{["Present","Absent","Late","Half Day","On Leave","Work From Home","Holiday"].map(s => (
|
| 435 |
+
<option key={s} value={s}>{s}</option>
|
| 436 |
+
))}
|
| 437 |
+
</select>
|
| 438 |
+
<ChevronDown className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
+
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 443 |
+
<button
|
| 444 |
+
type="button"
|
| 445 |
+
onClick={() => setShowEditDialog(false)}
|
| 446 |
+
className="btn-ghost h-9.5 px-4 text-[12px] rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 447 |
+
>
|
| 448 |
+
Cancel
|
| 449 |
+
</button>
|
| 450 |
+
<button
|
| 451 |
+
type="submit"
|
| 452 |
+
disabled={updateMutation.isPending}
|
| 453 |
+
className="btn-primary h-9.5 px-5 text-[12px] flex items-center gap-2 rounded-xl cursor-pointer"
|
| 454 |
+
>
|
| 455 |
+
{updateMutation.isPending ? (
|
| 456 |
+
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 457 |
+
) : (
|
| 458 |
+
<CheckCircle2 className="w-3.5 h-3.5" />
|
| 459 |
+
)}
|
| 460 |
+
Save Override
|
| 461 |
+
</button>
|
| 462 |
+
</div>
|
| 463 |
+
</form>
|
| 464 |
+
</div>
|
| 465 |
+
</div>
|
| 466 |
+
)}
|
| 467 |
+
</SidebarLayout>
|
| 468 |
+
);
|
| 469 |
+
}
|
frontend/app/audit/page.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { useQuery } from "@tanstack/react-query";
|
| 5 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
+
import { fetchApi, parseDateTime } from "@/app/utils/api";
|
| 7 |
+
import {
|
| 8 |
+
History, Search, RefreshCw, ChevronLeft, ChevronRight,
|
| 9 |
+
ShieldAlert, Activity, Key, UserPlus, Sliders, Laptop
|
| 10 |
+
} from "lucide-react";
|
| 11 |
+
|
| 12 |
+
export default function AuditLogsPage() {
|
| 13 |
+
const [page, setPage] = useState(0);
|
| 14 |
+
const limit = 20;
|
| 15 |
+
const [search, setSearch] = useState("");
|
| 16 |
+
|
| 17 |
+
const { data: logs, isLoading, isPlaceholderData, refetch } = useQuery({
|
| 18 |
+
queryKey: ["audit-logs", page],
|
| 19 |
+
queryFn: () => fetchApi(`/audit/?skip=${page * limit}&limit=${limit}`),
|
| 20 |
+
placeholderData: (prev) => prev
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
const filteredLogs = logs?.filter((log: any) => {
|
| 24 |
+
if (!search) return true;
|
| 25 |
+
const term = search.toLowerCase();
|
| 26 |
+
const actionMatch = log.action?.toLowerCase().includes(term);
|
| 27 |
+
const userMatch = log.user?.email?.toLowerCase().includes(term);
|
| 28 |
+
const detailsMatch = log.details?.toLowerCase().includes(term);
|
| 29 |
+
return actionMatch || userMatch || detailsMatch;
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
// Helper to map log actions to modern icons
|
| 33 |
+
const getActionIcon = (action: string) => {
|
| 34 |
+
const act = action.toLowerCase();
|
| 35 |
+
if (act.includes("login") || act.includes("auth")) return <Key className="w-4 h-4 text-emerald-600" />;
|
| 36 |
+
if (act.includes("create") || act.includes("enroll")) return <UserPlus className="w-4 h-4 text-blue-600" />;
|
| 37 |
+
if (act.includes("delete")) return <ShieldAlert className="w-4 h-4 text-rose-600" />;
|
| 38 |
+
if (act.includes("setting") || act.includes("update")) return <Sliders className="w-4 h-4 text-amber-600" />;
|
| 39 |
+
return <Activity className="w-4 h-4 text-zinc-500" />;
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<SidebarLayout>
|
| 44 |
+
<div className="space-y-6 page-enter">
|
| 45 |
+
{/* Header */}
|
| 46 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-zinc-100">
|
| 47 |
+
<div>
|
| 48 |
+
<h1 className="text-xl font-bold text-zinc-900 tracking-tight flex items-center gap-2">
|
| 49 |
+
<History className="w-5 h-5 text-zinc-700" />
|
| 50 |
+
System Audit Logs
|
| 51 |
+
</h1>
|
| 52 |
+
<p className="text-xs text-zinc-500 mt-0.5">Track admin dashboard actions, user activities, and kiosk alerts.</p>
|
| 53 |
+
</div>
|
| 54 |
+
<button
|
| 55 |
+
onClick={() => refetch()}
|
| 56 |
+
className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl border border-zinc-200 hover:bg-zinc-50 text-zinc-700 cursor-pointer transition-all"
|
| 57 |
+
>
|
| 58 |
+
<RefreshCw className="w-3.5 h-3.5" />
|
| 59 |
+
Refresh Logs
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{/* Filter Bar */}
|
| 64 |
+
<div className="relative max-w-md">
|
| 65 |
+
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400 pointer-events-none" />
|
| 66 |
+
<input
|
| 67 |
+
type="text"
|
| 68 |
+
placeholder="Search by action, email, or details..."
|
| 69 |
+
value={search}
|
| 70 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 71 |
+
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"
|
| 72 |
+
/>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
{/* Audit Log Table */}
|
| 76 |
+
<div className="glass-card rounded-2xl border border-zinc-200 overflow-hidden shadow-sm bg-white">
|
| 77 |
+
<div className="overflow-x-auto">
|
| 78 |
+
<table className="w-full text-left border-collapse text-[12px]">
|
| 79 |
+
<thead>
|
| 80 |
+
<tr className="border-b border-zinc-200 bg-zinc-50 text-zinc-500 uppercase tracking-wider font-mono text-[10px] font-bold">
|
| 81 |
+
<th className="py-3 px-5 w-[140px]">Timestamp</th>
|
| 82 |
+
<th className="py-3 px-5 w-[180px]">Action</th>
|
| 83 |
+
<th className="py-3 px-5 w-[220px]">Actor</th>
|
| 84 |
+
<th className="py-3 px-5">Details</th>
|
| 85 |
+
<th className="py-3 px-5 w-[140px]">IP Address</th>
|
| 86 |
+
</tr>
|
| 87 |
+
</thead>
|
| 88 |
+
<tbody className="divide-y divide-zinc-150 text-zinc-700">
|
| 89 |
+
{isLoading ? (
|
| 90 |
+
Array.from({ length: 8 }).map((_, i) => (
|
| 91 |
+
<tr key={i}>
|
| 92 |
+
{Array.from({ length: 5 }).map((_, j) => (
|
| 93 |
+
<td key={j} className="py-4 px-5">
|
| 94 |
+
<div className="skeleton h-4 w-28" />
|
| 95 |
+
</td>
|
| 96 |
+
))}
|
| 97 |
+
</tr>
|
| 98 |
+
))
|
| 99 |
+
) : !filteredLogs || filteredLogs.length === 0 ? (
|
| 100 |
+
<tr>
|
| 101 |
+
<td colSpan={5} className="py-20 text-center text-zinc-400 font-medium font-mono">
|
| 102 |
+
No audit logs found.
|
| 103 |
+
</td>
|
| 104 |
+
</tr>
|
| 105 |
+
) : (
|
| 106 |
+
filteredLogs.map((log: any) => (
|
| 107 |
+
<tr key={log.id} className="hover:bg-zinc-50/50 transition-colors">
|
| 108 |
+
<td className="py-3.5 px-5 font-mono text-zinc-500 text-[11px]">
|
| 109 |
+
{log.timestamp ? parseDateTime(log.timestamp)?.toLocaleString() : "—"}
|
| 110 |
+
</td>
|
| 111 |
+
<td className="py-3.5 px-5">
|
| 112 |
+
<div className="flex items-center gap-2">
|
| 113 |
+
<div className="p-1.5 rounded-lg bg-zinc-100 border border-zinc-200/80">
|
| 114 |
+
{getActionIcon(log.action)}
|
| 115 |
+
</div>
|
| 116 |
+
<span className="font-semibold text-zinc-900">{log.action}</span>
|
| 117 |
+
</div>
|
| 118 |
+
</td>
|
| 119 |
+
<td className="py-3.5 px-5 text-zinc-650 font-medium">
|
| 120 |
+
{log.user?.email || <span className="text-zinc-400 italic">System / Anonymous</span>}
|
| 121 |
+
</td>
|
| 122 |
+
<td className="py-3.5 px-5 text-zinc-600 pr-8">
|
| 123 |
+
{log.details}
|
| 124 |
+
</td>
|
| 125 |
+
<td className="py-3.5 px-5 font-mono text-zinc-500 text-[11px] flex items-center gap-1.5">
|
| 126 |
+
<Laptop className="w-3.5 h-3.5 text-zinc-400" />
|
| 127 |
+
{log.ip_address || "Local"}
|
| 128 |
+
</td>
|
| 129 |
+
</tr>
|
| 130 |
+
))
|
| 131 |
+
)}
|
| 132 |
+
</tbody>
|
| 133 |
+
</table>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* Pagination */}
|
| 137 |
+
<div className="px-5 py-3.5 border-t border-zinc-100 bg-zinc-50/50 flex items-center justify-between">
|
| 138 |
+
<span className="text-xs text-zinc-500 font-mono">
|
| 139 |
+
Page {page + 1}
|
| 140 |
+
</span>
|
| 141 |
+
<div className="flex items-center gap-2">
|
| 142 |
+
<button
|
| 143 |
+
onClick={() => setPage((old) => Math.max(old - 1, 0))}
|
| 144 |
+
disabled={page === 0}
|
| 145 |
+
className="btn-ghost p-1.5 rounded-lg border border-zinc-200 hover:bg-zinc-100 disabled:opacity-50 transition-all cursor-pointer"
|
| 146 |
+
>
|
| 147 |
+
<ChevronLeft className="w-4 h-4" />
|
| 148 |
+
</button>
|
| 149 |
+
<button
|
| 150 |
+
onClick={() => {
|
| 151 |
+
if (logs && logs.length === limit) {
|
| 152 |
+
setPage((old) => old + 1);
|
| 153 |
+
}
|
| 154 |
+
}}
|
| 155 |
+
disabled={!logs || logs.length < limit || isPlaceholderData}
|
| 156 |
+
className="btn-ghost p-1.5 rounded-lg border border-zinc-200 hover:bg-zinc-100 disabled:opacity-50 transition-all cursor-pointer"
|
| 157 |
+
>
|
| 158 |
+
<ChevronRight className="w-4 h-4" />
|
| 159 |
+
</button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</SidebarLayout>
|
| 165 |
+
);
|
| 166 |
+
}
|
frontend/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
| 5 |
+
import ReactECharts from "echarts-for-react";
|
| 6 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 7 |
+
import { fetchApi, getAccessToken, getBackendUrl, parseDateTime, getLocalDateString } from "@/app/utils/api";
|
| 8 |
+
import {
|
| 9 |
+
Users, UserCheck, UserMinus, Clock, TrendingUp, Activity,
|
| 10 |
+
ArrowRight, AlertTriangle, CheckCircle, Zap, ShieldAlert,
|
| 11 |
+
Calendar, Award, Server, Cpu, X
|
| 12 |
+
} from "lucide-react";
|
| 13 |
+
import Link from "next/link";
|
| 14 |
+
|
| 15 |
+
// Pure SVG sparkline helper for premium look
|
| 16 |
+
function Sparkline({ color, data }: { color: string; data: number[] }) {
|
| 17 |
+
const width = 90;
|
| 18 |
+
const height = 28;
|
| 19 |
+
const max = Math.max(...data);
|
| 20 |
+
const min = Math.min(...data);
|
| 21 |
+
const range = max - min || 1;
|
| 22 |
+
const points = data
|
| 23 |
+
.map((val, i) => {
|
| 24 |
+
const x = (i / (data.length - 1)) * width;
|
| 25 |
+
const y = height - ((val - min) / range) * (height - 6) - 3;
|
| 26 |
+
return `${x},${y}`;
|
| 27 |
+
})
|
| 28 |
+
.join(" ");
|
| 29 |
+
|
| 30 |
+
const colors = {
|
| 31 |
+
blue: "#3b82f6",
|
| 32 |
+
emerald: "#10b981",
|
| 33 |
+
amber: "#f59e0b",
|
| 34 |
+
rose: "#f43f5e",
|
| 35 |
+
indigo: "#6366f1",
|
| 36 |
+
};
|
| 37 |
+
const strokeColor = colors[color as keyof typeof colors] || "#3b82f6";
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<svg width={width} height={height} className="overflow-visible opacity-80 shrink-0">
|
| 41 |
+
<defs>
|
| 42 |
+
<linearGradient id={`spark-grad-${color}`} x1="0" y1="0" x2="0" y2="1">
|
| 43 |
+
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.25" />
|
| 44 |
+
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
|
| 45 |
+
</linearGradient>
|
| 46 |
+
</defs>
|
| 47 |
+
<path
|
| 48 |
+
d={`M 0,${height} L ${points} L ${width},${height} Z`}
|
| 49 |
+
fill={`url(#spark-grad-${color})`}
|
| 50 |
+
/>
|
| 51 |
+
<polyline
|
| 52 |
+
fill="none"
|
| 53 |
+
stroke={strokeColor}
|
| 54 |
+
strokeWidth="1.75"
|
| 55 |
+
strokeLinecap="round"
|
| 56 |
+
strokeLinejoin="round"
|
| 57 |
+
points={points}
|
| 58 |
+
/>
|
| 59 |
+
</svg>
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function StatCard({
|
| 64 |
+
label, value, icon: Icon, color, loading, sublabel, sparkData
|
| 65 |
+
}: {
|
| 66 |
+
label: string; value: string | number; icon: any;
|
| 67 |
+
color: "blue" | "emerald" | "amber" | "rose" | "indigo";
|
| 68 |
+
loading?: boolean; sublabel?: string; sparkData: number[];
|
| 69 |
+
}) {
|
| 70 |
+
const colors = {
|
| 71 |
+
blue: { icon: "text-zinc-600", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 72 |
+
emerald: { icon: "text-zinc-800", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 73 |
+
amber: { icon: "text-zinc-650", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 74 |
+
rose: { icon: "text-zinc-700", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 75 |
+
indigo: { icon: "text-zinc-900", bg: "bg-zinc-100", border: "border-zinc-200 hover:border-zinc-400", val: "text-zinc-900" },
|
| 76 |
+
};
|
| 77 |
+
const c = colors[color];
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div className={`glass-card rounded-2xl p-5 border ${c.border} stat-card flex flex-col justify-between h-[145px] hover:scale-[1.01] hover:shadow-[0_8px_30px_rgb(0,0,0,0.12)] transition-all duration-300`}>
|
| 81 |
+
<div className="flex items-center justify-between">
|
| 82 |
+
<p className="text-[11px] text-slate-400 font-semibold uppercase tracking-wider">{label}</p>
|
| 83 |
+
<div className={`w-8.5 h-8.5 rounded-xl ${c.bg} ${c.icon} flex items-center justify-center border border-white/5`}>
|
| 84 |
+
<Icon className="w-4 h-4" />
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div className="flex items-end justify-between mt-auto">
|
| 89 |
+
<div className="space-y-1">
|
| 90 |
+
{loading ? (
|
| 91 |
+
<div className="skeleton h-8 w-20" />
|
| 92 |
+
) : (
|
| 93 |
+
<p className="text-2xl font-bold tracking-tight text-[var(--text-primary)]">{value}</p>
|
| 94 |
+
)}
|
| 95 |
+
{sublabel && <p className="text-[10px] text-slate-500 font-mono">{sublabel}</p>}
|
| 96 |
+
</div>
|
| 97 |
+
{!loading && <Sparkline color={color} data={sparkData} />}
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const AVATAR_COLORS = [
|
| 104 |
+
"from-blue-500/20 to-indigo-500/20 text-blue-400 border-blue-500/15",
|
| 105 |
+
"from-emerald-500/20 to-teal-500/20 text-emerald-400 border-emerald-500/15",
|
| 106 |
+
"from-amber-500/20 to-orange-500/20 text-amber-400 border-amber-500/15",
|
| 107 |
+
"from-indigo-500/20 to-purple-500/20 text-indigo-400 border-indigo-500/15",
|
| 108 |
+
"from-cyan-500/20 to-blue-500/20 text-cyan-400 border-cyan-500/15",
|
| 109 |
+
];
|
| 110 |
+
|
| 111 |
+
function ScanLogItem({ log }: { log: any }) {
|
| 112 |
+
const isSuccess = log.status === "Match Success";
|
| 113 |
+
const isSpoof = log.is_spoof;
|
| 114 |
+
const isUnknown = log.status === "Unknown Person";
|
| 115 |
+
|
| 116 |
+
let dotColor = "bg-slate-600";
|
| 117 |
+
let statusColor = "text-slate-500";
|
| 118 |
+
let statusBg = "bg-slate-500/8 border-slate-500/15";
|
| 119 |
+
let statusLabel = log.status;
|
| 120 |
+
|
| 121 |
+
if (isSuccess) {
|
| 122 |
+
dotColor = "bg-emerald-400";
|
| 123 |
+
statusColor = "text-emerald-400";
|
| 124 |
+
statusBg = "bg-emerald-500/8 border-emerald-500/15";
|
| 125 |
+
statusLabel = "Matched";
|
| 126 |
+
} else if (isSpoof) {
|
| 127 |
+
dotColor = "bg-rose-500 animate-ping";
|
| 128 |
+
statusColor = "text-rose-400";
|
| 129 |
+
statusBg = "bg-rose-500/8 border-rose-500/20";
|
| 130 |
+
statusLabel = "Spoof Blocked";
|
| 131 |
+
} else if (isUnknown) {
|
| 132 |
+
dotColor = "bg-amber-400";
|
| 133 |
+
statusColor = "text-amber-400";
|
| 134 |
+
statusBg = "bg-amber-500/8 border-amber-500/20";
|
| 135 |
+
statusLabel = "Unknown";
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Generate a nice random avatar gradient color based on employee ID
|
| 139 |
+
const colorIndex = log.employee ? (log.employee.id % AVATAR_COLORS.length) : 0;
|
| 140 |
+
const avatarCls = AVATAR_COLORS[colorIndex];
|
| 141 |
+
|
| 142 |
+
return (
|
| 143 |
+
<div className="flex items-center gap-3.5 py-3 px-3.5 rounded-xl hover:bg-white/[0.02] border border-transparent hover:border-white/5 transition-all duration-200 group">
|
| 144 |
+
<div className="relative shrink-0">
|
| 145 |
+
{log.employee ? (
|
| 146 |
+
<div className={`w-9 h-9 rounded-xl bg-gradient-to-br ${avatarCls} flex items-center justify-center font-bold text-xs border`}>
|
| 147 |
+
{log.employee.name.charAt(0).toUpperCase()}
|
| 148 |
+
</div>
|
| 149 |
+
) : (
|
| 150 |
+
<div className="w-9 h-9 rounded-xl bg-slate-500/10 text-slate-500 flex items-center justify-center font-bold text-xs border border-white/5">
|
| 151 |
+
?
|
| 152 |
+
</div>
|
| 153 |
+
)}
|
| 154 |
+
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full bg-[#050810] flex items-center justify-center`}>
|
| 155 |
+
<div className={`w-1.5 h-1.5 rounded-full ${dotColor.split(" ")[0]}`} />
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div className="flex-1 min-w-0">
|
| 160 |
+
<div className="flex items-center gap-2">
|
| 161 |
+
<p className="text-[13px] font-semibold text-[var(--text-primary)] truncate">
|
| 162 |
+
{log.employee ? log.employee.name : "Unknown Person"}
|
| 163 |
+
</p>
|
| 164 |
+
<span className="text-[9px] text-zinc-500 font-mono">· {log.camera}</span>
|
| 165 |
+
</div>
|
| 166 |
+
<p className="text-[10px] text-slate-500 truncate mt-0.5">
|
| 167 |
+
{log.employee ? `${log.employee.designation} (${log.employee.employee_id})` : "Unauthorized access attempt"}
|
| 168 |
+
</p>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div className="shrink-0 text-right space-y-1">
|
| 172 |
+
<p className="text-[10px] text-slate-500 font-mono leading-none">
|
| 173 |
+
{parseDateTime(log.timestamp)?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) || ""}
|
| 174 |
+
</p>
|
| 175 |
+
<span className={`inline-block text-[9px] font-mono font-semibold px-2 py-0.5 rounded-full border ${statusBg} ${statusColor}`}>
|
| 176 |
+
{statusLabel}
|
| 177 |
+
</span>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const getDeptTheme = (code: string) => {
|
| 184 |
+
const themes = {
|
| 185 |
+
ENG: {
|
| 186 |
+
bg: "bg-blue-50/50 hover:bg-blue-50/70 border-blue-200 text-blue-900",
|
| 187 |
+
iconBg: "bg-blue-500/10 text-blue-600 border-blue-500/20",
|
| 188 |
+
barBg: "bg-blue-100",
|
| 189 |
+
barFill: "bg-gradient-to-r from-blue-500 to-blue-600"
|
| 190 |
+
},
|
| 191 |
+
HR: {
|
| 192 |
+
bg: "bg-purple-50/50 hover:bg-purple-50/70 border-purple-200 text-purple-900",
|
| 193 |
+
iconBg: "bg-purple-500/10 text-purple-600 border-purple-500/20",
|
| 194 |
+
barBg: "bg-purple-100",
|
| 195 |
+
barFill: "bg-gradient-to-r from-purple-500 to-purple-600"
|
| 196 |
+
},
|
| 197 |
+
MKT: {
|
| 198 |
+
bg: "bg-amber-50/50 hover:bg-amber-50/70 border-amber-200 text-amber-900",
|
| 199 |
+
iconBg: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
| 200 |
+
barBg: "bg-amber-100",
|
| 201 |
+
barFill: "bg-gradient-to-r from-amber-500 to-amber-600"
|
| 202 |
+
},
|
| 203 |
+
FIN: {
|
| 204 |
+
bg: "bg-emerald-50/50 hover:bg-emerald-50/70 border-emerald-200 text-emerald-900",
|
| 205 |
+
iconBg: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
|
| 206 |
+
barBg: "bg-emerald-100",
|
| 207 |
+
barFill: "bg-gradient-to-r from-emerald-500 to-emerald-600"
|
| 208 |
+
},
|
| 209 |
+
OPS: {
|
| 210 |
+
bg: "bg-rose-50/50 hover:bg-rose-50/70 border-rose-200 text-rose-900",
|
| 211 |
+
iconBg: "bg-rose-500/10 text-rose-600 border-rose-500/20",
|
| 212 |
+
barBg: "bg-rose-100",
|
| 213 |
+
barFill: "bg-gradient-to-r from-rose-500 to-rose-600"
|
| 214 |
+
},
|
| 215 |
+
};
|
| 216 |
+
return themes[code as keyof typeof themes] || {
|
| 217 |
+
bg: "bg-slate-50/50 hover:bg-slate-50/70 border-slate-200 text-slate-900",
|
| 218 |
+
iconBg: "bg-slate-500/10 text-slate-600 border-slate-500/20",
|
| 219 |
+
barBg: "bg-slate-100",
|
| 220 |
+
barFill: "bg-gradient-to-r from-slate-500 to-slate-600"
|
| 221 |
+
};
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
export default function DashboardPage() {
|
| 225 |
+
const [currentTime, setCurrentTime] = React.useState("");
|
| 226 |
+
const [currentDate, setCurrentDate] = React.useState("");
|
| 227 |
+
const [statusFilter, setStatusFilter] = React.useState<"ALL" | "MATCHED" | "UNKNOWN" | "SPOOF">("ALL");
|
| 228 |
+
const [selectedDept, setSelectedDept] = React.useState<any | null>(null);
|
| 229 |
+
const queryClient = useQueryClient();
|
| 230 |
+
|
| 231 |
+
const { data: deptAttendance, isLoading: loadingDeptAttendance } = useQuery({
|
| 232 |
+
queryKey: ["dept-attendance", selectedDept?.id],
|
| 233 |
+
queryFn: () => {
|
| 234 |
+
if (!selectedDept) return [];
|
| 235 |
+
const todayStr = getLocalDateString();
|
| 236 |
+
return fetchApi(`/attendance/daily?department_id=${selectedDept.id}&date_val=${todayStr}`);
|
| 237 |
+
},
|
| 238 |
+
enabled: !!selectedDept
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
React.useEffect(() => {
|
| 242 |
+
const updateTime = () => {
|
| 243 |
+
const now = new Date();
|
| 244 |
+
setCurrentTime(now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
|
| 245 |
+
setCurrentDate(now.toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }));
|
| 246 |
+
};
|
| 247 |
+
updateTime();
|
| 248 |
+
const interval = setInterval(updateTime, 1000);
|
| 249 |
+
return () => clearInterval(interval);
|
| 250 |
+
}, []);
|
| 251 |
+
|
| 252 |
+
React.useEffect(() => {
|
| 253 |
+
const token = getAccessToken();
|
| 254 |
+
if (!token) return;
|
| 255 |
+
|
| 256 |
+
const backendUrl = getBackendUrl();
|
| 257 |
+
const sseUrl = `${backendUrl}/analytics/live-stream?token=${encodeURIComponent(token)}`;
|
| 258 |
+
const eventSource = new EventSource(sseUrl);
|
| 259 |
+
|
| 260 |
+
eventSource.onmessage = (event) => {
|
| 261 |
+
try {
|
| 262 |
+
const newLog = JSON.parse(event.data);
|
| 263 |
+
|
| 264 |
+
// 1. Prepend the new log to the "recent-activity" list
|
| 265 |
+
queryClient.setQueryData(["recent-activity"], (oldData: any[] | undefined) => {
|
| 266 |
+
if (!oldData) return [newLog];
|
| 267 |
+
const filtered = oldData.filter((log: any) => log.id !== newLog.id);
|
| 268 |
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
| 269 |
+
return [newLog, ...filtered]
|
| 270 |
+
.filter((log: any) => {
|
| 271 |
+
const logDate = parseDateTime(log.timestamp);
|
| 272 |
+
return logDate ? logDate.getTime() >= cutoff : false;
|
| 273 |
+
})
|
| 274 |
+
.slice(0, 10);
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
// 2. Invalidate other dashboard stats to trigger refetch
|
| 278 |
+
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 279 |
+
queryClient.invalidateQueries({ queryKey: ["department-distribution"] });
|
| 280 |
+
queryClient.invalidateQueries({ queryKey: ["attendance-trends"] });
|
| 281 |
+
} catch (err) {
|
| 282 |
+
console.error("Error handling SSE message:", err);
|
| 283 |
+
}
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
eventSource.onerror = (err) => {
|
| 287 |
+
console.error("SSE connection error:", err);
|
| 288 |
+
};
|
| 289 |
+
|
| 290 |
+
return () => {
|
| 291 |
+
eventSource.close();
|
| 292 |
+
};
|
| 293 |
+
}, [queryClient]);
|
| 294 |
+
|
| 295 |
+
const { data: summary, isLoading: loadingSummary } = useQuery({
|
| 296 |
+
queryKey: ["dashboard-summary"],
|
| 297 |
+
queryFn: () => fetchApi("/analytics/dashboard-summary"),
|
| 298 |
+
refetchInterval: 60000
|
| 299 |
+
});
|
| 300 |
+
|
| 301 |
+
const { data: trends, isLoading: loadingTrends } = useQuery({
|
| 302 |
+
queryKey: ["attendance-trends"],
|
| 303 |
+
queryFn: () => fetchApi("/analytics/attendance-trends?days=7")
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
const { data: deptStats, isLoading: loadingDept } = useQuery({
|
| 307 |
+
queryKey: ["department-distribution"],
|
| 308 |
+
queryFn: () => fetchApi("/analytics/department-distribution")
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
const { data: recentLogs, isLoading: loadingLogs } = useQuery({
|
| 312 |
+
queryKey: ["recent-activity"],
|
| 313 |
+
queryFn: () => fetchApi("/analytics/recent-activity?limit=6"),
|
| 314 |
+
refetchInterval: 60000
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
// Map real database trends to the card sparklines
|
| 318 |
+
const trendsLoaded = trends && trends.length > 0;
|
| 319 |
+
|
| 320 |
+
const staffSpark = trendsLoaded
|
| 321 |
+
? trends.map(() => summary?.total_employees || 0)
|
| 322 |
+
: [0, 0, 0, 0, 0, 0, summary?.total_employees || 0];
|
| 323 |
+
|
| 324 |
+
const presentSpark = trendsLoaded
|
| 325 |
+
? trends.map((t: any) => t.present)
|
| 326 |
+
: [0, 0, 0, 0, 0, 0, summary?.present_today || 0];
|
| 327 |
+
|
| 328 |
+
const lateSpark = trendsLoaded
|
| 329 |
+
? trends.map((t: any) => t.late)
|
| 330 |
+
: [0, 0, 0, 0, 0, 0, summary?.late_today || 0];
|
| 331 |
+
|
| 332 |
+
const absentSpark = trendsLoaded
|
| 333 |
+
? trends.map((t: any) => Math.max(0, (summary?.total_employees || 0) - t.present))
|
| 334 |
+
: [0, 0, 0, 0, 0, 0, summary?.absent_today || 0];
|
| 335 |
+
|
| 336 |
+
const rateSpark = trendsLoaded
|
| 337 |
+
? trends.map((t: any) => Math.round((t.present / (summary?.total_employees || 1)) * 100))
|
| 338 |
+
: [0, 0, 0, 0, 0, 0, summary?.attendance_percentage || 0];
|
| 339 |
+
|
| 340 |
+
const trendOption = () => {
|
| 341 |
+
if (!trends) return {};
|
| 342 |
+
return {
|
| 343 |
+
backgroundColor: "transparent",
|
| 344 |
+
tooltip: {
|
| 345 |
+
trigger: "axis",
|
| 346 |
+
backgroundColor: "#ffffff",
|
| 347 |
+
borderColor: "rgba(24,24,27,0.08)",
|
| 348 |
+
borderWidth: 1,
|
| 349 |
+
shadowColor: "rgba(0,0,0,0.02)",
|
| 350 |
+
shadowBlur: 10,
|
| 351 |
+
textStyle: { color: "#18181b", fontSize: 11, fontFamily: "var(--font-inter)" },
|
| 352 |
+
extraCssText: "border-radius:12px;padding:8px 12px;box-shadow: 0 4px 16px rgba(0,0,0,0.04);"
|
| 353 |
+
},
|
| 354 |
+
legend: {
|
| 355 |
+
data: ["Present", "Late Arrivals"],
|
| 356 |
+
textStyle: { color: "#71717a", fontSize: 10, fontFamily: "var(--font-inter)" },
|
| 357 |
+
bottom: 0,
|
| 358 |
+
icon: "circle",
|
| 359 |
+
itemWidth: 8,
|
| 360 |
+
itemHeight: 8,
|
| 361 |
+
itemGap: 24
|
| 362 |
+
},
|
| 363 |
+
grid: { top: 20, left: 36, right: 16, bottom: 40 },
|
| 364 |
+
xAxis: {
|
| 365 |
+
type: "category",
|
| 366 |
+
data: trends.map((t: any) => {
|
| 367 |
+
const parts = t.date.split("-");
|
| 368 |
+
let d;
|
| 369 |
+
if (parts.length === 3) {
|
| 370 |
+
d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
|
| 371 |
+
} else {
|
| 372 |
+
d = new Date(t.date);
|
| 373 |
+
}
|
| 374 |
+
return d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
| 375 |
+
}),
|
| 376 |
+
axisLine: { lineStyle: { color: "rgba(24,24,27,0.06)" } },
|
| 377 |
+
axisTick: { show: false },
|
| 378 |
+
axisLabel: { color: "#71717a", fontSize: 10, fontFamily: "var(--font-inter)" }
|
| 379 |
+
},
|
| 380 |
+
yAxis: {
|
| 381 |
+
type: "value",
|
| 382 |
+
splitLine: { lineStyle: { color: "rgba(24,24,27,0.04)", type: "dashed" } },
|
| 383 |
+
axisLabel: { color: "#71717a", fontSize: 10, fontFamily: "var(--font-inter)" },
|
| 384 |
+
axisLine: { show: false }
|
| 385 |
+
},
|
| 386 |
+
series: [
|
| 387 |
+
{
|
| 388 |
+
name: "Present",
|
| 389 |
+
type: "line",
|
| 390 |
+
smooth: 0.35,
|
| 391 |
+
showSymbol: false,
|
| 392 |
+
symbolSize: 6,
|
| 393 |
+
data: trends.map((t: any) => t.present),
|
| 394 |
+
lineStyle: {
|
| 395 |
+
color: "#18181b",
|
| 396 |
+
width: 2.25,
|
| 397 |
+
shadowColor: "rgba(24,24,27,0.1)",
|
| 398 |
+
shadowBlur: 8,
|
| 399 |
+
shadowOffsetY: 4
|
| 400 |
+
},
|
| 401 |
+
itemStyle: { color: "#18181b" },
|
| 402 |
+
areaStyle: {
|
| 403 |
+
color: {
|
| 404 |
+
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
| 405 |
+
colorStops: [
|
| 406 |
+
{ offset: 0, color: "rgba(24,24,27,0.06)" },
|
| 407 |
+
{ offset: 1, color: "rgba(24,24,27,0)" }
|
| 408 |
+
]
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
},
|
| 412 |
+
{
|
| 413 |
+
name: "Late Arrivals",
|
| 414 |
+
type: "bar",
|
| 415 |
+
data: trends.map((t: any) => t.late),
|
| 416 |
+
itemStyle: {
|
| 417 |
+
color: "#71717a",
|
| 418 |
+
borderRadius: [3, 3, 0, 0]
|
| 419 |
+
},
|
| 420 |
+
barWidth: 6,
|
| 421 |
+
barMaxWidth: 10
|
| 422 |
+
}
|
| 423 |
+
]
|
| 424 |
+
};
|
| 425 |
+
};
|
| 426 |
+
|
| 427 |
+
const deptOption = () => {
|
| 428 |
+
if (!deptStats) return {};
|
| 429 |
+
return {
|
| 430 |
+
backgroundColor: "transparent",
|
| 431 |
+
tooltip: {
|
| 432 |
+
trigger: "axis",
|
| 433 |
+
axisPointer: { type: "shadow" },
|
| 434 |
+
backgroundColor: "#ffffff",
|
| 435 |
+
borderColor: "rgba(24,24,27,0.08)",
|
| 436 |
+
borderWidth: 1,
|
| 437 |
+
textStyle: { color: "#18181b", fontSize: 11 },
|
| 438 |
+
extraCssText: "border-radius:12px;padding:8px 12px;box-shadow: 0 4px 16px rgba(0,0,0,0.04);"
|
| 439 |
+
},
|
| 440 |
+
grid: { top: 10, left: 90, right: 20, bottom: 20 },
|
| 441 |
+
xAxis: {
|
| 442 |
+
type: "value",
|
| 443 |
+
splitLine: { lineStyle: { color: "rgba(24,24,27,0.04)", type: "dashed" } },
|
| 444 |
+
axisLabel: { color: "#71717a", fontSize: 9 },
|
| 445 |
+
axisLine: { show: false }
|
| 446 |
+
},
|
| 447 |
+
yAxis: {
|
| 448 |
+
type: "category",
|
| 449 |
+
data: deptStats.map((d: any) => d.name || d.code),
|
| 450 |
+
axisLine: { show: false },
|
| 451 |
+
axisTick: { show: false },
|
| 452 |
+
axisLabel: { color: "#18181b", fontSize: 10, fontFamily: "var(--font-inter)" }
|
| 453 |
+
},
|
| 454 |
+
series: [
|
| 455 |
+
{
|
| 456 |
+
name: "Total Employees",
|
| 457 |
+
type: "bar",
|
| 458 |
+
data: deptStats.map((d: any) => d.total_employees),
|
| 459 |
+
itemStyle: { color: "rgba(24,24,27,0.04)", borderRadius: [0, 4, 4, 0] },
|
| 460 |
+
barGap: "-100%",
|
| 461 |
+
barWidth: 10
|
| 462 |
+
},
|
| 463 |
+
{
|
| 464 |
+
name: "Present Today",
|
| 465 |
+
type: "bar",
|
| 466 |
+
data: deptStats.map((d: any) => d.present_today),
|
| 467 |
+
barWidth: 10,
|
| 468 |
+
itemStyle: {
|
| 469 |
+
borderRadius: [0, 4, 4, 0],
|
| 470 |
+
color: {
|
| 471 |
+
type: "linear", x: 0, y: 0, x2: 1, y2: 0,
|
| 472 |
+
colorStops: [
|
| 473 |
+
{ offset: 0, color: "#18181b" },
|
| 474 |
+
{ offset: 1, color: "#27272a" }
|
| 475 |
+
]
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
]
|
| 480 |
+
};
|
| 481 |
+
};
|
| 482 |
+
|
| 483 |
+
return (
|
| 484 |
+
<SidebarLayout>
|
| 485 |
+
<div className="space-y-7 page-enter">
|
| 486 |
+
{/* ─── Header ─── */}
|
| 487 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between pb-5 border-b border-zinc-200 gap-4">
|
| 488 |
+
<div>
|
| 489 |
+
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">
|
| 490 |
+
Dashboard Overview
|
| 491 |
+
</h1>
|
| 492 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 493 |
+
Live biometric monitoring and attendance intelligence platform
|
| 494 |
+
</p>
|
| 495 |
+
</div>
|
| 496 |
+
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 shrink-0">
|
| 497 |
+
{/* Real-time Clock widget */}
|
| 498 |
+
{currentTime && (
|
| 499 |
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-xl font-mono text-[11px] text-zinc-700 shadow-2xs">
|
| 500 |
+
<Calendar className="w-3.5 h-3.5 text-zinc-500" />
|
| 501 |
+
<span>{currentDate}</span>
|
| 502 |
+
<span className="text-zinc-300 font-sans mx-0.5">|</span>
|
| 503 |
+
<Clock className="w-3.5 h-3.5 text-zinc-500" />
|
| 504 |
+
<span className="tabular-nums font-semibold">{currentTime}</span>
|
| 505 |
+
</div>
|
| 506 |
+
)}
|
| 507 |
+
|
| 508 |
+
<div className="flex items-center gap-2 text-[10px] font-semibold bg-emerald-50 border border-emerald-200 px-3 py-1.5 rounded-full text-emerald-800">
|
| 509 |
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 510 |
+
<span className="font-mono uppercase tracking-wider">Live Feed Active</span>
|
| 511 |
+
</div>
|
| 512 |
+
</div>
|
| 513 |
+
</div>
|
| 514 |
+
|
| 515 |
+
{/* ─── KPI Grid ─── */}
|
| 516 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
| 517 |
+
<StatCard label="Total Staff" value={loadingSummary ? "—" : summary?.total_employees} icon={Users} color="blue" loading={loadingSummary} sublabel="Registered" sparkData={staffSpark} />
|
| 518 |
+
<StatCard label="Present Today" value={loadingSummary ? "—" : summary?.present_today} icon={UserCheck} color="emerald" loading={loadingSummary} sublabel="Clocked in" sparkData={presentSpark} />
|
| 519 |
+
<StatCard label="Late Arrivals" value={loadingSummary ? "—" : summary?.late_today} icon={Clock} color="amber" loading={loadingSummary} sublabel="Grace exceeded" sparkData={lateSpark} />
|
| 520 |
+
<StatCard label="Absent Today" value={loadingSummary ? "—" : summary?.absent_today} icon={UserMinus} color="rose" loading={loadingSummary} sublabel="Unexcused" sparkData={absentSpark} />
|
| 521 |
+
<StatCard label="Attendance Rate" value={loadingSummary ? "—" : `${summary?.attendance_percentage}%`} icon={TrendingUp} color="indigo" loading={loadingSummary} sublabel="Rate today" sparkData={rateSpark} />
|
| 522 |
+
</div>
|
| 523 |
+
|
| 524 |
+
{/* ─── Charts Row ─── */}
|
| 525 |
+
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
| 526 |
+
{/* Trend Chart */}
|
| 527 |
+
<div className="lg:col-span-3 glass-card rounded-2xl p-5 border border-white/6 flex flex-col justify-between">
|
| 528 |
+
<div className="flex items-center justify-between mb-6">
|
| 529 |
+
<div className="flex items-center gap-2.5">
|
| 530 |
+
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center border border-white/5">
|
| 531 |
+
<TrendingUp className="w-4 h-4 text-blue-400" />
|
| 532 |
+
</div>
|
| 533 |
+
<div>
|
| 534 |
+
<h2 className="text-xs font-semibold text-[var(--text-primary)] uppercase tracking-wider">Attendance Trends</h2>
|
| 535 |
+
<p className="text-[10px] text-slate-500 mt-0.5">Biometric logs for the past 7 days</p>
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
<div className="h-56">
|
| 540 |
+
{loadingTrends ? (
|
| 541 |
+
<div className="h-full flex items-center justify-center">
|
| 542 |
+
<div className="skeleton w-full h-full rounded-xl" />
|
| 543 |
+
</div>
|
| 544 |
+
) : (
|
| 545 |
+
<ReactECharts option={trendOption()} style={{ height: "100%", width: "100%" }} />
|
| 546 |
+
)}
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
|
| 550 |
+
{/* Live Feed */}
|
| 551 |
+
<div className="lg:col-span-2 glass-card rounded-2xl border border-white/6 flex flex-col">
|
| 552 |
+
<div className="p-5 pb-3.5 border-b border-white/5">
|
| 553 |
+
<div className="flex items-center justify-between">
|
| 554 |
+
<div className="flex items-center gap-2.5">
|
| 555 |
+
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center border border-white/5">
|
| 556 |
+
<Activity className="w-4 h-4 text-indigo-400" />
|
| 557 |
+
</div>
|
| 558 |
+
<div>
|
| 559 |
+
<h2 className="text-xs font-semibold text-[var(--text-primary)] uppercase tracking-wider">Live Activity Feed</h2>
|
| 560 |
+
<p className="text-[10px] text-slate-500 mt-0.5">Real-time scan logs ticker</p>
|
| 561 |
+
</div>
|
| 562 |
+
</div>
|
| 563 |
+
<div className="flex items-center gap-1.5 bg-indigo-500/5 px-2 py-1 rounded-md border border-indigo-500/15">
|
| 564 |
+
<span className="w-1 h-1 rounded-full bg-indigo-400 animate-pulse" />
|
| 565 |
+
<span className="text-[9px] text-indigo-400 font-mono tracking-wider">STREAMING</span>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
|
| 569 |
+
{/* Status Filter pills */}
|
| 570 |
+
<div className="flex flex-wrap gap-1.5 mt-3.5">
|
| 571 |
+
{(["ALL", "MATCHED", "UNKNOWN", "SPOOF"] as const).map((filter) => (
|
| 572 |
+
<button
|
| 573 |
+
key={filter}
|
| 574 |
+
onClick={() => setStatusFilter(filter)}
|
| 575 |
+
className={`px-2.5 py-1 rounded-lg text-[9px] font-bold tracking-wide transition-all border cursor-pointer uppercase font-mono ${
|
| 576 |
+
statusFilter === filter
|
| 577 |
+
? "bg-zinc-800 border-zinc-700 text-white"
|
| 578 |
+
: "bg-transparent border-white/5 text-slate-400 hover:text-slate-200 hover:border-white/10"
|
| 579 |
+
}`}
|
| 580 |
+
>
|
| 581 |
+
{filter === "SPOOF" ? "Spoofs" : filter.toLowerCase()}
|
| 582 |
+
</button>
|
| 583 |
+
))}
|
| 584 |
+
</div>
|
| 585 |
+
</div>
|
| 586 |
+
|
| 587 |
+
<div className="flex-1 overflow-y-auto p-3.5 space-y-1 max-h-[240px]">
|
| 588 |
+
{loadingLogs ? (
|
| 589 |
+
Array.from({ length: 4 }).map((_, i) => (
|
| 590 |
+
<div key={i} className="flex items-center gap-3.5 py-3 px-3.5">
|
| 591 |
+
<div className="skeleton w-9 h-9 rounded-xl shrink-0" />
|
| 592 |
+
<div className="flex-1 space-y-2">
|
| 593 |
+
<div className="skeleton h-3 w-32" />
|
| 594 |
+
<div className="skeleton h-2.5 w-24" />
|
| 595 |
+
</div>
|
| 596 |
+
</div>
|
| 597 |
+
))
|
| 598 |
+
) : (() => {
|
| 599 |
+
const filtered = recentLogs?.filter((log: any) => {
|
| 600 |
+
if (statusFilter === "ALL") return true;
|
| 601 |
+
if (statusFilter === "MATCHED") return log.status === "Match Success";
|
| 602 |
+
if (statusFilter === "UNKNOWN") return log.status === "Unknown Person";
|
| 603 |
+
if (statusFilter === "SPOOF") return log.is_spoof || log.status === "Spoof Rejected" || log.status === "Spoof Blocked";
|
| 604 |
+
return true;
|
| 605 |
+
});
|
| 606 |
+
|
| 607 |
+
if (!filtered || filtered.length === 0) {
|
| 608 |
+
return (
|
| 609 |
+
<div className="flex flex-col items-center justify-center h-48 text-slate-500 text-xs text-center space-y-2">
|
| 610 |
+
<Zap className="w-8 h-8 opacity-25 text-slate-400" />
|
| 611 |
+
<p className="font-semibold text-slate-400">No events</p>
|
| 612 |
+
<p className="text-[10px] text-slate-600 max-w-[160px]">No recent events matching this filter.</p>
|
| 613 |
+
</div>
|
| 614 |
+
);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
return filtered.map((log: any) => (
|
| 618 |
+
<ScanLogItem key={log.id} log={log} />
|
| 619 |
+
));
|
| 620 |
+
})()}
|
| 621 |
+
</div>
|
| 622 |
+
|
| 623 |
+
<div className="p-4 border-t border-white/5 bg-white/[0.01]">
|
| 624 |
+
<Link
|
| 625 |
+
href="/attendance"
|
| 626 |
+
className="flex items-center justify-between text-[11px] text-blue-400 hover:text-blue-300 font-semibold transition-colors group"
|
| 627 |
+
>
|
| 628 |
+
<span>Access Complete Ledger Logs</span>
|
| 629 |
+
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
|
| 630 |
+
</Link>
|
| 631 |
+
</div>
|
| 632 |
+
</div>
|
| 633 |
+
</div>
|
| 634 |
+
|
| 635 |
+
{/* ─── Bottom Section: Department Breakdown ─── */}
|
| 636 |
+
<div className="glass-card rounded-2xl p-5 border border-white/6">
|
| 637 |
+
<div className="flex items-center justify-between mb-5">
|
| 638 |
+
<div className="flex items-center gap-2.5">
|
| 639 |
+
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center border border-white/5">
|
| 640 |
+
<Users className="w-4 h-4 text-indigo-400" />
|
| 641 |
+
</div>
|
| 642 |
+
<div>
|
| 643 |
+
<h2 className="text-xs font-semibold text-[var(--text-primary)] uppercase tracking-wider">Department Distribution</h2>
|
| 644 |
+
<p className="text-[10px] text-slate-500 mt-0.5">Staff presence stats by company department</p>
|
| 645 |
+
</div>
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
|
| 649 |
+
<div className="min-h-36">
|
| 650 |
+
{loadingDept ? (
|
| 651 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
| 652 |
+
{Array.from({ length: 5 }).map((_, i) => (
|
| 653 |
+
<div key={i} className="skeleton h-32 w-full rounded-2xl" />
|
| 654 |
+
))}
|
| 655 |
+
</div>
|
| 656 |
+
) : !deptStats || deptStats.length === 0 ? (
|
| 657 |
+
<div className="p-8 text-center text-slate-400 text-xs">
|
| 658 |
+
No department stats found.
|
| 659 |
+
</div>
|
| 660 |
+
) : (
|
| 661 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
| 662 |
+
{deptStats.map((d: any) => {
|
| 663 |
+
const theme = getDeptTheme(d.code);
|
| 664 |
+
const percent = d.total_employees > 0
|
| 665 |
+
? Math.round((d.present_today / d.total_employees) * 100)
|
| 666 |
+
: 0;
|
| 667 |
+
|
| 668 |
+
return (
|
| 669 |
+
<div
|
| 670 |
+
key={d.code}
|
| 671 |
+
onClick={() => setSelectedDept(d)}
|
| 672 |
+
className={`p-4 rounded-2xl border transition-all duration-300 flex flex-col justify-between h-32 hover:scale-[1.02] cursor-pointer ${theme.bg}`}
|
| 673 |
+
>
|
| 674 |
+
{/* Top Header Row */}
|
| 675 |
+
<div className="flex items-center justify-between w-full">
|
| 676 |
+
<span className={`text-[9.5px] font-bold font-mono px-2 py-0.5 rounded-md border ${theme.iconBg}`}>
|
| 677 |
+
{d.code}
|
| 678 |
+
</span>
|
| 679 |
+
|
| 680 |
+
<div className="text-right">
|
| 681 |
+
<span className="text-xs font-bold text-slate-800 tracking-tight">
|
| 682 |
+
{d.present_today} <span className="text-slate-450 font-medium">/ {d.total_employees}</span>
|
| 683 |
+
</span>
|
| 684 |
+
</div>
|
| 685 |
+
</div>
|
| 686 |
+
|
| 687 |
+
{/* Department Name & Subheading */}
|
| 688 |
+
<div className="mt-2 flex-1 min-w-0">
|
| 689 |
+
<h3 className="text-xs font-bold text-slate-800 truncate" title={d.department}>
|
| 690 |
+
{d.department}
|
| 691 |
+
</h3>
|
| 692 |
+
<p className="text-[9.5px] text-slate-500 font-mono mt-0.5">
|
| 693 |
+
{percent}% present today
|
| 694 |
+
</p>
|
| 695 |
+
</div>
|
| 696 |
+
|
| 697 |
+
{/* Progress Bar */}
|
| 698 |
+
<div className="mt-2.5 w-full">
|
| 699 |
+
<div className={`w-full h-1.5 rounded-full overflow-hidden ${theme.barBg}`}>
|
| 700 |
+
<div
|
| 701 |
+
className={`h-full rounded-full transition-all duration-500 ${theme.barFill}`}
|
| 702 |
+
style={{ width: `${d.total_employees > 0 ? percent : 0}%` }}
|
| 703 |
+
/>
|
| 704 |
+
</div>
|
| 705 |
+
</div>
|
| 706 |
+
</div>
|
| 707 |
+
);
|
| 708 |
+
})}
|
| 709 |
+
</div>
|
| 710 |
+
)}
|
| 711 |
+
</div>
|
| 712 |
+
</div>
|
| 713 |
+
</div>
|
| 714 |
+
|
| 715 |
+
{/* Department Detail Modal */}
|
| 716 |
+
{selectedDept && (
|
| 717 |
+
<div className="modal-backdrop z-50">
|
| 718 |
+
<div className="modal-content max-w-2xl bg-white border border-zinc-200 text-zinc-900 shadow-2xl">
|
| 719 |
+
<div className="flex items-center justify-between mb-5 pb-4 border-b border-zinc-100">
|
| 720 |
+
<div>
|
| 721 |
+
<span className="text-[10px] font-bold font-mono px-2 py-0.5 rounded bg-zinc-100 text-zinc-700 border border-zinc-200 uppercase tracking-wider">
|
| 722 |
+
{selectedDept.code} Department
|
| 723 |
+
</span>
|
| 724 |
+
<h3 className="text-base font-bold text-zinc-900 mt-1.5">
|
| 725 |
+
{selectedDept.department}
|
| 726 |
+
</h3>
|
| 727 |
+
<p className="text-[10.5px] text-zinc-500 font-mono mt-0.5">
|
| 728 |
+
{selectedDept.present_today} Present / {selectedDept.total_employees} Total Employees Today
|
| 729 |
+
</p>
|
| 730 |
+
</div>
|
| 731 |
+
<button
|
| 732 |
+
onClick={() => setSelectedDept(null)}
|
| 733 |
+
className="p-2 rounded-xl hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
|
| 734 |
+
>
|
| 735 |
+
<X className="w-4.5 h-4.5" />
|
| 736 |
+
</button>
|
| 737 |
+
</div>
|
| 738 |
+
|
| 739 |
+
<div className="overflow-y-auto max-h-[350px] pr-1 space-y-2">
|
| 740 |
+
{loadingDeptAttendance ? (
|
| 741 |
+
Array.from({ length: 3 }).map((_, i) => (
|
| 742 |
+
<div key={i} className="flex items-center gap-3 py-2.5 px-3">
|
| 743 |
+
<div className="skeleton w-8 h-8 rounded-lg shrink-0" />
|
| 744 |
+
<div className="flex-1 space-y-1.5">
|
| 745 |
+
<div className="skeleton h-3.5 w-28" />
|
| 746 |
+
<div className="skeleton h-2.5 w-16" />
|
| 747 |
+
</div>
|
| 748 |
+
</div>
|
| 749 |
+
))
|
| 750 |
+
) : !deptAttendance || deptAttendance.length === 0 ? (
|
| 751 |
+
<div className="py-12 text-center text-zinc-400 text-xs font-mono">
|
| 752 |
+
No attendance records found for this department today.
|
| 753 |
+
</div>
|
| 754 |
+
) : (
|
| 755 |
+
<div className="divide-y divide-zinc-100">
|
| 756 |
+
{deptAttendance.map((rec: any) => {
|
| 757 |
+
const avatarColor = AVATAR_COLORS[rec.employee.id % AVATAR_COLORS.length];
|
| 758 |
+
return (
|
| 759 |
+
<div key={rec.id} className="flex items-center justify-between py-3">
|
| 760 |
+
<div className="flex items-center gap-3">
|
| 761 |
+
<div className={`w-8.5 h-8.5 rounded-lg bg-gradient-to-br ${avatarColor} flex items-center justify-center shrink-0 border font-bold text-[10.5px] shadow-sm`}>
|
| 762 |
+
{rec.employee.name.charAt(0).toUpperCase()}
|
| 763 |
+
</div>
|
| 764 |
+
<div>
|
| 765 |
+
<p className="text-[13px] font-semibold text-zinc-900">{rec.employee.name}</p>
|
| 766 |
+
<p className="text-[9.5px] text-zinc-500 font-mono mt-0.5">{rec.employee.employee_id} · {rec.employee.designation}</p>
|
| 767 |
+
</div>
|
| 768 |
+
</div>
|
| 769 |
+
<div className="flex items-center gap-4">
|
| 770 |
+
<div className="text-right">
|
| 771 |
+
<p className="text-[10px] text-zinc-605 font-mono leading-none">
|
| 772 |
+
IN: {rec.check_in ? parseDateTime(rec.check_in)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
|
| 773 |
+
</p>
|
| 774 |
+
<p className="text-[10px] text-zinc-400 font-mono mt-1 leading-none">
|
| 775 |
+
OUT: {rec.check_out ? parseDateTime(rec.check_out)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
|
| 776 |
+
</p>
|
| 777 |
+
</div>
|
| 778 |
+
<span className={`inline-block text-[9px] font-mono font-semibold px-2 py-0.5 rounded-full border ${
|
| 779 |
+
rec.status === "Present" ? "bg-emerald-50 border-emerald-200 text-emerald-800" :
|
| 780 |
+
rec.status === "Late" ? "bg-amber-50 border-amber-200 text-amber-800" :
|
| 781 |
+
rec.status === "Half Day" ? "bg-indigo-50 border-indigo-200 text-indigo-800" :
|
| 782 |
+
"bg-rose-55 border-rose-200 text-rose-800"
|
| 783 |
+
}`}>
|
| 784 |
+
{rec.status}
|
| 785 |
+
</span>
|
| 786 |
+
</div>
|
| 787 |
+
</div>
|
| 788 |
+
);
|
| 789 |
+
})}
|
| 790 |
+
</div>
|
| 791 |
+
)}
|
| 792 |
+
</div>
|
| 793 |
+
</div>
|
| 794 |
+
</div>
|
| 795 |
+
)}
|
| 796 |
+
</SidebarLayout>
|
| 797 |
+
);
|
| 798 |
+
}
|
frontend/app/employees/page.tsx
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState } from "react";
|
| 4 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 5 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 6 |
+
import { fetchApi, getBackendUrl, parseDateTime, getLocalDateString } from "@/app/utils/api";
|
| 7 |
+
import {
|
| 8 |
+
Plus, Search, Trash2, Camera, Upload, FileSpreadsheet,
|
| 9 |
+
X, Users, CheckCircle2, XCircle, ChevronDown, UserCheck, ShieldAlert,
|
| 10 |
+
Download, Mail, Phone, Calendar, Briefcase, Clock, TrendingUp
|
| 11 |
+
} from "lucide-react";
|
| 12 |
+
import Link from "next/link";
|
| 13 |
+
|
| 14 |
+
// Avatar gradient styles
|
| 15 |
+
const avatarColors = [
|
| 16 |
+
"from-blue-50 to-indigo-150 text-blue-600 border-blue-200",
|
| 17 |
+
"from-emerald-50 to-teal-150 text-emerald-600 border-emerald-200",
|
| 18 |
+
"from-rose-50 to-orange-150 text-rose-600 border-rose-200",
|
| 19 |
+
"from-purple-50 to-pink-150 text-purple-600 border-purple-200",
|
| 20 |
+
"from-cyan-50 to-blue-150 text-cyan-600 border-cyan-200",
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
function EmployeeAvatar({ emp, className, size = "md" }: { emp: any; className?: string; size?: "sm" | "md" | "lg" }) {
|
| 24 |
+
const [error, setError] = useState(false);
|
| 25 |
+
const avatarColor = avatarColors[emp.id % avatarColors.length];
|
| 26 |
+
const hasFrontImage = emp.images?.some((img: any) => img.pose_type.toLowerCase() === "front");
|
| 27 |
+
|
| 28 |
+
const sizeClasses = {
|
| 29 |
+
sm: "w-8 h-8 text-[10.5px] rounded-lg shrink-0",
|
| 30 |
+
md: "w-10 h-10 text-xs rounded-xl shrink-0",
|
| 31 |
+
lg: "w-14 h-14 text-lg rounded-2xl shrink-0",
|
| 32 |
+
};
|
| 33 |
+
const sc = sizeClasses[size];
|
| 34 |
+
|
| 35 |
+
if (hasFrontImage && !error) {
|
| 36 |
+
const baseUrl = getBackendUrl().replace("/api/v1", "");
|
| 37 |
+
return (
|
| 38 |
+
<img
|
| 39 |
+
src={`${baseUrl}/uploads/${emp.employee_id}/front.jpg`}
|
| 40 |
+
alt={emp.name}
|
| 41 |
+
className={`${sc} object-cover border border-zinc-200 shadow-sm ${className || ""}`}
|
| 42 |
+
onError={() => setError(true)}
|
| 43 |
+
/>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className={`${sc} bg-gradient-to-br ${avatarColor} flex items-center justify-center shrink-0 border font-bold shadow-sm ${className || ""}`}>
|
| 49 |
+
{emp.name.charAt(0).toUpperCase()}
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function InputField({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
| 55 |
+
return (
|
| 56 |
+
<div className="space-y-1.5">
|
| 57 |
+
<label className="block text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
| 58 |
+
{label} {required && <span className="text-blue-400">*</span>}
|
| 59 |
+
</label>
|
| 60 |
+
{children}
|
| 61 |
+
</div>
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export default function EmployeesPage() {
|
| 66 |
+
const queryClient = useQueryClient();
|
| 67 |
+
const [search, setSearch] = useState("");
|
| 68 |
+
const [deptFilter, setDeptFilter] = useState("");
|
| 69 |
+
const [statusFilter, setStatusFilter] = useState("");
|
| 70 |
+
const [showAddDialog, setShowAddDialog] = useState(false);
|
| 71 |
+
const [showImportDialog, setShowImportDialog] = useState(false);
|
| 72 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 73 |
+
const [importMessage, setImportMessage] = useState<string | null>(null);
|
| 74 |
+
const [importErrors, setImportErrors] = useState<string[]>([]);
|
| 75 |
+
const [submitting, setSubmitting] = useState(false);
|
| 76 |
+
const [selectedEmployee, setSelectedEmployee] = useState<any | null>(null);
|
| 77 |
+
|
| 78 |
+
const [empId, setEmpId] = useState("");
|
| 79 |
+
const [name, setName] = useState("");
|
| 80 |
+
const [email, setEmail] = useState("");
|
| 81 |
+
const [phone, setPhone] = useState("");
|
| 82 |
+
const [designation, setDesignation] = useState("");
|
| 83 |
+
const [joiningDate, setJoiningDate] = useState(getLocalDateString());
|
| 84 |
+
const [statusVal, setStatusVal] = useState("Active");
|
| 85 |
+
const [deptId, setDeptId] = useState("");
|
| 86 |
+
const [createUserLogin, setCreateUserLogin] = useState(false);
|
| 87 |
+
const [password, setPassword] = useState("");
|
| 88 |
+
|
| 89 |
+
const { data: departments } = useQuery({
|
| 90 |
+
queryKey: ["departments"],
|
| 91 |
+
queryFn: () => fetchApi("/departments/")
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
const { data: employees, isLoading: loadingEmployees } = useQuery({
|
| 95 |
+
queryKey: ["employees", search, deptFilter, statusFilter],
|
| 96 |
+
queryFn: () => {
|
| 97 |
+
const params = [];
|
| 98 |
+
if (search) params.push(`search=${encodeURIComponent(search)}`);
|
| 99 |
+
if (deptFilter) params.push(`department_id=${deptFilter}`);
|
| 100 |
+
if (statusFilter) params.push(`status=${statusFilter}`);
|
| 101 |
+
const qs = params.length ? `?${params.join("&")}` : "";
|
| 102 |
+
return fetchApi(`/employees/${qs}`);
|
| 103 |
+
}
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
const { data: empAttendance, isLoading: loadingEmpAttendance } = useQuery({
|
| 107 |
+
queryKey: ["employee-attendance", selectedEmployee?.id],
|
| 108 |
+
queryFn: () => {
|
| 109 |
+
if (!selectedEmployee) return [];
|
| 110 |
+
return fetchApi(`/attendance/employee/${selectedEmployee.id}`);
|
| 111 |
+
},
|
| 112 |
+
enabled: !!selectedEmployee
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
const createMutation = useMutation({
|
| 116 |
+
mutationFn: (payload: any) => fetchApi("/employees/", { method: "POST", body: JSON.stringify(payload) }),
|
| 117 |
+
onSuccess: () => {
|
| 118 |
+
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 119 |
+
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 120 |
+
resetForm();
|
| 121 |
+
setShowAddDialog(false);
|
| 122 |
+
},
|
| 123 |
+
onError: (err: any) => alert(err.message || "Failed to create employee.")
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
const deleteMutation = useMutation({
|
| 127 |
+
mutationFn: (id: number) => fetchApi(`/employees/${id}`, { method: "DELETE" }),
|
| 128 |
+
onSuccess: () => {
|
| 129 |
+
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 130 |
+
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
| 131 |
+
},
|
| 132 |
+
onError: (err: any) => alert(err.message || "Failed to delete employee.")
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
const resetForm = () => {
|
| 136 |
+
setEmpId(""); setName(""); setEmail(""); setPhone(""); setDesignation("");
|
| 137 |
+
setJoiningDate(getLocalDateString()); setStatusVal("Active");
|
| 138 |
+
setDeptId(""); setCreateUserLogin(false); setPassword("");
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const handleAddSubmit = (e: React.FormEvent) => {
|
| 142 |
+
e.preventDefault();
|
| 143 |
+
const payload: any = {
|
| 144 |
+
employee_id: empId, name, email, phone: phone || null,
|
| 145 |
+
designation: designation || null, joining_date: joiningDate,
|
| 146 |
+
status: statusVal, department_id: deptId ? parseInt(deptId) : null,
|
| 147 |
+
create_user_login: createUserLogin
|
| 148 |
+
};
|
| 149 |
+
if (createUserLogin) payload.password = password;
|
| 150 |
+
createMutation.mutate(payload);
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const handleDelete = (id: number, name: string) => {
|
| 154 |
+
if (confirm(`Are you absolutely sure you want to delete ${name}? All facial biometric profile records and logs will be permanently deleted.`))
|
| 155 |
+
deleteMutation.mutate(id);
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const handleImportSubmit = async (e: React.FormEvent) => {
|
| 159 |
+
e.preventDefault();
|
| 160 |
+
if (!selectedFile) return;
|
| 161 |
+
setSubmitting(true); setImportMessage(null); setImportErrors([]);
|
| 162 |
+
const formData = new FormData();
|
| 163 |
+
formData.append("file", selectedFile);
|
| 164 |
+
try {
|
| 165 |
+
const res = await fetchApi("/employees/import-csv", { method: "POST", body: formData });
|
| 166 |
+
setImportMessage(res.message);
|
| 167 |
+
if (res.errors?.length > 0) setImportErrors(res.errors);
|
| 168 |
+
queryClient.invalidateQueries({ queryKey: ["employees"] });
|
| 169 |
+
} catch (err: any) {
|
| 170 |
+
setImportMessage(err.message || "Import failed.");
|
| 171 |
+
} finally {
|
| 172 |
+
setSubmitting(false);
|
| 173 |
+
}
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
const handleExportEmployee = async () => {
|
| 177 |
+
if (!selectedEmployee) return;
|
| 178 |
+
try {
|
| 179 |
+
const responseBlob = await fetchApi(`/reports/export?report_type=employee&employee_id=${selectedEmployee.id}&format=csv`);
|
| 180 |
+
const url = window.URL.createObjectURL(responseBlob);
|
| 181 |
+
const a = document.createElement("a");
|
| 182 |
+
a.href = url;
|
| 183 |
+
a.download = `attendance_${selectedEmployee.name.replace(/\s+/g, "_")}.csv`;
|
| 184 |
+
document.body.appendChild(a);
|
| 185 |
+
a.click();
|
| 186 |
+
a.remove();
|
| 187 |
+
window.URL.revokeObjectURL(url);
|
| 188 |
+
} catch (err: any) {
|
| 189 |
+
alert(err.message || "Failed to export CSV.");
|
| 190 |
+
}
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
const inputCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all w-full";
|
| 194 |
+
const selectCls = "input-field h-9.5 text-[12.5px] bg-white border-slate-200 focus:border-slate-800 text-slate-900 rounded-xl transition-all appearance-none cursor-pointer w-full pr-8";
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
return (
|
| 199 |
+
<>
|
| 200 |
+
<SidebarLayout>
|
| 201 |
+
<div className="space-y-6 page-enter">
|
| 202 |
+
{/* Header */}
|
| 203 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
|
| 204 |
+
<div>
|
| 205 |
+
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Staff Management</h1>
|
| 206 |
+
<p className="text-xs text-slate-500 mt-0.5">Register staff, manage details, and initiate camera enrollment.</p>
|
| 207 |
+
</div>
|
| 208 |
+
<div className="flex items-center gap-2.5 shrink-0">
|
| 209 |
+
<button
|
| 210 |
+
onClick={() => setShowImportDialog(true)}
|
| 211 |
+
className="btn-ghost text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 212 |
+
>
|
| 213 |
+
<FileSpreadsheet className="w-3.5 h-3.5" />
|
| 214 |
+
Import CSV
|
| 215 |
+
</button>
|
| 216 |
+
<button
|
| 217 |
+
onClick={() => setShowAddDialog(true)}
|
| 218 |
+
className="btn-primary text-[12px] h-9.5 px-4 flex items-center gap-2 rounded-xl cursor-pointer"
|
| 219 |
+
>
|
| 220 |
+
<Plus className="w-3.5 h-3.5" />
|
| 221 |
+
Add Employee
|
| 222 |
+
</button>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
{/* Filter Bar */}
|
| 227 |
+
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 p-4.5 rounded-2xl bg-white/[0.015] border border-white/5 shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
|
| 228 |
+
<div className="relative col-span-2">
|
| 229 |
+
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 230 |
+
<input
|
| 231 |
+
type="text"
|
| 232 |
+
placeholder="Search by name, ID, or email..."
|
| 233 |
+
value={search}
|
| 234 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 235 |
+
className={`${inputCls} !pl-10 h-10`}
|
| 236 |
+
/>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div className="relative">
|
| 240 |
+
<select
|
| 241 |
+
value={deptFilter}
|
| 242 |
+
onChange={(e) => setDeptFilter(e.target.value)}
|
| 243 |
+
className={`${selectCls} h-10`}
|
| 244 |
+
>
|
| 245 |
+
<option value="">All Departments</option>
|
| 246 |
+
{departments?.map((d: any) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
| 247 |
+
</select>
|
| 248 |
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<div className="relative">
|
| 252 |
+
<select
|
| 253 |
+
value={statusFilter}
|
| 254 |
+
onChange={(e) => setStatusFilter(e.target.value)}
|
| 255 |
+
className={`${selectCls} h-10`}
|
| 256 |
+
>
|
| 257 |
+
<option value="">All Statuses</option>
|
| 258 |
+
<option value="Active">Active</option>
|
| 259 |
+
<option value="Inactive">Inactive</option>
|
| 260 |
+
</select>
|
| 261 |
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
{/* Table List Card */}
|
| 266 |
+
<div className="glass-card rounded-2xl border border-white/6 overflow-hidden">
|
| 267 |
+
<div className="px-5 py-3.5 border-b border-white/5 bg-white/[0.005] flex items-center justify-between">
|
| 268 |
+
<p className="text-[11px] text-slate-500 font-mono">
|
| 269 |
+
{loadingEmployees ? "Fetching..." : `${employees?.length || 0} registered personnel`}
|
| 270 |
+
</p>
|
| 271 |
+
</div>
|
| 272 |
+
|
| 273 |
+
<div className="overflow-x-auto">
|
| 274 |
+
<table className="data-table">
|
| 275 |
+
<thead>
|
| 276 |
+
<tr>
|
| 277 |
+
<th className="text-left py-3.5 px-5">Employee Info</th>
|
| 278 |
+
<th className="text-left py-3.5 px-5">Department</th>
|
| 279 |
+
<th className="text-left py-3.5 px-5">Designation</th>
|
| 280 |
+
<th className="text-left py-3.5 px-5">Status</th>
|
| 281 |
+
<th className="text-center py-3.5 px-5 w-[180px]">Actions</th>
|
| 282 |
+
</tr>
|
| 283 |
+
</thead>
|
| 284 |
+
<tbody>
|
| 285 |
+
{loadingEmployees ? (
|
| 286 |
+
Array.from({ length: 5 }).map((_, i) => (
|
| 287 |
+
<tr key={i}>
|
| 288 |
+
{Array.from({ length: 5 }).map((_, j) => (
|
| 289 |
+
<td key={j} className="py-4.5 px-5">
|
| 290 |
+
<div className="skeleton h-4 w-28" />
|
| 291 |
+
</td>
|
| 292 |
+
))}
|
| 293 |
+
</tr>
|
| 294 |
+
))
|
| 295 |
+
) : employees?.length === 0 ? (
|
| 296 |
+
<tr>
|
| 297 |
+
<td colSpan={5} className="py-20 text-center">
|
| 298 |
+
<div className="flex flex-col items-center gap-3 max-w-xs mx-auto">
|
| 299 |
+
<div className="w-12 h-12 rounded-2xl bg-white/4 flex items-center justify-center">
|
| 300 |
+
<Users className="w-5 h-5 text-slate-600" />
|
| 301 |
+
</div>
|
| 302 |
+
<p className="text-slate-400 font-semibold text-xs uppercase tracking-wider">No Records Found</p>
|
| 303 |
+
<p className="text-[11px] text-slate-600 leading-relaxed">Modify your filters or click 'Add Employee' to register new personnel.</p>
|
| 304 |
+
</div>
|
| 305 |
+
</td>
|
| 306 |
+
</tr>
|
| 307 |
+
) : (
|
| 308 |
+
employees?.map((emp: any) => {
|
| 309 |
+
const avatarColor = avatarColors[emp.id % avatarColors.length];
|
| 310 |
+
return (
|
| 311 |
+
<tr key={emp.id} className="group/row cursor-pointer hover:bg-white/[0.015]" onClick={() => setSelectedEmployee(emp)}>
|
| 312 |
+
<td className="py-3.5 px-5">
|
| 313 |
+
<div className="flex items-center gap-3">
|
| 314 |
+
<EmployeeAvatar emp={emp} size="md" />
|
| 315 |
+
<div>
|
| 316 |
+
<p className="text-[13px] font-semibold text-[var(--text-primary)]">{emp.name}</p>
|
| 317 |
+
<p className="text-[10px] text-slate-500 font-mono mt-0.5">{emp.employee_id} · {emp.email}</p>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
</td>
|
| 321 |
+
<td className="py-3.5 px-5 text-[12.5px] text-[var(--text-secondary)]">
|
| 322 |
+
{emp.department?.name || (
|
| 323 |
+
<span className="text-slate-400 italic">Unassigned</span>
|
| 324 |
+
)}
|
| 325 |
+
</td>
|
| 326 |
+
<td className="py-3.5 px-5 text-[12.5px] text-[var(--text-secondary)]">
|
| 327 |
+
{emp.designation || <span className="text-slate-400">—</span>}
|
| 328 |
+
</td>
|
| 329 |
+
<td className="py-3.5 px-5">
|
| 330 |
+
<span className={`badge ${emp.status === "Active" ? "badge-emerald" : "badge-slate"} flex items-center gap-1 w-fit`}>
|
| 331 |
+
{emp.status === "Active" && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />}
|
| 332 |
+
{emp.status}
|
| 333 |
+
</span>
|
| 334 |
+
</td>
|
| 335 |
+
<td className="py-3.5 px-5" onClick={(e) => e.stopPropagation()}>
|
| 336 |
+
<div className="flex items-center justify-center gap-2">
|
| 337 |
+
<Link
|
| 338 |
+
href={`/enroll/${emp.id}`}
|
| 339 |
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[10.5px] font-bold text-blue-400 bg-blue-500/8 hover:bg-blue-500/15 border border-blue-500/15 hover:border-blue-500/25 transition-all"
|
| 340 |
+
>
|
| 341 |
+
<Camera className="w-3.5 h-3.5" />
|
| 342 |
+
Enroll
|
| 343 |
+
</Link>
|
| 344 |
+
<button
|
| 345 |
+
onClick={() => handleDelete(emp.id, emp.name)}
|
| 346 |
+
className="p-2 rounded-xl text-slate-600 hover:text-rose-400 hover:bg-rose-500/8 border border-transparent hover:border-rose-500/15 transition-all cursor-pointer"
|
| 347 |
+
title="Delete employee profile"
|
| 348 |
+
>
|
| 349 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 350 |
+
</button>
|
| 351 |
+
</div>
|
| 352 |
+
</td>
|
| 353 |
+
</tr>
|
| 354 |
+
);
|
| 355 |
+
})
|
| 356 |
+
)}
|
| 357 |
+
</tbody>
|
| 358 |
+
</table>
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
{/* ─── Add Employee Modal ─── */}
|
| 364 |
+
{showAddDialog && (
|
| 365 |
+
<div className="modal-backdrop">
|
| 366 |
+
<div className="modal-content max-w-lg">
|
| 367 |
+
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
|
| 368 |
+
<div>
|
| 369 |
+
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Register Employee</h3>
|
| 370 |
+
<p className="text-xs text-slate-500 mt-0.5">Fill in the fields to create a new profile.</p>
|
| 371 |
+
</div>
|
| 372 |
+
<button
|
| 373 |
+
onClick={() => setShowAddDialog(false)}
|
| 374 |
+
className="p-2 rounded-xl hover:bg-white/6 text-slate-500 hover:text-slate-300 transition-all cursor-pointer"
|
| 375 |
+
>
|
| 376 |
+
<X className="w-4 h-4" />
|
| 377 |
+
</button>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<form onSubmit={handleAddSubmit} className="space-y-4">
|
| 381 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 382 |
+
<InputField label="Employee ID" required>
|
| 383 |
+
<input type="text" required placeholder="EMP001" value={empId}
|
| 384 |
+
onChange={(e) => setEmpId(e.target.value)} className={inputCls} />
|
| 385 |
+
</InputField>
|
| 386 |
+
<InputField label="Full Name" required>
|
| 387 |
+
<input type="text" required placeholder="John Smith" value={name}
|
| 388 |
+
onChange={(e) => setName(e.target.value)} className={inputCls} />
|
| 389 |
+
</InputField>
|
| 390 |
+
</div>
|
| 391 |
+
|
| 392 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 393 |
+
<InputField label="Email" required>
|
| 394 |
+
<input type="email" required placeholder="john@company.com" value={email}
|
| 395 |
+
onChange={(e) => setEmail(e.target.value)} className={inputCls} />
|
| 396 |
+
</InputField>
|
| 397 |
+
<InputField label="Phone Number">
|
| 398 |
+
<input type="text" placeholder="+1 555-0199" value={phone}
|
| 399 |
+
onChange={(e) => setPhone(e.target.value)} className={inputCls} />
|
| 400 |
+
</InputField>
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 404 |
+
<InputField label="Department">
|
| 405 |
+
<div className="relative">
|
| 406 |
+
<select value={deptId} onChange={(e) => setDeptId(e.target.value)} className={selectCls}>
|
| 407 |
+
<option value="">Select Department</option>
|
| 408 |
+
{departments?.map((d: any) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
| 409 |
+
</select>
|
| 410 |
+
<ChevronDown className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 411 |
+
</div>
|
| 412 |
+
</InputField>
|
| 413 |
+
<InputField label="Designation">
|
| 414 |
+
<input type="text" placeholder="Software Engineer" value={designation}
|
| 415 |
+
onChange={(e) => setDesignation(e.target.value)} className={inputCls} />
|
| 416 |
+
</InputField>
|
| 417 |
+
</div>
|
| 418 |
+
|
| 419 |
+
<div className="grid grid-cols-2 gap-3.5">
|
| 420 |
+
<InputField label="Joining Date">
|
| 421 |
+
<input type="date" value={joiningDate}
|
| 422 |
+
onChange={(e) => setJoiningDate(e.target.value)} className={inputCls} />
|
| 423 |
+
</InputField>
|
| 424 |
+
<InputField label="Status">
|
| 425 |
+
<div className="relative">
|
| 426 |
+
<select value={statusVal} onChange={(e) => setStatusVal(e.target.value)} className={selectCls}>
|
| 427 |
+
<option value="Active">Active</option>
|
| 428 |
+
<option value="Inactive">Inactive</option>
|
| 429 |
+
</select>
|
| 430 |
+
<ChevronDown className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
| 431 |
+
</div>
|
| 432 |
+
</InputField>
|
| 433 |
+
</div>
|
| 434 |
+
|
| 435 |
+
{/* Login credentials */}
|
| 436 |
+
<div className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 space-y-3.5">
|
| 437 |
+
<label className="flex items-center gap-3 cursor-pointer group">
|
| 438 |
+
<input
|
| 439 |
+
type="checkbox"
|
| 440 |
+
checked={createUserLogin}
|
| 441 |
+
onChange={() => setCreateUserLogin(!createUserLogin)}
|
| 442 |
+
className="w-4 h-4 rounded border-white/10 text-blue-500 bg-[#060a12] focus:ring-blue-500/50 cursor-pointer"
|
| 443 |
+
/>
|
| 444 |
+
<span className="text-[12px] font-semibold text-slate-700 select-none">Create Admin Dashboard Login</span>
|
| 445 |
+
</label>
|
| 446 |
+
{createUserLogin && (
|
| 447 |
+
<InputField label="Initial Password" required>
|
| 448 |
+
<input type="password" required placeholder="Minimum 8 characters" value={password}
|
| 449 |
+
onChange={(e) => setPassword(e.target.value)} className={inputCls} />
|
| 450 |
+
</InputField>
|
| 451 |
+
)}
|
| 452 |
+
</div>
|
| 453 |
+
|
| 454 |
+
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 455 |
+
<button
|
| 456 |
+
type="button"
|
| 457 |
+
onClick={() => setShowAddDialog(false)}
|
| 458 |
+
className="btn-ghost h-9.5 px-4 text-[12px] rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 459 |
+
>
|
| 460 |
+
Cancel
|
| 461 |
+
</button>
|
| 462 |
+
<button
|
| 463 |
+
type="submit"
|
| 464 |
+
disabled={createMutation.isPending}
|
| 465 |
+
className="btn-primary h-9.5 px-5 text-[12px] flex items-center gap-2 rounded-xl cursor-pointer"
|
| 466 |
+
>
|
| 467 |
+
{createMutation.isPending ? (
|
| 468 |
+
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Saving...</span></>
|
| 469 |
+
) : (
|
| 470 |
+
<><CheckCircle2 className="w-3.5 h-3.5" /><span>Save Record</span></>
|
| 471 |
+
)}
|
| 472 |
+
</button>
|
| 473 |
+
</div>
|
| 474 |
+
</form>
|
| 475 |
+
</div>
|
| 476 |
+
</div>
|
| 477 |
+
)}
|
| 478 |
+
|
| 479 |
+
{/* ─── Import CSV Modal ─── */}
|
| 480 |
+
{showImportDialog && (
|
| 481 |
+
<div className="modal-backdrop">
|
| 482 |
+
<div className="modal-content max-w-md">
|
| 483 |
+
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/5">
|
| 484 |
+
<div>
|
| 485 |
+
<h3 className="text-sm font-bold text-[var(--text-primary)] uppercase tracking-wider">Bulk Import via CSV</h3>
|
| 486 |
+
<p className="text-xs text-slate-500 mt-0.5">Register multiple staff via CSV upload.</p>
|
| 487 |
+
</div>
|
| 488 |
+
<button
|
| 489 |
+
onClick={() => { setShowImportDialog(false); setSelectedFile(null); setImportMessage(null); setImportErrors([]); }}
|
| 490 |
+
className="p-2 rounded-xl hover:bg-white/6 text-slate-500 hover:text-slate-300 transition-all cursor-pointer"
|
| 491 |
+
>
|
| 492 |
+
<X className="w-4 h-4" />
|
| 493 |
+
</button>
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
<form onSubmit={handleImportSubmit} className="space-y-4">
|
| 497 |
+
<label className="block">
|
| 498 |
+
<div className={`relative border border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${selectedFile ? "border-blue-500/40 bg-blue-500/5" : "border-white/10 hover:border-white/20 bg-white/[0.015]"}`}>
|
| 499 |
+
<input type="file" accept=".csv" required
|
| 500 |
+
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
| 501 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
| 502 |
+
<Upload className={`w-8 h-8 mx-auto mb-3.5 ${selectedFile ? "text-blue-400" : "text-slate-500"}`} />
|
| 503 |
+
<p className="text-[12.5px] font-semibold text-slate-700">
|
| 504 |
+
{selectedFile ? selectedFile.name : "Select CSV Flatfile"}
|
| 505 |
+
</p>
|
| 506 |
+
<p className="text-[10px] text-slate-500 mt-1">
|
| 507 |
+
{selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB` : "Click or drag & drop CSV file"}
|
| 508 |
+
</p>
|
| 509 |
+
</div>
|
| 510 |
+
</label>
|
| 511 |
+
|
| 512 |
+
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
|
| 513 |
+
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">Required Headers</p>
|
| 514 |
+
<code className="text-[10px] text-slate-700 font-mono leading-relaxed block break-all bg-slate-50 p-2 rounded-lg border border-slate-200">
|
| 515 |
+
employee_id, name, email, phone, designation, joining_date, department_code
|
| 516 |
+
</code>
|
| 517 |
+
</div>
|
| 518 |
+
|
| 519 |
+
{importMessage && (
|
| 520 |
+
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-blue-500/8 border border-blue-500/20 text-blue-400 text-[11.5px] animate-fadeInUp">
|
| 521 |
+
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
|
| 522 |
+
<span className="leading-relaxed">{importMessage}</span>
|
| 523 |
+
</div>
|
| 524 |
+
)}
|
| 525 |
+
|
| 526 |
+
{importErrors.length > 0 && (
|
| 527 |
+
<div className="p-3 rounded-xl bg-rose-500/8 border border-rose-500/20 text-rose-400 text-[10px] max-h-32 overflow-y-auto space-y-1 font-mono">
|
| 528 |
+
<p className="font-semibold uppercase tracking-wider mb-1">Import Exceptions:</p>
|
| 529 |
+
{importErrors.map((err, i) => <p key={i} className="opacity-85">{err}</p>)}
|
| 530 |
+
</div>
|
| 531 |
+
)}
|
| 532 |
+
|
| 533 |
+
<div className="flex justify-end gap-2.5 pt-3 border-t border-white/5">
|
| 534 |
+
<button
|
| 535 |
+
type="button"
|
| 536 |
+
onClick={() => { setShowImportDialog(false); setSelectedFile(null); setImportMessage(null); setImportErrors([]); }}
|
| 537 |
+
className="btn-ghost h-9.5 px-4 text-[12px] rounded-xl cursor-pointer hover:bg-white/[0.04]"
|
| 538 |
+
>
|
| 539 |
+
Close
|
| 540 |
+
</button>
|
| 541 |
+
<button
|
| 542 |
+
type="submit"
|
| 543 |
+
disabled={submitting || !selectedFile}
|
| 544 |
+
className="btn-primary h-9.5 px-5 text-[12px] flex items-center gap-2 rounded-xl cursor-pointer"
|
| 545 |
+
>
|
| 546 |
+
{submitting ? (
|
| 547 |
+
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Uploading...</span></>
|
| 548 |
+
) : (
|
| 549 |
+
<><Upload className="w-3.5 h-3.5" /><span>Process Import</span></>
|
| 550 |
+
)}
|
| 551 |
+
</button>
|
| 552 |
+
</div>
|
| 553 |
+
</form>
|
| 554 |
+
</div>
|
| 555 |
+
</div>
|
| 556 |
+
)}
|
| 557 |
+
</SidebarLayout>
|
| 558 |
+
|
| 559 |
+
{/* ─── Employee Details & Attendance History Modal ─── */}
|
| 560 |
+
{selectedEmployee && (
|
| 561 |
+
<div className="modal-backdrop z-50">
|
| 562 |
+
<div className="modal-content max-w-2xl bg-white border border-zinc-200 text-zinc-900 shadow-2xl">
|
| 563 |
+
{/* Header info */}
|
| 564 |
+
<div className="flex items-start justify-between pb-5 border-b border-zinc-100">
|
| 565 |
+
<div className="flex items-center gap-4">
|
| 566 |
+
<EmployeeAvatar emp={selectedEmployee} size="lg" />
|
| 567 |
+
<div>
|
| 568 |
+
<h3 className="text-base font-bold text-zinc-900 leading-tight">
|
| 569 |
+
{selectedEmployee.name}
|
| 570 |
+
</h3>
|
| 571 |
+
<p className="text-xs text-zinc-550 mt-1 flex items-center gap-1.5">
|
| 572 |
+
<span className="font-mono text-zinc-500 bg-zinc-100 px-1.5 py-0.5 rounded border border-zinc-200">{selectedEmployee.employee_id}</span>
|
| 573 |
+
<span>·</span>
|
| 574 |
+
<span className="font-semibold text-zinc-700">{selectedEmployee.designation || "No Title"}</span>
|
| 575 |
+
</p>
|
| 576 |
+
<p className="text-[10px] text-zinc-400 font-bold mt-1 uppercase tracking-wider">
|
| 577 |
+
{selectedEmployee.department?.name || "General Department"}
|
| 578 |
+
</p>
|
| 579 |
+
</div>
|
| 580 |
+
</div>
|
| 581 |
+
|
| 582 |
+
<div className="flex items-center gap-2">
|
| 583 |
+
<button
|
| 584 |
+
onClick={handleExportEmployee}
|
| 585 |
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-100 border border-zinc-200 hover:bg-zinc-200 rounded-xl text-[11px] font-bold text-zinc-700 transition-all cursor-pointer"
|
| 586 |
+
title="Export employee attendance data"
|
| 587 |
+
>
|
| 588 |
+
<Download className="w-3.5 h-3.5 text-zinc-500" />
|
| 589 |
+
Export CSV
|
| 590 |
+
</button>
|
| 591 |
+
<button
|
| 592 |
+
onClick={() => setSelectedEmployee(null)}
|
| 593 |
+
className="p-2 rounded-xl hover:bg-zinc-100 text-zinc-400 hover:text-zinc-650 transition-all cursor-pointer"
|
| 594 |
+
>
|
| 595 |
+
<X className="w-4.5 h-4.5" />
|
| 596 |
+
</button>
|
| 597 |
+
</div>
|
| 598 |
+
</div>
|
| 599 |
+
|
| 600 |
+
{/* Profile fields details grid */}
|
| 601 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3.5 py-4 border-b border-zinc-100 text-[11.5px] text-zinc-600">
|
| 602 |
+
<div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
|
| 603 |
+
<Mail className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
|
| 604 |
+
<span className="truncate font-medium text-zinc-700" title={selectedEmployee.email}>{selectedEmployee.email}</span>
|
| 605 |
+
</div>
|
| 606 |
+
<div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
|
| 607 |
+
<Phone className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
|
| 608 |
+
<span className="font-medium text-zinc-700">{selectedEmployee.phone || "No phone added"}</span>
|
| 609 |
+
</div>
|
| 610 |
+
<div className="flex items-center gap-2 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-100">
|
| 611 |
+
<Calendar className="w-3.5 h-3.5 text-zinc-400 shrink-0" />
|
| 612 |
+
<span className="font-medium text-zinc-700">Joined: <span className="font-mono">{selectedEmployee.joining_date}</span></span>
|
| 613 |
+
</div>
|
| 614 |
+
</div>
|
| 615 |
+
|
| 616 |
+
{/* Stats Summary cards */}
|
| 617 |
+
<div className="grid grid-cols-4 gap-3 py-4">
|
| 618 |
+
{(() => {
|
| 619 |
+
const total = empAttendance?.length || 0;
|
| 620 |
+
const present = empAttendance?.filter((r: any) => r.status === "Present").length || 0;
|
| 621 |
+
const late = empAttendance?.filter((r: any) => r.status === "Late").length || 0;
|
| 622 |
+
const half = empAttendance?.filter((r: any) => r.status === "Half Day").length || 0;
|
| 623 |
+
|
| 624 |
+
const cardCls = "p-3 rounded-xl border border-zinc-150 bg-zinc-50/30 flex flex-col justify-between h-[64px] shadow-sm";
|
| 625 |
+
return (
|
| 626 |
+
<>
|
| 627 |
+
<div className={cardCls}>
|
| 628 |
+
<span className="text-[9px] text-zinc-500 font-bold uppercase tracking-wider">Logged Days</span>
|
| 629 |
+
<span className="text-base font-bold text-zinc-800 leading-none mt-1 font-mono">{total}</span>
|
| 630 |
+
</div>
|
| 631 |
+
<div className={cardCls}>
|
| 632 |
+
<span className="text-[9px] text-emerald-600 font-bold uppercase tracking-wider">Present</span>
|
| 633 |
+
<span className="text-base font-bold text-emerald-600 leading-none mt-1 font-mono">{present}</span>
|
| 634 |
+
</div>
|
| 635 |
+
<div className={cardCls}>
|
| 636 |
+
<span className="text-[9px] text-amber-600 font-bold uppercase tracking-wider">Late Arrivals</span>
|
| 637 |
+
<span className="text-base font-bold text-amber-600 leading-none mt-1 font-mono">{late}</span>
|
| 638 |
+
</div>
|
| 639 |
+
<div className={cardCls}>
|
| 640 |
+
<span className="text-[9px] text-indigo-600 font-bold uppercase tracking-wider">Half Days</span>
|
| 641 |
+
<span className="text-base font-bold text-indigo-650 leading-none mt-1 font-mono">{half}</span>
|
| 642 |
+
</div>
|
| 643 |
+
</>
|
| 644 |
+
);
|
| 645 |
+
})()}
|
| 646 |
+
</div>
|
| 647 |
+
|
| 648 |
+
{/* Attendance Ledger Table */}
|
| 649 |
+
<div className="mt-2 space-y-2">
|
| 650 |
+
<h4 className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest font-mono">Attendance Ledger (Past 30 Days)</h4>
|
| 651 |
+
<div className="overflow-y-auto max-h-[200px] border border-zinc-200 rounded-xl bg-white shadow-sm">
|
| 652 |
+
<table className="w-full text-left border-collapse text-[11px]">
|
| 653 |
+
<thead>
|
| 654 |
+
<tr className="border-b border-zinc-200 bg-zinc-50 text-zinc-500 uppercase tracking-wider font-mono">
|
| 655 |
+
<th className="py-2.5 px-4 font-semibold">Date</th>
|
| 656 |
+
<th className="py-2.5 px-4 font-semibold">Check In</th>
|
| 657 |
+
<th className="py-2.5 px-4 font-semibold">Check Out</th>
|
| 658 |
+
<th className="py-2.5 px-4 font-semibold">Hours</th>
|
| 659 |
+
<th className="py-2.5 px-4 font-semibold text-center">Status</th>
|
| 660 |
+
</tr>
|
| 661 |
+
</thead>
|
| 662 |
+
<tbody className="divide-y divide-zinc-100 text-zinc-700">
|
| 663 |
+
{loadingEmpAttendance ? (
|
| 664 |
+
Array.from({ length: 3 }).map((_, i) => (
|
| 665 |
+
<tr key={i}>
|
| 666 |
+
{Array.from({ length: 5 }).map((_, j) => (
|
| 667 |
+
<td key={j} className="py-3 px-4"><div className="skeleton h-3 w-16" /></td>
|
| 668 |
+
))}
|
| 669 |
+
</tr>
|
| 670 |
+
))
|
| 671 |
+
) : !empAttendance || empAttendance.length === 0 ? (
|
| 672 |
+
<tr>
|
| 673 |
+
<td colSpan={5} className="py-8 text-center text-zinc-450 italic">No attendance records stored.</td>
|
| 674 |
+
</tr>
|
| 675 |
+
) : (
|
| 676 |
+
empAttendance.map((rec: any) => (
|
| 677 |
+
<tr key={rec.id} className="hover:bg-zinc-50/50 transition-colors">
|
| 678 |
+
<td className="py-2.5 px-4 font-mono text-zinc-550">{rec.date}</td>
|
| 679 |
+
<td className="py-2.5 px-4 font-mono text-zinc-800">
|
| 680 |
+
{rec.check_in ? parseDateTime(rec.check_in)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
|
| 681 |
+
</td>
|
| 682 |
+
<td className="py-2.5 px-4 font-mono text-zinc-800">
|
| 683 |
+
{rec.check_out ? parseDateTime(rec.check_out)?.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}) : "—"}
|
| 684 |
+
</td>
|
| 685 |
+
<td className="py-2.5 px-4 font-mono text-zinc-700">
|
| 686 |
+
{rec.working_hours ? `${rec.working_hours.toFixed(1)} hrs` : "—"}
|
| 687 |
+
</td>
|
| 688 |
+
<td className="py-2.5 px-4 text-center">
|
| 689 |
+
<span className={`inline-block text-[8.5px] font-semibold px-2 py-0.5 rounded-full border ${
|
| 690 |
+
rec.status === "Present" ? "bg-emerald-50 border-emerald-200 text-emerald-700" :
|
| 691 |
+
rec.status === "Late" ? "bg-amber-50 border-amber-200 text-amber-700" :
|
| 692 |
+
rec.status === "Half Day" ? "bg-indigo-50 border-indigo-200 text-indigo-750" :
|
| 693 |
+
"bg-rose-50 border-rose-200 text-rose-700"
|
| 694 |
+
}`}>
|
| 695 |
+
{rec.status}
|
| 696 |
+
</span>
|
| 697 |
+
</td>
|
| 698 |
+
</tr>
|
| 699 |
+
))
|
| 700 |
+
)}
|
| 701 |
+
</tbody>
|
| 702 |
+
</table>
|
| 703 |
+
</div>
|
| 704 |
+
</div>
|
| 705 |
+
</div>
|
| 706 |
+
</div>
|
| 707 |
+
)}
|
| 708 |
+
</>
|
| 709 |
+
);
|
| 710 |
+
}
|
frontend/app/enroll/[id]/page.tsx
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
+
import { useParams, useRouter } from "next/navigation";
|
| 5 |
+
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
| 6 |
+
import SidebarLayout from "@/components/SidebarLayout";
|
| 7 |
+
import { fetchApi } from "@/app/utils/api";
|
| 8 |
+
import {
|
| 9 |
+
Camera, Upload, CheckCircle2, ChevronLeft, XCircle, Video,
|
| 10 |
+
RefreshCw, AlertCircle, Trash2
|
| 11 |
+
} from "lucide-react";
|
| 12 |
+
|
| 13 |
+
const POSES: Record<string, { label: string; hint: string; icon: string }> = {
|
| 14 |
+
front: { label: "Front", hint: "Look straight into the camera with neutral expression.", icon: "🧑" },
|
| 15 |
+
left: { label: "Left", hint: "Turn head slowly to the left (profile view).", icon: "👈" },
|
| 16 |
+
right: { label: "Right", hint: "Turn head slowly to the right (profile view).", icon: "👉" },
|
| 17 |
+
up: { label: "Up", hint: "Tilt your chin upwards slightly.", icon: "⬆️" },
|
| 18 |
+
down: { label: "Down", hint: "Tilt your chin downwards slightly.", icon: "⬇️" },
|
| 19 |
+
smile: { label: "Smile", hint: "Give a natural, relaxed smile.", icon: "😊" },
|
| 20 |
+
neutral: { label: "Neutral", hint: "Keep a relaxed, standard neutral expression.", icon: "😐" },
|
| 21 |
+
indoor: { label: "Indoor", hint: "Enroll with typical indoor room lighting conditions.", icon: "💡" },
|
| 22 |
+
outdoor: { label: "Outdoor", hint: "Enroll with natural outdoor or bright lighting.", icon: "☀️" },
|
| 23 |
+
glasses: { label: "Glasses", hint: "Wear glasses if applicable. Optional – skip if not wearing.", icon: "🕶️" },
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export default function EnrollPage() {
|
| 27 |
+
const params = useParams();
|
| 28 |
+
const router = useRouter();
|
| 29 |
+
const queryClient = useQueryClient();
|
| 30 |
+
const employeeId = params.id;
|
| 31 |
+
|
| 32 |
+
const [selectedPose, setSelectedPose] = useState("front");
|
| 33 |
+
const [uploading, setUploading] = useState(false);
|
| 34 |
+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
| 35 |
+
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
| 36 |
+
const [capturedPreview, setCapturedPreview] = useState<string | null>(null);
|
| 37 |
+
const [webcamActive, setWebcamActive] = useState(false);
|
| 38 |
+
|
| 39 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
| 40 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 41 |
+
const streamRef = useRef<MediaStream | null>(null);
|
| 42 |
+
|
| 43 |
+
const { data: employee } = useQuery({
|
| 44 |
+
queryKey: ["employee", employeeId],
|
| 45 |
+
queryFn: () => fetchApi(`/employees/${employeeId}`)
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
const { data: status, refetch: refetchStatus } = useQuery({
|
| 49 |
+
queryKey: ["enroll-status", employeeId],
|
| 50 |
+
queryFn: () => fetchApi(`/enrollment/status/${employeeId}`),
|
| 51 |
+
enabled: !!employeeId
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
const clearMutation = useMutation({
|
| 55 |
+
mutationFn: () => fetchApi(`/enrollment/${employeeId}`, { method: "DELETE" }),
|
| 56 |
+
onSuccess: () => { refetchStatus(); setSuccessMsg("All facial data cleared."); setErrorMsg(null); }
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
const startWebcam = async () => {
|
| 60 |
+
setErrorMsg(null); setSuccessMsg(null);
|
| 61 |
+
try {
|
| 62 |
+
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: "user" } });
|
| 63 |
+
streamRef.current = stream;
|
| 64 |
+
if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.play(); }
|
| 65 |
+
setWebcamActive(true);
|
| 66 |
+
} catch {
|
| 67 |
+
setErrorMsg("Unable to access webcam. Check browser permissions.");
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const stopWebcam = () => {
|
| 72 |
+
streamRef.current?.getTracks().forEach(t => t.stop());
|
| 73 |
+
streamRef.current = null;
|
| 74 |
+
if (videoRef.current) videoRef.current.srcObject = null;
|
| 75 |
+
setWebcamActive(false);
|
| 76 |
+
setCapturedPreview(null);
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
useEffect(() => () => { stopWebcam(); }, []);
|
| 80 |
+
|
| 81 |
+
const handleCapture = async () => {
|
| 82 |
+
if (!videoRef.current || !canvasRef.current || !webcamActive) return;
|
| 83 |
+
const video = videoRef.current;
|
| 84 |
+
const canvas = canvasRef.current;
|
| 85 |
+
const ctx = canvas.getContext("2d");
|
| 86 |
+
if (!ctx) return;
|
| 87 |
+
|
| 88 |
+
const vw = video.videoWidth, vh = video.videoHeight;
|
| 89 |
+
if (!vw || !vh) { setErrorMsg("Camera not ready yet. Please wait a moment."); return; }
|
| 90 |
+
|
| 91 |
+
setUploading(true); setErrorMsg(null); setSuccessMsg(null); setCapturedPreview(null);
|
| 92 |
+
|
| 93 |
+
canvas.width = vw; canvas.height = vh;
|
| 94 |
+
ctx.drawImage(video, 0, 0, vw, vh);
|
| 95 |
+
|
| 96 |
+
// Black frame detection
|
| 97 |
+
const data = ctx.getImageData(0, 0, vw, vh).data;
|
| 98 |
+
let sum = 0;
|
| 99 |
+
for (let i = 0; i < data.length; i += 4) sum += (data[i] + data[i+1] + data[i+2]) / 3;
|
| 100 |
+
if (sum / (data.length / 4) < 5) {
|
| 101 |
+
setErrorMsg("Frame appears black. Ensure webcam is working and retry.");
|
| 102 |
+
setUploading(false); return;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
setCapturedPreview(canvas.toDataURL("image/jpeg", 0.85));
|
| 106 |
+
canvas.toBlob(async (blob) => {
|
| 107 |
+
if (!blob) { setErrorMsg("Capture failed."); setUploading(false); return; }
|
| 108 |
+
await uploadFile(blob);
|
| 109 |
+
}, "image/jpeg", 0.95);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 113 |
+
const file = e.target.files?.[0];
|
| 114 |
+
if (!file) return;
|
| 115 |
+
setUploading(true); setErrorMsg(null); setSuccessMsg(null);
|
| 116 |
+
await uploadFile(file);
|
| 117 |
+
e.target.value = "";
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
const uploadFile = async (blob: Blob) => {
|
| 121 |
+
const fd = new FormData();
|
| 122 |
+
fd.append("employee_id", employeeId as string);
|
| 123 |
+
fd.append("pose_type", selectedPose);
|
| 124 |
+
fd.append("file", blob, `${selectedPose}.jpg`);
|
| 125 |
+
try {
|
| 126 |
+
const res = await fetchApi("/enrollment/upload", { method: "POST", body: fd });
|
| 127 |
+
setSuccessMsg(res.message);
|
| 128 |
+
refetchStatus();
|
| 129 |
+
// Auto-advance to next missing pose
|
| 130 |
+
const missing = status?.missing_poses || [];
|
| 131 |
+
const keys = Object.keys(POSES);
|
| 132 |
+
const next = keys[keys.indexOf(selectedPose) + 1];
|
| 133 |
+
if (next && missing.includes(next)) setSelectedPose(next);
|
| 134 |
+
} catch (err: any) {
|
| 135 |
+
setErrorMsg(err.message || "Enrollment failed. Please retry.");
|
| 136 |
+
} finally {
|
| 137 |
+
setUploading(false);
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const isEnrolled = (pose: string) =>
|
| 142 |
+
status?.enrolled_poses?.some((p: string) => p.toLowerCase() === pose.toLowerCase());
|
| 143 |
+
|
| 144 |
+
const progress = Math.min(100, Math.round(((status?.enrolled_poses?.filter(
|
| 145 |
+
(p: string) => p.toLowerCase() !== "glasses"
|
| 146 |
+
).length || 0) / 9) * 100));
|
| 147 |
+
|
| 148 |
+
const enrolledCount = status?.enrolled_poses?.length || 0;
|
| 149 |
+
const totalCount = Object.keys(POSES).length;
|
| 150 |
+
|
| 151 |
+
return (
|
| 152 |
+
<SidebarLayout>
|
| 153 |
+
<div className="space-y-6 max-w-5xl page-enter">
|
| 154 |
+
{/* Back */}
|
| 155 |
+
<button
|
| 156 |
+
onClick={() => router.push("/employees")}
|
| 157 |
+
className="flex items-center gap-1.5 text-slate-500 hover:text-slate-900 transition-colors text-[12px] font-medium"
|
| 158 |
+
>
|
| 159 |
+
<ChevronLeft className="w-4 h-4" />
|
| 160 |
+
Back to Employees
|
| 161 |
+
</button>
|
| 162 |
+
|
| 163 |
+
{/* Header */}
|
| 164 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/5">
|
| 165 |
+
<div className="flex items-center gap-3">
|
| 166 |
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 border border-blue-500/20 flex items-center justify-center">
|
| 167 |
+
<span className="text-base font-bold text-blue-400">
|
| 168 |
+
{employee?.name?.charAt(0) || "?"}
|
| 169 |
+
</span>
|
| 170 |
+
</div>
|
| 171 |
+
<div>
|
| 172 |
+
<h1 className="text-xl font-bold text-[var(--text-primary)] tracking-tight">Biometric Enrollment</h1>
|
| 173 |
+
<p className="text-[12px] text-slate-500">
|
| 174 |
+
<span className="text-[var(--text-primary)] font-medium">{employee?.name}</span>
|
| 175 |
+
<span className="text-slate-400 mx-1.5">·</span>
|
| 176 |
+
<span className="font-mono text-slate-500">{employee?.employee_id}</span>
|
| 177 |
+
</p>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
<button
|
| 181 |
+
onClick={() => { if (confirm("Clear all registered facial data and embeddings?")) clearMutation.mutate(); }}
|
| 182 |
+
className="flex items-center gap-2 text-[11px] font-semibold text-rose-400 hover:text-rose-300 bg-rose-500/5 hover:bg-rose-500/10 px-3 py-2 border border-rose-500/15 rounded-xl transition-all"
|
| 183 |
+
>
|
| 184 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 185 |
+
Clear Facial Data
|
| 186 |
+
</button>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{/* Main Grid */}
|
| 190 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
| 191 |
+
{/* Left: Pose checklist */}
|
| 192 |
+
<div className="glass-card rounded-2xl border border-white/6 p-5 space-y-4">
|
| 193 |
+
<div>
|
| 194 |
+
<div className="flex items-center justify-between mb-2">
|
| 195 |
+
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Enrollment Progress</h2>
|
| 196 |
+
<span className="text-[10px] text-slate-500 font-mono">{enrolledCount}/{totalCount}</span>
|
| 197 |
+
</div>
|
| 198 |
+
{/* Progress bar */}
|
| 199 |
+
<div className="relative h-1.5 bg-zinc-150 rounded-full overflow-hidden">
|
| 200 |
+
<div
|
| 201 |
+
className="absolute inset-y-0 left-0 rounded-full bg-zinc-900 transition-all duration-700"
|
| 202 |
+
style={{ width: `${progress}%` }}
|
| 203 |
+
/>
|
| 204 |
+
</div>
|
| 205 |
+
<p className="text-[10px] text-slate-500 mt-1.5 text-right font-mono">{progress}% complete</p>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<div className="space-y-1">
|
| 209 |
+
{Object.entries(POSES).map(([key, pose]) => {
|
| 210 |
+
const done = isEnrolled(key);
|
| 211 |
+
const active = selectedPose === key;
|
| 212 |
+
return (
|
| 213 |
+
<button
|
| 214 |
+
key={key}
|
| 215 |
+
onClick={() => { setSelectedPose(key); setErrorMsg(null); setSuccessMsg(null); }}
|
| 216 |
+
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all cursor-pointer ${
|
| 217 |
+
active
|
| 218 |
+
? "bg-zinc-100 border border-zinc-300 text-zinc-950 shadow-xs"
|
| 219 |
+
: done
|
| 220 |
+
? "bg-zinc-50 border border-transparent text-slate-700 hover:bg-zinc-100"
|
| 221 |
+
: "border border-transparent text-slate-500 hover:bg-zinc-50 hover:text-slate-800"
|
| 222 |
+
}`}
|
| 223 |
+
>
|
| 224 |
+
<span className="text-base w-5 text-center leading-none shrink-0">{pose.icon}</span>
|
| 225 |
+
<span className="flex-1 text-[12px] font-medium">{pose.label}</span>
|
| 226 |
+
{done ? (
|
| 227 |
+
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 shrink-0" />
|
| 228 |
+
) : (
|
| 229 |
+
<div className="w-3.5 h-3.5 rounded-full border border-slate-200 shrink-0" />
|
| 230 |
+
)}
|
| 231 |
+
</button>
|
| 232 |
+
);
|
| 233 |
+
})}
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{/* Right: Camera capture panel */}
|
| 238 |
+
<div className="lg:col-span-2 glass-card rounded-2xl border border-white/6 flex flex-col">
|
| 239 |
+
<div className="p-5 border-b border-white/5">
|
| 240 |
+
<div className="flex items-center gap-2.5">
|
| 241 |
+
<span className="text-xl">{POSES[selectedPose]?.icon}</span>
|
| 242 |
+
<div>
|
| 243 |
+
<h3 className="text-[13px] font-semibold text-[var(--text-primary)] capitalize">
|
| 244 |
+
Capture {POSES[selectedPose]?.label} Pose
|
| 245 |
+
</h3>
|
| 246 |
+
<p className="text-[11px] text-slate-500 mt-0.5">{POSES[selectedPose]?.hint}</p>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<div className="flex-1 p-5 space-y-4">
|
| 252 |
+
{/* Status messages */}
|
| 253 |
+
{errorMsg && (
|
| 254 |
+
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-rose-50 border border-rose-250 text-rose-800 text-[11px]">
|
| 255 |
+
<XCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
| 256 |
+
<span className="leading-relaxed">{errorMsg}</span>
|
| 257 |
+
</div>
|
| 258 |
+
)}
|
| 259 |
+
{successMsg && (
|
| 260 |
+
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-emerald-50 border border-emerald-250 text-emerald-800 text-[11px]">
|
| 261 |
+
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
|
| 262 |
+
<span className="leading-relaxed">{successMsg}</span>
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
|
| 266 |
+
{/* Camera view */}
|
| 267 |
+
<div className="relative aspect-video w-full rounded-2xl bg-[#070c1a] border border-white/6 overflow-hidden flex items-center justify-center">
|
| 268 |
+
{/* Corner brackets */}
|
| 269 |
+
{(webcamActive || capturedPreview) && (
|
| 270 |
+
<>
|
| 271 |
+
<div className="corner-bracket corner-bracket-tl text-zinc-400/60" />
|
| 272 |
+
<div className="corner-bracket corner-bracket-tr text-zinc-400/60" />
|
| 273 |
+
<div className="corner-bracket corner-bracket-bl text-zinc-400/60" />
|
| 274 |
+
<div className="corner-bracket corner-bracket-br text-zinc-400/60" />
|
| 275 |
+
</>
|
| 276 |
+
)}
|
| 277 |
+
|
| 278 |
+
<video
|
| 279 |
+
ref={videoRef}
|
| 280 |
+
className={`w-full h-full object-cover scale-x-[-1] ${webcamActive ? "" : "hidden"}`}
|
| 281 |
+
autoPlay playsInline muted
|
| 282 |
+
/>
|
| 283 |
+
{webcamActive && <div className="scanner-laser" />}
|
| 284 |
+
|
| 285 |
+
{!webcamActive && capturedPreview && (
|
| 286 |
+
<>
|
| 287 |
+
<img src={capturedPreview} alt="Captured" className="w-full h-full object-cover scale-x-[-1]" />
|
| 288 |
+
<div className="absolute bottom-3 left-1/2 -translate-x-1/2">
|
| 289 |
+
<span className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1 rounded-full font-mono">
|
| 290 |
+
<CheckCircle2 className="w-3 h-3" />
|
| 291 |
+
Frame captured — review or re-take
|
| 292 |
+
</span>
|
| 293 |
+
</div>
|
| 294 |
+
</>
|
| 295 |
+
)}
|
| 296 |
+
|
| 297 |
+
{!webcamActive && !capturedPreview && (
|
| 298 |
+
<div className="text-center space-y-3 p-6">
|
| 299 |
+
<div className="w-12 h-12 rounded-2xl bg-white/4 flex items-center justify-center mx-auto">
|
| 300 |
+
<Video className="w-5 h-5 text-slate-700" />
|
| 301 |
+
</div>
|
| 302 |
+
<div>
|
| 303 |
+
<p className="text-[12px] text-slate-500 font-medium">Camera inactive</p>
|
| 304 |
+
<p className="text-[10px] text-slate-700 mt-0.5">Click "Start Camera" to begin enrollment</p>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
|
| 309 |
+
<canvas ref={canvasRef} className="hidden" />
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
{/* Action buttons */}
|
| 314 |
+
<div className="px-5 pb-5 flex flex-col sm:flex-row items-center gap-3 pt-4 border-t border-white/5">
|
| 315 |
+
{webcamActive ? (
|
| 316 |
+
<>
|
| 317 |
+
<button
|
| 318 |
+
onClick={handleCapture}
|
| 319 |
+
disabled={uploading}
|
| 320 |
+
className="btn-primary flex-1 h-10 flex items-center justify-center gap-2 text-[12px]"
|
| 321 |
+
>
|
| 322 |
+
{uploading ? (
|
| 323 |
+
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /><span>Processing...</span></>
|
| 324 |
+
) : (
|
| 325 |
+
<><Camera className="w-3.5 h-3.5" /><span>Take Snapshot</span></>
|
| 326 |
+
)}
|
| 327 |
+
</button>
|
| 328 |
+
<button
|
| 329 |
+
onClick={stopWebcam}
|
| 330 |
+
className="btn-ghost h-10 px-5 text-[12px] flex items-center gap-2"
|
| 331 |
+
>
|
| 332 |
+
<XCircle className="w-3.5 h-3.5" />
|
| 333 |
+
Stop Camera
|
| 334 |
+
</button>
|
| 335 |
+
</>
|
| 336 |
+
) : (
|
| 337 |
+
<button
|
| 338 |
+
onClick={startWebcam}
|
| 339 |
+
className="btn-primary flex-1 h-10 flex items-center justify-center gap-2 text-[12px]"
|
| 340 |
+
>
|
| 341 |
+
<Video className="w-3.5 h-3.5" />
|
| 342 |
+
Start Camera
|
| 343 |
+
</button>
|
| 344 |
+
)}
|
| 345 |
+
|
| 346 |
+
{/* File upload fallback */}
|
| 347 |
+
<label className="relative cursor-pointer">
|
| 348 |
+
<input
|
| 349 |
+
type="file" accept="image/*"
|
| 350 |
+
onChange={handleFileChange}
|
| 351 |
+
disabled={uploading}
|
| 352 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:pointer-events-none"
|
| 353 |
+
/>
|
| 354 |
+
<div className="btn-ghost h-10 px-4 text-[12px] flex items-center gap-2 pointer-events-none">
|
| 355 |
+
<Upload className="w-3.5 h-3.5" />
|
| 356 |
+
Upload Photo
|
| 357 |
+
</div>
|
| 358 |
+
</label>
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
{/* Tips */}
|
| 364 |
+
<div className="flex items-start gap-3 p-4 rounded-2xl bg-zinc-50 border border-zinc-200">
|
| 365 |
+
<AlertCircle className="w-4 h-4 text-zinc-700 shrink-0 mt-0.5" />
|
| 366 |
+
<div>
|
| 367 |
+
<p className="text-[11px] font-semibold text-[var(--text-primary)] mb-1">Enrollment Tips</p>
|
| 368 |
+
<p className="text-[10px] text-slate-650 leading-relaxed">
|
| 369 |
+
Enroll at least <strong className="text-[var(--text-primary)]">5–7 poses</strong> for reliable recognition. Ensure good lighting, avoid glasses for the first pose, and maintain consistent distance from the camera (approx. 50–80cm). Multiple angles improve matching accuracy significantly.
|
| 370 |
+
</p>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
</SidebarLayout>
|
| 375 |
+
);
|
| 376 |
+
}
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* ─── Design Tokens (Strict Minimalist Light Theme: White & Dark Grey Only) ─── */
|
| 6 |
+
:root {
|
| 7 |
+
--bg-base: #ffffff; /* Pure White */
|
| 8 |
+
--bg-surface: #ffffff; /* Pure White */
|
| 9 |
+
--bg-elevated: #ffffff; /* Pure White */
|
| 10 |
+
--bg-overlay: rgba(255, 255, 255, 0.95);
|
| 11 |
+
|
| 12 |
+
--accent-primary: #18181b; /* Zinc 900 / Dark Grey */
|
| 13 |
+
--accent-cyan: #27272a; /* Zinc 800 */
|
| 14 |
+
--accent-indigo: #27272a;
|
| 15 |
+
--accent-emerald: #18181b;
|
| 16 |
+
--accent-amber: #3f3f46;
|
| 17 |
+
--accent-rose: #18181b;
|
| 18 |
+
|
| 19 |
+
--border-subtle: #f4f4f5; /* Zinc 100 */
|
| 20 |
+
--border-medium: #e4e4e7; /* Zinc 200 */
|
| 21 |
+
--border-strong: #d4d4d8; /* Zinc 300 */
|
| 22 |
+
|
| 23 |
+
--text-primary: #18181b; /* Zinc 900 - Dark Grey */
|
| 24 |
+
--text-secondary: #3f3f46; /* Zinc 700 - Medium Dark Grey */
|
| 25 |
+
--text-muted: #71717a; /* Zinc 500 - Medium Grey */
|
| 26 |
+
--text-faint: #a1a1aa; /* Zinc 400 - Light Grey */
|
| 27 |
+
|
| 28 |
+
--glow-blue: 0 4px 20px rgba(24, 24, 27, 0.03);
|
| 29 |
+
--glow-cyan: 0 4px 20px rgba(24, 24, 27, 0.03);
|
| 30 |
+
--radius-sm: 8px;
|
| 31 |
+
--radius-md: 12px;
|
| 32 |
+
--radius-lg: 16px;
|
| 33 |
+
--radius-xl: 20px;
|
| 34 |
+
--radius-2xl: 24px;
|
| 35 |
+
--radius-3xl: 32px;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* ─── Base Reset ─── */
|
| 39 |
+
*, *::before, *::after {
|
| 40 |
+
box-sizing: border-box;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
html {
|
| 44 |
+
scroll-behavior: smooth;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
body {
|
| 48 |
+
background-color: var(--bg-base);
|
| 49 |
+
color: var(--text-primary);
|
| 50 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
| 51 |
+
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
| 52 |
+
-webkit-font-smoothing: antialiased;
|
| 53 |
+
-moz-osx-font-smoothing: grayscale;
|
| 54 |
+
overflow-x: hidden;
|
| 55 |
+
line-height: 1.6;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* ─── Typography ─── */
|
| 59 |
+
h1, h2, h3, h4, h5, h6 {
|
| 60 |
+
font-weight: 700;
|
| 61 |
+
line-height: 1.2;
|
| 62 |
+
letter-spacing: -0.02em;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.font-mono, code, pre, .mono {
|
| 66 |
+
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* ─── Scrollbar ─── */
|
| 70 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 71 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 72 |
+
::-webkit-scrollbar-thumb {
|
| 73 |
+
background: rgba(113, 113, 122, 0.2);
|
| 74 |
+
border-radius: 99px;
|
| 75 |
+
}
|
| 76 |
+
::-webkit-scrollbar-thumb:hover {
|
| 77 |
+
background: rgba(113, 113, 122, 0.35);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* ─── Glass Components ─── */
|
| 81 |
+
.glass {
|
| 82 |
+
background: #ffffff;
|
| 83 |
+
border: 1px solid var(--border-medium);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.glass-panel {
|
| 87 |
+
background: #ffffff;
|
| 88 |
+
border: 1px solid var(--border-medium);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.glass-card {
|
| 92 |
+
background: #ffffff;
|
| 93 |
+
border: 1px solid var(--border-medium);
|
| 94 |
+
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.02);
|
| 95 |
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.glass-card:hover {
|
| 99 |
+
border-color: var(--border-strong);
|
| 100 |
+
background: #ffffff;
|
| 101 |
+
transform: translateY(-0.5px);
|
| 102 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/* ─── Card Glow Variants (Clean zinc shadows) ─── */
|
| 106 |
+
.card-glow-blue,
|
| 107 |
+
.card-glow-cyan,
|
| 108 |
+
.card-glow-emerald,
|
| 109 |
+
.card-glow-amber,
|
| 110 |
+
.card-glow-rose {
|
| 111 |
+
box-shadow: 0 4px 20px rgba(24, 24, 27, 0.02);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* ─── Gradient Text ─── */
|
| 115 |
+
.gradient-text,
|
| 116 |
+
.gradient-text-blue,
|
| 117 |
+
.gradient-text-cyan {
|
| 118 |
+
background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
|
| 119 |
+
-webkit-background-clip: text;
|
| 120 |
+
-webkit-text-fill-color: transparent;
|
| 121 |
+
background-clip: text;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* ─── Buttons ─── */
|
| 125 |
+
.btn-primary {
|
| 126 |
+
background: var(--text-primary);
|
| 127 |
+
color: white;
|
| 128 |
+
font-weight: 650;
|
| 129 |
+
border: 1px solid var(--text-primary);
|
| 130 |
+
border-radius: var(--radius-lg);
|
| 131 |
+
padding: 10px 20px;
|
| 132 |
+
font-size: 13px;
|
| 133 |
+
cursor: pointer;
|
| 134 |
+
transition: all 0.15s ease;
|
| 135 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 136 |
+
position: relative;
|
| 137 |
+
overflow: hidden;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.btn-primary:hover {
|
| 141 |
+
background: #27272a;
|
| 142 |
+
border-color: #27272a;
|
| 143 |
+
transform: translateY(-0.5px);
|
| 144 |
+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
|
| 145 |
+
}
|
| 146 |
+
.btn-primary:active { transform: translateY(0); }
|
| 147 |
+
.btn-primary:disabled { background: #e4e4e7; border-color: #e4e4e7; color: #a1a1aa; cursor: not-allowed; transform: none; box-shadow: none; }
|
| 148 |
+
|
| 149 |
+
.btn-ghost {
|
| 150 |
+
background: #ffffff;
|
| 151 |
+
color: var(--text-secondary);
|
| 152 |
+
border: 1px solid var(--border-medium);
|
| 153 |
+
border-radius: var(--radius-lg);
|
| 154 |
+
padding: 9px 18px;
|
| 155 |
+
font-size: 13px;
|
| 156 |
+
font-weight: 500;
|
| 157 |
+
cursor: pointer;
|
| 158 |
+
transition: all 0.15s ease;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.btn-ghost:hover {
|
| 162 |
+
background: #f4f4f5;
|
| 163 |
+
border-color: var(--border-strong);
|
| 164 |
+
color: var(--text-primary);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* ─── Form Inputs ─── */
|
| 168 |
+
.input-field {
|
| 169 |
+
width: 100%;
|
| 170 |
+
background: #ffffff;
|
| 171 |
+
border: 1px solid var(--border-medium);
|
| 172 |
+
border-radius: var(--radius-md);
|
| 173 |
+
padding: 0 14px;
|
| 174 |
+
height: 40px;
|
| 175 |
+
color: var(--text-primary);
|
| 176 |
+
font-size: 13px;
|
| 177 |
+
font-family: inherit;
|
| 178 |
+
transition: all 0.15s ease;
|
| 179 |
+
outline: none;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.input-field::placeholder { color: var(--text-muted); }
|
| 183 |
+
|
| 184 |
+
.input-field:focus {
|
| 185 |
+
border-color: var(--text-primary);
|
| 186 |
+
background: #ffffff;
|
| 187 |
+
box-shadow: 0 0 0 3px rgba(24, 24, 27, 0.05);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
select.input-field option {
|
| 191 |
+
background: #ffffff;
|
| 192 |
+
color: var(--text-primary);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* Chrome Autofill Styling Override */
|
| 196 |
+
.input-field:-webkit-autofill,
|
| 197 |
+
.input-field:-webkit-autofill:hover,
|
| 198 |
+
.input-field:-webkit-autofill:focus,
|
| 199 |
+
.input-field:-webkit-autofill:active {
|
| 200 |
+
-webkit-text-fill-color: var(--text-primary) !important;
|
| 201 |
+
-webkit-box-shadow: 0 0 0px 1000px #ffffff inset !important;
|
| 202 |
+
box-shadow: 0 0 0px 1000px #ffffff inset !important;
|
| 203 |
+
transition: background-color 5000s ease-in-out 0s;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* Hide native Chrome autofill key/card icons to prevent overlap with Lucide icons */
|
| 207 |
+
.input-field::-webkit-contacts-auto-fill-button,
|
| 208 |
+
.input-field::-webkit-credentials-auto-fill-button {
|
| 209 |
+
visibility: hidden;
|
| 210 |
+
display: none !important;
|
| 211 |
+
pointer-events: none;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/* ─── Status Badges (Strict Minimalist Grays) ─── */
|
| 215 |
+
.badge {
|
| 216 |
+
display: inline-flex;
|
| 217 |
+
align-items: center;
|
| 218 |
+
gap: 5px;
|
| 219 |
+
padding: 3px 10px;
|
| 220 |
+
border-radius: 99px;
|
| 221 |
+
font-size: 11px;
|
| 222 |
+
font-weight: 650;
|
| 223 |
+
letter-spacing: 0.01em;
|
| 224 |
+
background: #f4f4f5;
|
| 225 |
+
color: #27272a;
|
| 226 |
+
border: 1px solid #e4e4e7;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.badge-emerald,
|
| 230 |
+
.badge-blue,
|
| 231 |
+
.badge-indigo,
|
| 232 |
+
.badge-slate {
|
| 233 |
+
background: #f4f4f5;
|
| 234 |
+
color: #18181b;
|
| 235 |
+
border: 1px solid #e4e4e7;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.badge-amber {
|
| 239 |
+
background: #fffbeb;
|
| 240 |
+
color: #78350f;
|
| 241 |
+
border: 1px solid #fef3c7;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.badge-rose {
|
| 245 |
+
background: #fef2f2;
|
| 246 |
+
color: #991b1b;
|
| 247 |
+
border: 1px solid #fee2e2;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* ─── Dividers ─── */
|
| 251 |
+
.divider {
|
| 252 |
+
border: none;
|
| 253 |
+
border-top: 1px solid var(--border-subtle);
|
| 254 |
+
margin: 0;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/* ─── Ambient Background Effects (Hidden for strict minimalism) ─── */
|
| 258 |
+
.ambient-bg {
|
| 259 |
+
position: fixed;
|
| 260 |
+
inset: 0;
|
| 261 |
+
pointer-events: none;
|
| 262 |
+
z-index: 0;
|
| 263 |
+
background: #ffffff;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
/* ─── Grid Mesh Background (Minimalist) ─── */
|
| 267 |
+
.mesh-bg {
|
| 268 |
+
background-image:
|
| 269 |
+
linear-gradient(rgba(24, 24, 27, 0.01) 1px, transparent 1px),
|
| 270 |
+
linear-gradient(90deg, rgba(24, 24, 27, 0.01) 1px, transparent 1px);
|
| 271 |
+
background-size: 48px 48px;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
/* ─── Kiosk Scanner Laser ─── */
|
| 275 |
+
@keyframes scan-line {
|
| 276 |
+
0% { top: 0%; opacity: 1; }
|
| 277 |
+
48% { opacity: 1; }
|
| 278 |
+
50% { top: 100%; opacity: 0; }
|
| 279 |
+
51% { top: 0%; opacity: 0; }
|
| 280 |
+
52% { opacity: 1; }
|
| 281 |
+
100% { top: 100%; opacity: 1; }
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.scanner-laser {
|
| 285 |
+
position: absolute;
|
| 286 |
+
left: 0;
|
| 287 |
+
width: 100%;
|
| 288 |
+
height: 1.5px;
|
| 289 |
+
background: linear-gradient(90deg, transparent 0%, rgba(24, 24, 27, 0.5) 30%, rgba(24, 24, 27, 0.5) 70%, transparent 100%);
|
| 290 |
+
box-shadow: 0 0 8px rgba(24, 24, 27, 0.15);
|
| 291 |
+
animation: scan-line 3s cubic-bezier(0.4,0,0.6,1) infinite;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/* ─── Fade In Animation ─── */
|
| 295 |
+
@keyframes fadeInUp {
|
| 296 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 297 |
+
to { opacity: 1; transform: translateY(0); }
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.animate-fadeInUp {
|
| 301 |
+
animation: fadeInUp 0.35s cubic-bezier(0.4,0,0.2,1) both;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
@keyframes fadeIn {
|
| 305 |
+
from { opacity: 0; }
|
| 306 |
+
to { opacity: 1; }
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.animate-fade-in {
|
| 310 |
+
animation: fadeIn 0.25s ease both;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/* ─── Pulse Ring ─── */
|
| 314 |
+
@keyframes pulse-ring {
|
| 315 |
+
0% { transform: scale(1); opacity: 0.5; }
|
| 316 |
+
100% { transform: scale(1.4); opacity: 0; }
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.pulse-ring::after {
|
| 320 |
+
content: '';
|
| 321 |
+
position: absolute;
|
| 322 |
+
inset: 0;
|
| 323 |
+
border-radius: 50%;
|
| 324 |
+
background: currentColor;
|
| 325 |
+
animation: pulse-ring 1.5s ease infinite;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/* ─── Loading Skeleton ─── */
|
| 329 |
+
@keyframes shimmer {
|
| 330 |
+
0% { background-position: -400px 0; }
|
| 331 |
+
100% { background-position: 400px 0; }
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.skeleton {
|
| 335 |
+
background: linear-gradient(90deg, rgba(24, 24, 27, 0.02) 25%, rgba(24, 24, 27, 0.05) 50%, rgba(24, 24, 27, 0.02) 75%);
|
| 336 |
+
background-size: 400px 100%;
|
| 337 |
+
animation: shimmer 1.5s infinite;
|
| 338 |
+
border-radius: var(--radius-md);
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
/* ─── Page Transition ─── */
|
| 342 |
+
.page-enter {
|
| 343 |
+
animation: fadeInUp 0.3s cubic-bezier(0.4,0,0.2,1) both;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
/* ─── Sidebar Active Indicator ─── */
|
| 347 |
+
.nav-active-dot {
|
| 348 |
+
width: 3px;
|
| 349 |
+
height: 24px;
|
| 350 |
+
border-radius: 99px;
|
| 351 |
+
background: var(--text-primary);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/* ─── Table Styles ─── */
|
| 355 |
+
.data-table {
|
| 356 |
+
width: 100%;
|
| 357 |
+
border-collapse: collapse;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.data-table thead tr {
|
| 361 |
+
border-bottom: 1px solid var(--border-medium);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.data-table thead th {
|
| 365 |
+
padding: 12px 20px;
|
| 366 |
+
font-size: 10px;
|
| 367 |
+
font-weight: 650;
|
| 368 |
+
text-transform: uppercase;
|
| 369 |
+
letter-spacing: 0.08em;
|
| 370 |
+
color: var(--text-secondary);
|
| 371 |
+
font-family: 'JetBrains Mono', monospace;
|
| 372 |
+
background: #f4f4f5;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.data-table tbody tr {
|
| 376 |
+
border-bottom: 1px solid rgba(24, 24, 27, 0.04);
|
| 377 |
+
transition: background 0.15s ease;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.data-table tbody tr:last-child { border-bottom: none; }
|
| 381 |
+
|
| 382 |
+
.data-table tbody tr:hover {
|
| 383 |
+
background: #f4f4f5;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.data-table tbody td {
|
| 387 |
+
padding: 14px 20px;
|
| 388 |
+
font-size: 12.5px;
|
| 389 |
+
color: var(--text-secondary);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
/* ─── Modal Backdrop ─── */
|
| 393 |
+
.modal-backdrop {
|
| 394 |
+
position: fixed;
|
| 395 |
+
inset: 0;
|
| 396 |
+
background: rgba(0, 0, 0, 0.2);
|
| 397 |
+
backdrop-filter: blur(4px);
|
| 398 |
+
-webkit-backdrop-filter: blur(4px);
|
| 399 |
+
z-index: 50;
|
| 400 |
+
display: flex;
|
| 401 |
+
align-items: center;
|
| 402 |
+
justify-content: center;
|
| 403 |
+
padding: 16px;
|
| 404 |
+
animation: fadeIn 0.2s ease;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.modal-content {
|
| 408 |
+
background: var(--bg-surface);
|
| 409 |
+
border: 1px solid var(--border-medium);
|
| 410 |
+
border-radius: var(--radius-2xl);
|
| 411 |
+
padding: 28px;
|
| 412 |
+
width: 100%;
|
| 413 |
+
max-height: 90vh;
|
| 414 |
+
overflow-y: auto;
|
| 415 |
+
animation: fadeInUp 0.25s cubic-bezier(0.4,0,0.2,1) both;
|
| 416 |
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.06);
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
/* ─── Stat Card Accent Lines ─── */
|
| 420 |
+
.stat-card {
|
| 421 |
+
position: relative;
|
| 422 |
+
overflow: hidden;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.stat-card::before {
|
| 426 |
+
content: '';
|
| 427 |
+
position: absolute;
|
| 428 |
+
top: 0;
|
| 429 |
+
left: 0;
|
| 430 |
+
right: 0;
|
| 431 |
+
height: 1px;
|
| 432 |
+
background: var(--border-medium);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
/* ─── Kiosk Full Screen ─── */
|
| 436 |
+
.kiosk-frame {
|
| 437 |
+
background: #ffffff;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
/* ─── Corner Brackets ─── */
|
| 441 |
+
.corner-bracket {
|
| 442 |
+
position: absolute;
|
| 443 |
+
width: 20px;
|
| 444 |
+
height: 20px;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.corner-bracket-tl { top: 0; left: 0; border-top: 2px solid; border-left: 2px solid; border-radius: 4px 0 0 0; }
|
| 448 |
+
.corner-bracket-tr { top: 0; right: 0; border-top: 2px solid; border-right: 2px solid; border-radius: 0 4px 0 0; }
|
| 449 |
+
.corner-bracket-bl { bottom: 0; left: 0; border-bottom: 2px solid; border-left: 2px solid; border-radius: 0 0 0 4px; }
|
| 450 |
+
.corner-bracket-br { bottom: 0; right: 0; border-bottom: 2px solid; border-right: 2px solid; border-radius: 0 0 4px 0; }
|
| 451 |
+
|
| 452 |
+
/* ─── Tooltip ─── */
|
| 453 |
+
[data-tooltip] {
|
| 454 |
+
position: relative;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
[data-tooltip]::after {
|
| 458 |
+
content: attr(data-tooltip);
|
| 459 |
+
position: absolute;
|
| 460 |
+
bottom: calc(100% + 6px);
|
| 461 |
+
left: 50%;
|
| 462 |
+
transform: translateX(-50%);
|
| 463 |
+
background: rgba(24, 24, 27, 0.95);
|
| 464 |
+
border: 1px solid var(--border-medium);
|
| 465 |
+
color: #ffffff;
|
| 466 |
+
font-size: 11px;
|
| 467 |
+
padding: 5px 10px;
|
| 468 |
+
border-radius: var(--radius-sm);
|
| 469 |
+
white-space: nowrap;
|
| 470 |
+
opacity: 0;
|
| 471 |
+
pointer-events: none;
|
| 472 |
+
transition: opacity 0.15s ease;
|
| 473 |
+
z-index: 99;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
[data-tooltip]:hover::after { opacity: 1; }
|
| 477 |
+
|
| 478 |
+
/* ─── Number Ticker ─── */
|
| 479 |
+
@keyframes countUp {
|
| 480 |
+
from { transform: translateY(6px); opacity: 0; }
|
| 481 |
+
to { transform: translateY(0); opacity: 1; }
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.count-up {
|
| 485 |
+
animation: countUp 0.5s cubic-bezier(0.4,0,0.2,1) both;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
/* ─── Custom Form Icon Padding Helpers ─── */
|
| 489 |
+
.input-field.pl-icon {
|
| 490 |
+
padding-left: 40px !important;
|
| 491 |
+
}
|
| 492 |
+
.input-field.pr-icon {
|
| 493 |
+
padding-right: 40px !important;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
/* ─── Robotic Eye & Cybernetic Animations ─── */
|
| 497 |
+
@keyframes eyeBlink {
|
| 498 |
+
0%, 90%, 94%, 98%, 100% {
|
| 499 |
+
transform: scaleY(1);
|
| 500 |
+
}
|
| 501 |
+
92%, 96% {
|
| 502 |
+
transform: scaleY(0.15);
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
@keyframes pupilPulse {
|
| 507 |
+
0%, 100% {
|
| 508 |
+
transform: scale(1);
|
| 509 |
+
filter: drop-shadow(0 0 2px #22d3ee) drop-shadow(0 0 6px #0891b2);
|
| 510 |
+
}
|
| 511 |
+
50% {
|
| 512 |
+
transform: scale(1.15);
|
| 513 |
+
filter: drop-shadow(0 0 4px #22d3ee) drop-shadow(0 0 12px #0891b2);
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
@keyframes rotateRing {
|
| 518 |
+
0% {
|
| 519 |
+
transform: rotate(0deg);
|
| 520 |
+
}
|
| 521 |
+
100% {
|
| 522 |
+
transform: rotate(360deg);
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
@keyframes laserFlicker {
|
| 527 |
+
0%, 100% {
|
| 528 |
+
opacity: 0.4;
|
| 529 |
+
}
|
| 530 |
+
50% {
|
| 531 |
+
opacity: 0.85;
|
| 532 |
+
filter: drop-shadow(0 0 3px #22d3ee);
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.animate-eye-lid {
|
| 537 |
+
transform-origin: 50px 50px;
|
| 538 |
+
animation: eyeBlink 5s ease-in-out infinite;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.animate-pupil {
|
| 542 |
+
transform-origin: 50px 50px;
|
| 543 |
+
animation: pupilPulse 2s ease-in-out infinite;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.animate-rotate-ring {
|
| 547 |
+
transform-origin: 50px 50px;
|
| 548 |
+
animation: rotateRing 12s linear infinite;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.animate-rotate-ring-reverse {
|
| 552 |
+
transform-origin: 50px 50px;
|
| 553 |
+
animation: rotateRing 8s linear infinite reverse;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
.animate-laser {
|
| 557 |
+
animation: laserFlicker 1.5s ease-in-out infinite;
|
| 558 |
+
}
|
| 559 |
+
|
frontend/app/kiosk/page.tsx
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef } from "react";
|
| 4 |
+
import {
|
| 5 |
+
Camera, UserCheck, ShieldAlert, HelpCircle, Maximize, Minimize,
|
| 6 |
+
Volume2, VolumeX, Clock as ClockIcon, Play, Wifi, Fingerprint, Shield
|
| 7 |
+
} from "lucide-react";
|
| 8 |
+
import { getBackendUrl } from "@/app/utils/api";
|
| 9 |
+
|
| 10 |
+
export default function KioskPage() {
|
| 11 |
+
const [kioskActive, setKioskActive] = useState(false);
|
| 12 |
+
const [currentTime, setCurrentTime] = useState("");
|
| 13 |
+
const [currentDate, setCurrentDate] = useState("");
|
| 14 |
+
const [matchTime, setMatchTime] = useState("");
|
| 15 |
+
const [scanning, setScanning] = useState(false);
|
| 16 |
+
const [scanStatus, setScanStatus] = useState<"idle" | "success" | "spoof" | "unknown" | "maintenance" | "no_employees">("idle");
|
| 17 |
+
const [scanResult, setScanResult] = useState<any>(null);
|
| 18 |
+
const [scanFeedback, setScanFeedback] = useState<string | null>(null);
|
| 19 |
+
const [cameraLabel] = useState("Main Entrance");
|
| 20 |
+
const [voiceEnabled, setVoiceEnabled] = useState(true);
|
| 21 |
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 22 |
+
const [profileImageError, setProfileImageError] = useState(false);
|
| 23 |
+
|
| 24 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
| 25 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 26 |
+
const streamRef = useRef<MediaStream | null>(null);
|
| 27 |
+
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
| 28 |
+
const cooldownRef = useRef(false);
|
| 29 |
+
|
| 30 |
+
// Clock
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
const tick = () => {
|
| 33 |
+
const now = new Date();
|
| 34 |
+
setCurrentTime(now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }));
|
| 35 |
+
setCurrentDate(now.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" }));
|
| 36 |
+
};
|
| 37 |
+
tick();
|
| 38 |
+
const t = setInterval(tick, 1000);
|
| 39 |
+
return () => clearInterval(t);
|
| 40 |
+
}, []);
|
| 41 |
+
|
| 42 |
+
const toggleFullscreen = () => {
|
| 43 |
+
if (!document.fullscreenElement) {
|
| 44 |
+
document.documentElement.requestFullscreen().catch(() => {});
|
| 45 |
+
setIsFullscreen(true);
|
| 46 |
+
} else {
|
| 47 |
+
document.exitFullscreen().catch(() => {});
|
| 48 |
+
setIsFullscreen(false);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const startKiosk = async () => {
|
| 53 |
+
setScanFeedback(null);
|
| 54 |
+
try {
|
| 55 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 56 |
+
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: "user" }
|
| 57 |
+
});
|
| 58 |
+
streamRef.current = stream;
|
| 59 |
+
|
| 60 |
+
// Since video element is always mounted now, videoRef.current is guaranteed to exist!
|
| 61 |
+
if (videoRef.current) {
|
| 62 |
+
videoRef.current.srcObject = stream;
|
| 63 |
+
videoRef.current.play().catch(err => console.error("Error playing video:", err));
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
setKioskActive(true);
|
| 67 |
+
setScanStatus("idle");
|
| 68 |
+
intervalRef.current = setInterval(captureAndScan, 1000);
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.error(err);
|
| 71 |
+
alert("Unable to access camera. Please check browser permissions and ensure no other application is using it.");
|
| 72 |
+
}
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const stopKiosk = () => {
|
| 76 |
+
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
| 77 |
+
streamRef.current?.getTracks().forEach(t => t.stop());
|
| 78 |
+
streamRef.current = null;
|
| 79 |
+
if (videoRef.current) {
|
| 80 |
+
videoRef.current.srcObject = null;
|
| 81 |
+
}
|
| 82 |
+
setKioskActive(false);
|
| 83 |
+
setScanStatus("idle");
|
| 84 |
+
setScanResult(null);
|
| 85 |
+
setScanFeedback(null);
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
return () => {
|
| 90 |
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
| 91 |
+
streamRef.current?.getTracks().forEach(t => t.stop());
|
| 92 |
+
};
|
| 93 |
+
}, []);
|
| 94 |
+
|
| 95 |
+
const captureAndScan = async () => {
|
| 96 |
+
if (cooldownRef.current || !videoRef.current || !canvasRef.current) return;
|
| 97 |
+
const video = videoRef.current;
|
| 98 |
+
const canvas = canvasRef.current;
|
| 99 |
+
const ctx = canvas.getContext("2d");
|
| 100 |
+
if (!ctx) return;
|
| 101 |
+
|
| 102 |
+
// Check if the video is actually ready and playing
|
| 103 |
+
if (video.readyState < 2) return;
|
| 104 |
+
|
| 105 |
+
canvas.width = 640; canvas.height = 480;
|
| 106 |
+
ctx.translate(canvas.width, 0); ctx.scale(-1, 1);
|
| 107 |
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 108 |
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 109 |
+
|
| 110 |
+
const base64 = canvas.toDataURL("image/jpeg", 0.82);
|
| 111 |
+
setScanning(true);
|
| 112 |
+
try {
|
| 113 |
+
const host = window.location.hostname;
|
| 114 |
+
const port = window.location.port ? `:${window.location.port}` : "";
|
| 115 |
+
|
| 116 |
+
// Connect to the backend running at port 8000
|
| 117 |
+
const url = `http://${host}:8000/api/v1/kiosk/scan`;
|
| 118 |
+
const res = await fetch(url, {
|
| 119 |
+
method: "POST",
|
| 120 |
+
headers: { "Content-Type": "application/json" },
|
| 121 |
+
body: JSON.stringify({ image: base64, camera: cameraLabel })
|
| 122 |
+
});
|
| 123 |
+
if (!res.ok) throw new Error("Scan failed");
|
| 124 |
+
const data = await res.json();
|
| 125 |
+
|
| 126 |
+
if (data.status === "no_face") {
|
| 127 |
+
setScanFeedback("Align your face in frame");
|
| 128 |
+
} else if (data.status === "multiple_faces") {
|
| 129 |
+
setScanFeedback("One person at a time");
|
| 130 |
+
} else {
|
| 131 |
+
setScanFeedback(null);
|
| 132 |
+
handleResult(data);
|
| 133 |
+
}
|
| 134 |
+
} catch (err) {
|
| 135 |
+
console.error("Scan API connection error:", err);
|
| 136 |
+
setScanFeedback("Connection error");
|
| 137 |
+
} finally {
|
| 138 |
+
setScanning(false);
|
| 139 |
+
}
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
const handleResult = (data: any) => {
|
| 143 |
+
if (data.status === "success") {
|
| 144 |
+
setProfileImageError(false);
|
| 145 |
+
setScanStatus("success"); setScanResult(data);
|
| 146 |
+
setMatchTime(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true }));
|
| 147 |
+
triggerCooldown(4500);
|
| 148 |
+
if (voiceEnabled && data.tts_url) {
|
| 149 |
+
const host = window.location.hostname;
|
| 150 |
+
new Audio(`http://${host}:8000${data.tts_url}`).play().catch((err) => {
|
| 151 |
+
console.error("Autoplay voice greeting failed:", err);
|
| 152 |
+
});
|
| 153 |
+
}
|
| 154 |
+
} else if (data.status === "spoof_detected") {
|
| 155 |
+
setScanStatus("spoof"); setScanResult(data);
|
| 156 |
+
triggerCooldown(3000);
|
| 157 |
+
} else if (data.status === "unknown") {
|
| 158 |
+
setScanStatus("unknown"); setScanResult(data);
|
| 159 |
+
triggerCooldown(2500);
|
| 160 |
+
} else if (data.status === "maintenance") {
|
| 161 |
+
setScanStatus("maintenance"); setScanResult(data);
|
| 162 |
+
triggerCooldown(5000);
|
| 163 |
+
} else if (data.status === "no_employees") {
|
| 164 |
+
setScanStatus("no_employees"); setScanResult(data);
|
| 165 |
+
triggerCooldown(4000);
|
| 166 |
+
}
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
const triggerCooldown = (ms: number) => {
|
| 170 |
+
cooldownRef.current = true;
|
| 171 |
+
setTimeout(() => {
|
| 172 |
+
cooldownRef.current = false;
|
| 173 |
+
setScanStatus("idle");
|
| 174 |
+
setScanResult(null);
|
| 175 |
+
}, ms);
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div className="min-h-screen bg-[var(--bg-base)] text-[var(--text-primary)] flex flex-col relative select-none overflow-hidden font-sans">
|
| 180 |
+
{/* Background Grid Mesh */}
|
| 181 |
+
<div className="absolute inset-0 mesh-bg opacity-30 pointer-events-none" />
|
| 182 |
+
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[350px] bg-slate-500/5 blur-[120px] pointer-events-none rounded-full" />
|
| 183 |
+
|
| 184 |
+
{/* ─── Top HUD Bar ─── */}
|
| 185 |
+
<header className="relative z-30 h-16 border-b border-[var(--border-medium)] px-8 flex items-center justify-between bg-white/95 backdrop-blur-xl shadow-sm">
|
| 186 |
+
{/* Brand info */}
|
| 187 |
+
<div className="flex items-center gap-3">
|
| 188 |
+
<div className="relative inline-flex items-center justify-center w-8.5 h-8.5 rounded-xl bg-slate-950 border border-slate-800/80 shadow-[0_0_10px_rgba(6,182,212,0.15)] overflow-hidden shrink-0">
|
| 189 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)]" />
|
| 190 |
+
<div className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 border border-white z-20" />
|
| 191 |
+
<svg viewBox="0 0 100 100" className="w-5.5 h-5.5 relative z-10 animate-fade-in" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 192 |
+
<circle cx="50" cy="50" r="45" stroke="#1e293b" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 193 |
+
<circle cx="50" cy="50" r="40" stroke="#0891b2" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.6 }} />
|
| 194 |
+
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#475569" strokeWidth="2.5" />
|
| 195 |
+
<g className="animate-eye-lid">
|
| 196 |
+
<circle cx="50" cy="50" r="22" fill="#0f172a" stroke="#0891b2" strokeWidth="2" />
|
| 197 |
+
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 198 |
+
</g>
|
| 199 |
+
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 200 |
+
</svg>
|
| 201 |
+
</div>
|
| 202 |
+
<div>
|
| 203 |
+
<h1 className="font-bold text-[13.5px] tracking-tight text-slate-800 leading-none">NetraID Kiosk</h1>
|
| 204 |
+
<div className="flex items-center gap-1.5 mt-1">
|
| 205 |
+
<Wifi className="w-2.5 h-2.5 text-emerald-500" />
|
| 206 |
+
<p className="text-[9.5px] text-slate-500 font-mono uppercase tracking-wider">{cameraLabel}</p>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Clock */}
|
| 212 |
+
<div className="text-center absolute left-1/2 -translate-x-1/2">
|
| 213 |
+
<p className="text-lg font-bold font-mono tracking-tight text-slate-800 leading-none tabular-nums">
|
| 214 |
+
{currentTime || "00:00:00"}
|
| 215 |
+
</p>
|
| 216 |
+
<p className="text-[9.5px] text-slate-500 mt-1 uppercase tracking-wider font-mono">
|
| 217 |
+
{currentDate}
|
| 218 |
+
</p>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* Controls */}
|
| 222 |
+
<div className="flex items-center gap-2">
|
| 223 |
+
<button
|
| 224 |
+
onClick={() => setVoiceEnabled(!voiceEnabled)}
|
| 225 |
+
title={voiceEnabled ? "Mute audio assistance" : "Enable audio assistance"}
|
| 226 |
+
className={`p-2 rounded-xl border transition-all cursor-pointer ${
|
| 227 |
+
voiceEnabled
|
| 228 |
+
? "bg-zinc-950 border-zinc-950 text-white"
|
| 229 |
+
: "bg-slate-100 border-slate-200 text-slate-400"
|
| 230 |
+
}`}
|
| 231 |
+
>
|
| 232 |
+
{voiceEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
|
| 233 |
+
</button>
|
| 234 |
+
<button
|
| 235 |
+
onClick={toggleFullscreen}
|
| 236 |
+
title="Fullscreen toggle"
|
| 237 |
+
className="p-2 rounded-xl bg-slate-100 border border-slate-200 text-slate-500 hover:text-slate-800 transition-all cursor-pointer"
|
| 238 |
+
>
|
| 239 |
+
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
| 240 |
+
</button>
|
| 241 |
+
</div>
|
| 242 |
+
</header>
|
| 243 |
+
|
| 244 |
+
{/* ─── Main Area ─── */}
|
| 245 |
+
<main className="flex-1 flex flex-col items-center justify-center p-6 relative z-10">
|
| 246 |
+
<canvas ref={canvasRef} className="hidden" />
|
| 247 |
+
|
| 248 |
+
{/* Video / Camera frame (Always mounted to prevent null refs) */}
|
| 249 |
+
<div className="w-full max-w-3xl space-y-4 relative">
|
| 250 |
+
|
| 251 |
+
{/* Pre-start overlay */}
|
| 252 |
+
{!kioskActive && (
|
| 253 |
+
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-[var(--bg-base)] text-center p-8 space-y-7 animate-fadeInUp">
|
| 254 |
+
<div className="relative">
|
| 255 |
+
{/* Subtle outer breathing ring */}
|
| 256 |
+
<div className="absolute inset-[-12px] rounded-full bg-zinc-100 border border-zinc-200/60 animate-pulse animate-fade-in" />
|
| 257 |
+
|
| 258 |
+
{/* Cybernetic blinking/glowing robot eye logo */}
|
| 259 |
+
<div className="relative inline-flex items-center justify-center w-22 h-22 rounded-full bg-slate-950 border border-slate-800/80 shadow-[0_0_20px_rgba(6,182,212,0.25)] overflow-hidden shrink-0">
|
| 260 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.25)_0%,transparent_70%)]" />
|
| 261 |
+
<svg viewBox="0 0 100 100" className="w-12 h-12 relative z-10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 262 |
+
<circle cx="50" cy="50" r="45" stroke="#1e293b" strokeWidth="2" strokeDasharray="8 12" className="animate-rotate-ring" />
|
| 263 |
+
<circle cx="50" cy="50" r="40" stroke="#0891b2" strokeWidth="1.5" strokeDasharray="30 15" className="animate-rotate-ring-reverse" style={{ opacity: 0.6 }} />
|
| 264 |
+
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#475569" strokeWidth="2.5" />
|
| 265 |
+
<g className="animate-eye-lid">
|
| 266 |
+
<circle cx="50" cy="50" r="22" fill="#0f172a" stroke="#0891b2" strokeWidth="2" />
|
| 267 |
+
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 268 |
+
</g>
|
| 269 |
+
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="2" className="animate-laser" />
|
| 270 |
+
</svg>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<div className="space-y-2 max-w-sm">
|
| 275 |
+
<h2 className="text-2xl font-black text-[var(--text-primary)] tracking-tight">Kiosk Scanner Offline</h2>
|
| 276 |
+
<p className="text-sm text-[var(--text-secondary)] font-medium leading-relaxed">
|
| 277 |
+
Initialize the secure terminal to start the live camera feed and begin biometric attendance recording.
|
| 278 |
+
</p>
|
| 279 |
+
</div>
|
| 280 |
+
|
| 281 |
+
<button
|
| 282 |
+
onClick={startKiosk}
|
| 283 |
+
className="btn-primary h-11 px-8 text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2.5 shadow-md shadow-zinc-950/10 cursor-pointer"
|
| 284 |
+
>
|
| 285 |
+
<Play className="w-3.5 h-3.5 fill-current" />
|
| 286 |
+
Start Scanner Terminal
|
| 287 |
+
</button>
|
| 288 |
+
</div>
|
| 289 |
+
)}
|
| 290 |
+
|
| 291 |
+
{/* Camera Frame Frame */}
|
| 292 |
+
<div className="relative aspect-video rounded-3xl overflow-hidden bg-black border border-[var(--border-strong)] shadow-lg shadow-slate-200">
|
| 293 |
+
|
| 294 |
+
{/* Native Video player (Centrally mounted) */}
|
| 295 |
+
<video
|
| 296 |
+
ref={videoRef}
|
| 297 |
+
className="w-full h-full object-cover scale-x-[-1]"
|
| 298 |
+
autoPlay playsInline muted
|
| 299 |
+
/>
|
| 300 |
+
|
| 301 |
+
{/* Scanning overlay: active standby state */}
|
| 302 |
+
{kioskActive && scanStatus === "idle" && (
|
| 303 |
+
<>
|
| 304 |
+
<div className="scanner-laser" />
|
| 305 |
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
| 306 |
+
<div className="relative w-60 h-60">
|
| 307 |
+
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-zinc-950 rounded-tl-2xl" />
|
| 308 |
+
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-zinc-950 rounded-tr-2xl" />
|
| 309 |
+
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-zinc-950 rounded-bl-2xl" />
|
| 310 |
+
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-zinc-950 rounded-br-2xl" />
|
| 311 |
+
|
| 312 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 313 |
+
<span className="text-[10px] text-zinc-950 font-mono font-bold tracking-wider uppercase bg-white/95 px-4 py-2 rounded-full border border-zinc-200/80 shadow-sm">
|
| 314 |
+
{scanFeedback || (scanning ? "Processing..." : "Center Face")}
|
| 315 |
+
</span>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
</>
|
| 320 |
+
)}
|
| 321 |
+
|
| 322 |
+
{/* SUCCESS screen */}
|
| 323 |
+
{kioskActive && scanStatus === "success" && (
|
| 324 |
+
<div className="absolute inset-0 bg-white flex flex-col items-center justify-center p-8 text-center animate-fadeInUp">
|
| 325 |
+
<div className="space-y-6 max-w-sm w-full animate-fade-in">
|
| 326 |
+
{/* Initials Circle */}
|
| 327 |
+
<div className="relative mx-auto w-24 h-24">
|
| 328 |
+
{/* Ring animation */}
|
| 329 |
+
<div className="absolute inset-[-6px] rounded-full border border-emerald-500/30 animate-ping" />
|
| 330 |
+
{scanResult?.employee?.employee_id && !profileImageError ? (
|
| 331 |
+
<img
|
| 332 |
+
src={`${getBackendUrl().replace("/api/v1", "")}/uploads/${scanResult.employee.employee_id}/front.jpg`}
|
| 333 |
+
alt={scanResult.employee.name}
|
| 334 |
+
className="w-24 h-24 rounded-full object-cover border border-zinc-200 shadow-lg shadow-zinc-950/20"
|
| 335 |
+
onError={() => setProfileImageError(true)}
|
| 336 |
+
/>
|
| 337 |
+
) : (
|
| 338 |
+
<div className="w-24 h-24 rounded-full bg-slate-950 border border-slate-800 flex items-center justify-center text-white font-mono text-3xl font-extrabold shadow-lg shadow-zinc-950/20">
|
| 339 |
+
{scanResult?.employee?.name
|
| 340 |
+
? scanResult.employee.name.split(" ").map((n: string) => n[0]).join("").substring(0, 2).toUpperCase()
|
| 341 |
+
: "PK"}
|
| 342 |
+
</div>
|
| 343 |
+
)}
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
{/* Name and Location info */}
|
| 347 |
+
<div className="space-y-2">
|
| 348 |
+
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">
|
| 349 |
+
{scanResult?.employee?.name || "Employee"}
|
| 350 |
+
</h2>
|
| 351 |
+
|
| 352 |
+
<div className="flex items-center justify-center gap-1.5 text-xs text-slate-500 font-mono uppercase font-semibold">
|
| 353 |
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 354 |
+
<span>{cameraLabel}</span>
|
| 355 |
+
</div>
|
| 356 |
+
|
| 357 |
+
<p className="text-sm text-slate-700 font-semibold">
|
| 358 |
+
{scanResult?.employee?.designation || "SDE-1"} ({scanResult?.employee?.employee_id || "int01"})
|
| 359 |
+
</p>
|
| 360 |
+
</div>
|
| 361 |
+
|
| 362 |
+
{/* Divider */}
|
| 363 |
+
<div className="w-12 h-px bg-slate-200 mx-auto" />
|
| 364 |
+
|
| 365 |
+
{/* Real-time Clock & Matched Status */}
|
| 366 |
+
<div className="space-y-4">
|
| 367 |
+
<p className="text-2xl font-black font-mono text-slate-900 tracking-tight tabular-nums">
|
| 368 |
+
{matchTime || "00:00:00 AM"}
|
| 369 |
+
</p>
|
| 370 |
+
|
| 371 |
+
<div className="flex flex-col items-center gap-1.5 mt-1">
|
| 372 |
+
<span className="inline-flex items-center gap-1.5 px-4.5 py-1.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 font-mono text-xs font-bold uppercase tracking-wider shadow-sm">
|
| 373 |
+
<UserCheck className="w-3.5 h-3.5" />
|
| 374 |
+
Matched
|
| 375 |
+
</span>
|
| 376 |
+
{scanResult?.confidence && (
|
| 377 |
+
<p className="text-[10px] text-slate-400 font-mono">
|
| 378 |
+
Match: <span className="font-bold text-slate-600">{(scanResult.confidence * 100).toFixed(1)}%</span> · Liveness: <span className="font-bold text-emerald-600">{(scanResult.liveness_score * 100).toFixed(1)}% Real</span>
|
| 379 |
+
</p>
|
| 380 |
+
)}
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
)}
|
| 386 |
+
|
| 387 |
+
{/* SPOOF screen */}
|
| 388 |
+
{kioskActive && scanStatus === "spoof" && (
|
| 389 |
+
<div className="absolute inset-0 bg-red-50/98 flex flex-col items-center justify-center p-8 text-center animate-fadeInUp">
|
| 390 |
+
<div className="relative mb-5">
|
| 391 |
+
<div className="w-18 h-18 rounded-full bg-rose-500/10 border border-rose-500 flex items-center justify-center">
|
| 392 |
+
<ShieldAlert className="w-8 h-8 text-rose-600" />
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
<h2 className="text-2xl font-bold text-slate-800 tracking-tight">Security Check Failed</h2>
|
| 396 |
+
<p className="text-sm text-rose-600 mt-1 font-semibold">{scanResult?.message}</p>
|
| 397 |
+
<p className="text-[10px] text-slate-400 font-mono mt-4">
|
| 398 |
+
Liveness Prob: {scanResult?.liveness_score?.toFixed(3)} · Lockout applied
|
| 399 |
+
</p>
|
| 400 |
+
</div>
|
| 401 |
+
)}
|
| 402 |
+
|
| 403 |
+
{/* UNKNOWN screen */}
|
| 404 |
+
{kioskActive && scanStatus === "unknown" && (
|
| 405 |
+
<div className="absolute inset-0 bg-amber-50/98 flex flex-col items-center justify-center p-8 text-center animate-fadeInUp">
|
| 406 |
+
<div className="relative mb-5">
|
| 407 |
+
<div className="w-18 h-18 rounded-full bg-amber-500/10 border border-amber-500 flex items-center justify-center">
|
| 408 |
+
<HelpCircle className="w-8 h-8 text-amber-600" />
|
| 409 |
+
</div>
|
| 410 |
+
</div>
|
| 411 |
+
<h2 className="text-xl font-bold text-slate-800 tracking-tight">Unknown Identity</h2>
|
| 412 |
+
<p className="text-xs text-amber-700 mt-1.5 font-medium">{scanResult?.message || "Face not registered on company database."}</p>
|
| 413 |
+
<p className="text-[10px] text-slate-400 font-mono mt-3">
|
| 414 |
+
Match Score: {scanResult?.confidence?.toFixed(3)}
|
| 415 |
+
</p>
|
| 416 |
+
</div>
|
| 417 |
+
)}
|
| 418 |
+
|
| 419 |
+
{/* MAINTENANCE screen */}
|
| 420 |
+
{kioskActive && scanStatus === "maintenance" && (
|
| 421 |
+
<div className="absolute inset-0 bg-zinc-50/98 flex flex-col items-center justify-center p-8 text-center animate-fadeInUp">
|
| 422 |
+
<div className="relative mb-5">
|
| 423 |
+
<div className="w-18 h-18 rounded-full bg-zinc-950/10 border border-zinc-950 flex items-center justify-center animate-pulse">
|
| 424 |
+
<ShieldAlert className="w-8 h-8 text-zinc-900" />
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
<h2 className="text-2xl font-bold text-slate-800 tracking-tight">System Under Maintenance</h2>
|
| 428 |
+
<p className="text-xs text-zinc-650 mt-1.5 font-semibold max-w-xs leading-relaxed">
|
| 429 |
+
{scanResult?.message || "Biometric logs and active attendance scans are temporarily suspended."}
|
| 430 |
+
</p>
|
| 431 |
+
<div className="mt-4">
|
| 432 |
+
<span className="inline-flex items-center gap-1.5 px-4.5 py-1.5 rounded-full bg-zinc-100 border border-zinc-200 text-zinc-800 font-mono text-xs font-bold uppercase tracking-wider shadow-sm">
|
| 433 |
+
Offline Standby
|
| 434 |
+
</span>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
)}
|
| 438 |
+
|
| 439 |
+
{/* NO_EMPLOYEES screen */}
|
| 440 |
+
{kioskActive && scanStatus === "no_employees" && (
|
| 441 |
+
<div className="absolute inset-0 bg-amber-50/98 flex flex-col items-center justify-center p-8 text-center animate-fadeInUp">
|
| 442 |
+
<div className="relative mb-5">
|
| 443 |
+
<div className="w-18 h-18 rounded-full bg-amber-500/10 border border-amber-500 flex items-center justify-center">
|
| 444 |
+
<UserCheck className="w-8 h-8 text-amber-600" />
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
<h2 className="text-2xl font-bold text-slate-800 tracking-tight">No Registered Employees</h2>
|
| 448 |
+
<p className="text-sm text-amber-700 mt-2.5 font-semibold max-w-xs leading-relaxed">
|
| 449 |
+
{scanResult?.message || "Please add the employee"}
|
| 450 |
+
</p>
|
| 451 |
+
</div>
|
| 452 |
+
)}
|
| 453 |
+
|
| 454 |
+
{/* Frame Info HUD Overlay */}
|
| 455 |
+
{kioskActive && (
|
| 456 |
+
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between pointer-events-none z-10">
|
| 457 |
+
<div className="flex items-center gap-1.5 text-[9px] font-mono font-bold text-slate-500 bg-white/90 border border-slate-200 shadow-sm px-3 py-1.5 rounded-xl">
|
| 458 |
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
| 459 |
+
ONLINE · {cameraLabel.toUpperCase()}
|
| 460 |
+
</div>
|
| 461 |
+
<button
|
| 462 |
+
onClick={(e) => { e.stopPropagation(); stopKiosk(); }}
|
| 463 |
+
className="pointer-events-auto text-[10px] font-bold text-rose-600 bg-white border border-rose-200 hover:bg-rose-50 shadow-sm px-3.5 py-1.5 rounded-xl transition-all cursor-pointer"
|
| 464 |
+
>
|
| 465 |
+
Disable Camera
|
| 466 |
+
</button>
|
| 467 |
+
</div>
|
| 468 |
+
)}
|
| 469 |
+
</div>
|
| 470 |
+
|
| 471 |
+
{/* Active bottom status bar indicators */}
|
| 472 |
+
{kioskActive && (
|
| 473 |
+
<div className="flex items-center justify-center gap-6 text-[10px] font-mono text-slate-500 font-semibold">
|
| 474 |
+
<div className="flex items-center gap-1.5">
|
| 475 |
+
<div className={`w-1.5 h-1.5 rounded-full ${scanning ? "bg-zinc-950 animate-pulse" : "bg-slate-400"}`} />
|
| 476 |
+
<span>{scanning ? "SCANNING..." : "ACTIVE STANDBY"}</span>
|
| 477 |
+
</div>
|
| 478 |
+
<div className="flex items-center gap-1.5">
|
| 479 |
+
<Shield className="w-3.5 h-3.5 text-zinc-700" />
|
| 480 |
+
<span>Anti-Spoofing Protocol</span>
|
| 481 |
+
</div>
|
| 482 |
+
<div className="flex items-center gap-1.5">
|
| 483 |
+
<Camera className="w-3.5 h-3.5 text-zinc-700" />
|
| 484 |
+
<span>Auto-indexing</span>
|
| 485 |
+
</div>
|
| 486 |
+
</div>
|
| 487 |
+
)}
|
| 488 |
+
</div>
|
| 489 |
+
</main>
|
| 490 |
+
|
| 491 |
+
{/* ─── Footer ─── */}
|
| 492 |
+
<footer className="relative z-10 h-10 border-t border-[var(--border-medium)] flex items-center justify-center bg-white shadow-inner">
|
| 493 |
+
<p className="text-[9.5px] font-mono font-bold text-slate-400 tracking-wider">
|
| 494 |
+
NETRAID SECURE TERMINAL GATEWAY v1.0.0 · MOCKED OFFLINE ENGINE
|
| 495 |
+
</p>
|
| 496 |
+
</footer>
|
| 497 |
+
</div>
|
| 498 |
+
);
|
| 499 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Inter, JetBrains_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import Providers from "./providers";
|
| 5 |
+
|
| 6 |
+
const inter = Inter({
|
| 7 |
+
subsets: ["latin"],
|
| 8 |
+
variable: "--font-inter",
|
| 9 |
+
display: "swap",
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const jetbrainsMono = JetBrains_Mono({
|
| 13 |
+
subsets: ["latin"],
|
| 14 |
+
variable: "--font-mono",
|
| 15 |
+
display: "swap",
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
export const metadata: Metadata = {
|
| 19 |
+
title: "NetraID — AI Biometric Attendance Platform",
|
| 20 |
+
description: "Enterprise-grade AI face recognition attendance system. 100% open source & self-hosted.",
|
| 21 |
+
authors: [{ name: "NetraID" }],
|
| 22 |
+
keywords: ["face recognition", "attendance", "biometric", "AI", "open source"],
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export default function RootLayout({
|
| 26 |
+
children,
|
| 27 |
+
}: Readonly<{
|
| 28 |
+
children: React.ReactNode;
|
| 29 |
+
}>) {
|
| 30 |
+
return (
|
| 31 |
+
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
| 32 |
+
<body
|
| 33 |
+
className="antialiased min-h-screen bg-[var(--bg-base)] text-[var(--text-primary)]"
|
| 34 |
+
style={{ fontFamily: "var(--font-inter), -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}
|
| 35 |
+
>
|
| 36 |
+
<Providers>
|
| 37 |
+
{children}
|
| 38 |
+
</Providers>
|
| 39 |
+
</body>
|
| 40 |
+
</html>
|
| 41 |
+
);
|
| 42 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { Lock, Mail, ArrowRight, Eye, EyeOff, ShieldAlert, ExternalLink } from "lucide-react";
|
| 6 |
+
import { fetchApi, setTokens, setUserProfile, getAccessToken } from "@/app/utils/api";
|
| 7 |
+
|
| 8 |
+
export default function LoginPage() {
|
| 9 |
+
const router = useRouter();
|
| 10 |
+
const [email, setEmail] = useState("");
|
| 11 |
+
const [password, setPassword] = useState("");
|
| 12 |
+
const [showPass, setShowPass] = useState(false);
|
| 13 |
+
const [loading, setLoading] = useState(false);
|
| 14 |
+
const [error, setError] = useState<string | null>(null);
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
if (getAccessToken()) router.push("/dashboard");
|
| 18 |
+
}, [router]);
|
| 19 |
+
|
| 20 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 21 |
+
e.preventDefault();
|
| 22 |
+
setLoading(true);
|
| 23 |
+
setError(null);
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
const params = new URLSearchParams();
|
| 27 |
+
params.append("username", email);
|
| 28 |
+
params.append("password", password);
|
| 29 |
+
|
| 30 |
+
const response = await fetchApi("/auth/login", {
|
| 31 |
+
method: "POST",
|
| 32 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 33 |
+
body: params,
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
setTokens(response.access_token, response.refresh_token);
|
| 37 |
+
const profile = await fetchApi("/auth/me");
|
| 38 |
+
setUserProfile(profile);
|
| 39 |
+
router.push("/dashboard");
|
| 40 |
+
} catch (err: any) {
|
| 41 |
+
setError(err.message || "Invalid credentials. Please try again.");
|
| 42 |
+
} finally {
|
| 43 |
+
setLoading(false);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className="min-h-screen relative bg-[var(--bg-base)] flex items-center justify-center overflow-hidden px-4">
|
| 49 |
+
{/* Grid Mesh background */}
|
| 50 |
+
<div className="absolute inset-0 mesh-bg opacity-30 pointer-events-none" />
|
| 51 |
+
|
| 52 |
+
{/* Ambient background glows */}
|
| 53 |
+
<div className="absolute top-0 left-1/4 w-96 h-96 bg-slate-100/50 rounded-full blur-[100px] pointer-events-none" />
|
| 54 |
+
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-zinc-100/50 rounded-full blur-[100px] pointer-events-none" />
|
| 55 |
+
|
| 56 |
+
<div className="w-full max-w-sm relative z-10 animate-fadeInUp">
|
| 57 |
+
|
| 58 |
+
{/* Logo block */}
|
| 59 |
+
<div className="text-center mb-8">
|
| 60 |
+
<div className="relative inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-slate-950 border border-slate-800/80 shadow-[0_0_20px_rgba(6,182,212,0.25)] mb-4 overflow-hidden group">
|
| 61 |
+
{/* Ambient glowing background inside the container */}
|
| 62 |
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(6,182,212,0.15)_0%,transparent_70%)] animate-pulse" />
|
| 63 |
+
|
| 64 |
+
{/* Robotic eye SVG */}
|
| 65 |
+
<svg viewBox="0 0 100 100" className="w-10 h-10 relative z-10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 66 |
+
{/* Outer technical rotating rings */}
|
| 67 |
+
<circle cx="50" cy="50" r="45" stroke="#1e293b" strokeWidth="1.5" strokeDasharray="6 12" className="animate-rotate-ring" />
|
| 68 |
+
<circle cx="50" cy="50" r="40" stroke="#0891b2" strokeWidth="1" strokeDasharray="40 10 15 5" className="animate-rotate-ring-reverse" style={{ opacity: 0.6 }} />
|
| 69 |
+
|
| 70 |
+
{/* Outer eye contour (fixed robot frame) */}
|
| 71 |
+
<path d="M15 50 C 30 25, 70 25, 85 50 C 70 75, 30 75, 15 50 Z" stroke="#334155" strokeWidth="2" strokeLinecap="round" />
|
| 72 |
+
<path d="M25 50 C 35 33, 65 33, 75 50 C 65 67, 35 67, 25 50 Z" stroke="#475569" strokeWidth="1" strokeDasharray="3 3" />
|
| 73 |
+
|
| 74 |
+
{/* Blinking Eyelid Overlay/Aperture */}
|
| 75 |
+
<g className="animate-eye-lid">
|
| 76 |
+
{/* Sclera/Iris */}
|
| 77 |
+
<circle cx="50" cy="50" r="22" fill="#0f172a" stroke="#0891b2" strokeWidth="1.5" />
|
| 78 |
+
<circle cx="50" cy="50" r="14" fill="#0e7490" stroke="#22d3ee" strokeWidth="1" opacity="0.5" />
|
| 79 |
+
|
| 80 |
+
{/* Glowing Iris Details */}
|
| 81 |
+
<path d="M50 28 L50 34 M50 66 L50 72 M28 50 L34 50 M66 50 L72 50" stroke="#22d3ee" strokeWidth="1" opacity="0.7" />
|
| 82 |
+
|
| 83 |
+
{/* Glowing Pupil */}
|
| 84 |
+
<circle cx="50" cy="50" r="7" fill="#22d3ee" className="animate-pupil" />
|
| 85 |
+
{/* Pupil reflection */}
|
| 86 |
+
<circle cx="47" cy="47" r="2" fill="#ffffff" opacity="0.8" />
|
| 87 |
+
</g>
|
| 88 |
+
|
| 89 |
+
{/* Futuristic crosshairs / HUD markers */}
|
| 90 |
+
<path d="M50 5 L50 12 M50 88 L50 95 M5 50 L12 50 M88 50 L95 50" stroke="#475569" strokeWidth="1.5" />
|
| 91 |
+
<path d="M30 30 L35 35 M65 65 L70 70 M65 30 L60 35 M35 65 L30 70" stroke="#0891b2" strokeWidth="1" opacity="0.5" />
|
| 92 |
+
|
| 93 |
+
{/* Scanning laser beam overlay */}
|
| 94 |
+
<line x1="15" y1="50" x2="85" y2="50" stroke="#22d3ee" strokeWidth="1.5" className="animate-laser" filter="url(#glow-logo)" />
|
| 95 |
+
|
| 96 |
+
{/* Def for glow filter */}
|
| 97 |
+
<defs>
|
| 98 |
+
<filter id="glow-logo" x="-20%" y="-20%" width="140%" height="140%">
|
| 99 |
+
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
| 100 |
+
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
| 101 |
+
</filter>
|
| 102 |
+
</defs>
|
| 103 |
+
</svg>
|
| 104 |
+
</div>
|
| 105 |
+
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">
|
| 106 |
+
NetraID
|
| 107 |
+
</h1>
|
| 108 |
+
<p className="text-slate-500 text-[12.5px] mt-1">
|
| 109 |
+
AI Biometric Attendance Platform
|
| 110 |
+
</p>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{/* Login card */}
|
| 114 |
+
<div className="bg-white border border-slate-200 rounded-2xl p-8 shadow-lg shadow-slate-100">
|
| 115 |
+
<div className="mb-6">
|
| 116 |
+
<h2 className="text-base font-bold text-slate-800">
|
| 117 |
+
Sign in to dashboard
|
| 118 |
+
</h2>
|
| 119 |
+
<p className="text-xs text-slate-500 mt-1">
|
| 120 |
+
Use your administrator credentials to continue
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{/* Error state */}
|
| 125 |
+
{error && (
|
| 126 |
+
<div className="flex items-start gap-2.5 bg-rose-500/5 border border-rose-500/15 text-rose-600 p-3 rounded-xl mb-5 text-xs animate-fadeInUp">
|
| 127 |
+
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
| 128 |
+
<span className="leading-relaxed">{error}</span>
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
|
| 132 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 133 |
+
{/* Email */}
|
| 134 |
+
<div className="space-y-1.5">
|
| 135 |
+
<label className="block text-[10.5px] font-bold text-slate-500 uppercase tracking-wider">
|
| 136 |
+
Email Address
|
| 137 |
+
</label>
|
| 138 |
+
<div className="relative">
|
| 139 |
+
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
| 140 |
+
<input
|
| 141 |
+
id="login-email"
|
| 142 |
+
type="email"
|
| 143 |
+
required
|
| 144 |
+
placeholder="admin@netraid.ai"
|
| 145 |
+
value={email}
|
| 146 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 147 |
+
className="input-field pl-icon h-11 bg-white border-slate-200 text-slate-900 rounded-xl focus:border-slate-800"
|
| 148 |
+
autoComplete="email"
|
| 149 |
+
suppressHydrationWarning
|
| 150 |
+
/>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{/* Password */}
|
| 155 |
+
<div className="space-y-1.5">
|
| 156 |
+
<label className="block text-[10.5px] font-bold text-slate-500 uppercase tracking-wider">
|
| 157 |
+
Password
|
| 158 |
+
</label>
|
| 159 |
+
<div className="relative">
|
| 160 |
+
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
| 161 |
+
<input
|
| 162 |
+
id="login-password"
|
| 163 |
+
type={showPass ? "text" : "password"}
|
| 164 |
+
required
|
| 165 |
+
placeholder="••••••••"
|
| 166 |
+
value={password}
|
| 167 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 168 |
+
className="input-field pl-icon pr-icon h-11 bg-white border-slate-200 text-slate-900 rounded-xl focus:border-slate-800"
|
| 169 |
+
autoComplete="current-password"
|
| 170 |
+
suppressHydrationWarning
|
| 171 |
+
/>
|
| 172 |
+
<button
|
| 173 |
+
type="button"
|
| 174 |
+
onClick={() => setShowPass(!showPass)}
|
| 175 |
+
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
| 176 |
+
suppressHydrationWarning
|
| 177 |
+
>
|
| 178 |
+
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 179 |
+
</button>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
{/* Submit */}
|
| 184 |
+
<button
|
| 185 |
+
id="login-submit"
|
| 186 |
+
type="submit"
|
| 187 |
+
disabled={loading}
|
| 188 |
+
className="btn-primary w-full h-11 flex items-center justify-center gap-2 mt-2 rounded-xl bg-slate-900 text-white border-slate-900 hover:bg-slate-800 cursor-pointer shadow-sm"
|
| 189 |
+
suppressHydrationWarning
|
| 190 |
+
>
|
| 191 |
+
{loading ? (
|
| 192 |
+
<>
|
| 193 |
+
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 194 |
+
<span>Authenticating...</span>
|
| 195 |
+
</>
|
| 196 |
+
) : (
|
| 197 |
+
<>
|
| 198 |
+
<span>Sign In</span>
|
| 199 |
+
<ArrowRight className="w-4 h-4" />
|
| 200 |
+
</>
|
| 201 |
+
)}
|
| 202 |
+
</button>
|
| 203 |
+
</form>
|
| 204 |
+
|
| 205 |
+
{/* Default credentials hint */}
|
| 206 |
+
<div className="mt-5 pt-5 border-t border-slate-100">
|
| 207 |
+
<p className="text-[10px] text-slate-500 text-center font-mono">
|
| 208 |
+
Default: admin@netraid.ai / Admin@NetraID2026
|
| 209 |
+
</p>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
{/* Kiosk link */}
|
| 214 |
+
<div className="mt-5 text-center">
|
| 215 |
+
<a
|
| 216 |
+
href="/kiosk"
|
| 217 |
+
target="_blank"
|
| 218 |
+
rel="noopener noreferrer"
|
| 219 |
+
className="inline-flex items-center gap-2 text-[12px] text-slate-500 hover:text-slate-800 transition-colors"
|
| 220 |
+
>
|
| 221 |
+
<ExternalLink className="w-3.5 h-3.5" />
|
| 222 |
+
<span>Open Kiosk Terminal</span>
|
| 223 |
+
</a>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
{/* Footer */}
|
| 227 |
+
<p className="text-center text-[10px] text-slate-400 mt-6 font-mono">
|
| 228 |
+
NetraID v1.0.0 · Open Source · MIT License
|
| 229 |
+
</p>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
);
|
| 233 |
+
}
|