diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..7b19d3a0309c9bbb6cb8d06d3c10a718244ddfc9 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,82 @@ +name: Backend CI + +on: + push: + branches: + - main + - master + paths: + - 'backend/**' + - '.github/workflows/backend-ci.yml' + pull_request: + branches: + - main + - master + paths: + - 'backend/**' + - '.github/workflows/backend-ci.yml' + +jobs: + lint-and-test: + name: Lint & Verify Backend + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: backend/requirements.txt + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install System Dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential libpq-dev + + - name: Install Python Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install ruff + + - name: Generate Prisma Client + run: | + python -m prisma generate + + - name: Run Ruff Linter + run: | + ruff check app/ main.py + + docker-build: + name: Verify Docker Build + runs-on: ubuntu-latest + needs: lint-and-test + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker Image + uses: docker/build-push-action@v5 + with: + context: backend + file: backend/Dockerfile + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d8af29bf97248f5c7baf735333426c3799e49fd --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,46 @@ +name: Frontend CI + +on: + push: + branches: + - main + - master + paths: + - 'frontend/**' + - '.github/workflows/frontend-ci.yml' + pull_request: + branches: + - main + - master + paths: + - 'frontend/**' + - '.github/workflows/frontend-ci.yml' + +jobs: + build: + name: Lint & Build Frontend + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Dependencies + run: npm ci + + - name: Run Linter + run: npm run lint + + - name: Build Application + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5dcae1970d63cd9b1162338f325a2a945701c09c --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# OS Specific +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# IDEs and Editors +.vscode/ +!.vscode/extensions.json +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.swp +*.swo +*~ +.project +.classpath +.cproject +.settings/ + +# Logs +logs/ +*.log + +# Local/Private +.env +.env.local +.env.*.local +.env* diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ec642036cf850acb70b1e4c0e8fba7efe374e28a --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# Smart Attendance System + +An asynchronous, AI-powered multi-layered attendance verification system designed to eliminate buddy punching, proxy check-ins, and attendance fraud. The project consists of a FastAPI backend using TensorFlow/DeepFace, a Next.js web application for administration/teachers, and a Flutter mobile application for students. + +--- + +## 🚀 Key Features + +### 1. Multi-Layered AI Verification +To mark attendance, the student uploads a live selfie which undergoes three independent stages of verification: +- **Facial Recognition**: Matches the student's live face embedding against their registered template using the **FaceNet** model (via **DeepFace**) with **128-dimensional** vector embeddings stored in PostgreSQL using `pgvector`. +- **Liveness Detection**: Employs a custom-trained **MobileNetV2** model to check if the submission is a real person, preventing spoofing attempts using photos, videos, or masks. +- **Background Validation**: Utilizes a custom **MobileNetV1** model to verify that the background of the image matches the expected classroom environment. + +### 2. Location & Geofencing +- Verifies student's physical location against active class coordinates. +- Teachers define geofenced regions (latitude, longitude, and radius in meters). +- Submissions outside the geofence boundary are automatically flagged or rejected. + +### 3. Device Binding (Anti-Proxy) +- Restricts each student account to a single mobile device. +- Generates and binds a unique hardware UUID (`device_uuid`) on first login. +- Students must submit a **Device Change Request** to be approved by administrators/teachers before they can log in on a new device. + +### 4. Real-time Communication & Notifications +- Websocket-based live connection to push real-time attendance updates to teachers' dashboards. +- Firebase Cloud Messaging (FCM) integration to dispatch push notifications for new sessions, reminders, and leave status updates. + +### 5. Gamification Suite +- Encourages student attendance through engagement features including current/highest streaks, levels, leaderboards, and point systems. + +--- + +## 🛠️ Technology Stack & Versions + +| Layer | Technology | Version / Specification | Key Libraries | +| :--- | :--- | :--- | :--- | +| **Backend** | Python 3.11 / FastAPI | `0.115.6` | Prisma ORM, TensorFlow `2.15.0`, DeepFace `0.0.93`, OpenCV `4.10.0`, Redis `5.2.1` | +| **Frontend** | Next.js (React 19) | `16.2.6` | Tailwind CSS `4.x`, Recharts `3.8.1`, Zustand `5.0.13`, Leaflet Map `1.9.4` | +| **Mobile** | Flutter SDK | `^3.8.0` | Riverpod `^2.6.1`, Dio `^5.7.0`, Geolocator `^13.0.2`, Hive `^2.2.3` | +| **Database** | PostgreSQL | 15+ | `pgvector` extension enabled for biometric representations | + +--- + +## 📂 Project Structure + +``` +. +├── backend/ # FastAPI python application, database migrations, and AI models +│ ├── app/ # Application source code (api, core, db, middleware, services, etc.) +│ ├── models/ # Local folder for downloading/caching TF models +│ ├── prisma/ # Prisma schema and seeding configurations +│ └── main.py # App entrypoint +├── frontend/ # Next.js web application for admins and teachers +│ ├── src/ # Next.js pages/components +│ └── package.json # Frontend dependency definitions +└── mobile/ # Flutter student companion app + ├── lib/ # Flutter implementation source code + └── pubspec.yaml # Flutter dependency configuration +``` + +--- + +## ⚙️ Getting Started + +### Prerequisites +1. **Python 3.11** installed on the host system. +2. **Node.js 20+** and **npm** installed. +3. **Flutter SDK (v3.8.x+)** and target development environment (Android/iOS simulator or physical device). +4. **PostgreSQL** database with `pgvector` extension enabled. +5. **Redis Server** running locally or accessible via network. +6. A **HuggingFace** token (`HF_TOKEN`) to download pre-trained liveness & background models. + +--- + +### 1. Backend Setup + +1. **Navigate to the directory**: + ```bash + cd backend + ``` + +2. **Configure environment variables**: + Create a `.env` file by copying the template: + ```bash + cp .env.example .env + ``` + Fill in the required fields (database connection strings, Redis URL, JWT Secret, and HF token if required). + +3. **Install dependencies**: + ```bash + pip install -r requirements.txt + ``` + +4. **Prepare the database (Prisma)**: + Ensure your PostgreSQL service is running and has the `pgvector` extension enabled, then run: + ```bash + python -m prisma db push + python -m prisma generate + ``` + +5. **Seed the database (Optional)**: + ```bash + python prisma/seed.py + ``` + +6. **Start the server**: + ```bash + uvicorn main:app --reload --port 8000 + ``` + Interactive API documentation will be available at [http://localhost:8000/docs](http://localhost:8000/docs). + +--- + +### 2. Frontend Setup + +1. **Navigate to the directory**: + ```bash + cd frontend + ``` + +2. **Configure environment variables**: + Ensure a `.env.local` file exists: + ```env + NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 + ``` + +3. **Install dependencies**: + ```bash + npm install + ``` + +4. **Start the development server**: + ```bash + npm run dev + ``` + The dashboard will be running at [http://localhost:3000](http://localhost:3000). + +--- + +### 3. Mobile Setup + +1. **Navigate to the directory**: + ```bash + cd mobile + ``` + +2. **Get Flutter packages**: + ```bash + flutter pub get + ``` + +3. **Run the application**: + Make sure you have an active emulator or connected device: + ```bash + flutter run + ``` + +--- + +## 🔒 Security & Verification Parameters +The verification strictness can be controlled globally via the administrator settings page or in `.env`: +* **Face Embedding matching threshold**: Standard threshold is configured to `0.75` (cosine similarity/confidence score). +* **Liveness Detection threshold**: Values above `0.5` denote real face image inputs. +* **Geofencing validation**: Distance calculated dynamically using the Haversine formula based on student's GPS reports and active class geofence boundaries. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..a1ed14fb865ef38df82f91d4ac370533166c02b1 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,23 @@ +# Project Settings +PROJECT_NAME="Smart Attendance System API" +API_V1_STR="/api/v1" + +# Database Configuration +# Replace with actual PostgreSQL connection credentials +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/smart_attendance?schema=public" + +# JWT Security +# Generate a secure secret using: openssl rand -hex 32 +JWT_SECRET="supersecretkeychangeinproduction" +JWT_ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# Redis Cache +REDIS_URL="redis://localhost:6379/0" + +# AI Thresholds +PASS_THRESHOLD=0.75 + +# Environment (development / production) +ENVIRONMENT="development" + diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c0907d44c68b429a91617b92a0700431a2c570d1 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,66 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.flakes8 +.installed.cfg +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environments +.venv/ +venv/ +ENV/ + +# Environment variables +.env +.env.* +!.env.example + +# Firebase & Secrets +firebase-credentials.json +google-services.json + +# Prisma +prisma/client/ +*.db +*.sqlite3 + +# Logs +logs/ +*.log + +# Project specific +static/ +uploads/ +!static/.gitkeep +!uploads/.gitkeep + +# AI/ML +*.h5 +*.pb +*.pt +*.pth +*.pkl +*.joblib +*.onnx + +# Pytest +.pytest_cache/ +.coverage +htmlcov/ diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..902b2c90c86bce733594862f9a5893c7315b6441 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.11 \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3ca1ed26047c4048e9f362165a00b39ecb8484cc --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,13 @@ +import sys + +try: + import tf_keras + import tensorflow as tf + import keras._tf_keras.keras.layers as compatibility_layers + + compatibility_layers.LocallyConnected2D = tf_keras.layers.LocallyConnected2D + sys.modules["tensorflow.keras.layers.LocallyConnected2D"] = tf_keras.layers.LocallyConnected2D + tf.keras.layers.LocallyConnected2D = tf_keras.layers.LocallyConnected2D +except Exception: + pass + diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..e816d1873f1d5386fe73ce0e3fd90d7af40ca47b --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,388 @@ +from functools import wraps + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from prisma.models import User + +from app.api.dependencies import RoleChecker, get_current_user +from app.db.client import db +from app.repositories.attendance_repo import AttendanceRepository +from app.schemas.student import StudentCreate, StudentResponse, StudentUpdate +from app.schemas.teacher import TeacherCreate, TeacherResponse, TeacherUpdate +from app.schemas.admin import ( + ClassCreate, ClassUpdate, ClassResponse, AssignTeacherRequest, EnrollRequest, + DepartmentCreate, DepartmentUpdate, DepartmentResponse, + AuditLogResponse, AdminStatsResponse, AdminResetPasswordRequest, +) +from app.schemas.master_data import ( + SubjectCreate, SubjectUpdate, SubjectResponse, + ClassroomCreate, ClassroomUpdate, ClassroomResponse, + DesignationCreate, DesignationUpdate, DesignationResponse, +) +from app.services.admin_service import AdminService +from app.services.absentee_scanner import run_absentee_scan + +admin_protection = Depends(RoleChecker(allowed_roles=["ADMIN"])) +router = APIRouter(prefix="/admin", tags=["Admin System Operations"], dependencies=[admin_protection]) + + +def _get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _handle_value_err(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ValueError as err: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(err)) + except Exception as err: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) + return wrapper + + +def _handle_generic_err(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as err: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) + return wrapper + + +@router.post("/users/student", response_model=StudentResponse, status_code=status.HTTP_201_CREATED) +@_handle_generic_err +async def create_student(data: StudentCreate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + return await admin_service.create_student(data, actor=current_user.email, ip=_get_client_ip(request)) + + +@router.post("/users/teacher", response_model=TeacherResponse, status_code=status.HTTP_201_CREATED) +@_handle_generic_err +async def create_teacher(data: TeacherCreate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + return await admin_service.create_teacher(data, actor=current_user.email, ip=_get_client_ip(request)) + + +@router.post("/classes", response_model=ClassResponse, status_code=status.HTTP_201_CREATED) +@_handle_value_err +async def create_class(data: ClassCreate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + return await admin_service.create_class(data, actor=current_user.email, ip=_get_client_ip(request)) + + +@router.put("/classes/{class_id}/assign-teacher", response_model=ClassResponse) +@_handle_value_err +async def assign_teacher(class_id: str, data: AssignTeacherRequest, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + return await admin_service.assign_teacher(class_id=class_id, teacher_id=data.teacher_id, actor=current_user.email, ip=_get_client_ip(request)) + + +@router.post("/classes/{class_id}/enroll", status_code=status.HTTP_200_OK) +@_handle_value_err +async def enroll_students(class_id: str, data: EnrollRequest, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()) -> dict: + enrolled_count = await admin_service.enroll_students(class_id=class_id, student_ids=data.student_ids, actor=current_user.email, ip=_get_client_ip(request)) + return {"status": "success", "enrolled_count": enrolled_count} + + +@router.get("/users/students", response_model=list[StudentResponse]) +async def get_students(admin_service: AdminService = Depends()): + return await admin_service.get_all_students() + + +@router.get("/users/students/{id}", response_model=StudentResponse) +async def get_student_by_id(id: str, admin_service: AdminService = Depends()): + student = await db.student.find_unique(where={"id": id}, include={"user": True, "department": True}) + if not student: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student not found") + return StudentResponse( + id=student.id, user_id=student.userId, enrollment_number=student.enrollmentNumber, + email=student.user.email if student.user else "", first_name=student.firstName, + last_name=student.lastName, phone=student.phone, gender=student.gender, + date_of_birth=student.dateOfBirth, department_id=student.departmentId, + department_name=student.department.name if student.department else None, + semester=student.semester, batch=student.batch, + ) + + +@router.put("/users/students/{id}", response_model=StudentResponse) +@_handle_generic_err +async def update_student(id: str, data: StudentUpdate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + return await admin_service.update_student(id, data.model_dump(exclude_unset=True), actor=current_user.email, ip=_get_client_ip(request)) + + + +@router.get("/users/teachers", response_model=list[TeacherResponse]) +async def get_teachers(admin_service: AdminService = Depends()): + return await admin_service.get_all_teachers() + + +@router.get("/users/teachers/{id}", response_model=TeacherResponse) +async def get_teacher_by_id(id: str): + teacher = await db.teacher.find_unique(where={"id": id}, include={"user": True, "department": True, "designation": True}) + if not teacher: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Teacher not found") + return TeacherResponse( + id=teacher.id, user_id=teacher.userId, email=teacher.user.email if teacher.user else "", + employee_id=teacher.employeeId, first_name=teacher.firstName, last_name=teacher.lastName, + department_id=teacher.departmentId, designation_id=teacher.designationId, + department=teacher.department.name if teacher.department else "", + designation=teacher.designation.name if teacher.designation else "", + phone=teacher.phone, qualification=teacher.qualification, specialization=teacher.specialization, + experience_years=teacher.experienceYears, joining_date=teacher.joiningDate, + ) + + +@router.put("/users/teachers/{id}", response_model=TeacherResponse) +@_handle_generic_err +async def update_teacher(id: str, data: TeacherUpdate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + return await admin_service.update_teacher(id, data.model_dump(exclude_unset=True), actor=current_user.email, ip=_get_client_ip(request)) + + +@router.put("/users/{user_id}/reset-password") +async def reset_user_password(user_id: str, data: AdminResetPasswordRequest, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + try: + await admin_service.reset_user_password(user_id, data.new_password, actor=current_user.email, ip=_get_client_ip(request)) + return {"status": "success", "message": "Password updated successfully"} + except ValueError as err: + raise HTTPException(status_code=404, detail=str(err)) + except Exception as err: + raise HTTPException(status_code=400, detail=str(err)) + + +@router.get("/classes", response_model=list[ClassResponse]) +async def get_classes(admin_service: AdminService = Depends()): + return await admin_service.get_all_classes() + + +@router.get("/classes/{class_id}", response_model=ClassResponse) +async def get_class_by_id(class_id: str, admin_service: AdminService = Depends()): + cls = await db.academicclass.find_unique(where={"id": class_id}, include={"subject": True, "classroom": True, "enrollments": True}) + if not cls: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Class not found") + return ClassResponse( + id=cls.id, name=cls.name, subject_name=cls.subject.name if cls.subject else "", + subject_code=cls.subject.code if cls.subject else "", teacherId=cls.teacherId, + classroom_name=cls.classroom.name if cls.classroom else None, + semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents, + enrolled_count=len(cls.enrollments) if cls.enrollments else 0, + enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [], + ) + + +@router.put("/classes/{class_id}", response_model=ClassResponse) +@_handle_generic_err +async def update_class(class_id: str, data: ClassUpdate, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + return await admin_service.update_class(class_id, data.model_dump(exclude_unset=True), actor=current_user.email, ip=_get_client_ip(request)) + + +# --- Departments --- +@router.get("/departments", response_model=list[DepartmentResponse]) +async def get_departments(admin_service: AdminService = Depends()): + return await admin_service.get_all_departments() + + +@router.get("/departments/{id}", response_model=DepartmentResponse) +async def get_department(id: str, admin_service: AdminService = Depends()): + dept = await admin_service.get_department_by_id(id) + if not dept: + raise HTTPException(status_code=404, detail="Department not found") + return dept + + +@router.post("/departments", response_model=DepartmentResponse) +@_handle_generic_err +async def create_department(data: DepartmentCreate, admin_service: AdminService = Depends()): + return await admin_service.create_department(data.name, data.code, data.head, data.description) + + +@router.put("/departments/{id}", response_model=DepartmentResponse) +@_handle_generic_err +async def update_department(id: str, data: DepartmentUpdate, admin_service: AdminService = Depends()): + return await admin_service.update_department(id, data.model_dump(exclude_unset=True)) + + +@router.delete("/departments/{id}") +async def delete_department(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + try: + await admin_service.delete_department(id, actor=current_user.email, ip=_get_client_ip(request)) + return {"status": "success"} + except ValueError as err: + raise HTTPException(status_code=400, detail=str(err)) + except Exception: + raise HTTPException(status_code=500, detail="Internal server error") + + +# --- Subjects --- +@router.get("/subjects", response_model=list[SubjectResponse]) +async def get_subjects(admin_service: AdminService = Depends()): + return await admin_service.get_all_subjects() + + +@router.get("/subjects/{id}", response_model=SubjectResponse) +async def get_subject(id: str, admin_service: AdminService = Depends()): + sub = await admin_service.get_subject_by_id(id) + if not sub: + raise HTTPException(status_code=404, detail="Subject not found") + return sub + + +@router.post("/subjects", response_model=SubjectResponse) +@_handle_generic_err +async def create_subject(data: SubjectCreate, admin_service: AdminService = Depends()): + return await admin_service.create_subject(data.name, data.code, data.description) + + +@router.put("/subjects/{id}", response_model=SubjectResponse) +@_handle_generic_err +async def update_subject(id: str, data: SubjectUpdate, admin_service: AdminService = Depends()): + return await admin_service.update_subject(id, data.model_dump(exclude_unset=True)) + + +@router.delete("/subjects/{id}") +async def delete_subject(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + try: + await admin_service.delete_subject(id, actor=current_user.email, ip=_get_client_ip(request)) + return {"status": "success"} + except ValueError as err: + raise HTTPException(status_code=400, detail=str(err)) + except Exception: + raise HTTPException(status_code=500, detail="Internal server error") + + +# --- Classrooms --- +@router.get("/classrooms", response_model=list[ClassroomResponse]) +async def get_classrooms(admin_service: AdminService = Depends()): + return await admin_service.get_all_classrooms() + + +@router.get("/classrooms/{id}", response_model=ClassroomResponse) +async def get_classroom(id: str, admin_service: AdminService = Depends()): + classroom = await admin_service.get_classroom_by_id(id) + if not classroom: + raise HTTPException(status_code=404, detail="Classroom not found") + return classroom + + +@router.post("/classrooms", response_model=ClassroomResponse) +@_handle_generic_err +async def create_classroom(data: ClassroomCreate, admin_service: AdminService = Depends()): + return await admin_service.create_classroom(data.name, data.building, data.capacity) + + +@router.put("/classrooms/{id}", response_model=ClassroomResponse) +@_handle_generic_err +async def update_classroom(id: str, data: ClassroomUpdate, admin_service: AdminService = Depends()): + return await admin_service.update_classroom(id, data.model_dump(exclude_unset=True)) + + +@router.delete("/classrooms/{id}") +async def delete_classroom(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + try: + await admin_service.delete_classroom(id, actor=current_user.email, ip=_get_client_ip(request)) + return {"status": "success"} + except ValueError as err: + raise HTTPException(status_code=400, detail=str(err)) + except Exception: + raise HTTPException(status_code=500, detail="Internal server error") + + +# --- Designations --- +@router.get("/designations", response_model=list[DesignationResponse]) +async def get_designations(admin_service: AdminService = Depends()): + return await admin_service.get_all_designations() + + +@router.get("/designations/{id}", response_model=DesignationResponse) +async def get_designation(id: str, admin_service: AdminService = Depends()): + desig = await admin_service.get_designation_by_id(id) + if not desig: + raise HTTPException(status_code=404, detail="Designation not found") + return desig + + +@router.post("/designations", response_model=DesignationResponse) +@_handle_generic_err +async def create_designation(data: DesignationCreate, admin_service: AdminService = Depends()): + return await admin_service.create_designation(data.name, data.code, data.description) + + +@router.put("/designations/{id}", response_model=DesignationResponse) +@_handle_generic_err +async def update_designation(id: str, data: DesignationUpdate, admin_service: AdminService = Depends()): + return await admin_service.update_designation(id, data.model_dump(exclude_unset=True)) + + +@router.delete("/designations/{id}") +async def delete_designation(id: str, request: Request, current_user: User = Depends(get_current_user), admin_service: AdminService = Depends()): + try: + await admin_service.delete_designation(id, actor=current_user.email, ip=_get_client_ip(request)) + return {"status": "success"} + except ValueError as err: + raise HTTPException(status_code=400, detail=str(err)) + except Exception: + raise HTTPException(status_code=500, detail="Internal server error") + + +# --- Misc Admin --- +@router.get("/audit", response_model=list[AuditLogResponse]) +async def get_audit_logs(admin_service: AdminService = Depends()): + return await admin_service.get_audit_logs() + + +@router.get("/stats", response_model=AdminStatsResponse) +async def get_admin_stats(admin_service: AdminService = Depends()): + return await admin_service.get_stats() + + +@router.post("/scan-absentees", status_code=status.HTTP_200_OK) +async def scan_absentee_anomalies( + contamination: float = 0.10, + attendance_repo: AttendanceRepository = Depends(), +) -> list[dict]: + records = await attendance_repo.get_all_absences() + if not records or len(records) < 5: + return [] + + student_map = {} + for r in records: + if r.student: + first_name = r.student.firstName or "" + last_name = r.student.lastName or "" + full_name = f"{first_name} {last_name}".strip() or "Unknown Student" + student_map[r.studentId] = { + "student_name": full_name, + "enrollment_number": r.student.enrollmentNumber, + } + + rows = [{"student_id": r.studentId, "status": r.status, "day_of_week": r.createdAt.strftime("%A")} for r in records] + try: + flagged = await run_absentee_scan(attendance_records=rows, contamination=contamination) + for item in flagged: + s_id = item.get("student_id") + s_info = student_map.get(s_id, {}) + item["student_name"] = s_info.get("student_name", "Unknown Student") + item["enrollment_number"] = s_info.get("enrollment_number", "N/A") + return flagged + except Exception as err: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Outlier pattern extraction failed: {str(err)}") + + +# --- System Config --- +from app.schemas.system_config import SystemConfigResponse, SystemConfigUpdate +from app.services.system_config_service import SystemConfigService + +@router.get("/config", response_model=SystemConfigResponse) +async def get_system_config(config_service: SystemConfigService = Depends()): + return await config_service.get_config() + + +@router.patch("/config", response_model=SystemConfigResponse) +async def update_system_config(data: SystemConfigUpdate, config_service: SystemConfigService = Depends()): + return await config_service.update_config( + is_face_recognition_enabled=data.is_face_recognition_enabled, + is_gps_verification_enabled=data.is_gps_verification_enabled, + is_ai_background_validation_enabled=data.is_ai_background_validation_enabled, + ) + + diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..be0722daa77e840f8a92537b1801e3351f82bcf9 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,140 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from prisma.models import User + +from app.api.dependencies import get_current_user, reusable_oauth2 +from app.core.config import settings +from app.core.logging_config import get_logger +from app.core.security import decode_access_token +from app.db.client import db +from app.db.redis import get_redis +from app.repositories.student_repo import StudentRepository +from app.schemas.auth import Token, UserLogin, UserProfileResponse, DeviceChangeRequestCreate +from app.schemas.student import StudentCreate, StudentResponse +from app.schemas.teacher import TeacherCreate, TeacherResponse +from app.services.auth_service import AuthService +from app.services.device_change_service import DeviceChangeService + +logger = get_logger("app.api.auth") + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + +_RATE_LIMIT_WINDOW = 60 +_RATE_LIMIT_MAX = 10 + + +async def _rate_limit(request: Request) -> None: + if settings.ENVIRONMENT == "development": + return + forwarded = request.headers.get("X-Forwarded-For") + ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "unknown") + key = f"ratelimit:auth:{ip}" + r = await get_redis() + count = await r.incr(key) + if count == 1: + await r.expire(key, _RATE_LIMIT_WINDOW) + if count > _RATE_LIMIT_MAX: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests. Please try again later.") + + +@router.post("/login", response_model=Token) +async def login(login_data: UserLogin, request: Request, auth_service: AuthService = Depends()) -> Token: + await _rate_limit(request) + token = await auth_service.authenticate(login_data) + if not token: + logger.warning("Failed login: %s", login_data.email) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password") + return token + + +@router.post("/register/student", response_model=StudentResponse, status_code=status.HTTP_201_CREATED) +async def register_student(data: StudentCreate, request: Request, auth_service: AuthService = Depends()) -> StudentResponse: + await _rate_limit(request) + student = await auth_service.register_student(data) + if not student: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email is already registered") + return student + + +@router.post("/register/teacher", response_model=TeacherResponse, status_code=status.HTTP_201_CREATED) +async def register_teacher(data: TeacherCreate, request: Request, auth_service: AuthService = Depends()) -> TeacherResponse: + await _rate_limit(request) + teacher = await auth_service.register_teacher(data) + if not teacher: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email is already registered") + return teacher + + +@router.get("/me", response_model=UserProfileResponse) +async def get_me(current_user: User = Depends(get_current_user)) -> UserProfileResponse: + student_profile = None + teacher_profile = None + + if current_user.role == "STUDENT": + student = await db.student.find_unique(where={"userId": current_user.id}) + if student: + embedding = await StudentRepository().get_face_embedding(student.id) + student_profile = { + "id": student.id, + "enrollment_number": student.enrollmentNumber, + "first_name": student.firstName, + "last_name": student.lastName, + "face_registered": embedding is not None and len(embedding) > 0, + } + elif current_user.role == "TEACHER": + teacher = await db.teacher.find_unique(where={"userId": current_user.id}) + if teacher: + teacher_profile = { + "id": teacher.id, + "department": teacher.department.name if teacher.department else "", + "designation": teacher.designation.name if teacher.designation else "", + "employee_id": teacher.employeeId, + "first_name": teacher.firstName, + "last_name": teacher.lastName, + } + + return UserProfileResponse( + id=current_user.id, + email=current_user.email, + role=current_user.role, + is_active=current_user.isActive, + student_profile=student_profile, + teacher_profile=teacher_profile, + ) + + + +@router.post("/logout", status_code=status.HTTP_200_OK) +async def logout(token: str = Depends(reusable_oauth2)) -> dict: + payload = decode_access_token(token) + if payload: + exp = payload.get("exp") + if exp: + ttl = exp - int(datetime.now(timezone.utc).timestamp()) + if ttl > 0: + try: + await get_redis().setex(f"denylist:{token}", ttl, "revoked") + logger.info("Token revoked: user=%s", payload.get("sub")) + except Exception as cache_err: + logger.warning("Failed to add token to Redis denylist: %s", cache_err) + return {"status": "success", "message": "Successfully logged out."} +@router.post("/request-device-change", status_code=status.HTTP_200_OK) +async def request_device_change( + data: DeviceChangeRequestCreate, + request: Request, + device_change_service: DeviceChangeService = Depends(), +) -> dict: + await _rate_limit(request) + await device_change_service.request_device_change(data) + return {"status": "success", "message": "Device change request submitted successfully."} + + +# --- System Config (Public) --- +from app.schemas.system_config import SystemConfigResponse +from app.services.system_config_service import SystemConfigService + +@router.get("/config", response_model=SystemConfigResponse) +async def get_public_system_config(config_service: SystemConfigService = Depends()): + return await config_service.get_config() + diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..62e6cb0c77a34ce2fccef182c455735d273b7168 --- /dev/null +++ b/backend/app/api/dependencies.py @@ -0,0 +1,108 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from prisma.models import User, Student, Teacher + +from app.core.logging_config import get_logger +from app.core.security import decode_access_token +from app.repositories.user_repo import UserRepository +from app.repositories.student_repo import StudentRepository +from app.repositories.teacher_repo import TeacherRepository + +logger = get_logger("app.auth") + +reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +_UNAUTHORIZED = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) +_FORBIDDEN = {"STUDENT": "Access forbidden: Students only", "TEACHER": "Access forbidden: Teachers only"} +_INACTIVE = "User account is inactive or disabled" + + +async def _check_token_revoked(token: str) -> None: + try: + from app.db.redis import get_redis + if await get_redis().get(f"denylist:{token}"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has been revoked", + headers={"WWW-Authenticate": "Bearer"}, + ) + except HTTPException: + raise + except Exception: + pass + + +async def _validate_token_payload(token: str) -> dict: + await _check_token_revoked(token) + payload = decode_access_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Subject not found in token", + ) + return payload + + +async def get_current_user(token: str = Depends(reusable_oauth2)) -> User: + payload = await _validate_token_payload(token) + user_id = payload.get("sub") + user = await UserRepository().get_by_id(user_id) + if not user or not user.isActive: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=_INACTIVE, + ) + return user + + +async def get_current_student(current_user: User = Depends(get_current_user)) -> Student: + if current_user.role != "STUDENT": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_FORBIDDEN["STUDENT"]) + student = await StudentRepository().get_by_user_id(current_user.id) + if not student: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student profile not found") + return student + + +async def get_current_teacher(current_user: User = Depends(get_current_user)) -> Teacher: + if current_user.role != "TEACHER": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_FORBIDDEN["TEACHER"]) + teacher = await TeacherRepository().get_by_user_id(current_user.id) + if not teacher: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Teacher profile not found") + return teacher + + +class RoleChecker: + def __init__(self, allowed_roles: list[str]) -> None: + self.allowed_roles = allowed_roles + + def __call__(self, current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in self.allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied: Insufficient permissions", + ) + return current_user + + +async def get_current_user_from_token(token: str) -> User: + await _check_token_revoked(token) + payload = decode_access_token(token) + if not payload: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials") + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Subject not found in token") + user = await UserRepository().get_by_id(user_id) + if not user or not user.isActive: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=_INACTIVE) + return user + diff --git a/backend/app/api/logs.py b/backend/app/api/logs.py new file mode 100644 index 0000000000000000000000000000000000000000..98bff79d537edaf39a71ec7c094d22756e17e801 --- /dev/null +++ b/backend/app/api/logs.py @@ -0,0 +1,20 @@ +import logging + +from fastapi import APIRouter, status + +from app.core.logging_config import get_logger +from app.schemas.log import LogEvent + +router = APIRouter(prefix="/logs", tags=["Logging"]) + +logger = get_logger("app.client") + + +@router.post("", status_code=status.HTTP_200_OK) +async def ingest_log(log_event: LogEvent) -> dict: + logger.log( + getattr(logging, log_event.level.upper(), logging.INFO), + "[%s] [%s] %s", + log_event.source, log_event.timestamp, log_event.message, + ) + return {"status": "success"} diff --git a/backend/app/api/student.py b/backend/app/api/student.py new file mode 100644 index 0000000000000000000000000000000000000000..cc48d429597d570b8427c89129d45b1334f1750b --- /dev/null +++ b/backend/app/api/student.py @@ -0,0 +1,308 @@ +import os +import uuid +import shutil +from datetime import datetime, timezone, date, timedelta + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status +from prisma.models import Student + +from app.api.dependencies import get_current_student +from app.core.config import settings +from app.core.logging_config import get_logger +from app.core.security import create_access_token +from app.db.client import db +from app.repositories.leave_repo import LeaveRepository + +from app.schemas.attendance import AttendanceMarkResponse, AttendanceAnalyzeResponse +from app.schemas.student import StudentAttendanceHistoryResponse, StudentClassResponse +from app.schemas.leave import LeaveRequestResponse, LeaveRequestListResponse +from app.services.attendance_service import AttendanceService, AttendanceSubmission +from app.services.student_service import StudentService +from app.services.gamification_service import GamificationService + +logger = get_logger("app.api.student") + +router = APIRouter(prefix="/student", tags=["Student Features"]) + +_MAX_IMAGE_SIZE = 5 * 1024 * 1024 +_VALID_IMAGE_TYPES = {"image/jpeg", "image/png", "image/jpg"} + + +def _save_uploaded_image(upload_file: UploadFile, folder: str) -> str: + target_dir = os.path.join(settings.UPLOAD_DIR, folder) + os.makedirs(target_dir, exist_ok=True) + ext = os.path.splitext(upload_file.filename or "")[1] or ".jpg" + target_path = os.path.join(target_dir, f"{uuid.uuid4()}{ext}") + with open(target_path, "wb") as buffer: + shutil.copyfileobj(upload_file.file, buffer) + return target_path + + + +def _validate_image(image: UploadFile) -> None: + if image.content_type not in _VALID_IMAGE_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Unsupported media type. Upload must be a valid JPEG or PNG image.", + ) + if image.size is not None and image.size > _MAX_IMAGE_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="Payload too large. Uploaded image cannot exceed 5MB.", + ) + + +def _make_leave_response(leave, student_name: str, enrollment_number: str = None) -> LeaveRequestResponse: + return LeaveRequestResponse( + id=leave.id, + student_id=leave.studentId, + student_name=student_name or "Unknown", + enrollment_number=enrollment_number or (leave.student.enrollmentNumber if leave.student else "N/A"), + start_date=leave.startDate, + end_date=leave.endDate, + reason=leave.reason, + document_url=leave.documentUrl, + status=leave.status, + approved_by=leave.approvedBy, + approver_note=leave.approverNote, + created_at=leave.createdAt, + updated_at=leave.updatedAt, + ) + + +def _student_name(student: Student) -> str: + if not student: + return "Unknown" + return f"{student.firstName or ''} {student.lastName or ''}".strip() + + +@router.post("/attendance/mark", response_model=AttendanceMarkResponse) +async def mark_attendance( + session_id: str = Form(...), + latitude: float = Form(...), + longitude: float = Form(...), + accuracy: float = Form(...), + image: UploadFile | None = File(None), + student: Student = Depends(get_current_student), + attendance_service: AttendanceService = Depends(), +) -> AttendanceMarkResponse: + image_path = None + if image: + _validate_image(image) + image_path = _save_uploaded_image(image, "attendance") + submission = AttendanceSubmission( + student_id=student.id, session_id=session_id, + latitude=latitude, longitude=longitude, accuracy=accuracy, image_path=image_path, + ) + try: + attendance = await attendance_service.mark_attendance(submission) + return AttendanceMarkResponse.model_validate(attendance) + except ValueError as err: + if image_path and os.path.exists(image_path): + os.remove(image_path) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) + + +@router.post("/attendance/analyze", response_model=AttendanceAnalyzeResponse) +async def analyze_attendance( + session_id: str = Form(...), + latitude: float = Form(...), + longitude: float = Form(...), + accuracy: float = Form(...), + image: UploadFile | None = File(None), + student: Student = Depends(get_current_student), + attendance_service: AttendanceService = Depends(), +) -> AttendanceAnalyzeResponse: + """Run AI scoring and validation without saving the attendance record. + + Returns scores and a short-lived review_token (5 min) that the student + can use to confirm submission via POST /attendance/confirm. + """ + image_path = None + if image: + _validate_image(image) + image_path = _save_uploaded_image(image, "attendance") + submission = AttendanceSubmission( + student_id=student.id, session_id=session_id, + latitude=latitude, longitude=longitude, accuracy=accuracy, image_path=image_path, + ) + try: + result = await attendance_service.analyze_attendance(submission) + return AttendanceAnalyzeResponse(**result) + except ValueError as err: + if image_path and os.path.exists(image_path): + os.remove(image_path) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) + + +@router.post("/attendance/confirm", response_model=AttendanceMarkResponse) +async def confirm_attendance( + review_token: str = Form(...), + student: Student = Depends(get_current_student), + attendance_service: AttendanceService = Depends(), +) -> AttendanceMarkResponse: + """Confirm a previously analyzed attendance submission. + + Accepts the review_token returned by POST /attendance/analyze and saves + the attendance record without re-running AI inference. + """ + try: + attendance = await attendance_service.confirm_attendance(student.id, review_token) + return AttendanceMarkResponse.model_validate(attendance) + except ValueError as err: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) + + +@router.post("/register-face", status_code=status.HTTP_200_OK) +async def register_face( + image: UploadFile = File(...), + student: Student = Depends(get_current_student), + attendance_service: AttendanceService = Depends(), +) -> dict: + _validate_image(image) + image_path = _save_uploaded_image(image, "registration") + try: + success = await attendance_service.register_face(student.id, image_path) + if not success: + raise ValueError("Could not extract a valid face from the image.") + return {"status": "success", "message": "Face embedding registered successfully."} + except ValueError as err: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)) + finally: + if os.path.exists(image_path): + os.remove(image_path) + + +@router.get("/my-attendance", response_model=StudentAttendanceHistoryResponse) +async def get_my_attendance( + student: Student = Depends(get_current_student), + student_service: StudentService = Depends(), +) -> StudentAttendanceHistoryResponse: + return await student_service.get_student_attendance_history(student.userId) + + +@router.get("/classes", response_model=list[StudentClassResponse]) +async def get_my_classes( + student: Student = Depends(get_current_student), + student_service: StudentService = Depends(), +) -> list[StudentClassResponse]: + return await student_service.get_student_classes(student.userId) + + +@router.post("/fcm-token", status_code=status.HTTP_200_OK) +async def register_fcm_token( + payload: dict, + student: Student = Depends(get_current_student), +) -> dict: + token = payload.get("token") + if not token: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="FCM token is required.") + await db.student.update(where={"id": student.id}, data={"fcmToken": token}) + return {"status": "success", "message": "FCM token registered."} + + +@router.post("/attendance/{attendance_id}/note", status_code=status.HTTP_200_OK) +async def submit_flagged_note( + attendance_id: str, + payload: dict, + student: Student = Depends(get_current_student), +) -> dict: + note = payload.get("note", "").strip() + if not note: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Note cannot be empty.") + if len(note) > 500: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Note cannot exceed 500 characters.") + record = await db.attendance.find_unique(where={"id": attendance_id}) + if not record or record.studentId != student.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attendance record not found.") + if record.status != "Flagged": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Notes can only be added to flagged records.") + await db.attendance.update(where={"id": attendance_id}, data={"studentNote": note}) + return {"status": "success", "message": "Note submitted successfully."} + + +@router.get("/leaves", response_model=LeaveRequestListResponse) +async def get_my_leaves( + student: Student = Depends(get_current_student), + leave_repo: LeaveRepository = Depends(), +) -> LeaveRequestListResponse: + leaves = await leave_repo.get_by_student_id(student.id) + leave_responses = [_make_leave_response(req, _student_name(req.student)) for req in leaves] + return LeaveRequestListResponse( + leaves=leave_responses, + total=len(leave_responses), + pending=sum(1 for req in leaves if req.status == "PENDING"), + approved=sum(1 for req in leaves if req.status == "APPROVED"), + rejected=sum(1 for req in leaves if req.status == "REJECTED"), + ) + + +@router.post("/leaves", response_model=LeaveRequestResponse, status_code=status.HTTP_201_CREATED) +async def create_leave_request( + start_date: str = Form(...), + end_date: str = Form(...), + reason: str = Form(...), + document: UploadFile = File(None), + student: Student = Depends(get_current_student), + leave_repo: LeaveRepository = Depends(), +) -> LeaveRequestResponse: + try: + s_date = date.fromisoformat(start_date) + e_date = date.fromisoformat(end_date) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date format. Use YYYY-MM-DD.") + if e_date < s_date: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="End date must be after or equal to start date.") + if len(reason) < 10 or len(reason) > 500: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reason must be between 10 and 500 characters.") + + document_url = None + if document and document.filename: + try: + upload_dir = "static/leaves" + os.makedirs(upload_dir, exist_ok=True) + ext = os.path.splitext(document.filename)[1] + filename = f"leave_{student.id}_{int(datetime.now().timestamp())}{ext}" + file_path = os.path.join(upload_dir, filename) + with open(file_path, "wb") as f: + f.write(await document.read()) + document_url = f"/static/leaves/{filename}" + except Exception: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not save leave document.") + + leave = await leave_repo.create({ + "studentId": student.id, + "startDate": datetime.combine(s_date, datetime.min.time()).replace(tzinfo=timezone.utc), + "endDate": datetime.combine(e_date, datetime.max.time()).replace(tzinfo=timezone.utc), + "reason": reason, + "documentUrl": document_url, + "status": "PENDING", + }) + return _make_leave_response(leave, _student_name(student), student.enrollmentNumber) + + +@router.get("/smart-pass", response_model=dict) +async def get_smart_pass(student: Student = Depends(get_current_student)) -> dict: + qr_token = create_access_token( + subject=student.userId, + role="STUDENT", + expires_delta=timedelta(seconds=30), + extra_data={"student_id": student.id, "enrollment_number": student.enrollmentNumber, "type": "smart_pass"}, + ) + return { + "qr_token": qr_token, + "expires_at": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), + "student_name": _student_name(student), + "enrollment_number": student.enrollmentNumber, + } + + +@router.get("/stats", response_model=dict) +async def get_my_stats(student: Student = Depends(get_current_student)) -> dict: + return await GamificationService().get_student_stats(student.id) + + +@router.get("/leaderboard", response_model=dict) +async def get_leaderboard(student: Student = Depends(get_current_student)) -> dict: + return await GamificationService().get_leaderboard(student.id) + diff --git a/backend/app/api/teacher.py b/backend/app/api/teacher.py new file mode 100644 index 0000000000000000000000000000000000000000..d7426d14936f1595abf49aede63aa04f5bed7e39 --- /dev/null +++ b/backend/app/api/teacher.py @@ -0,0 +1,247 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from prisma.models import Teacher + +from app.api.dependencies import get_current_teacher +from app.db.client import db +from app.repositories.attendance_repo import AttendanceRepository +from app.repositories.leave_repo import LeaveRepository +from app.schemas.teacher import ( + SessionResponse, SessionStart, GeofenceUpsert, GeofenceResponse, + AcademicClassWithGeofenceResponse, SessionAttendanceResponse, + ClassStatsResponse, AttendanceManualOverride, SessionWithClassResponse, + BulkMarkRequest, AbsentStudentItem, DeviceChangeResponse, DeviceChangeApprove +) +from app.schemas.attendance import AttendanceReview, FlaggedAttendanceResponse +from app.schemas.leave import LeaveRequestResponse, LeaveRequestApprove +from app.services.session_service import SessionService +from app.services.attendance_service import AttendanceService +from app.services.teacher_service import TeacherService +from app.services.leave_service import LeaveService +from app.services.device_change_service import DeviceChangeService + +router = APIRouter(prefix="/teacher", tags=["Teacher Features"]) + + +def _make_leave_response(leave) -> LeaveRequestResponse: + name = f"{leave.student.firstName or ''} {leave.student.lastName or ''}".strip() + return LeaveRequestResponse( + id=leave.id, student_id=leave.studentId, student_name=name or "Unknown", + enrollment_number=leave.student.enrollmentNumber, + start_date=leave.startDate, end_date=leave.endDate, reason=leave.reason, + document_url=leave.documentUrl, status=leave.status, approved_by=leave.approvedBy, + approver_note=leave.approverNote, created_at=leave.createdAt, updated_at=leave.updatedAt, + ) + + +@router.post("/sessions/start", response_model=SessionResponse) +async def start_session( + data: SessionStart, + teacher: Teacher = Depends(get_current_teacher), + session_service: SessionService = Depends(), +) -> SessionResponse: + session = await session_service.start_session(data, teacher.id) + if not session: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Could not open session. Class not found or unauthorized.") + return session + + +@router.post("/sessions/{id}/stop", status_code=status.HTTP_200_OK) +async def stop_session( + id: str, + teacher: Teacher = Depends(get_current_teacher), + session_service: SessionService = Depends(), +) -> dict: + if not await session_service.stop_session(id, teacher.id): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session not found, already stopped, or unauthorized.") + return {"status": "success", "message": "Session closed successfully."} + + +@router.get("/attendance/flagged", response_model=list[FlaggedAttendanceResponse]) +async def get_flagged_attendance( + teacher: Teacher = Depends(get_current_teacher), + attendance_repo: AttendanceRepository = Depends(), +) -> list[FlaggedAttendanceResponse]: + classes = await db.academicclass.find_many(where={"teacherId": teacher.id}) + my_class_ids = [c.id for c in classes] + records = await db.attendance.find_many( + where={"status": "Flagged", "session": {"is": {"academicClassId": {"in": my_class_ids}}}}, + include={ + "student": {"include": {"user": True}}, + "session": {"include": {"academicClass": {"include": {"subject": True}}}}, + }, + ) + return [ + FlaggedAttendanceResponse( + id=r.id, + enrollment_number=r.student.enrollmentNumber if r.student else "N/A", + student_name=( + f"{r.student.firstName or ''} {r.student.lastName or ''}".strip() + if r.student else "Unknown Student" + ), + class_name=ac.subject.name if (ac := r.session.academicClass if r.session else None) and ac.subject else (ac.name if ac else "N/A"), + subject=ac.subject.name if ac and ac.subject else (ac.name if ac else "N/A"), + face_score=r.faceScore, liveness_score=r.livenessScore, + background_score=r.backgroundScore, final_ai_score=r.finalAiScore, + gps_latitude=r.gpsLatitude, gps_longitude=r.gpsLongitude, + created_at=r.createdAt, + student_note=r.studentNote, + ) + for r in records + ] + + +@router.get("/attendance/{id}", response_model=FlaggedAttendanceResponse) +async def get_attendance_by_id(id: str, attendance_repo: AttendanceRepository = Depends()): + r = await attendance_repo.get_by_id(id) + if not r: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attendance record not found") + ac = r.session.academicClass if r.session else None + return FlaggedAttendanceResponse( + id=r.id, enrollment_number=r.student.enrollmentNumber if r.student else "N/A", + student_name=(f"{r.student.firstName or ''} {r.student.lastName or ''}".strip() if r.student else "Unknown Student"), + class_name=ac.name if ac else "N/A", subject=ac.subject.name if ac and ac.subject else (ac.name if ac else "N/A"), + face_score=r.faceScore, liveness_score=r.livenessScore, + background_score=r.backgroundScore, final_ai_score=r.finalAiScore, + gps_latitude=r.gpsLatitude, gps_longitude=r.gpsLongitude, created_at=r.createdAt, + student_note=r.studentNote, + ) + + +@router.put("/attendance/{id}/review", status_code=status.HTTP_200_OK) +async def review_flagged_attendance( + id: str, + review: AttendanceReview, + teacher: Teacher = Depends(get_current_teacher), + attendance_service: AttendanceService = Depends(), +) -> dict: + if not await attendance_service.review_attendance(attendance_id=id, status=review.status, remarks=review.remarks): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Attendance record not found, is not currently flagged, or review failed.") + return {"status": "success", "message": f"Attendance record has been {review.status}."} + + +@router.get("/my-classes", response_model=list[AcademicClassWithGeofenceResponse]) +async def get_my_classes( + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> list[AcademicClassWithGeofenceResponse]: + return await teacher_service.get_classes_by_teacher_user_id(teacher.userId) + + +@router.post("/classes/{class_id}/geofence", response_model=GeofenceResponse) +async def upsert_geofence( + class_id: str, data: GeofenceUpsert, + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> GeofenceResponse: + return await teacher_service.upsert_geofence(user_id=teacher.userId, class_id=class_id, data=data) + + +@router.get("/sessions/{session_id}/attendance", response_model=SessionAttendanceResponse) +async def get_session_attendance( + session_id: str, + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> SessionAttendanceResponse: + return await teacher_service.get_session_attendance_roster(user_id=teacher.userId, session_id=session_id) + + +@router.get("/classes/{class_id}/stats", response_model=ClassStatsResponse) +async def get_class_stats( + class_id: str, + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> ClassStatsResponse: + return await teacher_service.get_class_stats(user_id=teacher.userId, class_id=class_id) + + +@router.post("/sessions/{session_id}/override", status_code=status.HTTP_200_OK) +async def manual_override_attendance( + session_id: str, data: AttendanceManualOverride, + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> dict: + if not await teacher_service.manual_override_attendance(user_id=teacher.userId, session_id=session_id, student_id=data.student_id, status_val=data.status): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Failed to apply attendance manual override.") + return {"status": "success", "message": f"Attendance overridden to {data.status}."} + + +@router.get("/sessions/all", response_model=list[SessionWithClassResponse]) +async def get_teacher_sessions( + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> list[SessionWithClassResponse]: + return await teacher_service.get_teacher_sessions(user_id=teacher.userId) + + +@router.get("/sessions/{session_id}/absent-students", response_model=list[AbsentStudentItem]) +async def get_absent_students( + session_id: str, + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> list[AbsentStudentItem]: + return await teacher_service.get_absent_students(session_id=session_id, user_id=teacher.userId) + + +@router.post("/sessions/{session_id}/mark-bulk", status_code=status.HTTP_200_OK) +async def bulk_mark_attendance( + session_id: str, data: BulkMarkRequest, + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> dict: + count = await teacher_service.bulk_mark_attendance(session_id=session_id, user_id=teacher.userId, request=data) + return {"status": "success", "count": count} + + +@router.get("/classes/{class_id}/export-attendance", response_model=list[dict]) +async def export_class_attendance( + class_id: str, + from_date: Optional[datetime] = None, + to_date: Optional[datetime] = None, + teacher: Teacher = Depends(get_current_teacher), + teacher_service: TeacherService = Depends(), +) -> list[dict]: + return await teacher_service.export_class_attendance(class_id=class_id, user_id=teacher.userId, from_date=from_date, to_date=to_date) + + +@router.get("/leaves/pending", response_model=list[LeaveRequestResponse]) +async def get_pending_leaves( + teacher: Teacher = Depends(get_current_teacher), + leave_repo: LeaveRepository = Depends(), +) -> list[LeaveRequestResponse]: + return [_make_leave_response(leave) for leave in await leave_repo.get_pending_for_teacher(teacher.id)] + + +@router.put("/leaves/{leave_id}/approve", status_code=status.HTTP_200_OK) +async def approve_leave( + leave_id: str, data: LeaveRequestApprove, + teacher: Teacher = Depends(get_current_teacher), + leave_service: LeaveService = Depends(), +) -> dict: + result = await leave_service.approve_leave(leave_id=leave_id, teacher_id=teacher.id, status=data.status, approver_note=data.approver_note) + if not result: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Leave request not found.") + return {"status": "success", "message": f"Leave request {data.status.lower()} successfully."} + + +@router.get("/device-changes/pending", response_model=list[DeviceChangeResponse]) +async def get_pending_device_changes( + teacher: Teacher = Depends(get_current_teacher), + device_change_service: DeviceChangeService = Depends(), +) -> list[DeviceChangeResponse]: + return await device_change_service.get_pending_requests(teacher_id=teacher.id) + + +@router.put("/device-changes/{request_id}/approve", status_code=status.HTTP_200_OK) +async def approve_device_change( + request_id: str, data: DeviceChangeApprove, + teacher: Teacher = Depends(get_current_teacher), + device_change_service: DeviceChangeService = Depends(), +) -> dict: + result = await device_change_service.approve_request(request_id=request_id, teacher_id=teacher.id, new_status=data.status) + if not result: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device change request not found or not pending.") + return {"status": "success", "message": f"Device change request {data.status.lower()} successfully."} + diff --git a/backend/app/api/ws.py b/backend/app/api/ws.py new file mode 100644 index 0000000000000000000000000000000000000000..253b4b33c6074c181c750e586466be58745276b1 --- /dev/null +++ b/backend/app/api/ws.py @@ -0,0 +1,177 @@ +import asyncio +import json +from typing import Dict, Set + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.api.dependencies import get_current_user_from_token +from app.core.logging_config import get_logger + +logger = get_logger("app.websocket") + +router = APIRouter(prefix="/ws", tags=["WebSocket"]) + +_PING_INTERVAL = 25 +_PONG_TIMEOUT = 15 + + +class ConnectionManager: + def __init__(self): + self.student_connections: Dict[str, Set[WebSocket]] = {} + self.teacher_connections: Dict[str, Set[WebSocket]] = {} + self._heartbeat_task: asyncio.Task | None = None + + async def connect_student(self, websocket: WebSocket, student_id: str): + await websocket.accept() + self.student_connections.setdefault(student_id, set()).add(websocket) + logger.info("WebSocket student connected: %s", student_id) + + async def connect_teacher(self, websocket: WebSocket, teacher_id: str): + await websocket.accept() + self.teacher_connections.setdefault(teacher_id, set()).add(websocket) + logger.info("WebSocket teacher connected: %s", teacher_id) + + def disconnect(self, websocket: WebSocket, user_type: str, user_id: str): + connections = self.student_connections if user_type == "student" else self.teacher_connections + if user_id in connections: + connections[user_id].discard(websocket) + if not connections[user_id]: + del connections[user_id] + logger.info("WebSocket %s disconnected: %s", user_type, user_id) + + async def send_personal_message(self, message: dict, student_id: str): + conns = self.student_connections.get(student_id) + if not conns: + return + disconnected = set() + for connection in conns: + try: + await connection.send_json(message) + except Exception as e: + logger.warning("Failed to send message to %s: %s", student_id, e) + disconnected.add(connection) + for conn in disconnected: + self.student_connections[student_id].discard(conn) + + async def broadcast_to_teachers(self, message: dict): + disconnected = set() + for teacher_id, conns in self.teacher_connections.items(): + for conn in conns: + try: + await conn.send_json(message) + except Exception as e: + logger.warning("Failed to send to teacher %s: %s", teacher_id, e) + disconnected.add(conn) + for conn in disconnected: + for teacher_id, conns in self.teacher_connections.items(): + conns.discard(conn) + if not conns: + del self.teacher_connections[teacher_id] + + async def _heartbeat_loop(self): + while True: + await asyncio.sleep(_PING_INTERVAL) + ping = {"type": "ping"} + disconnected = set() + + for sid, conns in list(self.student_connections.items()): + for conn in list(conns): + try: + await asyncio.wait_for( + conn.send_json(ping), timeout=_PONG_TIMEOUT + ) + except Exception: + disconnected.add((conn, "student", sid)) + + for tid, conns in list(self.teacher_connections.items()): + for conn in list(conns): + try: + await asyncio.wait_for( + conn.send_json(ping), timeout=_PONG_TIMEOUT + ) + except Exception: + disconnected.add((conn, "teacher", tid)) + + for conn, utype, uid in disconnected: + self.disconnect(conn, utype, uid) + + if disconnected: + logger.info( + "Heartbeat cleaned %d stale connections", len(disconnected) + ) + + def start_heartbeat(self): + if self._heartbeat_task is None: + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + logger.info("WebSocket heartbeat started") + + def stop_heartbeat(self): + if self._heartbeat_task is not None: + self._heartbeat_task.cancel() + self._heartbeat_task = None + logger.info("WebSocket heartbeat stopped") + + @property + def total_connections(self) -> int: + student_count = sum(len(c) for c in self.student_connections.values()) + teacher_count = sum(len(c) for c in self.teacher_connections.values()) + return student_count + teacher_count + +manager = ConnectionManager() + + +@router.websocket("/connect") +async def websocket_endpoint(websocket: WebSocket): + try: + await websocket.accept() + auth_data = await websocket.receive_text() + auth_json = json.loads(auth_data) + + if auth_json.get("type") != "auth" or not auth_json.get("token"): + await websocket.close(code=1008, reason="Authentication required") + return + + user = await get_current_user_from_token(auth_json["token"]) + if not user: + await websocket.close(code=1008, reason="Unauthorized") + return + + if user.role == "STUDENT" and user.student: + student_id = user.student.id + await manager.connect_student(websocket, student_id) + await websocket.send_json({"type": "connected", "message": "WebSocket connection established", "user_id": student_id, "role": "student"}) + try: + while True: + data = await asyncio.wait_for(websocket.receive_text(), timeout=_PING_INTERVAL) + if data == "ping": + await websocket.send_json({"type": "pong"}) + except asyncio.TimeoutError: + logger.info("WebSocket ping timeout for student %s", student_id) + except WebSocketDisconnect: + manager.disconnect(websocket, "student", student_id) + + elif user.role == "TEACHER" and user.teacher: + teacher_id = user.teacher.id + await manager.connect_teacher(websocket, teacher_id) + await websocket.send_json({"type": "connected", "message": "WebSocket connection established", "user_id": teacher_id, "role": "teacher"}) + try: + while True: + data = await asyncio.wait_for(websocket.receive_text(), timeout=_PING_INTERVAL) + if data == "ping": + await websocket.send_json({"type": "pong"}) + except asyncio.TimeoutError: + logger.info("WebSocket ping timeout for teacher %s", teacher_id) + except WebSocketDisconnect: + manager.disconnect(websocket, "teacher", teacher_id) + + else: + await websocket.close(code=1008, reason="Unauthorized: Student or Teacher profile required") + + except Exception as e: + logger.error("WebSocket error: %s", e, exc_info=True) + try: + await websocket.close(code=1011, reason="Internal server error") + except Exception: + pass + + diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..301e28b765e59ed544637d415c748cff033361bd --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,41 @@ +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore" + ) + + PROJECT_NAME: str = "Smart Attendance System API" + API_V1_STR: str = "/api/v1" + DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/smart_attendance" + JWT_SECRET: str = Field(default="", description="JWT signing secret (must be set via JWT_SECRET env var)") + + @field_validator("JWT_SECRET") + @classmethod + def jwt_secret_must_be_set(cls, v: str) -> str: + if not v: + raise ValueError( + "JWT_SECRET environment variable is required. " + "Set it in your .env file for security." + ) + return v + + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 + REDIS_URL: str = "redis://localhost:6379/0" + UPLOAD_DIR: str = "static" + FACE_WEIGHT: float = 0.50 + LIVENESS_WEIGHT: float = 0.30 + BACKGROUND_WEIGHT: float = 0.20 + PASS_THRESHOLD: float = 0.75 + FRONTEND_URL: str = Field(default="http://localhost:3000", description="Frontend URL for CORS") + ENVIRONMENT: str = "development" + LOG_LEVEL: str = Field(default="DEBUG", description="Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL") + + +settings = Settings() + diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py new file mode 100644 index 0000000000000000000000000000000000000000..08839d496625d5d1ffdb61388d303878dfac9e6b --- /dev/null +++ b/backend/app/core/logging_config.py @@ -0,0 +1,37 @@ +import logging + +_SILENCED_LOGGERS = [ + "uvicorn.access", "httpx", "httpcore", "deepface", + "tensorflow", "absl", "h5py", "PIL", "numba", + "huggingface_hub", "filelock", "urllib3", "werkzeug", "asyncio", +] + +_FORMAT = logging.Formatter( + "%(asctime)s [%(levelname)-8s] [%(name)s] %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", +) + + +def setup_logging(level: str | None = None) -> None: + if level is None: + from app.core.config import settings + level = settings.LOG_LEVEL + + effective_level = getattr(logging, level.upper(), logging.DEBUG) + + console = logging.StreamHandler() + console.setFormatter(_FORMAT) + + for logger_name in ["app", "app.access"]: + logger = logging.getLogger(logger_name) + logger.setLevel(effective_level) + logger.propagate = False + logger.handlers.clear() + logger.addHandler(console) + + for name in _SILENCED_LOGGERS: + logging.getLogger(name).setLevel(logging.ERROR) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000000000000000000000000000000000000..a80e2d31eb770e4e9cbdf414282950d2d2f5c90c --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,46 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +import bcrypt +import jwt + +from app.core.config import settings + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + try: + return bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) + except (ValueError, TypeError): + return False + + +def create_access_token( + subject: str, + role: str, + expires_delta: timedelta | None = None, + extra_data: dict[str, Any] | None = None, +) -> str: + expire = datetime.now(timezone.utc) + ( + expires_delta if expires_delta + else timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode: dict[str, Any] = { + "sub": subject, + "role": role, + "exp": int(expire.timestamp()), + } + if extra_data: + to_encode.update(extra_data) + return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + + +def decode_access_token(token: str) -> dict[str, Any] | None: + try: + return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) + except jwt.PyJWTError: + return None + diff --git a/backend/app/db/client.py b/backend/app/db/client.py new file mode 100644 index 0000000000000000000000000000000000000000..dabc7bf65a7cfc9895a8c4344c85ba21b70d673d --- /dev/null +++ b/backend/app/db/client.py @@ -0,0 +1,24 @@ +from app.core.logging_config import get_logger +from prisma import Prisma + +logger = get_logger("app.db") + +db = Prisma() + + +async def connect_db() -> None: + try: + await db.connect() + logger.info("Connected to database") + except Exception as e: + logger.error("Failed to connect to database: %s", e, exc_info=True) + raise + + +async def disconnect_db() -> None: + try: + if db.is_connected(): + await db.disconnect() + except Exception as e: + logger.error("Error disconnecting database: %s", e, exc_info=True) + diff --git a/backend/app/db/redis.py b/backend/app/db/redis.py new file mode 100644 index 0000000000000000000000000000000000000000..013bfad5e8dbdd7513bfdfd983f3070b34febe9f --- /dev/null +++ b/backend/app/db/redis.py @@ -0,0 +1,39 @@ +from redis.asyncio import Redis +from app.core.config import settings +from app.core.logging_config import get_logger + +logger = get_logger("app.redis") + +redis_client: Redis | None = None + + +async def connect_redis() -> Redis: + global redis_client + if redis_client is None: + try: + redis_client = Redis.from_url(settings.REDIS_URL, decode_responses=True) + await redis_client.ping() + logger.info("Connected to Redis") + except Exception as err: + logger.error("Failed to connect to Redis at %s: %s", settings.REDIS_URL, err, exc_info=True) + redis_client = None + raise + return redis_client + + +async def disconnect_redis() -> None: + global redis_client + if redis_client is not None: + try: + await redis_client.close() + except Exception as err: + logger.error("Error closing Redis connection: %s", err, exc_info=True) + finally: + redis_client = None + + +def get_redis() -> Redis: + if redis_client is None: + raise RuntimeError("Redis client is not initialized. Please call connect_redis during startup.") + return redis_client + diff --git a/backend/app/middleware/request_logging.py b/backend/app/middleware/request_logging.py new file mode 100644 index 0000000000000000000000000000000000000000..aa95282955ffb78759e8f01e616b7ad894f91707 --- /dev/null +++ b/backend/app/middleware/request_logging.py @@ -0,0 +1,25 @@ +import time +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from app.core.logging_config import get_logger + +logger = get_logger("app.access") + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + start_ts = time.perf_counter() + method = request.method + path = request.url.path + + try: + response: Response = await call_next(request) + except Exception as exc: + elapsed_ms = int((time.perf_counter() - start_ts) * 1000) + logger.error("%s %s — %dms | error=%s", method, path, elapsed_ms, exc, exc_info=True) + raise + + elapsed_ms = int((time.perf_counter() - start_ts) * 1000) + log = logger.warning if response.status_code >= 400 else logger.info + log("%s %s %d %dms", method, path, response.status_code, elapsed_ms) + return response diff --git a/backend/app/repositories/attendance_repo.py b/backend/app/repositories/attendance_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..67a08d848e5afa30deac1a206cf01b7bbc219ddf --- /dev/null +++ b/backend/app/repositories/attendance_repo.py @@ -0,0 +1,64 @@ +from typing import List + +from prisma.models import Attendance +from app.db.client import db + + +class AttendanceRepository: + async def get_by_id(self, attendance_id: str) -> Attendance | None: + return await db.attendance.find_unique( + where={"id": attendance_id}, + include={ + "student": {"include": {"user": True}}, + "session": {"include": {"academicClass": {"include": {"subject": True}}}}, + }, + ) + + async def get_by_student_and_session(self, student_id: str, session_id: str) -> Attendance | None: + return await db.attendance.find_unique( + where={"studentId_sessionId": {"studentId": student_id, "sessionId": session_id}} + ) + + async def get_flagged(self) -> List[Attendance]: + return await db.attendance.find_many( + where={"status": "Flagged"}, + include={ + "student": {"include": {"user": True}}, + "session": {"include": {"academicClass": {"include": {"subject": True}}}}, + }, + ) + + async def create(self, data_dict: dict) -> Attendance: + return await db.attendance.create(data=data_dict) + + async def update_review(self, attendance_id: str, status: str, remarks: str) -> Attendance: + return await db.attendance.update( + where={"id": attendance_id}, data={"status": status, "remarks": remarks} + ) + + async def get_all_absences(self) -> List[Attendance]: + return await db.attendance.find_many( + where={"status": {"in": ["Absent", "Rejected"]}}, include={"student": True} + ) + + async def get_by_student_id(self, student_id: str) -> List[Attendance]: + return await db.attendance.find_many( + where={"studentId": student_id}, + include={"session": {"include": {"academicClass": True}}}, + order={"createdAt": "desc"}, + ) + + async def get_by_session_id(self, session_id: str) -> List[Attendance]: + return await db.attendance.find_many( + where={"sessionId": session_id}, include={"student": True} + ) + + async def get_by_student_in_date_range(self, student_id: str, start_date, end_date) -> List[Attendance]: + return await db.attendance.find_many( + where={"studentId": student_id, "createdAt": {"gte": start_date, "lte": end_date}}, + include={"session": True}, + order={"createdAt": "desc"}, + ) + + async def update(self, attendance_id: str, data: dict) -> Attendance: + return await db.attendance.update(where={"id": attendance_id}, data=data) diff --git a/backend/app/repositories/class_repo.py b/backend/app/repositories/class_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..6b8b58738df08be54fd9a2230cf03f27afd1db2f --- /dev/null +++ b/backend/app/repositories/class_repo.py @@ -0,0 +1,12 @@ +from typing import List + +from prisma.models import AcademicClass +from app.db.client import db + + +class ClassRepository: + async def get_by_id(self, class_id: str) -> AcademicClass | None: + return await db.academicclass.find_unique(where={"id": class_id}) + + async def get_by_teacher_id(self, teacher_id: str) -> List[AcademicClass]: + return await db.academicclass.find_many(where={"teacherId": teacher_id}) diff --git a/backend/app/repositories/enrollment_repo.py b/backend/app/repositories/enrollment_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..4455e2ac33390e2bb872a49fdaebf358b3f8d233 --- /dev/null +++ b/backend/app/repositories/enrollment_repo.py @@ -0,0 +1,19 @@ +from typing import List + +from prisma.models import Enrollment +from app.db.client import db + + +class EnrollmentRepository: + async def get_by_student_id(self, student_id: str) -> List[Enrollment]: + return await db.enrollment.find_many( + where={"studentId": student_id}, include={"academicClass": True} + ) + + async def get_by_class_id(self, class_id: str) -> List[Enrollment]: + return await db.enrollment.find_many( + where={"academicClassId": class_id}, include={"student": True} + ) + + async def enroll_student(self, student_id: str, class_id: str) -> Enrollment: + return await db.enrollment.create(data={"studentId": student_id, "academicClassId": class_id}) diff --git a/backend/app/repositories/geofence_repo.py b/backend/app/repositories/geofence_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..cde70e70b1bf15c08d6131f795d60e267b16bf49 --- /dev/null +++ b/backend/app/repositories/geofence_repo.py @@ -0,0 +1,18 @@ +from prisma.models import Geofence +from app.db.client import db + + +class GeofenceRepository: + async def get_by_class_id(self, class_id: str) -> Geofence | None: + return await db.geofence.find_unique(where={"academicClassId": class_id}) + + async def upsert_geofence(self, class_id: str, latitude: float, longitude: float, radius: float) -> Geofence: + existing = await self.get_by_class_id(class_id) + if existing: + return await db.geofence.update( + where={"academicClassId": class_id}, + data={"latitude": latitude, "longitude": longitude, "radiusMeters": radius}, + ) + return await db.geofence.create( + data={"academicClassId": class_id, "latitude": latitude, "longitude": longitude, "radiusMeters": radius} + ) diff --git a/backend/app/repositories/leave_repo.py b/backend/app/repositories/leave_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..dba03b1ba6f3dbdd5ee27514cbdb29b7b5bc3dfd --- /dev/null +++ b/backend/app/repositories/leave_repo.py @@ -0,0 +1,44 @@ +from datetime import datetime, timezone +from typing import List, Optional + +from prisma.models import LeaveRequest +from app.db.client import db + + +class LeaveRepository: + async def create(self, data: dict) -> LeaveRequest: + return await db.leaverequest.create(data=data, include={"student": True}) + + async def get_by_id(self, leave_id: str) -> Optional[LeaveRequest]: + return await db.leaverequest.find_unique(where={"id": leave_id}, include={"student": True}) + + async def get_by_student_id(self, student_id: str) -> List[LeaveRequest]: + return await db.leaverequest.find_many( + where={"studentId": student_id}, include={"student": True}, order={"createdAt": "desc"} + ) + + async def get_pending_for_teacher(self, teacher_id: str) -> List[LeaveRequest]: + enrollments = await db.enrollment.find_many( + where={"academicClass": {"is": {"teacherId": teacher_id}}}, + include={"student": True}, + ) + student_ids = [e.studentId for e in enrollments] + return await db.leaverequest.find_many( + where={"studentId": {"in": student_ids}, "status": "PENDING"}, + include={"student": True}, + order={"createdAt": "asc"}, + ) + + async def update_status( + self, leave_id: str, status: str, approved_by: str, approver_note: Optional[str] = None + ) -> Optional[LeaveRequest]: + return await db.leaverequest.update( + where={"id": leave_id}, + data={ + "status": status, + "approvedBy": approved_by, + "approverNote": approver_note, + "updatedAt": datetime.now(timezone.utc), + }, + ) + diff --git a/backend/app/repositories/session_repo.py b/backend/app/repositories/session_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..fac55d25afb0f580e0751495c0438190b053b5a0 --- /dev/null +++ b/backend/app/repositories/session_repo.py @@ -0,0 +1,34 @@ +from datetime import datetime, timezone + +from prisma.models import Session +from app.db.client import db + + +class SessionRepository: + async def get_by_id(self, session_id: str) -> Session | None: + return await db.session.find_unique(where={"id": session_id}) + + async def get_active_session_by_class(self, class_id: str) -> Session | None: + now = datetime.now(timezone.utc) + return await db.session.find_first( + where={"academicClassId": class_id, "isActive": True, "endTime": {"gt": now}} + ) + + async def create(self, class_id: str, start_time: datetime, end_time: datetime) -> Session: + return await db.session.create( + data={"academicClassId": class_id, "startTime": start_time, "endTime": end_time, "isActive": True} + ) + + async def deactivate(self, session_id: str) -> Session: + return await db.session.update(where={"id": session_id}, data={"isActive": False}) + + async def get_sessions_in_date_range(self, start_date: datetime, end_date: datetime): + return await db.session.find_many( + where={"startTime": {"gte": start_date, "lte": end_date}}, + include={"academicClass": True}, + ) + + async def get_by_class_id(self, class_id: str): + return await db.session.find_many( + where={"academicClassId": class_id}, order={"startTime": "desc"} + ) diff --git a/backend/app/repositories/student_repo.py b/backend/app/repositories/student_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..45e775b4f69bba0269d91143b32f883773309f71 --- /dev/null +++ b/backend/app/repositories/student_repo.py @@ -0,0 +1,47 @@ +from typing import List + +from prisma.models import Student +from app.db.client import db + + +class StudentRepository: + async def get_by_id(self, student_id: str) -> Student | None: + return await db.student.find_unique(where={"id": student_id}, include={"user": True}) + + async def get_by_user_id(self, user_id: str) -> Student | None: + return await db.student.find_unique(where={"userId": user_id}, include={"user": True}) + + async def get_by_enrollment(self, enrollment: str) -> Student | None: + return await db.student.find_unique(where={"enrollmentNumber": enrollment}, include={"user": True}) + + async def create(self, user_id: str, enrollment: str) -> Student: + return await db.student.create(data={"userId": user_id, "enrollmentNumber": enrollment}) + + async def update_face_embedding(self, student_id: str, embedding: List[float]) -> bool: + await db.execute_raw("UPDATE students SET face_embedding = $1::vector WHERE id = $2", embedding, student_id) + return True + + async def get_face_embedding(self, student_id: str) -> List[float] | None: + records = await db.query_raw("SELECT face_embedding::text FROM students WHERE id = $1", student_id) + if not records or not records[0].get("face_embedding"): + return None + val = records[0]["face_embedding"] + if isinstance(val, list): + return [float(x) for x in val] + if isinstance(val, str): + cleaned = val.strip("[]") + return [float(x) for x in cleaned.split(",")] if cleaned else [] + return None + + async def update_streak(self, student_id: str, current_streak: int, highest_streak: int) -> bool: + await db.student.update( + where={"id": student_id}, + data={"currentStreak": current_streak, "highestStreak": highest_streak}, + ) + return True + + async def get_all_active(self) -> List[Student]: + return await db.student.find_many( + where={"user": {"is": {"isActive": True}}}, + include={"user": True}, + ) diff --git a/backend/app/repositories/system_config_repo.py b/backend/app/repositories/system_config_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..530c25c1205a190730f728ff94883988f3427095 --- /dev/null +++ b/backend/app/repositories/system_config_repo.py @@ -0,0 +1,40 @@ +from prisma.models import SystemConfiguration +from app.db.client import db + + +class SystemConfigRepository: + async def get_config(self) -> SystemConfiguration: + """ + Gets the system configuration. If it doesn't exist, creates a default one. + """ + config = await db.systemconfiguration.find_first() + if not config: + config = await db.systemconfiguration.create( + data={ + "isFaceRecognitionEnabled": True, + "isGpsVerificationEnabled": True, + "isAiBackgroundValidationEnabled": True, + } + ) + return config + + async def update_config( + self, + is_face_recognition_enabled: bool | None = None, + is_gps_verification_enabled: bool | None = None, + is_ai_background_validation_enabled: bool | None = None, + ) -> SystemConfiguration: + config = await self.get_config() + + update_data = {} + if is_face_recognition_enabled is not None: + update_data["isFaceRecognitionEnabled"] = is_face_recognition_enabled + if is_gps_verification_enabled is not None: + update_data["isGpsVerificationEnabled"] = is_gps_verification_enabled + if is_ai_background_validation_enabled is not None: + update_data["isAiBackgroundValidationEnabled"] = is_ai_background_validation_enabled + + return await db.systemconfiguration.update( + where={"id": config.id}, + data=update_data, + ) diff --git a/backend/app/repositories/teacher_repo.py b/backend/app/repositories/teacher_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..5a5e8118c534f6ded01477a5c00b214dd0b4f248 --- /dev/null +++ b/backend/app/repositories/teacher_repo.py @@ -0,0 +1,10 @@ +from prisma.models import Teacher +from app.db.client import db + + +class TeacherRepository: + async def get_by_id(self, teacher_id: str) -> Teacher | None: + return await db.teacher.find_unique(where={"id": teacher_id}, include={"user": True}) + + async def get_by_user_id(self, user_id: str) -> Teacher | None: + return await db.teacher.find_unique(where={"userId": user_id}, include={"user": True}) diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..9a2e3a1f0c7f5914dfd95162bb6aa69b80192f22 --- /dev/null +++ b/backend/app/repositories/user_repo.py @@ -0,0 +1,13 @@ +from prisma.models import User +from app.db.client import db + + +class UserRepository: + async def get_by_email(self, email: str) -> User | None: + return await db.user.find_unique(where={"email": email}) + + async def get_by_id(self, user_id: str) -> User | None: + return await db.user.find_unique(where={"id": user_id}) + + async def create(self, email: str, password_hash: str, role: str) -> User: + return await db.user.create(data={"email": email, "hashedPassword": password_hash, "role": role}) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..0dda4626dc9697351301dfffc34fbe05436bd3ff --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,97 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, ConfigDict + + +class ClassCreate(BaseModel): + name: str = Field(..., min_length=2, max_length=100, description="Name of the class e.g. CS-101-A") + subject_id: str = Field(..., description="UUID of the linked Subject") + teacher_id: str = Field(..., min_length=36, max_length=36, description="UUID of the associated Teacher profile") + classroom_id: Optional[str] = Field(None, description="UUID of the assigned Classroom (optional)") + semester: Optional[int] = Field(None, ge=1, le=8, description="Academic semester (1–8)") + batch: Optional[str] = Field(None, max_length=20, description="Batch year range e.g. 2022-2026") + max_students: Optional[int] = Field(None, ge=1, description="Maximum student capacity for the class") + + +class ClassUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=2, max_length=100) + subject_id: Optional[str] = Field(None) + teacher_id: Optional[str] = Field(None, min_length=36, max_length=36) + classroom_id: Optional[str] = Field(None) + semester: Optional[int] = Field(None, ge=1, le=8) + batch: Optional[str] = Field(None, max_length=20) + max_students: Optional[int] = Field(None, ge=1) + + +class AssignTeacherRequest(BaseModel): + teacher_id: str = Field(..., min_length=36, max_length=36, description="UUID of the Teacher profile to assign") + + +class EnrollRequest(BaseModel): + student_ids: list[str] = Field(..., min_length=1, description="List of Student UUIDs to enroll in the class") + + +class ClassResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str = Field(..., description="Unique UUID of the Academic Class") + name: str = Field(..., description="Name of the class") + subject_name: str = Field(..., description="Resolved subject name") + subject_code: str = Field(..., description="Resolved subject code") + teacher_id: str = Field(..., alias="teacherId", description="Teacher ID associated with this class") + classroom_name: Optional[str] = Field(None, description="Resolved classroom name") + semester: Optional[int] = Field(None, description="Academic semester") + batch: Optional[str] = Field(None, description="Batch year range") + max_students: Optional[int] = Field(None, description="Maximum student capacity") + enrolled_count: int = Field(0, description="Current number of enrolled students") + enrolled_student_ids: list[str] = Field(default_factory=list, description="List of student IDs currently enrolled") + + +class DepartmentCreate(BaseModel): + name: str = Field(..., min_length=3, max_length=100) + code: str = Field(..., min_length=2, max_length=10) + head: Optional[str] = None + description: Optional[str] = None + + +class DepartmentUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=3, max_length=100) + code: Optional[str] = Field(None, min_length=2, max_length=10) + head: Optional[str] = None + description: Optional[str] = None + + +class DepartmentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + code: str + head: Optional[str] = None + description: Optional[str] = None + classCount: int = 0 + + +class AuditLogResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str + timestamp: datetime + eventType: str + severity: str + actor: str + target: str + description: str + ip: Optional[str] = Field(None, alias="ipAddress") + meta: Optional[dict] = Field(None, alias="metadata") + + +class AdminStatsResponse(BaseModel): + studentCount: int + teacherCount: int + classCount: int + + +class AdminResetPasswordRequest(BaseModel): + new_password: str = Field(..., min_length=8, description="New password for the user") diff --git a/backend/app/schemas/attendance.py b/backend/app/schemas/attendance.py new file mode 100644 index 0000000000000000000000000000000000000000..d00f7e60f4f3637f90aaa5014d12a2636bbff31b --- /dev/null +++ b/backend/app/schemas/attendance.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, ConfigDict + + +class AttendanceMarkResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str = Field(..., description="Unique UUID of the attendance record") + student_id: str = Field(..., validation_alias="studentId", description="Student UUID") + session_id: str = Field(..., validation_alias="sessionId", description="Session UUID") + status: str = Field(..., description="Outcome of the weighted decision: 'Present' or 'Flagged'") + face_score: float = Field(..., validation_alias="faceScore", description="AI Face similarity score (0.0 to 1.0)") + liveness_score: float = Field(..., validation_alias="livenessScore", description="AI Face liveness score (0.0 to 1.0)") + background_score: float = Field(..., validation_alias="backgroundScore", description="AI Background learning-environment score (0.0 to 1.0)") + final_ai_score: float = Field(..., validation_alias="finalAiScore", description="Composite decision engine score (0.0 to 1.0)") + gps_latitude: float = Field(..., validation_alias="gpsLatitude", description="Submitted GPS Latitude") + gps_longitude: float = Field(..., validation_alias="gpsLongitude", description="Submitted GPS Longitude") + created_at: datetime = Field(..., validation_alias="createdAt", description="Verification timestamp") + + +class FlaggedAttendanceResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str = Field(..., description="Attendance Record UUID") + enrollment_number: str = Field(..., description="Student enrollment number") + student_name: str = Field(..., description="Student full email or name context") + class_name: str = Field(..., description="Class name") + subject: str = Field(..., description="Course Subject") + face_score: float = Field(..., validation_alias="faceScore", description="Similarity confidence") + liveness_score: float = Field(..., validation_alias="livenessScore", description="Liveness confidence") + background_score: float = Field(..., validation_alias="backgroundScore", description="Background confidence") + final_ai_score: float = Field(..., validation_alias="finalAiScore", description="Composite engine decision score") + gps_latitude: float = Field(..., validation_alias="gpsLatitude", description="Submitted GPS Latitude") + gps_longitude: float = Field(..., validation_alias="gpsLongitude", description="Submitted GPS Longitude") + created_at: datetime = Field(..., validation_alias="createdAt", description="Verification timestamp") + student_note: Optional[str] = Field(None, validation_alias="studentNote", description="Student note on flagged record") + + +class AttendanceAnalyzeResponse(BaseModel): + """Returned by the analyze endpoint — scores only, no record saved yet.""" + face_score: float = Field(..., description="AI Face similarity score (0.0 to 1.0)") + liveness_score: float = Field(..., description="AI Face liveness score (0.0 to 1.0)") + background_score: float = Field(..., description="AI Background score (0.0 to 1.0)") + final_ai_score: float = Field(..., description="Composite weighted score (0.0 to 1.0)") + predicted_status: str = Field(..., description="'Present' or 'Flagged' — what would be saved on confirm") + review_token: str = Field(..., description="Short-lived signed token to confirm submission without re-running AI") + + +class AttendanceReview(BaseModel): + status: str = Field(..., pattern="^(Approved|Rejected)$", description="Review decision: 'Approved' or 'Rejected'") + remarks: Optional[str] = Field(default="", max_length=250, description="Audit notes/justification from the teacher") + + + diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..68a1322176bbb1287c7d6e2f0181380cec40bfbe --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class UserLogin(BaseModel): + email: EmailStr = Field(..., description="Unique email address of the user") + password: str = Field(..., min_length=8, max_length=100, description="Plaintext password") + device_uuid: Optional[str] = Field(None, description="Hardware device UUID for student device binding") + + +class Token(BaseModel): + access_token: str = Field(..., description="Signed JWT access token") + token_type: str = Field("bearer", description="Token protocol type") + role: str = Field(..., description="Role of the authenticated user (STUDENT, TEACHER, ADMIN)") + + +class UserProfileResponse(BaseModel): + id: str = Field(..., description="Unique UUID of the user") + email: EmailStr = Field(..., description="Email address of the user") + role: str = Field(..., description="Assigned role of the user") + is_active: bool = Field(..., description="System status flag") + student_profile: Optional[dict] = Field(None, description="Detailed student profile if role is STUDENT") + teacher_profile: Optional[dict] = Field(None, description="Detailed teacher profile if role is TEACHER") + + +class DeviceChangeRequestCreate(BaseModel): + email: EmailStr = Field(..., description="Unique email address of the user") + password: str = Field(..., min_length=8, max_length=100, description="Plaintext password") + new_device_uuid: str = Field(..., description="The new hardware device UUID") + reason: Optional[str] = Field(None, description="Optional reason for changing the device") diff --git a/backend/app/schemas/leave.py b/backend/app/schemas/leave.py new file mode 100644 index 0000000000000000000000000000000000000000..74146979870012b52ee9a64ef3fbc686ef094681 --- /dev/null +++ b/backend/app/schemas/leave.py @@ -0,0 +1,49 @@ +from datetime import datetime, date +from typing import Optional + +from pydantic import BaseModel, Field, field_validator, ConfigDict + + +class LeaveRequestCreate(BaseModel): + start_date: date = Field(..., description="Leave start date") + end_date: date = Field(..., description="Leave end date") + reason: str = Field(..., min_length=10, max_length=500, description="Reason for leave") + document_url: Optional[str] = Field(None, description="Supporting document URL (medical certificate, etc.)") + + @field_validator('end_date') + @classmethod + def validate_date_range(cls, v, info): + if 'start_date' in info.data and v < info.data['start_date']: + raise ValueError('end_date must be after or equal to start_date') + return v + + +class LeaveRequestResponse(BaseModel): + id: str + student_id: str + student_name: str + enrollment_number: str + start_date: datetime + end_date: datetime + reason: str + document_url: Optional[str] + status: str + approved_by: Optional[str] + approver_note: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class LeaveRequestApprove(BaseModel): + status: str = Field(..., pattern="^(APPROVED|REJECTED)$", description="Approval status") + approver_note: Optional[str] = Field(None, max_length=300, description="Optional note from approver") + + +class LeaveRequestListResponse(BaseModel): + leaves: list[LeaveRequestResponse] + total: int + pending: int + approved: int + rejected: int diff --git a/backend/app/schemas/log.py b/backend/app/schemas/log.py new file mode 100644 index 0000000000000000000000000000000000000000..9bcb8976dd47b7ee72bcf7798ade9bc3df283604 --- /dev/null +++ b/backend/app/schemas/log.py @@ -0,0 +1,35 @@ +from typing import Optional, Dict, Any +from datetime import datetime, timezone + +from pydantic import BaseModel, Field, field_validator + +_VALID_SOURCES = {"frontend", "mobile"} +_VALID_LEVELS = {"DEBUG", "INFO", "WARN", "WARNING", "ERROR", "CRITICAL"} + + +class LogEvent(BaseModel): + source: str = Field(..., description="Origin of the log event: 'frontend' or 'mobile'") + level: str = Field(default="INFO", description="Severity: DEBUG, INFO, WARN, ERROR, CRITICAL") + message: str = Field(..., min_length=1, description="The human-readable log message") + timestamp: Optional[str] = Field(default=None, description="ISO-8601 UTC timestamp of the event") + context: Optional[Dict[str, Any]] = Field(default=None, description="Optional structured metadata") + user_id: Optional[str] = Field(default=None, description="Authenticated user ID at the time of the event") + platform_version: Optional[str] = Field(default=None, description="Client platform version") + + @field_validator("source") + @classmethod + def validate_source(cls, value: str) -> str: + if value.lower() not in _VALID_SOURCES: + raise ValueError(f"Invalid log source '{value}'. Must be one of: {sorted(_VALID_SOURCES)}") + return value.lower() + + @field_validator("level") + @classmethod + def validate_level(cls, value: str) -> str: + normalized = value.upper() + return normalized if normalized in _VALID_LEVELS else "INFO" + + @field_validator("timestamp", mode="before") + @classmethod + def set_default_timestamp(cls, value: Optional[str]) -> str: + return value if value is not None else datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/schemas/master_data.py b/backend/app/schemas/master_data.py new file mode 100644 index 0000000000000000000000000000000000000000..91e1b1d1c0e7c4d997d9fd27c12a6387b5ca09a2 --- /dev/null +++ b/backend/app/schemas/master_data.py @@ -0,0 +1,65 @@ +from pydantic import BaseModel, Field, ConfigDict + + + +class SubjectCreate(BaseModel): + name: str = Field(..., min_length=3, max_length=100) + code: str = Field(..., min_length=2, max_length=10) + description: str | None = None + + +class SubjectUpdate(BaseModel): + name: str | None = Field(None, min_length=3, max_length=100) + code: str | None = Field(None, min_length=2, max_length=10) + description: str | None = None + + +class SubjectResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + code: str + description: str | None = None + + +class ClassroomCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + building: str | None = None + capacity: int | None = Field(None, ge=1) + + +class ClassroomUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + building: str | None = None + capacity: int | None = Field(None, ge=1) + + +class ClassroomResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + building: str | None = None + capacity: int | None = None + + +class DesignationCreate(BaseModel): + name: str = Field(..., min_length=3, max_length=100) + code: str = Field(..., min_length=2, max_length=10) + description: str | None = None + + +class DesignationUpdate(BaseModel): + name: str | None = Field(None, min_length=3, max_length=100) + code: str | None = Field(None, min_length=2, max_length=10) + description: str | None = None + + +class DesignationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + code: str + description: str | None = None diff --git a/backend/app/schemas/student.py b/backend/app/schemas/student.py new file mode 100644 index 0000000000000000000000000000000000000000..33fd4bacd2468deb7896bd11581720b660a0088b --- /dev/null +++ b/backend/app/schemas/student.py @@ -0,0 +1,81 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +class StudentCreate(BaseModel): + email: EmailStr = Field(..., description="Unique email address of the student") + password: str = Field(..., min_length=8, max_length=100, description="Secure account password") + enrollment_number: str = Field(..., min_length=5, max_length=30, description="University Enrollment Number") + first_name: str = Field(..., min_length=1, max_length=100, description="Student first name") + last_name: str = Field(..., min_length=1, max_length=100, description="Student last name") + phone: Optional[str] = Field(None, max_length=20, description="Contact phone number") + gender: Optional[str] = Field(None, description="Gender identity") + date_of_birth: Optional[datetime] = Field(None, description="Date of birth") + semester: Optional[int] = Field(None, ge=1, le=8, description="Current academic semester (1–8)") + batch: Optional[str] = Field(None, max_length=20, description="Batch year range e.g. 2022-2026") + department_id: Optional[str] = Field(None, description="UUID of the student's department") + + +class StudentUpdate(BaseModel): + enrollment_number: Optional[str] = Field(None, min_length=5, max_length=30) + first_name: Optional[str] = Field(None, min_length=1, max_length=100) + last_name: Optional[str] = Field(None, min_length=1, max_length=100) + phone: Optional[str] = Field(None, max_length=20) + gender: Optional[str] = Field(None) + date_of_birth: Optional[datetime] = Field(None) + semester: Optional[int] = Field(None, ge=1, le=8) + batch: Optional[str] = Field(None, max_length=20) + department_id: Optional[str] = Field(None) + + +class StudentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str = Field(..., description="Unique UUID of the student record") + user_id: str = Field(..., description="Mapped User UUID") + enrollment_number: str = Field(..., description="Student enrollment number") + email: str = Field(..., description="Email address associated with the user profile") + first_name: Optional[str] = Field(None, description="Student first name") + last_name: Optional[str] = Field(None, description="Student last name") + phone: Optional[str] = Field(None, description="Contact phone number") + gender: Optional[str] = Field(None, description="Gender identity") + date_of_birth: Optional[datetime] = Field(None, description="Date of birth") + department_id: Optional[str] = Field(None, description="Raw department UUID") + department_name: Optional[str] = Field(None, description="Resolved department name") + semester: Optional[int] = Field(None, description="Current semester") + batch: Optional[str] = Field(None, description="Batch year range") + + +class StudentAttendanceItem(BaseModel): + attendance_id: str = Field(..., description="UUID of attendance record") + class_id: str = Field(..., description="Class UUID") + class_name: str = Field(..., description="Class name") + subject: str = Field(..., description="Subject name") + session_id: str = Field(..., description="Session UUID") + status: str = Field(..., description="Attendance status (Present, Flagged, Absent)") + marked_at: datetime = Field(..., description="Timestamp marked") + face_score: Optional[float] = Field(None, description="AI face similarity score") + liveness_score: Optional[float] = Field(None, description="AI liveness score") + background_score: Optional[float] = Field(None, description="AI background score") + final_ai_score: Optional[float] = Field(None, description="Composite AI score") + teacher_note: Optional[str] = Field(None, description="Teacher review note/remarks") + + +class StudentAttendanceHistoryResponse(BaseModel): + student_id: str = Field(..., description="Student profile UUID") + overall_attendance_percentage: float = Field(..., description="Overall attendance percentage over all enrolled courses") + history: list[StudentAttendanceItem] = Field(..., description="Detailed history itemized logs") + + +class StudentClassResponse(BaseModel): + class_id: str = Field(..., description="Class UUID") + class_name: str = Field(..., description="Class name") + subject: str = Field(..., description="Subject name") + teacher_name: str = Field(..., description="Teacher name") + active_session_id: Optional[str] = Field(None, description="UUID of active session if any") + session_end_time: Optional[datetime] = Field(None, description="Active session end time if any") + latitude: Optional[float] = Field(None, description="Geofence center latitude") + longitude: Optional[float] = Field(None, description="Geofence center longitude") + radius_meters: Optional[float] = Field(None, description="Geofence radius in meters") diff --git a/backend/app/schemas/system_config.py b/backend/app/schemas/system_config.py new file mode 100644 index 0000000000000000000000000000000000000000..6a592364f617a8cbba3d02c7f09587d7228d2bba --- /dev/null +++ b/backend/app/schemas/system_config.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + + +class SystemConfigResponse(BaseModel): + is_face_recognition_enabled: bool = Field(..., alias="isFaceRecognitionEnabled") + is_gps_verification_enabled: bool = Field(..., alias="isGpsVerificationEnabled") + is_ai_background_validation_enabled: bool = Field(..., alias="isAiBackgroundValidationEnabled") + + class Config: + populate_by_name = True + + +class SystemConfigUpdate(BaseModel): + is_face_recognition_enabled: bool | None = Field(None, alias="isFaceRecognitionEnabled") + is_gps_verification_enabled: bool | None = Field(None, alias="isGpsVerificationEnabled") + is_ai_background_validation_enabled: bool | None = Field(None, alias="isAiBackgroundValidationEnabled") diff --git a/backend/app/schemas/teacher.py b/backend/app/schemas/teacher.py new file mode 100644 index 0000000000000000000000000000000000000000..822723b1e61d9995b14fae9977ca014c4eeeb75e --- /dev/null +++ b/backend/app/schemas/teacher.py @@ -0,0 +1,176 @@ +from datetime import datetime +from typing import Optional, Literal + +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +class TeacherCreate(BaseModel): + email: EmailStr = Field(..., description="Unique email address of the teacher") + password: str = Field(..., min_length=8, max_length=100, description="Secure account password") + employee_id: str = Field(..., min_length=3, max_length=30, description="Unique employee identifier e.g. EMP2024001") + first_name: str = Field(..., min_length=1, max_length=100, description="Teacher first name") + last_name: str = Field(..., min_length=1, max_length=100, description="Teacher last name") + department_id: str = Field(..., description="UUID of the teacher's department") + designation_id: str = Field(..., description="UUID of the teacher's designation") + phone: Optional[str] = Field(None, max_length=20, description="Contact phone number") + qualification: Optional[str] = Field(None, max_length=100, description="Academic qualification e.g. Ph.D, M.Tech") + specialization: Optional[str] = Field(None, max_length=100, description="Area of specialization e.g. Machine Learning") + experience_years: Optional[int] = Field(None, ge=0, description="Years of professional experience") + joining_date: Optional[datetime] = Field(None, description="Date of joining the institution") + + +class TeacherUpdate(BaseModel): + employee_id: Optional[str] = Field(None, min_length=3, max_length=30) + first_name: Optional[str] = Field(None, min_length=1, max_length=100) + last_name: Optional[str] = Field(None, min_length=1, max_length=100) + department_id: Optional[str] = Field(None) + designation_id: Optional[str] = Field(None) + phone: Optional[str] = Field(None, max_length=20) + qualification: Optional[str] = Field(None, max_length=100) + specialization: Optional[str] = Field(None, max_length=100) + experience_years: Optional[int] = Field(None, ge=0) + joining_date: Optional[datetime] = Field(None) + + +class TeacherResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str = Field(..., description="Unique UUID of the teacher record") + user_id: str = Field(..., description="Mapped User UUID") + email: str = Field(..., description="Email address associated with the user profile") + employee_id: str = Field(..., description="Unique employee ID") + first_name: str = Field(..., description="Teacher first name") + last_name: str = Field(..., description="Teacher last name") + department_id: str = Field(..., description="Raw department UUID") + designation_id: str = Field(..., description="Raw designation UUID") + department: str = Field(..., description="Resolved department name") + designation: str = Field(..., description="Resolved designation name") + phone: Optional[str] = Field(None, description="Contact phone number") + qualification: Optional[str] = Field(None, description="Academic qualification") + specialization: Optional[str] = Field(None, description="Area of specialization") + experience_years: Optional[int] = Field(None, description="Years of professional experience") + joining_date: Optional[datetime] = Field(None, description="Joining date") + + +class SessionStart(BaseModel): + academic_class_id: str = Field(..., description="Target Class UUID for this session") + duration_minutes: int = Field(10, ge=1, le=180, description="Session validity window in minutes") + + +class SessionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str = Field(..., description="Unique UUID of the active session") + academic_class_id: str = Field(..., alias="academicClassId", description="Associated Class UUID") + start_time: datetime = Field(..., alias="startTime", description="Timestamp when the session opened") + end_time: datetime = Field(..., alias="endTime", description="Timestamp when the session will close") + is_active: bool = Field(..., alias="isActive", description="Current state of the session") + + +class GeofenceUpsert(BaseModel): + latitude: float = Field(..., description="GPS Latitude coordinate") + longitude: float = Field(..., description="GPS Longitude coordinate") + radius_meters: float = Field(..., gt=0.0, description="Geofence boundary radius in meters") + + +class GeofenceResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str = Field(..., description="Unique UUID of the Geofence record") + academic_class_id: str = Field(..., alias="academicClassId", description="Class ID") + latitude: float = Field(..., description="Latitude coordinate") + longitude: float = Field(..., description="Longitude coordinate") + radius_meters: float = Field(..., alias="radiusMeters", description="Radius in meters") + created_at: datetime = Field(..., alias="createdAt", description="Timestamp created") + updated_at: datetime = Field(..., alias="updatedAt", description="Timestamp updated") + + +class AcademicClassWithGeofenceResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str = Field(..., description="Unique UUID of the Academic Class") + name: str = Field(..., description="Class name") + subject: str = Field(..., description="Resolved subject name") + teacher_id: str = Field(..., alias="teacherId", description="Teacher ID") + geofence: Optional[GeofenceResponse] = Field(None, description="Class geofence configuration") + + +class StudentRosterItem(BaseModel): + student_id: str = Field(..., description="Student UUID") + enrollment_number: str = Field(..., description="Student enrollment number") + full_name: str = Field(..., description="Student full name (first + last)") + email: str = Field(..., description="Student email address") + status: str = Field(..., description="Attendance status (Present, Flagged, Absent)") + final_score: float = Field(..., description="Final calculated AI attendance score") + marked_at: Optional[datetime] = Field(None, description="Timestamp attendance was registered") + + +class SessionAttendanceResponse(BaseModel): + session_id: str = Field(..., description="Session UUID") + class_name: str = Field(..., description="Class name") + roster: list[StudentRosterItem] = Field(..., description="Enrolled roster list details") + + +class SessionTrendItem(BaseModel): + session_id: str = Field(..., description="Session UUID") + session_name: str = Field(..., description="Display name of session") + attendance_percentage: float = Field(..., description="Attendance percentage for session") + + +class ClassStatsResponse(BaseModel): + class_id: str = Field(..., description="Class UUID") + total_sessions: int = Field(..., description="Total sessions held for class") + total_students: int = Field(..., description="Total student count enrolled in class") + overall_attendance_percentage: float = Field(..., description="Overall class attendance percentage") + history: list[SessionTrendItem] = Field(default_factory=list, description="Chronological list of sessions with stats") + + +class AttendanceManualOverride(BaseModel): + student_id: str = Field(..., description="Student UUID to override") + status: str = Field(..., description="Target status (Present or Absent)") + + +class SessionWithClassResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + id: str = Field(..., description="Unique UUID of the session") + academic_class_id: str = Field(..., alias="academicClassId", description="Associated Class UUID") + class_name: str = Field(..., description="Class Name") + subject: str = Field(..., description="Resolved subject name") + start_time: datetime = Field(..., alias="startTime", description="Timestamp when session opened") + end_time: datetime = Field(..., alias="endTime", description="Timestamp when session closed") + is_active: bool = Field(..., alias="isActive", description="Current state of session") + + +class BulkAttendanceRecord(BaseModel): + student_id: str = Field(..., description="Student UUID") + status: Literal["Present", "Absent"] = Field(..., description="Attendance status to set") + + +class BulkMarkRequest(BaseModel): + records: list[BulkAttendanceRecord] = Field(..., min_length=1, description="List of student attendance records") + + +class AbsentStudentItem(BaseModel): + student_id: str = Field(..., description="Student UUID") + enrollment_number: str = Field(..., description="Student enrollment number") + full_name: str = Field(..., description="Student full name") + email: str = Field(..., description="Student email address") + + +class DeviceChangeResponse(BaseModel): + id: str = Field(..., description="Device Change Request UUID") + student_id: str = Field(..., description="Student UUID") + student_name: str = Field(..., description="Student full name") + enrollment_number: str = Field(..., description="Student enrollment number") + new_device_uuid: str = Field(..., description="New device UUID requested") + reason: Optional[str] = Field(None, description="Reason for device change") + status: str = Field(..., description="Request status (PENDING, APPROVED, REJECTED)") + approved_by: Optional[str] = Field(None, description="UUID of approver") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + +class DeviceChangeApprove(BaseModel): + status: Literal["APPROVED", "REJECTED"] = Field(..., description="Approval status decision") + diff --git a/backend/app/services/absentee_scanner.py b/backend/app/services/absentee_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..06981ca26287a5fdb50edab5fd29e73ec2228c43 --- /dev/null +++ b/backend/app/services/absentee_scanner.py @@ -0,0 +1,56 @@ +import asyncio +from typing import List, Dict, Any + +import pandas as pd +from sklearn.ensemble import IsolationForest + +from app.core.logging_config import get_logger + +logger = get_logger("app.ai.scanner") + +_REQUIRED_COLS = {'student_id', 'status', 'day_of_week'} + + +def _run_isolation_forest(attendance_records: List[Dict[str, Any]], contamination: float) -> List[Dict[str, Any]]: + try: + if not attendance_records: + return [] + + df = pd.DataFrame(attendance_records) + if not _REQUIRED_COLS.issubset(df.columns): + logger.error("Missing required columns in attendance records. Required: %s", _REQUIRED_COLS) + return [] + + absences = df[df['status'] == 'Absent'] + if absences.empty: + return [] + + profile = absences.groupby('student_id').size().reset_index(name='total_absences') + day_absences = pd.crosstab(absences['student_id'], absences['day_of_week']).reset_index() + profile = pd.merge(profile, day_absences, on='student_id', how='left').fillna(0) + + features = profile.drop(columns=['student_id']) + model = IsolationForest(n_estimators=100, contamination=contamination, random_state=42) + model.fit(features) + + profile['pred'] = model.predict(features) + profile['anomaly_score'] = -model.decision_function(features) + + flagged = profile[profile['pred'] == -1].copy() + flagged = flagged.sort_values(by='total_absences', ascending=False).drop(columns=['pred']) + return flagged.to_dict(orient='records') + + except Exception as e: + logger.error("Error in IsolationForest absentee scan: %s", e, exc_info=True) + return [] + + +async def run_absentee_scan(attendance_records: List[Dict[str, Any]], contamination: float = 0.10) -> List[Dict[str, Any]]: + try: + flagged = await asyncio.to_thread(_run_isolation_forest, attendance_records, contamination) + if flagged: + logger.info("Absentee scan: %d at-risk students", len(flagged)) + return flagged + except Exception as e: + logger.error("Failed to run async absentee scan wrapper: %s", e, exc_info=True) + return [] diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py new file mode 100644 index 0000000000000000000000000000000000000000..8cd2c753dd369525ac89a4c1dce6290c7749ed80 --- /dev/null +++ b/backend/app/services/admin_service.py @@ -0,0 +1,361 @@ +from typing import List, Optional + +from prisma.models import Department, AuditLog, Subject, Classroom, Designation + +from app.core.security import hash_password +from app.db.client import db +from app.repositories.user_repo import UserRepository +from app.repositories.student_repo import StudentRepository +from app.repositories.teacher_repo import TeacherRepository +from app.repositories.class_repo import ClassRepository +from app.repositories.enrollment_repo import EnrollmentRepository +from app.schemas.student import StudentCreate, StudentResponse +from app.schemas.teacher import TeacherCreate, TeacherResponse +from app.schemas.admin import ClassCreate, ClassResponse + + +class AdminService: + def __init__(self) -> None: + self.user_repo = UserRepository() + self.student_repo = StudentRepository() + self.teacher_repo = TeacherRepository() + self.class_repo = ClassRepository() + self.enrollment_repo = EnrollmentRepository() + + @staticmethod + async def _log_action(event_type: str, severity: str, actor: str, target: str, description: str, ip: Optional[str] = None) -> None: + await db.auditlog.create(data={ + "eventType": event_type, + "severity": severity, + "actor": actor, + "target": target, + "description": description, + "ip": ip, + }) + + # --- Students --- + + async def create_student(self, data: StudentCreate, actor: str = "system", ip: Optional[str] = None) -> StudentResponse: + if data.department_id: + if not await db.department.find_unique(where={"id": data.department_id}): + raise ValueError("Department not found.") + + user = await db.user.create(data={ + "email": data.email, "hashedPassword": hash_password(data.password), "role": "STUDENT", + }) + student = await db.student.create( + data={k: v for k, v in { + "userId": user.id, "enrollmentNumber": data.enrollment_number, + "firstName": data.first_name, "lastName": data.last_name, + "phone": data.phone, "gender": data.gender, "dateOfBirth": data.date_of_birth, + "semester": data.semester, "batch": data.batch, "departmentId": data.department_id, + }.items() if v is not None}, + include={"department": True}, + ) + await self._log_action("CREATE_STUDENT", "INFO", actor, student.id, f"Created student {data.email}", ip) + return StudentResponse( + id=student.id, user_id=user.id, enrollment_number=student.enrollmentNumber, email=user.email, + first_name=student.firstName, last_name=student.lastName, phone=student.phone, + gender=student.gender, date_of_birth=student.dateOfBirth, + department_id=student.departmentId, + department_name=student.department.name if student.department else None, + semester=student.semester, batch=student.batch, + ) + + async def get_all_students(self) -> List[StudentResponse]: + students = await db.student.find_many(include={"user": True, "department": True}) + return [ + StudentResponse( + id=s.id, user_id=s.userId, enrollment_number=s.enrollmentNumber, + email=s.user.email if s.user else "", first_name=s.firstName, + last_name=s.lastName, phone=s.phone, gender=s.gender, + date_of_birth=s.dateOfBirth, department_id=s.departmentId, + department_name=s.department.name if s.department else None, + semester=s.semester, batch=s.batch, + ) + for s in students + ] + + async def update_student(self, id: str, data: dict, actor: str = "system", ip: Optional[str] = None) -> StudentResponse: + mapping = { + "enrollment_number": "enrollmentNumber", "first_name": "firstName", "last_name": "lastName", + "phone": "phone", "gender": "gender", "date_of_birth": "dateOfBirth", + "semester": "semester", "batch": "batch", "department_id": "departmentId", + } + update_data = {mapping[k]: v for k, v in data.items() if k in mapping} + student = await db.student.update(where={"id": id}, data=update_data, include={"user": True, "department": True}) + await self._log_action("UPDATE_STUDENT", "INFO", actor, id, f"Updated student {student.user.email if student.user else id}", ip) + return StudentResponse( + id=student.id, user_id=student.userId, enrollment_number=student.enrollmentNumber, + email=student.user.email if student.user else "", first_name=student.firstName, + last_name=student.lastName, phone=student.phone, gender=student.gender, + date_of_birth=student.dateOfBirth, department_id=student.departmentId, + department_name=student.department.name if student.department else None, + semester=student.semester, batch=student.batch, + ) + + # --- Teachers --- + + async def create_teacher(self, data: TeacherCreate, actor: str = "system", ip: Optional[str] = None) -> TeacherResponse: + if not await db.department.find_unique(where={"id": data.department_id}): + raise ValueError(f"Department with id '{data.department_id}' not found.") + if not await db.designation.find_unique(where={"id": data.designation_id}): + raise ValueError(f"Designation with id '{data.designation_id}' not found.") + + user = await db.user.create(data={ + "email": data.email, "hashedPassword": hash_password(data.password), "role": "TEACHER", + }) + teacher = await db.teacher.create( + data={k: v for k, v in { + "userId": user.id, "employeeId": data.employee_id, + "firstName": data.first_name, "lastName": data.last_name, + "departmentId": data.department_id, "designationId": data.designation_id, + "phone": data.phone, "qualification": data.qualification, + "specialization": data.specialization, "experienceYears": data.experience_years, + "joiningDate": data.joining_date, + }.items() if v is not None}, + include={"department": True, "designation": True}, + ) + await self._log_action("CREATE_TEACHER", "INFO", actor, teacher.id, f"Created teacher {data.email}", ip) + return TeacherResponse( + id=teacher.id, user_id=user.id, email=user.email, employee_id=teacher.employeeId, + first_name=teacher.firstName, last_name=teacher.lastName, + department_id=teacher.departmentId, designation_id=teacher.designationId, + department=teacher.department.name, designation=teacher.designation.name, + phone=teacher.phone, qualification=teacher.qualification, + specialization=teacher.specialization, experience_years=teacher.experienceYears, + joining_date=teacher.joiningDate, + ) + + async def get_all_teachers(self) -> List[TeacherResponse]: + teachers = await db.teacher.find_many(include={"user": True, "department": True, "designation": True}) + return [ + TeacherResponse( + id=t.id, user_id=t.userId, email=t.user.email if t.user else "", + employee_id=t.employeeId, first_name=t.firstName, last_name=t.lastName, + department_id=t.departmentId, designation_id=t.designationId, + department=t.department.name if t.department else "", + designation=t.designation.name if t.designation else "", + phone=t.phone, qualification=t.qualification, specialization=t.specialization, + experience_years=t.experienceYears, joining_date=t.joiningDate, + ) + for t in teachers + ] + + async def update_teacher(self, id: str, data: dict, actor: str = "system", ip: Optional[str] = None) -> TeacherResponse: + mapping = { + "employee_id": "employeeId", "first_name": "firstName", "last_name": "lastName", + "department_id": "departmentId", "designation_id": "designationId", "phone": "phone", + "qualification": "qualification", "specialization": "specialization", + "experience_years": "experienceYears", "joining_date": "joiningDate", + } + update_data = {mapping[k]: v for k, v in data.items() if k in mapping} + teacher = await db.teacher.update(where={"id": id}, data=update_data, include={"user": True, "department": True, "designation": True}) + await self._log_action("UPDATE_TEACHER", "INFO", actor, id, f"Updated teacher {teacher.user.email if teacher.user else id}", ip) + return TeacherResponse( + id=teacher.id, user_id=teacher.userId, email=teacher.user.email if teacher.user else "", + employee_id=teacher.employeeId, first_name=teacher.firstName, last_name=teacher.lastName, + department_id=teacher.departmentId, designation_id=teacher.designationId, + department=teacher.department.name if teacher.department else "", + designation=teacher.designation.name if teacher.designation else "", + phone=teacher.phone, qualification=teacher.qualification, + specialization=teacher.specialization, experience_years=teacher.experienceYears, + joining_date=teacher.joiningDate, + ) + + # --- Classes --- + + async def create_class(self, data: ClassCreate, actor: str = "system", ip: Optional[str] = None) -> ClassResponse: + if not await self.teacher_repo.get_by_id(data.teacher_id): + raise ValueError("Teacher profile not found.") + if not await db.subject.find_unique(where={"id": data.subject_id}): + raise ValueError(f"Subject with id '{data.subject_id}' not found.") + if data.classroom_id and not await db.classroom.find_unique(where={"id": data.classroom_id}): + raise ValueError(f"Classroom with id '{data.classroom_id}' not found.") + + cls = await db.academicclass.create( + data={k: v for k, v in { + "name": data.name, "teacherId": data.teacher_id, "subjectId": data.subject_id, + "classroomId": data.classroom_id, "semester": data.semester, + "batch": data.batch, "maxStudents": data.max_students, + }.items() if v is not None}, + include={"subject": True, "classroom": True, "enrollments": True}, + ) + await self._log_action("CREATE_CLASS", "INFO", actor, cls.id, f"Created class {data.name}", ip) + return ClassResponse( + id=cls.id, name=cls.name, subject_name=cls.subject.name, subject_code=cls.subject.code, + teacherId=cls.teacherId, classroom_name=cls.classroom.name if cls.classroom else None, + semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents, + enrolled_count=len(cls.enrollments) if cls.enrollments else 0, + enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [], + ) + + async def get_all_classes(self) -> List[ClassResponse]: + classes = await db.academicclass.find_many(include={"subject": True, "classroom": True, "enrollments": True}) + return [ + ClassResponse( + id=c.id, name=c.name, subject_name=c.subject.name if c.subject else "", + subject_code=c.subject.code if c.subject else "", teacherId=c.teacherId, + classroom_name=c.classroom.name if c.classroom else None, + semester=c.semester, batch=c.batch, max_students=c.maxStudents, + enrolled_count=len(c.enrollments) if c.enrollments else 0, + enrolled_student_ids=[e.studentId for e in c.enrollments] if c.enrollments else [], + ) + for c in classes + ] + + async def update_class(self, class_id: str, data: dict, actor: str = "system", ip: Optional[str] = None) -> ClassResponse: + renames = {"subject_id": "subjectId", "classroom_id": "classroomId", "teacher_id": "teacherId"} + for old, new in renames.items(): + if old in data: + data[new] = data.pop(old) + cls = await db.academicclass.update(where={"id": class_id}, data=data, include={"subject": True, "classroom": True, "enrollments": True}) + await self._log_action("UPDATE_CLASS", "INFO", actor, class_id, f"Updated class {cls.name}", ip) + return ClassResponse( + id=cls.id, name=cls.name, subject_name=cls.subject.name if cls.subject else "", + subject_code=cls.subject.code if cls.subject else "", teacherId=cls.teacherId, + classroom_name=cls.classroom.name if cls.classroom else None, + semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents, + enrolled_count=len(cls.enrollments) if cls.enrollments else 0, + enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [], + ) + + async def assign_teacher(self, class_id: str, teacher_id: str, actor: str = "system", ip: Optional[str] = None) -> ClassResponse: + if not await self.class_repo.get_by_id(class_id): + raise ValueError("Academic class not found.") + if not await self.teacher_repo.get_by_id(teacher_id): + raise ValueError("Teacher profile not found.") + cls = await db.academicclass.update(where={"id": class_id}, data={"teacherId": teacher_id}, include={"subject": True, "classroom": True, "enrollments": True}) + await self._log_action("ASSIGN_TEACHER", "INFO", actor, class_id, f"Assigned teacher {teacher_id} to class {cls.name}", ip) + return ClassResponse( + id=cls.id, name=cls.name, subject_name=cls.subject.name if cls.subject else "", + subject_code=cls.subject.code if cls.subject else "", teacherId=cls.teacherId, + classroom_name=cls.classroom.name if cls.classroom else None, + semester=cls.semester, batch=cls.batch, max_students=cls.maxStudents, + enrolled_count=len(cls.enrollments) if cls.enrollments else 0, + enrolled_student_ids=[e.studentId for e in cls.enrollments] if cls.enrollments else [], + ) + + async def enroll_students(self, class_id: str, student_ids: List[str], actor: str = "system", ip: Optional[str] = None) -> int: + if not await self.class_repo.get_by_id(class_id): + raise ValueError("Academic class not found.") + count = 0 + for sid in student_ids: + if await self.student_repo.get_by_id(sid): + existing = await db.enrollment.find_first( + where={"studentId": sid, "academicClassId": class_id} + ) + if not existing: + await self.enrollment_repo.enroll_student(sid, class_id) + count += 1 + await self._log_action("ENROLL_STUDENTS", "INFO", actor, class_id, f"Enrolled {count} student(s) in class", ip) + return count + + # --- Master Data Helpers --- + + @staticmethod + async def _validate_delete(entity_name: str, record, ref_field: str, ref_table_name: str): + if not record: + raise ValueError(f"{entity_name} not found.") + ref_count = await getattr(db, ref_table_name).count(where={ref_field: record.id}) + if ref_count > 0: + raise ValueError(f"Cannot delete {entity_name.lower()} because it is currently assigned.") + + # --- Departments --- + + async def get_all_departments(self) -> List[Department]: + return await db.department.find_many() + + async def get_department_by_id(self, id: str) -> Optional[Department]: + return await db.department.find_unique(where={"id": id}) + + async def create_department(self, name: str, code: str, head: Optional[str] = None, description: Optional[str] = None) -> Department: + return await db.department.create(data={"name": name, "code": code, "head": head, "description": description}) + + async def update_department(self, id: str, data: dict) -> Department: + return await db.department.update(where={"id": id}, data=data) + + async def delete_department(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None: + dept = await db.department.find_unique(where={"id": id}) + await self._validate_delete("Department", dept, "departmentId", "teacher") + await self._validate_delete("Department", dept, "departmentId", "student") + await db.department.delete(where={"id": id}) + await self._log_action("DELETE_DEPARTMENT", "WARNING", actor, id, f"Deleted department {dept.name if dept else id}", ip) + + # --- Subjects --- + + async def get_all_subjects(self) -> List[Subject]: + return await db.subject.find_many() + + async def get_subject_by_id(self, id: str) -> Optional[Subject]: + return await db.subject.find_unique(where={"id": id}) + + async def create_subject(self, name: str, code: str, description: Optional[str] = None) -> Subject: + return await db.subject.create(data={"name": name, "code": code, "description": description}) + + async def update_subject(self, id: str, data: dict) -> Subject: + return await db.subject.update(where={"id": id}, data=data) + + async def delete_subject(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None: + sub = await db.subject.find_unique(where={"id": id}) + await self._validate_delete("Subject", sub, "subjectId", "academicclass") + await db.subject.delete(where={"id": id}) + await self._log_action("DELETE_SUBJECT", "WARNING", actor, id, f"Deleted subject {sub.name if sub else id}", ip) + + # --- Classrooms --- + + async def get_all_classrooms(self) -> List[Classroom]: + return await db.classroom.find_many() + + async def get_classroom_by_id(self, id: str) -> Optional[Classroom]: + return await db.classroom.find_unique(where={"id": id}) + + async def create_classroom(self, name: str, building: Optional[str] = None, capacity: Optional[int] = None) -> Classroom: + return await db.classroom.create(data={"name": name, "building": building, "capacity": capacity}) + + async def update_classroom(self, id: str, data: dict) -> Classroom: + return await db.classroom.update(where={"id": id}, data=data) + + async def delete_classroom(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None: + classroom = await db.classroom.find_unique(where={"id": id}) + await self._validate_delete("Classroom", classroom, "classroomId", "academicclass") + await db.classroom.delete(where={"id": id}) + await self._log_action("DELETE_CLASSROOM", "WARNING", actor, id, f"Deleted classroom {classroom.name if classroom else id}", ip) + + # --- Designations --- + + async def get_all_designations(self) -> List[Designation]: + return await db.designation.find_many() + + async def get_designation_by_id(self, id: str) -> Optional[Designation]: + return await db.designation.find_unique(where={"id": id}) + + async def create_designation(self, name: str, code: str, description: Optional[str] = None) -> Designation: + return await db.designation.create(data={"name": name, "code": code, "description": description}) + + async def update_designation(self, id: str, data: dict) -> Designation: + return await db.designation.update(where={"id": id}, data=data) + + async def delete_designation(self, id: str, actor: str = "system", ip: Optional[str] = None) -> None: + desig = await db.designation.find_unique(where={"id": id}) + await self._validate_delete("Designation", desig, "designationId", "teacher") + await db.designation.delete(where={"id": id}) + await self._log_action("DELETE_DESIGNATION", "WARNING", actor, id, f"Deleted designation {desig.name if desig else id}", ip) + + # --- Misc --- + + async def get_audit_logs(self) -> List[AuditLog]: + return await db.auditlog.find_many(order={"timestamp": "desc"}) + + async def get_stats(self) -> dict: + return { + "studentCount": await db.student.count(), + "teacherCount": await db.teacher.count(), + "classCount": await db.academicclass.count(), + } + + async def reset_user_password(self, user_id: str, new_password: str, actor: str = "system", ip: Optional[str] = None) -> None: + if not await db.user.find_unique(where={"id": user_id}): + raise ValueError("User not found.") + await db.user.update(where={"id": user_id}, data={"hashedPassword": hash_password(new_password)}) + await self._log_action("RESET_PASSWORD", "WARNING", actor, user_id, "Reset user password", ip) diff --git a/backend/app/services/ai_orchestrator.py b/backend/app/services/ai_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..5ff487c8e9a8e58ce2fe855987442d69c12e60a5 --- /dev/null +++ b/backend/app/services/ai_orchestrator.py @@ -0,0 +1,174 @@ +import asyncio +import os +import shutil +import threading +from typing import List, Optional, Tuple + +import cv2 +import numpy as np +import tensorflow as tf +from deepface import DeepFace +from huggingface_hub import hf_hub_download +from tensorflow.keras.applications.mobilenet import preprocess_input + +from app.core.logging_config import get_logger + +logger = get_logger("app.ai") + +os.environ.setdefault('TF_CPP_MIN_LOG_LEVEL', '3') +os.environ.setdefault('TF_ENABLE_ONEDNN_OPTS', '0') +os.environ.setdefault('CUDA_VISIBLE_DEVICES', '-1') + +BASE_MODELS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../models")) +LIVENESS_REPO = "prathamrajbhar/smart-attendance-liveness-detection" +BACKGROUND_REPO = "prathamrajbhar/smart-attendance-background-validation" +LIVENESS_FILENAME = "liveness_mobilenet_v2.h5" +BACKGROUND_FILENAME = "background_mobilenet_v1.h5" + +LIVENESS_MODEL_PATH_V2 = os.path.join(BASE_MODELS_DIR, "liveness_detection", "liveness_mobilenet_v2.h5") +LIVENESS_MODEL_PATH_V1 = os.path.join(BASE_MODELS_DIR, "liveness_detection", "liveness_mobilenet_v1.h5") +BACKGROUND_MODEL_PATH = os.path.join(BASE_MODELS_DIR, "background_validation", "background_mobilenet_v1.h5") + + +def _ensure_model_downloaded(repo_id: str, filename: str, local_path: str) -> str: + if os.path.exists(local_path): + return local_path + logger.info("Downloading model: %s/%s", repo_id, filename) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + try: + downloaded = hf_hub_download(repo_id=repo_id, filename=filename, token=os.environ.get("HF_TOKEN")) + shutil.copy(downloaded, local_path) + return local_path + except Exception as e: + logger.error("Failed to download model from Hugging Face: %s", e, exc_info=True) + raise RuntimeError(f"Could not load model {filename} from {repo_id}: {e}") from e + + +def _load_liveness_model(model_path: str) -> tf.keras.Model: + base = tf.keras.applications.MobileNetV2(input_shape=(224, 224, 3), include_top=False, weights=None) + x = base.output + x = tf.keras.layers.GlobalAveragePooling2D(name="global_average_pooling2d_3")(x) + x = tf.keras.layers.Dropout(0.001, name="dropout_3")(x) + outputs = tf.keras.layers.Dense(1, activation="sigmoid", name="dense_3")(x) + model = tf.keras.models.Model(inputs=base.input, outputs=outputs) + model.load_weights(model_path, by_name=True) + return model + + +_liveness_path = LIVENESS_MODEL_PATH_V2 if os.path.exists(LIVENESS_MODEL_PATH_V2) else (LIVENESS_MODEL_PATH_V1 if os.path.exists(LIVENESS_MODEL_PATH_V1) else LIVENESS_MODEL_PATH_V2) +_final_liveness_path = _ensure_model_downloaded(LIVENESS_REPO, LIVENESS_FILENAME, _liveness_path) +_final_background_path = _ensure_model_downloaded(BACKGROUND_REPO, BACKGROUND_FILENAME, BACKGROUND_MODEL_PATH) + +liveness_model = _load_liveness_model(_final_liveness_path) +background_model = tf.keras.models.load_model(_final_background_path) + +_liveness_lock = threading.Lock() +_background_lock = threading.Lock() +_deepface_lock = threading.Lock() + + +class AIOrchestrator: + def _detect_and_crop_face(self, img: np.ndarray) -> Tuple[Optional[np.ndarray], Optional[Tuple[int, int, int, int]]]: + try: + with _deepface_lock: + faces = DeepFace.extract_faces(img_path=img, detector_backend="opencv", enforce_detection=True) + if not faces: + return None, None + fa = faces[0]["facial_area"] + x, y, w, h = fa["x"], fa["y"], fa["w"], fa["h"] + return img[y:y+h, x:x+w], (x, y, w, h) + except Exception as e: + logger.warning("DeepFace face extraction failed: %s", e) + return None, None + + @staticmethod + def _preprocess_liveness(face_crop: np.ndarray) -> np.ndarray: + return np.expand_dims((cv2.resize(face_crop, (224, 224)).astype(np.float32) / 127.5) - 1.0, axis=0) + + @staticmethod + def _preprocess_background(img: np.ndarray) -> np.ndarray: + return preprocess_input(np.expand_dims(cv2.resize(img, (224, 224)), axis=0).astype(np.float32)) + + def _run_face_comparison(self, stored_embedding: List[float], live_img: np.ndarray) -> float: + if not stored_embedding or live_img is None: + return 0.0 + try: + with _deepface_lock: + results = DeepFace.represent(img_path=live_img, model_name="Facenet", enforce_detection=False) + if not results: + return 0.0 + vec_s = np.array(stored_embedding, dtype=np.float32) + vec_l = np.array(results[0]["embedding"], dtype=np.float32) + norm_s, norm_l = np.linalg.norm(vec_s), np.linalg.norm(vec_l) + if norm_s == 0.0 or norm_l == 0.0: + return 0.0 + return max(0.0, min(1.0, float(np.dot(vec_s, vec_l) / (norm_s * norm_l)))) + except Exception as e: + logger.error("DeepFace face comparison failed safely: %s", e, exc_info=True) + return 0.0 + + def _run_liveness_inference(self, face_crop: np.ndarray) -> float: + if face_crop is None: + return 0.0 + with _liveness_lock: + return float(liveness_model.predict(self._preprocess_liveness(cv2.cvtColor(face_crop, cv2.COLOR_RGB2BGR)), verbose=0)[0][0]) + + def _run_background_inference(self, img: np.ndarray) -> float: + if img is None: + return 0.0 + with _background_lock: + return float(background_model.predict(self._preprocess_background(img), verbose=0)[0][0]) + + def _run_embedding_extraction(self, image_path: str) -> List[float]: + try: + img = cv2.imread(image_path) + if img is None: + return [] + with _deepface_lock: + results = DeepFace.represent(img_path=cv2.cvtColor(img, cv2.COLOR_BGR2RGB), model_name="Facenet", enforce_detection=True) + return [float(v) for v in results[0]["embedding"]] if results else [] + except Exception as e: + logger.error("DeepFace face embedding extraction failed safely: %s", e, exc_info=True) + return [] + + async def extract_face_embedding(self, image_path: str) -> List[float]: + if not os.path.exists(image_path): + return [] + try: + return await asyncio.to_thread(self._run_embedding_extraction, image_path) + except Exception as e: + logger.error("Face embedding extraction thread run failed: %s", e, exc_info=True) + return [] + + async def analyze_attendance(self, image_path: str, face_embedding: List[float]) -> dict: + if not os.path.exists(image_path): + logger.warning("Image path does not exist for attendance analysis: %s", image_path) + return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0} + + def _load_and_crop(): + img = cv2.imread(image_path) + if img is None: + return None, None, None + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + face_crop, _ = self._detect_and_crop_face(img_rgb) + return img_rgb, face_crop, _ + + try: + img_rgb, face_crop, _ = await asyncio.to_thread(_load_and_crop) + except Exception as e: + logger.error("Failed to load and crop image: %s", e, exc_info=True) + return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0} + + if img_rgb is None: + return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0} + + try: + face_score, liveness_score, bg_score = await asyncio.gather( + asyncio.to_thread(self._run_face_comparison, face_embedding, img_rgb), + asyncio.to_thread(self._run_liveness_inference, face_crop), + asyncio.to_thread(self._run_background_inference, img_rgb), + ) + return {"face_score": face_score, "liveness_score": liveness_score, "background_score": bg_score} + except Exception as e: + logger.error("Concurrent AI inference failed: %s", e, exc_info=True) + return {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0} diff --git a/backend/app/services/attendance_service.py b/backend/app/services/attendance_service.py new file mode 100644 index 0000000000000000000000000000000000000000..ec6d145be3f6c7f3837de34dca3fff3738367aca --- /dev/null +++ b/backend/app/services/attendance_service.py @@ -0,0 +1,465 @@ +import os +import json +from dataclasses import dataclass +from datetime import datetime, timezone + +from prisma.models import Attendance + +from app.core.config import settings +from app.core.logging_config import get_logger +from app.db.redis import get_redis +from app.repositories.attendance_repo import AttendanceRepository +from app.repositories.session_repo import SessionRepository +from app.repositories.geofence_repo import GeofenceRepository +from app.repositories.student_repo import StudentRepository +from app.repositories.class_repo import ClassRepository +from app.services.ai_orchestrator import AIOrchestrator +from app.services.system_config_service import SystemConfigService +from app.utils.geofencing import GPSCoordinate, calculate_haversine_distance, is_within_geofence +from app.api.ws import manager + +logger = get_logger("app.attendance") + + +class CachedSession: + def __init__(self, id: str, is_active: bool, academic_class_id: str, end_time: datetime): + self.id = id + self.isActive = is_active + self.academicClassId = academic_class_id + self.endTime = end_time + + +@dataclass(frozen=True) +class AttendanceSubmission: + student_id: str + session_id: str + latitude: float + longitude: float + accuracy: float + image_path: str | None = None + + +class AttendanceService: + def __init__(self) -> None: + self.attendance_repo = AttendanceRepository() + self.session_repo = SessionRepository() + self.geofence_repo = GeofenceRepository() + self.student_repo = StudentRepository() + self.class_repo = ClassRepository() + self.ai_orchestrator = AIOrchestrator() + + async def mark_attendance(self, submission: AttendanceSubmission) -> Attendance: + session = None + redis_client = None + cache_key = f"session:{submission.session_id}" + + try: + redis_client = get_redis() + cached = await redis_client.get(cache_key) + if cached: + data = json.loads(cached) + end_time_str = data["endTime"].replace("Z", "+00:00") + session = CachedSession( + id=data["id"], is_active=data["isActive"], + academic_class_id=data["academicClassId"], + end_time=datetime.fromisoformat(end_time_str), + ) + except Exception: + logger.warning("Redis session cache read failed. Falling back to DB.") + + if not session: + session = await self.session_repo.get_by_id(submission.session_id) + if not session: + raise ValueError("Attendance session is not active or not found.") + + if redis_client: + try: + now = datetime.now(timezone.utc) + end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime + ttl = max(1, min(int((end - now).total_seconds()), 600)) + await redis_client.setex(cache_key, ttl, json.dumps({ + "id": session.id, "isActive": session.isActive, + "academicClassId": session.academicClassId, + "endTime": session.endTime.isoformat(), + })) + except Exception as e: + logger.warning("Redis session cache write failed: %s", e) + + now = datetime.now(timezone.utc) + session_end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime + if not session.isActive or session_end <= now: + if session.isActive: + await self.session_repo.deactivate(session.id) + try: + await get_redis().delete(f"session:{session.id}") + except Exception: + pass + raise ValueError("Attendance session is not active or not found.") + + config = await SystemConfigService().get_config() + + geofence_missing = False + remarks = None + + if config.isGpsVerificationEnabled: + geofence = await self.geofence_repo.get_by_class_id(session.academicClassId) + if not geofence: + logger.warning("Missing geofence for class %s (student %s)", session.academicClassId, submission.student_id) + geofence_missing = True + remarks = "Missing Geofence Data" + else: + student_coord = GPSCoordinate(submission.latitude, submission.longitude) + classroom_coord = GPSCoordinate(geofence.latitude, geofence.longitude) + is_inside = is_within_geofence( + student_coord=student_coord, + classroom_coord=classroom_coord, + base_radius=geofence.radiusMeters, + student_accuracy=submission.accuracy, + ) + if not is_inside: + distance = calculate_haversine_distance(student_coord, classroom_coord) + effective_radius = geofence.radiusMeters + submission.accuracy + raise ValueError( + f"Student is outside geofence boundary by {distance - effective_radius:.1f}m. " + f"(Distance: {distance:.1f}m, Effective Allowed Radius: {effective_radius:.1f}m)" + ) + + if config.isFaceRecognitionEnabled: + face_embedding = await self.student_repo.get_face_embedding(submission.student_id) + if not face_embedding: + raise ValueError("Student face embedding is not registered.") + else: + face_embedding = [] + + existing = await self.attendance_repo.get_by_student_and_session(submission.student_id, submission.session_id) + if existing: + raise ValueError("Attendance already submitted for this session.") + + if config.isFaceRecognitionEnabled or config.isAiBackgroundValidationEnabled: + if not submission.image_path: + raise ValueError("Image is required when verification is enabled.") + ai_results = await self.ai_orchestrator.analyze_attendance(submission.image_path, face_embedding) + else: + ai_results = {"face_score": 1.0, "liveness_score": 1.0, "background_score": 1.0} + + if not config.isFaceRecognitionEnabled: + ai_results["face_score"] = 1.0 + ai_results["liveness_score"] = 1.0 + + if not config.isAiBackgroundValidationEnabled: + ai_results["background_score"] = 1.0 + final_score = ( + settings.FACE_WEIGHT * ai_results["face_score"] + + settings.LIVENESS_WEIGHT * ai_results["liveness_score"] + + settings.BACKGROUND_WEIGHT * ai_results["background_score"] + ) + status = "Flagged" if geofence_missing else ("Present" if final_score >= settings.PASS_THRESHOLD else "Flagged") + + attendance_record = await self.attendance_repo.create({ + "studentId": submission.student_id, "sessionId": submission.session_id, + "status": status, + "faceScore": ai_results["face_score"], "livenessScore": ai_results["liveness_score"], + "backgroundScore": ai_results["background_score"], "finalAiScore": final_score, + "gpsLatitude": submission.latitude, "gpsLongitude": submission.longitude, + "remarks": remarks, + }) + + try: + msg = {"type": "attendance_updated", "session_id": submission.session_id, "status": status} + await manager.send_personal_message(msg, student_id=submission.student_id) + msg["student_id"] = submission.student_id + await manager.broadcast_to_teachers(msg) + except Exception as e: + logger.warning("WebSocket broadcast failed: %s", e) + + if status == "Flagged": + try: + from app.services.notification_service import notify_student_attendance_flagged + student = await self.student_repo.get_by_id(submission.student_id) + if student and student.fcmToken: + ac = await self.class_repo.get_by_id(session.academicClassId) + class_name = ac.name if ac else "your class" + await notify_student_attendance_flagged(student.fcmToken, student.firstName or "Student", class_name, attendance_record.id) + except Exception as e: + logger.warning("FCM notification failed: %s", e) + + try: + from app.services.gamification_service import GamificationService + await GamificationService().update_streak(submission.student_id, status) + except Exception as e: + logger.warning("Streak update failed: %s", e) + + if status == "Present" and submission.image_path and os.path.exists(submission.image_path): + try: + os.remove(submission.image_path) + except Exception as e: + logger.error("Failed to remove temp image %s: %s", submission.image_path, e, exc_info=True) + + return attendance_record + + async def analyze_attendance(self, submission: AttendanceSubmission) -> dict: + """Run all validation + AI scoring but do NOT save the record. + + Returns a dict with scores, predicted status, and a short-lived + review_token that can be passed to mark_attendance to skip re-running AI. + """ + from datetime import timedelta + from app.core.security import create_access_token + + # ── Session validation ────────────────────────────────────────────── + session = None + redis_client = None + cache_key = f"session:{submission.session_id}" + + try: + redis_client = get_redis() + cached = await redis_client.get(cache_key) + if cached: + data = json.loads(cached) + end_time_str = data["endTime"].replace("Z", "+00:00") + session = CachedSession( + id=data["id"], is_active=data["isActive"], + academic_class_id=data["academicClassId"], + end_time=datetime.fromisoformat(end_time_str), + ) + except Exception: + logger.warning("Redis session cache read failed. Falling back to DB.") + + if not session: + session = await self.session_repo.get_by_id(submission.session_id) + if not session: + raise ValueError("Attendance session is not active or not found.") + + now = datetime.now(timezone.utc) + session_end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime + if not session.isActive or session_end <= now: + if session.isActive: + await self.session_repo.deactivate(session.id) + try: + await get_redis().delete(f"session:{session.id}") + except Exception: + pass + raise ValueError("Attendance session is not active or not found.") + + config = await SystemConfigService().get_config() + + # ── Geofence check ────────────────────────────────────────────────── + geofence_missing = False + if config.isGpsVerificationEnabled: + geofence = await self.geofence_repo.get_by_class_id(session.academicClassId) + if not geofence: + logger.warning("Missing geofence for class %s (student %s)", session.academicClassId, submission.student_id) + geofence_missing = True + else: + student_coord = GPSCoordinate(submission.latitude, submission.longitude) + classroom_coord = GPSCoordinate(geofence.latitude, geofence.longitude) + is_inside = is_within_geofence( + student_coord=student_coord, + classroom_coord=classroom_coord, + base_radius=geofence.radiusMeters, + student_accuracy=submission.accuracy, + ) + if not is_inside: + distance = calculate_haversine_distance(student_coord, classroom_coord) + effective_radius = geofence.radiusMeters + submission.accuracy + raise ValueError( + f"Student is outside geofence boundary by {distance - effective_radius:.1f}m. " + f"(Distance: {distance:.1f}m, Effective Allowed Radius: {effective_radius:.1f}m)" + ) + + # ── Face embedding ────────────────────────────────────────────────── + if config.isFaceRecognitionEnabled: + face_embedding = await self.student_repo.get_face_embedding(submission.student_id) + if not face_embedding: + raise ValueError("Student face embedding is not registered.") + else: + face_embedding = [] + + # ── Duplicate check ───────────────────────────────────────────────── + existing = await self.attendance_repo.get_by_student_and_session(submission.student_id, submission.session_id) + if existing: + raise ValueError("Attendance already submitted for this session.") + + # ── AI scoring ────────────────────────────────────────────────────── + if config.isFaceRecognitionEnabled or config.isAiBackgroundValidationEnabled: + if not submission.image_path: + raise ValueError("Image is required when verification is enabled.") + ai_results = await self.ai_orchestrator.analyze_attendance(submission.image_path, face_embedding) + else: + ai_results = {"face_score": 1.0, "liveness_score": 1.0, "background_score": 1.0} + + if not config.isFaceRecognitionEnabled: + ai_results["face_score"] = 1.0 + ai_results["liveness_score"] = 1.0 + + if not config.isAiBackgroundValidationEnabled: + ai_results["background_score"] = 1.0 + final_score = ( + settings.FACE_WEIGHT * ai_results["face_score"] + + settings.LIVENESS_WEIGHT * ai_results["liveness_score"] + + settings.BACKGROUND_WEIGHT * ai_results["background_score"] + ) + predicted_status = "Flagged" if geofence_missing else ("Present" if final_score >= settings.PASS_THRESHOLD else "Flagged") + + # ── Build review token (5-minute TTL) ─────────────────────────────── + review_token = create_access_token( + subject=submission.student_id, + role="STUDENT", + expires_delta=timedelta(minutes=5), + extra_data={ + "type": "attendance_review", + "session_id": submission.session_id, + "face_score": ai_results["face_score"], + "liveness_score": ai_results["liveness_score"], + "background_score": ai_results["background_score"], + "final_ai_score": final_score, + "predicted_status": predicted_status, + "geofence_missing": geofence_missing, + "image_path": submission.image_path, + "latitude": submission.latitude, + "longitude": submission.longitude, + }, + ) + + return { + "face_score": ai_results["face_score"], + "liveness_score": ai_results["liveness_score"], + "background_score": ai_results["background_score"], + "final_ai_score": final_score, + "predicted_status": predicted_status, + "review_token": review_token, + } + + async def confirm_attendance(self, student_id: str, review_token: str) -> "Attendance": + """Confirm a previously analyzed submission using its review token. + + Decodes the token, validates it, and saves the attendance record + without re-running AI inference. + """ + from app.core.security import decode_access_token + + payload = decode_access_token(review_token) + if not payload or payload.get("type") != "attendance_review": + raise ValueError("Invalid or expired review token.") + if payload.get("sub") != student_id: + raise ValueError("Review token does not belong to this student.") + + session_id = payload["session_id"] + + # Re-check duplicate (student might have confirmed twice) + existing = await self.attendance_repo.get_by_student_and_session(student_id, session_id) + if existing: + raise ValueError("Attendance already submitted for this session.") + + # Re-check session still active + session = await self.session_repo.get_by_id(session_id) + if not session: + raise ValueError("Attendance session is no longer active.") + + now = datetime.now(timezone.utc) + session_end = session.endTime.replace(tzinfo=timezone.utc) if session.endTime.tzinfo is None else session.endTime + if not session.isActive or session_end <= now: + if session.isActive: + await self.session_repo.deactivate(session.id) + try: + await get_redis().delete(f"session:{session.id}") + except Exception: + pass + raise ValueError("Attendance session is no longer active.") + + face_score = payload["face_score"] + liveness_score = payload["liveness_score"] + background_score = payload["background_score"] + final_score = payload["final_ai_score"] + predicted_status = payload["predicted_status"] + geofence_missing = payload.get("geofence_missing", False) + image_path = payload["image_path"] + latitude = payload["latitude"] + longitude = payload["longitude"] + remarks = "Missing Geofence Data" if geofence_missing else None + + attendance_record = await self.attendance_repo.create({ + "studentId": student_id, + "sessionId": session_id, + "status": predicted_status, + "faceScore": face_score, + "livenessScore": liveness_score, + "backgroundScore": background_score, + "finalAiScore": final_score, + "gpsLatitude": latitude, + "gpsLongitude": longitude, + "remarks": remarks, + }) + + try: + msg = {"type": "attendance_updated", "session_id": session_id, "status": predicted_status} + await manager.send_personal_message(msg, student_id=student_id) + msg["student_id"] = student_id + await manager.broadcast_to_teachers(msg) + except Exception as e: + logger.warning("WebSocket broadcast failed: %s", e) + + if predicted_status == "Flagged": + try: + from app.services.notification_service import notify_student_attendance_flagged + student = await self.student_repo.get_by_id(student_id) + if student and student.fcmToken: + ac = await self.class_repo.get_by_id(session.academicClassId) + class_name = ac.name if ac else "your class" + await notify_student_attendance_flagged(student.fcmToken, student.firstName or "Student", class_name, attendance_record.id) + except Exception as e: + logger.warning("FCM notification failed: %s", e) + + try: + from app.services.gamification_service import GamificationService + await GamificationService().update_streak(student_id, predicted_status) + except Exception as e: + logger.warning("Streak update failed: %s", e) + + if predicted_status == "Present" and image_path and os.path.exists(image_path): + try: + os.remove(image_path) + except Exception as e: + logger.error("Failed to remove temp image %s: %s", image_path, e, exc_info=True) + + return attendance_record + + async def register_face(self, student_id: str, image_path: str) -> bool: + embedding = await self.ai_orchestrator.extract_face_embedding(image_path) + if not embedding: + return False + await self.student_repo.update_face_embedding(student_id, embedding) + return True + + async def review_attendance(self, attendance_id: str, status: str, remarks: str) -> bool: + record = await self.attendance_repo.get_by_id(attendance_id) + if not record or record.status != "Flagged": + return False + + await self.attendance_repo.update_review(attendance_id, status, remarks) + + try: + msg = {"type": "attendance_updated", "session_id": record.sessionId, "status": status} + await manager.send_personal_message(msg, student_id=record.studentId) + msg["student_id"] = record.studentId + await manager.broadcast_to_teachers(msg) + except Exception as e: + logger.warning("WebSocket broadcast failed: %s", e) + + try: + from app.services.notification_service import notify_student_attendance_reviewed + student = await self.student_repo.get_by_id(record.studentId) + if student and student.fcmToken: + ac = await self.class_repo.get_by_id(record.session.academicClassId if record.session else "") + class_name = ac.name if ac else "your class" + await notify_student_attendance_reviewed(student.fcmToken, status, class_name) + except Exception as e: + logger.warning("FCM notification failed: %s", e) + + try: + from app.services.gamification_service import GamificationService + await GamificationService().recalculate_student_streak(record.studentId) + except Exception as e: + logger.warning("Failed to recalculate streak for student %s: %s", record.studentId, e) + + return True diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..50c46c2015468cc631e46c73bfd3883225f5cf85 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,56 @@ +from typing import Optional + +from fastapi import HTTPException, status + +from app.core.security import hash_password, verify_password, create_access_token +from app.db.client import db +from app.repositories.user_repo import UserRepository +from app.repositories.student_repo import StudentRepository +from app.repositories.teacher_repo import TeacherRepository +from app.schemas.auth import Token, UserLogin +from app.schemas.student import StudentCreate, StudentResponse +from app.schemas.teacher import TeacherCreate, TeacherResponse + + +class AuthService: + def __init__(self) -> None: + self.user_repo = UserRepository() + self.student_repo = StudentRepository() + self.teacher_repo = TeacherRepository() + + async def authenticate(self, login_data: UserLogin) -> Optional[Token]: + user = await self.user_repo.get_by_email(login_data.email) + if not user or not verify_password(login_data.password, user.hashedPassword): + return None + + if user.role == "STUDENT": + student = await self.student_repo.get_by_user_id(user.id) + if student and login_data.device_uuid: + if not student.deviceUuid: + await db.student.update( + where={"id": student.id}, data={"deviceUuid": login_data.device_uuid} + ) + elif student.deviceUuid != login_data.device_uuid: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is bound to another device.", + ) + + token = create_access_token(subject=user.id, role=user.role) + return Token(access_token=token, token_type="bearer", role=user.role) + + async def register_student(self, data: StudentCreate) -> Optional[StudentResponse]: + if await self.user_repo.get_by_email(data.email): + return None + hashed = hash_password(data.password) + user = await self.user_repo.create(email=data.email, password_hash=hashed, role="STUDENT") + student = await self.student_repo.create(user_id=user.id, enrollment=data.enrollment_number) + return StudentResponse(id=student.id, user_id=user.id, enrollment_number=student.enrollmentNumber, email=user.email) + + async def register_teacher(self, data: TeacherCreate) -> Optional[TeacherResponse]: + if await self.user_repo.get_by_email(data.email): + return None + hashed = hash_password(data.password) + user = await self.user_repo.create(email=data.email, password_hash=hashed, role="TEACHER") + teacher = await self.teacher_repo.create(user_id=user.id, department=data.department_id, designation=data.designation_id) + return TeacherResponse(id=teacher.id, user_id=user.id, department=teacher.department, designation=teacher.designation, email=user.email) diff --git a/backend/app/services/device_change_service.py b/backend/app/services/device_change_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e44bc2bf8d523bbddff551139aaff9ba4d16c695 --- /dev/null +++ b/backend/app/services/device_change_service.py @@ -0,0 +1,105 @@ +from typing import List +from fastapi import HTTPException, status +from app.db.client import db +from app.core.security import verify_password +from app.repositories.user_repo import UserRepository +from app.repositories.student_repo import StudentRepository +from app.schemas.auth import DeviceChangeRequestCreate +from app.schemas.teacher import DeviceChangeResponse + +class DeviceChangeService: + def __init__(self) -> None: + self.user_repo = UserRepository() + self.student_repo = StudentRepository() + + async def request_device_change(self, data: DeviceChangeRequestCreate) -> None: + user = await self.user_repo.get_by_email(data.email) + if not user or not verify_password(data.password, user.hashedPassword) or user.role != "STUDENT": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + + student = await self.student_repo.get_by_user_id(user.id) + if not student: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student record not found") + + # Check if already has pending request + pending_request = await db.devicechangerequest.find_first( + where={ + "studentId": student.id, + "status": "PENDING" + } + ) + + if pending_request: + # Update existing pending request + await db.devicechangerequest.update( + where={"id": pending_request.id}, + data={ + "newDeviceUuid": data.new_device_uuid, + "reason": data.reason + } + ) + else: + # Create new request + await db.devicechangerequest.create( + data={ + "studentId": student.id, + "newDeviceUuid": data.new_device_uuid, + "reason": data.reason, + "status": "PENDING" + } + ) + + async def get_pending_requests(self, teacher_id: str) -> List[DeviceChangeResponse]: + # For simplicity, returning all pending requests in the system. + # Ideally, we would filter by department or class. + records = await db.devicechangerequest.find_many( + where={"status": "PENDING"}, + include={ + "student": { + "include": { + "user": True + } + } + }, + order={"createdAt": "desc"} + ) + + response_list = [] + for r in records: + name = f"{r.student.firstName or ''} {r.student.lastName or ''}".strip() + response_list.append(DeviceChangeResponse( + id=r.id, + student_id=r.studentId, + student_name=name or "Unknown", + enrollment_number=r.student.enrollmentNumber, + new_device_uuid=r.newDeviceUuid, + reason=r.reason, + status=r.status, + approved_by=r.approvedBy, + created_at=r.createdAt, + updated_at=r.updatedAt + )) + return response_list + + async def approve_request(self, request_id: str, teacher_id: str, new_status: str) -> bool: + request_record = await db.devicechangerequest.find_unique(where={"id": request_id}) + if not request_record or request_record.status != "PENDING": + return False + + if new_status == "APPROVED": + # Update student device_uuid + await db.student.update( + where={"id": request_record.studentId}, + data={"deviceUuid": request_record.newDeviceUuid} + ) + + # Update request status + await db.devicechangerequest.update( + where={"id": request_id}, + data={ + "status": new_status, + "approvedBy": teacher_id + } + ) + + return True diff --git a/backend/app/services/gamification_service.py b/backend/app/services/gamification_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0f987fe24ac0184e768a416cc2c96cb8ad847535 --- /dev/null +++ b/backend/app/services/gamification_service.py @@ -0,0 +1,293 @@ +from datetime import datetime, timedelta, timezone +from typing import Tuple + +from app.core.logging_config import get_logger +from app.repositories.student_repo import StudentRepository +from app.repositories.attendance_repo import AttendanceRepository + +logger = get_logger("app.gamification") + + +class GamificationService: + def __init__(self): + self.student_repo = StudentRepository() + self.attendance_repo = AttendanceRepository() + + async def update_streak(self, student_id: str, attendance_status: str) -> dict: + """Helper to compatibility-wrap recalculate_student_streak.""" + return await self.recalculate_student_streak(student_id) + + async def recalculate_student_streak(self, student_id: str) -> dict: + """Recalculate student streak based on historical attendance logs and active sessions.""" + student = await self.student_repo.get_by_id(student_id) + if not student: + return {"error": "Student not found"} + + from app.db.client import db + enrollments = await db.enrollment.find_many(where={"studentId": student_id}) + class_ids = [e.academicClassId for e in enrollments] + + if not class_ids: + await self.student_repo.update_streak(student_id, 0, student.highestStreak or 0) + await self._update_redis_score(student_id, 0, student.highestStreak or 0) + return {"current_streak": 0, "highest_streak": student.highestStreak or 0} + + now = datetime.now(timezone.utc) + + # Get all sessions that have already started + sessions = await db.session.find_many( + where={ + "academicClassId": {"in": class_ids}, + "startTime": {"lte": now} + }, + order={"startTime": "desc"} + ) + + attendance = await db.attendance.find_many(where={"studentId": student_id}) + attendance_map = {a.sessionId: a for a in attendance} + + leaves = await db.leaverequest.find_many(where={"studentId": student_id, "status": "APPROVED"}) + + def is_on_leave(session_start: datetime) -> bool: + s_start = session_start.replace(tzinfo=timezone.utc) if session_start.tzinfo is None else session_start + for leave in leaves: + l_start = leave.startDate.replace(tzinfo=timezone.utc) if leave.startDate.tzinfo is None else leave.startDate + l_end = leave.endDate.replace(tzinfo=timezone.utc) if leave.endDate.tzinfo is None else leave.endDate + if l_start <= s_start <= l_end: + return True + return False + + # Filter out active sessions where student hasn't checked in yet + valid_sessions = [] + for s in sessions: + s_end = s.endTime.replace(tzinfo=timezone.utc) if s.endTime.tzinfo is None else s.endTime + is_active = s.isActive and s_end > now + has_checked_in = s.id in attendance_map and attendance_map[s.id].status in ("Present", "Approved") + + if is_active and not has_checked_in: + continue + valid_sessions.append(s) + + # 1. Current streak calculation (descending order) + current_streak = 0 + for s in valid_sessions: + att = attendance_map.get(s.id) + status = att.status if att else None + + if status in ("Present", "Approved"): + current_streak += 1 + elif is_on_leave(s.startTime): + continue + elif status == "Flagged": + continue + else: + break + + # 2. Highest streak calculation (ascending order) + highest_streak = student.highestStreak or 0 + running_streak = 0 + for s in reversed(valid_sessions): + att = attendance_map.get(s.id) + status = att.status if att else None + + if status in ("Present", "Approved"): + running_streak += 1 + highest_streak = max(highest_streak, running_streak) + elif is_on_leave(s.startTime): + continue + elif status == "Flagged": + continue + else: + running_streak = 0 + + highest_streak = max(highest_streak, current_streak) + + await self.student_repo.update_streak(student_id, current_streak, highest_streak) + await self._update_redis_score(student_id, current_streak, highest_streak) + return {"current_streak": current_streak, "highest_streak": highest_streak} + + async def calculate_consecutive_absences(self, student_id: str, days: int = 3) -> int: + end = datetime.now(timezone.utc) + records = await self.attendance_repo.get_by_student_in_date_range( + student_id=student_id, start_date=end - timedelta(days=7), end_date=end + ) + consecutive = 0 + for record in sorted(records, key=lambda r: r.createdAt, reverse=True): + if record.status == "Absent": + consecutive += 1 + else: + break + return consecutive + + async def get_student_stats(self, student_id: str) -> dict: + student = await self.student_repo.get_by_id(student_id) + if not student: + return {} + + all_records = await self.attendance_repo.get_by_student_id(student_id) + total = len(all_records) + present = sum(1 for r in all_records if r.status in ("Present", "Approved")) + absent = sum(1 for r in all_records if r.status == "Absent") + flagged = sum(1 for r in all_records if r.status == "Flagged") + excused = sum(1 for r in all_records if r.status == "Excused") + + return { + "current_streak": student.currentStreak or 0, + "highest_streak": student.highestStreak or 0, + "total_classes": total, + "present_count": present, + "absent_count": absent, + "flagged_count": flagged, + "excused_count": excused, + "attendance_percentage": round((present / total * 100) if total > 0 else 0, 2), + } + + def _get_redis_client(self): + """Return the active Redis client if available.""" + try: + from app.db.redis import get_redis + return get_redis() + except Exception as e: + logger.warning("Redis client not available: %s", e) + return None + + async def _update_redis_score(self, student_id: str, current_streak: int, highest_streak: int) -> None: + """Update a student's score in the Redis leaderboard.""" + try: + redis = self._get_redis_client() + if redis: + all_records = await self.attendance_repo.get_by_student_id(student_id) + present_count = sum(1 for r in all_records if r.status in ("Present", "Approved")) + new_points = present_count * 50 + highest_streak * 100 + current_streak * 20 + await redis.zadd("leaderboard:points", {student_id: float(new_points)}) + except Exception as e: + logger.warning("Failed to update leaderboard cache: %s", e) + + async def get_leaderboard(self, current_student_id: str) -> dict: + """Fetch the leaderboard from Redis, falling back to DB if empty.""" + redis = self._get_redis_client() + cache_key = "leaderboard:points" + + try: + if redis and not await redis.exists(cache_key): + await self._rebuild_leaderboard_cache(redis, cache_key) + except Exception as e: + logger.warning("Redis operation failed in leaderboard check: %s", e) + + leaderboard_data = [] + if redis: + leaderboard_data = await self._fetch_leaderboard_from_cache(redis, cache_key) + + if not leaderboard_data: + return await self._get_leaderboard_from_db(current_student_id) + + user_rank, user_points = await self._fetch_user_rank_and_points(redis, cache_key, current_student_id) + return { + "leaderboard": leaderboard_data, + "user_rank": user_rank, + "user_points": user_points + } + + async def _rebuild_leaderboard_cache(self, redis, cache_key: str) -> None: + """Rebuild the leaderboard cache from DB data.""" + if not redis: + return + try: + from app.db.client import db + students = await db.student.find_many( + where={"user": {"is": {"isActive": True}}}, + include={"attendance": True} + ) + scores_dict = {} + for s in students: + present_count = sum(1 for r in s.attendance if r.status in ("Present", "Approved")) + points = present_count * 50 + (s.highestStreak or 0) * 100 + (s.currentStreak or 0) * 20 + scores_dict[s.id] = float(points) + if scores_dict: + await redis.zadd(cache_key, scores_dict) + await redis.expire(cache_key, 3600) + except Exception as e: + logger.error("Failed to rebuild leaderboard cache: %s", e, exc_info=True) + + async def _fetch_leaderboard_from_cache(self, redis, cache_key: str) -> list: + """Fetch top 10 students from Redis cache and load details from DB.""" + try: + top_members = await redis.zrevrange(cache_key, 0, 9, withscores=True) + if not top_members: + return [] + top_ids = [m[0] for m in top_members] + from app.db.client import db + top_students = await db.student.find_many(where={"id": {"in": top_ids}}) + students_map = {s.id: s for s in top_students} + + leaderboard_data = [] + for s_id, score in top_members: + s = students_map.get(s_id) + if s: + name = f"{s.firstName or ''} {s.lastName or ''}".strip() or "Student" + leaderboard_data.append({ + "student_id": s_id, + "name": name, + "points": int(score), + "current_streak": s.currentStreak or 0, + }) + return leaderboard_data + except Exception as e: + logger.error("Failed to fetch leaderboard from cache: %s", e, exc_info=True) + return [] + + async def _fetch_user_rank_and_points(self, redis, cache_key: str, student_id: str) -> Tuple[int | None, int]: + """Fetch rank and points for a specific user from Redis.""" + if not redis: + return None, 0 + try: + current_rank_0 = await redis.zrevrank(cache_key, student_id) + user_rank = current_rank_0 + 1 if current_rank_0 is not None else None + current_score = await redis.zscore(cache_key, student_id) + user_points = int(current_score) if current_score is not None else 0 + return user_rank, user_points + except Exception as e: + logger.warning("Failed to fetch user rank from Redis: %s", e) + return None, 0 + + async def _get_leaderboard_from_db(self, current_student_id: str) -> dict: + """Generate leaderboard directly from database query (fallback).""" + try: + from app.db.client import db + students = await db.student.find_many( + where={"user": {"is": {"isActive": True}}}, + include={"attendance": True} + ) + student_list = [] + for s in students: + present_count = sum(1 for r in s.attendance if r.status in ("Present", "Approved")) + points = present_count * 50 + (s.highestStreak or 0) * 100 + (s.currentStreak or 0) * 20 + student_list.append((s, points)) + + student_list.sort(key=lambda x: x[1], reverse=True) + + leaderboard_data = [] + for s, points in student_list[:10]: + name = f"{s.firstName or ''} {s.lastName or ''}".strip() or "Student" + leaderboard_data.append({ + "student_id": s.id, + "name": name, + "points": points, + "current_streak": s.currentStreak or 0, + }) + + user_rank = None + user_points = 0 + for index, (s, points) in enumerate(student_list): + if s.id == current_student_id: + user_rank = index + 1 + user_points = points + break + return { + "leaderboard": leaderboard_data, + "user_rank": user_rank, + "user_points": user_points + } + except Exception as e: + logger.error("Database fallback leaderboard query failed: %s", e, exc_info=True) + return {"leaderboard": [], "user_rank": None, "user_points": 0} diff --git a/backend/app/services/leave_service.py b/backend/app/services/leave_service.py new file mode 100644 index 0000000000000000000000000000000000000000..d07150ad8e9e96f4cfbf82e8a9200f596447d365 --- /dev/null +++ b/backend/app/services/leave_service.py @@ -0,0 +1,65 @@ +from typing import Optional + +from prisma.models import LeaveRequest + +from app.core.logging_config import get_logger +from app.repositories.leave_repo import LeaveRepository +from app.repositories.attendance_repo import AttendanceRepository +from app.repositories.enrollment_repo import EnrollmentRepository +from app.repositories.session_repo import SessionRepository + +logger = get_logger("app.leave_service") + + +class LeaveService: + def __init__(self): + self.leave_repo = LeaveRepository() + self.attendance_repo = AttendanceRepository() + self.enrollment_repo = EnrollmentRepository() + self.session_repo = SessionRepository() + + async def approve_leave(self, leave_id: str, teacher_id: str, status: str, approver_note: Optional[str] = None) -> Optional[LeaveRequest]: + leave = await self.leave_repo.get_by_id(leave_id) + if not leave: + return None + + updated_leave = await self.leave_repo.update_status( + leave_id=leave_id, status=status, approved_by=teacher_id, approver_note=approver_note + ) + + if status == "APPROVED": + await self._mark_excused_attendance(leave) + logger.info("Leave approved: student=%s dates=%s to %s", leave.studentId, leave.startDate, leave.endDate) + + try: + from app.services.notification_service import notify_student_leave_status + if leave.student and leave.student.fcmToken: + await notify_student_leave_status(leave.student.fcmToken, status) + except Exception as e: + logger.warning("FCM notification failed: %s", e) + + return updated_leave + + async def _mark_excused_attendance(self, leave: LeaveRequest): + sessions = await self.session_repo.get_sessions_in_date_range( + start_date=leave.startDate, end_date=leave.endDate + ) + enrollments = await self.enrollment_repo.get_by_student_id(leave.studentId) + enrolled_class_ids = {e.academicClassId for e in enrollments} + relevant_sessions = [s for s in sessions if s.academicClassId in enrolled_class_ids] + + for session in relevant_sessions: + existing = await self.attendance_repo.get_by_student_and_session( + student_id=leave.studentId, session_id=session.id + ) + data = {"status": "Excused", "remarks": f"Approved leave: {leave.reason}"} + if existing: + await self.attendance_repo.update(attendance_id=existing.id, data=data) + else: + await self.attendance_repo.create({ + "studentId": leave.studentId, "sessionId": session.id, + "faceScore": 0.0, "livenessScore": 0.0, "backgroundScore": 0.0, + "finalAiScore": 0.0, "gpsLatitude": 0.0, "gpsLongitude": 0.0, + **data, + }) + diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e355fe182b33d36c6d5fea5a0917bc49f5cb142b --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,70 @@ +import asyncio +from typing import Optional + +from app.core.logging_config import get_logger + +logger = get_logger("app.notification") + +try: + import firebase_admin + from firebase_admin import credentials, messaging + + _cred_path = "firebase-credentials.json" + try: + if not firebase_admin._apps: + cred = credentials.Certificate(_cred_path) + firebase_admin.initialize_app(cred) + _fcm_available = True + logger.info("Firebase Admin SDK initialized successfully") + except Exception as e: + _fcm_available = False + logger.warning("Firebase Admin SDK not available: %s. Push notifications disabled.", e) + +except ImportError: + _fcm_available = False + logger.info("firebase-admin not installed. Push notifications disabled.") + + +async def send_push_notification(token: str, title: str, body: str, data: Optional[dict] = None) -> bool: + if not _fcm_available or not token: + return False + + try: + message = messaging.Message( + notification=messaging.Notification(title=title, body=body), + data={k: str(v) for k, v in (data or {}).items()}, + token=token, + ) + response = await asyncio.to_thread(messaging.send, message) + logger.info("FCM sent: %s", response) + return True + except Exception as e: + logger.warning("FCM send failed: %s", e) + return False + + +async def notify_student_attendance_flagged(student_fcm_token: str, student_name: str, class_name: str, attendance_id: str): + await send_push_notification( + token=student_fcm_token, + title="Attendance Flagged", + body=f"Hi {student_name}, your attendance for {class_name} has been flagged and requires review.", + data={"route": "/flagged_detail", "attendance_id": attendance_id}, + ) + + +async def notify_student_attendance_reviewed(student_fcm_token: str, status: str, class_name: str): + await send_push_notification( + token=student_fcm_token, + title=f"Attendance {status}", + body=f"Your attendance for {class_name} has been reviewed and marked as {status}.", + data={"route": "/history"}, + ) + + +async def notify_student_leave_status(student_fcm_token: str, status: str): + await send_push_notification( + token=student_fcm_token, + title=f"Leave {status}", + body=f"Your leave request has been {status.lower()}.", + data={"route": "/leave/history"}, + ) diff --git a/backend/app/services/session_service.py b/backend/app/services/session_service.py new file mode 100644 index 0000000000000000000000000000000000000000..091532199fac00bd1c778421d3f70df971bea8d4 --- /dev/null +++ b/backend/app/services/session_service.py @@ -0,0 +1,115 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional + +from app.core.logging_config import get_logger +from app.db.redis import get_redis +from app.repositories.session_repo import SessionRepository +from app.repositories.class_repo import ClassRepository +from app.schemas.teacher import SessionResponse, SessionStart + +logger = get_logger("app.session") + + +class SessionService: + def __init__(self) -> None: + self.session_repo = SessionRepository() + self.class_repo = ClassRepository() + + async def start_session(self, data: SessionStart, teacher_id: str) -> Optional[SessionResponse]: + subject_class = await self.class_repo.get_by_id(data.academic_class_id) + if not subject_class or subject_class.teacherId != teacher_id: + return None + + active = await self.session_repo.get_active_session_by_class(data.academic_class_id) + if active: + await self.close_session(active.id) + + now = datetime.now(timezone.utc) + session = await self.session_repo.create( + class_id=data.academic_class_id, start_time=now, end_time=now + timedelta(minutes=data.duration_minutes) + ) + return SessionResponse.model_validate(session) + + async def stop_session(self, session_id: str, teacher_id: str) -> bool: + session = await self.session_repo.get_by_id(session_id) + if not session or not session.isActive: + return False + + subject_class = await self.class_repo.get_by_id(session.academicClassId) + if not subject_class or subject_class.teacherId != teacher_id: + return False + + await self.close_session(session_id) + return True + + async def close_session(self, session_id: str) -> None: + """Mark session as inactive, delete from Redis, and mark all unsubmitted students as Absent or Excused.""" + session = await self.session_repo.get_by_id(session_id) + if not session or not session.isActive: + return + + # 1. Deactivate in DB + await self.session_repo.deactivate(session_id) + + # 2. Delete from Redis + try: + await get_redis().delete(f"session:{session_id}") + except Exception as e: + logger.warning("Failed to delete session from Redis: %s", e) + + # 3. Get enrollments and existing attendance + from app.db.client import db + from app.services.gamification_service import GamificationService + + enrollments = await db.enrollment.find_many( + where={"academicClassId": session.academicClassId}, + include={"student": {"include": {"leaveRequests": True}}} + ) + + attendance_records = await db.attendance.find_many(where={"sessionId": session_id}) + submitted_student_ids = {a.studentId for a in attendance_records} + + gamification_service = GamificationService() + sess_start = session.startTime.replace(tzinfo=timezone.utc) if session.startTime.tzinfo is None else session.startTime + + for enrollment in enrollments: + student = enrollment.student + if not student or student.id in submitted_student_ids: + continue + + # Check if student was on approved leave during the session + on_leave = False + for leave in student.leaveRequests: + if leave.status == "APPROVED": + l_start = leave.startDate.replace(tzinfo=timezone.utc) if leave.startDate.tzinfo is None else leave.startDate + l_end = leave.endDate.replace(tzinfo=timezone.utc) if leave.endDate.tzinfo is None else leave.endDate + if l_start <= sess_start <= l_end: + on_leave = True + break + + # Create the record + status_val = "Excused" if on_leave else "Absent" + remarks_val = "Excused via approved leave request" if on_leave else "Session ended without student submission" + + try: + await db.attendance.create(data={ + "studentId": student.id, + "sessionId": session_id, + "status": status_val, + "faceScore": 0.0, + "livenessScore": 0.0, + "backgroundScore": 0.0, + "finalAiScore": 0.0, + "gpsLatitude": 0.0, + "gpsLongitude": 0.0, + "remarks": remarks_val, + }) + except Exception as e: + logger.warning("Failed to create default attendance for student %s: %s", student.id, e) + continue + + # Recalculate streak + try: + await gamification_service.recalculate_student_streak(student.id) + except Exception as e: + logger.warning("Failed to recalculate streak for student %s: %s", student.id, e) diff --git a/backend/app/services/student_service.py b/backend/app/services/student_service.py new file mode 100644 index 0000000000000000000000000000000000000000..8daba332faf91c20d992bbe0c4ee75f635a70c69 --- /dev/null +++ b/backend/app/services/student_service.py @@ -0,0 +1,109 @@ +from fastapi import HTTPException, status + +from app.db.client import db +from app.repositories.student_repo import StudentRepository +from app.schemas.student import StudentAttendanceItem, StudentAttendanceHistoryResponse, StudentClassResponse + + +class StudentService: + def __init__(self) -> None: + self.student_repo = StudentRepository() + + async def get_student_by_user_id(self, user_id: str): + student = await self.student_repo.get_by_user_id(user_id) + if not student: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student profile not found.") + return student + + async def get_student_classes(self, user_id: str) -> list[StudentClassResponse]: + student = await self.get_student_by_user_id(user_id) + enrollments = await db.enrollment.find_many( + where={"studentId": student.id}, + include={ + "academicClass": { + "include": {"subject": True, "teacher": True, "geofence": True, "sessions": {"where": {"isActive": True}}} + } + }, + ) + + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + + # Proactively deactivate expired sessions to sync database state + expired_session_ids = [] + for e in enrollments: + for s in e.academicClass.sessions: + s_end = s.endTime.replace(tzinfo=timezone.utc) if s.endTime.tzinfo is None else s.endTime + if s.isActive and s_end <= now: + expired_session_ids.append(s.id) + + if expired_session_ids: + await db.session.update_many( + where={"id": {"in": expired_session_ids}}, + data={"isActive": False} + ) + + res = [] + for e in enrollments: + valid_sessions = [ + s for s in e.academicClass.sessions + if s.isActive and s.id not in expired_session_ids + ] + active_s = valid_sessions[0] if valid_sessions else None + + res.append(StudentClassResponse( + class_id=e.academicClass.id, + class_name=e.academicClass.name, + subject=e.academicClass.subject.name if e.academicClass.subject else "—", + teacher_name=f"{e.academicClass.teacher.firstName} {e.academicClass.teacher.lastName}" if e.academicClass.teacher else "—", + active_session_id=active_s.id if active_s else None, + session_end_time=active_s.endTime if active_s else None, + latitude=e.academicClass.geofence.latitude if e.academicClass.geofence else None, + longitude=e.academicClass.geofence.longitude if e.academicClass.geofence else None, + radius_meters=e.academicClass.geofence.radiusMeters if e.academicClass.geofence else None, + )) + return res + + async def get_student_attendance_history(self, user_id: str) -> StudentAttendanceHistoryResponse: + student = await self.get_student_by_user_id(user_id) + enrollments = await db.enrollment.find_many( + where={"studentId": student.id}, include={"academicClass": True} + ) + class_ids = [e.academicClassId for e in enrollments] + + overall_percentage = 0.0 + if class_ids: + total_sessions = await db.session.count(where={"academicClassId": {"in": class_ids}}) + if total_sessions > 0: + present_count = await db.attendance.count( + where={"studentId": student.id, "status": {"in": ["Present", "Approved"]}} + ) + overall_percentage = round((present_count / total_sessions) * 100.0, 2) + + records = await db.attendance.find_many( + where={"studentId": student.id}, + include={"session": {"include": {"academicClass": {"include": {"subject": True}}}}}, + order={"createdAt": "desc"}, + ) + + return StudentAttendanceHistoryResponse( + student_id=student.id, + overall_attendance_percentage=overall_percentage, + history=[ + StudentAttendanceItem( + attendance_id=r.id, + class_id=ac.id, + class_name=ac.name, + subject=ac.subject.name if ac.subject and hasattr(ac.subject, 'name') else "—", + session_id=r.session.id, + status=r.status, + marked_at=r.createdAt, + face_score=r.faceScore, + liveness_score=r.livenessScore, + background_score=r.backgroundScore, + final_ai_score=r.finalAiScore, + teacher_note=r.remarks, + ) + for r in records if (ac := r.session.academicClass) + ], + ) diff --git a/backend/app/services/system_config_service.py b/backend/app/services/system_config_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b923723dbd0656583938aafa38462bcd49a83d88 --- /dev/null +++ b/backend/app/services/system_config_service.py @@ -0,0 +1,75 @@ +import json +from prisma.models import SystemConfiguration +from app.repositories.system_config_repo import SystemConfigRepository +from app.db.redis import get_redis +from app.core.logging_config import get_logger + +logger = get_logger("app.system_config") + +CACHE_KEY = "system:config" + + +class SystemConfigService: + def __init__(self) -> None: + self.repo = SystemConfigRepository() + + async def get_config(self) -> SystemConfiguration: + try: + redis_client = get_redis() + cached = await redis_client.get(CACHE_KEY) + if cached: + data = json.loads(cached) + return SystemConfiguration( + id=data.get("id", ""), + isFaceRecognitionEnabled=data["isFaceRecognitionEnabled"], + isGpsVerificationEnabled=data["isGpsVerificationEnabled"], + isAiBackgroundValidationEnabled=data["isAiBackgroundValidationEnabled"], + ) + except Exception: + logger.warning("Redis config cache read failed. Falling back to DB.") + + config = await self.repo.get_config() + + try: + redis_client = get_redis() + await redis_client.set( + CACHE_KEY, + json.dumps({ + "id": config.id, + "isFaceRecognitionEnabled": config.isFaceRecognitionEnabled, + "isGpsVerificationEnabled": config.isGpsVerificationEnabled, + "isAiBackgroundValidationEnabled": config.isAiBackgroundValidationEnabled, + }) + ) + except Exception as e: + logger.warning("Redis config cache write failed: %s", e) + + return config + + async def update_config( + self, + is_face_recognition_enabled: bool | None = None, + is_gps_verification_enabled: bool | None = None, + is_ai_background_validation_enabled: bool | None = None, + ) -> SystemConfiguration: + config = await self.repo.update_config( + is_face_recognition_enabled=is_face_recognition_enabled, + is_gps_verification_enabled=is_gps_verification_enabled, + is_ai_background_validation_enabled=is_ai_background_validation_enabled, + ) + + try: + redis_client = get_redis() + await redis_client.set( + CACHE_KEY, + json.dumps({ + "id": config.id, + "isFaceRecognitionEnabled": config.isFaceRecognitionEnabled, + "isGpsVerificationEnabled": config.isGpsVerificationEnabled, + "isAiBackgroundValidationEnabled": config.isAiBackgroundValidationEnabled, + }) + ) + except Exception as e: + logger.warning("Redis config cache write failed: %s", e) + + return config diff --git a/backend/app/services/teacher_service.py b/backend/app/services/teacher_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c265b5c3b74819d8e3113cd8e7089997dc8d0689 --- /dev/null +++ b/backend/app/services/teacher_service.py @@ -0,0 +1,270 @@ +from datetime import datetime +from typing import List, Optional + +from fastapi import HTTPException, status + +from app.db.client import db +from app.repositories.teacher_repo import TeacherRepository +from app.repositories.class_repo import ClassRepository +from app.repositories.geofence_repo import GeofenceRepository +from app.schemas.teacher import ( + GeofenceUpsert, GeofenceResponse, AcademicClassWithGeofenceResponse, + StudentRosterItem, SessionAttendanceResponse, ClassStatsResponse, + SessionWithClassResponse, SessionTrendItem, BulkMarkRequest, AbsentStudentItem, +) + + +class TeacherService: + def __init__(self) -> None: + self.teacher_repo = TeacherRepository() + self.class_repo = ClassRepository() + self.geofence_repo = GeofenceRepository() + + async def get_teacher_by_user_id(self, user_id: str): + teacher = await self.teacher_repo.get_by_user_id(user_id) + if not teacher: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Teacher profile not found.") + return teacher + + @staticmethod + def _resolve_full_name(first: Optional[str], last: Optional[str]) -> str: + return f"{first or ''} {last or ''}".strip() or "—" + + async def get_classes_by_teacher_user_id(self, user_id: str) -> List[AcademicClassWithGeofenceResponse]: + teacher = await self.get_teacher_by_user_id(user_id) + classes = await db.academicclass.find_many( + where={"teacherId": teacher.id}, include={"geofence": True, "subject": True} + ) + return [ + AcademicClassWithGeofenceResponse( + id=c.id, name=c.name, subject=c.subject.name if c.subject else "—", + teacherId=c.teacherId, + geofence=GeofenceResponse.model_validate(c.geofence) if c.geofence else None, + ) + for c in classes + ] + + async def upsert_geofence(self, user_id: str, class_id: str, data: GeofenceUpsert) -> GeofenceResponse: + teacher = await self.get_teacher_by_user_id(user_id) + academic_class = await self.class_repo.get_by_id(class_id) + if not academic_class: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Academic Class not found.") + if academic_class.teacherId != teacher.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access Denied: You do not teach this academic class.") + geofence = await self.geofence_repo.upsert_geofence(class_id=class_id, latitude=data.latitude, longitude=data.longitude, radius=data.radius_meters) + return GeofenceResponse.model_validate(geofence) + + async def _get_session_with_auth(self, session_id: str, teacher_id: str): + session = await db.session.find_unique(where={"id": session_id}, include={"academicClass": True}) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attendance Session not found.") + if session.academicClass.teacherId != teacher_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access Denied: You do not teach this academic class.") + return session + + async def get_session_attendance_roster(self, user_id: str, session_id: str) -> SessionAttendanceResponse: + teacher = await self.get_teacher_by_user_id(user_id) + session = await self._get_session_with_auth(session_id, teacher.id) + + enrollments = await db.enrollment.find_many( + where={"academicClassId": session.academicClassId}, + include={"student": {"include": {"user": True}}}, + ) + attendance_map = {r.studentId: r for r in await db.attendance.find_many(where={"sessionId": session_id})} + + roster = [ + StudentRosterItem( + student_id=s.id, enrollment_number=s.enrollmentNumber, + full_name=self._resolve_full_name(s.firstName, s.lastName), + email=s.user.email, + status=(rec := attendance_map.get(s.id)).status if s.id in attendance_map else "Absent", + final_score=rec.finalAiScore if s.id in attendance_map else 0.0, + marked_at=rec.createdAt if s.id in attendance_map else None, + ) + for e in enrollments if (s := e.student) + ] + return SessionAttendanceResponse(session_id=session_id, class_name=session.academicClass.name, roster=roster) + + async def get_absent_students(self, session_id: str, user_id: str) -> List[AbsentStudentItem]: + teacher = await self.get_teacher_by_user_id(user_id) + session = await self._get_session_with_auth(session_id, teacher.id) + + marked_ids = {r.studentId for r in await db.attendance.find_many(where={"sessionId": session_id})} + enrollments = await db.enrollment.find_many( + where={"academicClassId": session.academicClassId}, + include={"student": {"include": {"user": True}}}, + ) + return [ + AbsentStudentItem( + student_id=s.id, enrollment_number=s.enrollmentNumber, + full_name=self._resolve_full_name(s.firstName, s.lastName), + email=s.user.email if s.user else "", + ) + for e in enrollments if (s := e.student) and s.id not in marked_ids + ] + + async def bulk_mark_attendance(self, session_id: str, user_id: str, request: BulkMarkRequest) -> int: + teacher = await self.get_teacher_by_user_id(user_id) + await self._get_session_with_auth(session_id, teacher.id) + + count = 0 + from app.services.gamification_service import GamificationService + gamification_service = GamificationService() + for record in request.records: + await db.attendance.upsert( + where={"studentId_sessionId": {"studentId": record.student_id, "sessionId": session_id}}, + data={ + "create": { + "studentId": record.student_id, "sessionId": session_id, "status": record.status, + "faceScore": 0.0, "livenessScore": 0.0, "backgroundScore": 0.0, "finalAiScore": 0.0, + "gpsLatitude": 0.0, "gpsLongitude": 0.0, "remarks": "Manual entry by teacher", + }, + "update": {"status": record.status, "remarks": "Manual entry by teacher"}, + }, + ) + count += 1 + try: + await gamification_service.recalculate_student_streak(record.student_id) + except Exception as e: + from app.core.logging_config import get_logger + get_logger("app.teacher").warning("Failed to recalculate streak for student %s in bulk mark: %s", record.student_id, e) + return count + + async def export_class_attendance(self, class_id: str, user_id: str, from_date: Optional[datetime], to_date: Optional[datetime]) -> List[dict]: + teacher = await self.get_teacher_by_user_id(user_id) + academic_class = await self.class_repo.get_by_id(class_id) + if not academic_class: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Academic Class not found.") + if academic_class.teacherId != teacher.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access Denied: You do not teach this academic class.") + + session_where: dict = {"academicClassId": class_id} + if from_date or to_date: + time_filter = {} + if from_date: + time_filter["gte"] = from_date + if to_date: + time_filter["lte"] = to_date + session_where["startTime"] = time_filter + + rows = [] + for s in await db.session.find_many(where=session_where, include={"academicClass": {"include": {"subject": True}}}, order={"startTime": "asc"}): + for rec in await db.attendance.find_many(where={"sessionId": s.id}, include={"student": {"include": {"user": True}}}): + stu = rec.student + rows.append({ + "enrollment_number": stu.enrollmentNumber if stu else "", + "first_name": stu.firstName or "" if stu else "", + "last_name": stu.lastName or "" if stu else "", + "email": stu.user.email if stu and stu.user else "", + "session_date": s.startTime.isoformat(), + "class_name": s.academicClass.name if s.academicClass else "—", + "subject": s.academicClass.subject.name if s.academicClass and s.academicClass.subject else "—", + "status": rec.status, + "final_ai_score": rec.finalAiScore, + "remarks": rec.remarks or "", + }) + return rows + + async def get_class_stats(self, user_id: str, class_id: str) -> ClassStatsResponse: + teacher = await self.get_teacher_by_user_id(user_id) + academic_class = await self.class_repo.get_by_id(class_id) + if not academic_class: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Academic Class not found.") + if academic_class.teacherId != teacher.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access Denied.") + + total_students = await db.enrollment.count(where={"academicClassId": class_id}) + total_sessions = await db.session.count(where={"academicClassId": class_id}) + overall_percentage = 0.0 + history: List[SessionTrendItem] = [] + + if total_students > 0 and total_sessions > 0: + sessions = await db.session.find_many(where={"academicClassId": class_id}, order={"startTime": "asc"}) + session_ids = [s.id for s in sessions] + + present_count = await db.attendance.count( + where={"sessionId": {"in": session_ids}, "status": {"in": ["Present", "Approved"]}} + ) + overall_percentage = round((present_count / (total_students * total_sessions)) * 100.0, 2) + + for idx, s in enumerate(sessions): + p_count = await db.attendance.count( + where={"sessionId": s.id, "status": {"in": ["Present", "Approved"]}} + ) + history.append(SessionTrendItem( + session_id=s.id, session_name=f"Session {idx + 1}", + attendance_percentage=round((p_count / total_students) * 100.0, 2), + )) + + return ClassStatsResponse( + class_id=class_id, total_sessions=total_sessions, total_students=total_students, + overall_attendance_percentage=overall_percentage, history=history, + ) + + async def manual_override_attendance(self, user_id: str, session_id: str, student_id: str, status_val: str) -> bool: + teacher = await self.get_teacher_by_user_id(user_id) + session = await self._get_session_with_auth(session_id, teacher.id) + + enrollment = await db.enrollment.find_unique( + where={"studentId_academicClassId": {"studentId": student_id, "academicClassId": session.academicClassId}} + ) + if not enrollment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Student is not enrolled in this class.") + + existing = await db.attendance.find_unique( + where={"studentId_sessionId": {"studentId": student_id, "sessionId": session_id}} + ) + if existing: + await db.attendance.update( + where={"id": existing.id}, + data={"status": status_val, "remarks": f"Manual override by Teacher to {status_val}"}, + ) + else: + score = 1.0 if status_val == "Present" else 0.0 + await db.attendance.create(data={ + "studentId": student_id, "sessionId": session_id, "status": status_val, + "faceScore": score, "livenessScore": score, "backgroundScore": score, "finalAiScore": score, + "gpsLatitude": 0.0, "gpsLongitude": 0.0, "remarks": f"Manual override by Teacher to {status_val}", + }) + + try: + from app.services.gamification_service import GamificationService + await GamificationService().recalculate_student_streak(student_id) + except Exception as e: + from app.core.logging_config import get_logger + get_logger("app.teacher").warning("Failed to recalculate streak for student %s in manual override: %s", student_id, e) + + return True + + async def get_teacher_sessions(self, user_id: str) -> List[SessionWithClassResponse]: + teacher = await self.get_teacher_by_user_id(user_id) + sessions = await db.session.find_many( + where={"academicClass": {"teacherId": teacher.id}}, + include={"academicClass": {"include": {"subject": True}}}, + order={"endTime": "desc"}, + ) + + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + + # Proactively deactivate expired sessions in DB + expired_ids = [s.id for s in sessions if s.isActive and (s.endTime.replace(tzinfo=timezone.utc) if s.endTime.tzinfo is None else s.endTime) <= now] + if expired_ids: + from app.services.session_service import SessionService + session_service = SessionService() + for s_id in expired_ids: + try: + await session_service.close_session(s_id) + except Exception as e: + from app.core.logging_config import get_logger + get_logger("app.teacher").warning("Failed to close expired session %s: %s", s_id, e) + + return [ + SessionWithClassResponse( + id=s.id, academicClassId=s.academicClassId, class_name=s.academicClass.name, + subject=s.academicClass.subject.name if s.academicClass and s.academicClass.subject else "—", + startTime=s.startTime, endTime=s.endTime, + isActive=s.isActive and (s.endTime.replace(tzinfo=timezone.utc) if s.endTime.tzinfo is None else s.endTime) > now, + ) + for s in sessions + ] diff --git a/backend/app/utils/geofencing.py b/backend/app/utils/geofencing.py new file mode 100644 index 0000000000000000000000000000000000000000..0e0deda7024c3171ae8dc09adf6438783ebdad29 --- /dev/null +++ b/backend/app/utils/geofencing.py @@ -0,0 +1,42 @@ +import math +from dataclasses import dataclass + +EARTH_RADIUS_M = 6371000.0 + + +@dataclass(frozen=True) +class GPSCoordinate: + latitude: float + longitude: float + + +def calculate_haversine_distance(coord_a: GPSCoordinate, coord_b: GPSCoordinate) -> float: + lat_rad_a = math.radians(coord_a.latitude) + lat_rad_b = math.radians(coord_b.latitude) + delta_lat = math.radians(coord_b.latitude - coord_a.latitude) + delta_lon = math.radians(coord_b.longitude - coord_a.longitude) + + haversine_term = ( + math.sin(delta_lat / 2.0) ** 2 + + math.cos(lat_rad_a) * math.cos(lat_rad_b) * (math.sin(delta_lon / 2.0) ** 2) + ) + + angular_distance = 2.0 * math.atan2(math.sqrt(haversine_term), math.sqrt(1.0 - haversine_term)) + return EARTH_RADIUS_M * angular_distance + + +def is_within_geofence( + student_coord: GPSCoordinate, + classroom_coord: GPSCoordinate, + base_radius: float, + student_accuracy: float, +) -> bool: + """Validates if student is within the classroom geofence, accounting for GPS drift. + + The effective radius is the base classroom radius plus student accuracy. + """ + distance = calculate_haversine_distance(student_coord, classroom_coord) + effective_radius = base_radius + student_accuracy + return distance <= effective_radius + + diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..0380b3b988c1b5f662476e969b245a6046bda8f3 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,93 @@ +import os + +os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' +os.environ['TF_ENABLE_ONEDNN_OPTS'] = '0' +os.environ['CUDA_VISIBLE_DEVICES'] = '-1' + +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import JSONResponse + +from app.core.config import settings +from app.core.logging_config import setup_logging, get_logger +from app.db.client import connect_db, disconnect_db, db +from app.db.redis import connect_redis, disconnect_redis, get_redis +from app.api import auth, student, teacher, admin, logs, ws as ws_module +from app.middleware.request_logging import RequestLoggingMiddleware + +setup_logging(level=settings.LOG_LEVEL) +logger = get_logger("app.main") + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + logger.info("Starting server...") + await connect_db() + await connect_redis() + ws_module.manager.start_heartbeat() + logger.info("Server ready") + yield + logger.info("Shutting down...") + ws_module.manager.stop_heartbeat() + await disconnect_db() + await disconnect_redis() + logger.info("Shutdown complete") + + +app = FastAPI( + title=settings.PROJECT_NAME, + description="Asynchronous AI-powered Multi-Layered Smart Attendance verification backend.", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan, +) + +app.add_middleware(RequestLoggingMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.FRONTEND_URL.split(",") if "," in settings.FRONTEND_URL else [settings.FRONTEND_URL], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix=settings.API_V1_STR) +app.include_router(student.router, prefix=settings.API_V1_STR) +app.include_router(teacher.router, prefix=settings.API_V1_STR) +app.include_router(admin.router, prefix=settings.API_V1_STR) +app.include_router(logs.router, prefix=settings.API_V1_STR) +app.include_router(ws_module.router, prefix=settings.API_V1_STR) + +os.makedirs("static/proofs", exist_ok=True) +os.makedirs("static/leaves", exist_ok=True) +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.critical("Unhandled exception: %s %s — %s", request.method, request.url.path, exc, exc_info=True) + return JSONResponse(status_code=500, content={"detail": "Something went wrong"}) + + +@app.get("/health", tags=["System Maintenance"], status_code=status.HTTP_200_OK) +async def system_health_check() -> dict: + db_ok = False + redis_ok = False + try: + await db.user.count() + db_ok = True + except Exception: + pass + try: + r = await get_redis() + await r.ping() + redis_ok = True + except Exception: + pass + overall = "healthy" if db_ok and redis_ok else "degraded" + return {"status": overall, "service": settings.PROJECT_NAME, "database": "ok" if db_ok else "unreachable", "redis": "ok" if redis_ok else "unreachable"} diff --git a/backend/prisma/migrations/add_fcm_token_and_student_note/migration.sql b/backend/prisma/migrations/add_fcm_token_and_student_note/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..ad46848810eb7d249e44a26179846506f5219b6a --- /dev/null +++ b/backend/prisma/migrations/add_fcm_token_and_student_note/migration.sql @@ -0,0 +1,5 @@ +-- Add fcm_token column to students table +ALTER TABLE "students" ADD COLUMN "fcm_token" TEXT; + +-- Add student_note column to attendance table +ALTER TABLE "attendance" ADD COLUMN "student_note" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000000000000000000000000000000000000..9a45a9db8e57dfcc2c1561d6c4d26ebfa07017de --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,296 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + extensions = [vector] +} + +generator client { + provider = "prisma-client-py" + recursive_type_depth = 5 + previewFeatures = ["postgresqlExtensions"] +} + +enum Role { + STUDENT + TEACHER + ADMIN +} + +enum LeaveStatus { + PENDING + APPROVED + REJECTED +} + +enum DeviceChangeStatus { + PENDING + APPROVED + REJECTED +} + +model User { + id String @id @default(uuid()) + email String @unique + hashedPassword String @map("hashed_password") + role Role @default(STUDENT) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + student Student? + teacher Teacher? + + @@map("users") +} + +model Student { + id String @id @default(uuid()) + userId String @unique @map("user_id") + enrollmentNumber String @unique @map("enrollment_number") + firstName String? @map("first_name") + lastName String? @map("last_name") + phone String? @map("phone") + gender String? @map("gender") + dateOfBirth DateTime? @map("date_of_birth") + semester Int? @map("semester") + batch String? @map("batch") + departmentId String? @map("department_id") + deviceUuid String? @map("device_uuid") + fcmToken String? @map("fcm_token") + faceEmbedding Unsupported("vector(128)")? @map("face_embedding") + currentStreak Int @default(0) @map("current_streak") + highestStreak Int @default(0) @map("highest_streak") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + department Department? @relation(fields: [departmentId], references: [id]) + attendance Attendance[] + enrollments Enrollment[] + leaveRequests LeaveRequest[] + deviceChangeRequests DeviceChangeRequest[] + + @@map("students") +} + +model Teacher { + id String @id @default(uuid()) + userId String @unique @map("user_id") + firstName String @map("first_name") + lastName String @map("last_name") + employeeId String @unique @map("employee_id") + phone String? @map("phone") + qualification String? @map("qualification") + specialization String? @map("specialization") + experienceYears Int? @map("experience_years") + joiningDate DateTime? @map("joining_date") + departmentId String @map("department_id") + designationId String @map("designation_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + department Department @relation(fields: [departmentId], references: [id]) + designation Designation @relation(fields: [designationId], references: [id]) + classes AcademicClass[] + + @@map("teachers") +} + +model AcademicClass { + id String @id @default(uuid()) + name String + subjectId String @map("subject_id") + classroomId String? @map("classroom_id") + teacherId String @map("teacher_id") + semester Int? @map("semester") + batch String? @map("batch") + maxStudents Int? @map("max_students") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + teacher Teacher @relation(fields: [teacherId], references: [id]) + subject Subject @relation(fields: [subjectId], references: [id]) + classroom Classroom? @relation(fields: [classroomId], references: [id]) + sessions Session[] + geofence Geofence? + enrollments Enrollment[] + + @@map("classes") +} + +model Session { + id String @id @default(uuid()) + academicClassId String @map("class_id") + startTime DateTime @map("start_time") + endTime DateTime @map("end_time") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + academicClass AcademicClass @relation(fields: [academicClassId], references: [id]) + attendance Attendance[] + + @@map("sessions") +} + +model Geofence { + id String @id @default(uuid()) + academicClassId String @unique @map("class_id") + latitude Float + longitude Float + radiusMeters Float @map("radius_meters") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + academicClass AcademicClass @relation(fields: [academicClassId], references: [id]) + + @@map("geofences") +} + +model Attendance { + id String @id @default(uuid()) + studentId String @map("student_id") + sessionId String @map("session_id") + status String + faceScore Float @map("face_score") + livenessScore Float @map("liveness_score") + backgroundScore Float @map("background_score") + finalAiScore Float @map("final_ai_score") + gpsLatitude Float @map("gps_latitude") + gpsLongitude Float @map("gps_longitude") + remarks String? + studentNote String? @map("student_note") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + student Student @relation(fields: [studentId], references: [id]) + session Session @relation(fields: [sessionId], references: [id]) + + @@unique([studentId, sessionId]) + @@map("attendance") +} + +model Enrollment { + id String @id @default(uuid()) + studentId String @map("student_id") + academicClassId String @map("academic_class_id") + enrolledAt DateTime @default(now()) @map("enrolled_at") + + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + academicClass AcademicClass @relation(fields: [academicClassId], references: [id], onDelete: Cascade) + + @@unique([studentId, academicClassId]) + @@map("enrollments") +} + +model Department { + id String @id @default(uuid()) + name String @unique + code String @unique + head String? + description String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + teachers Teacher[] + students Student[] + + @@map("departments") +} + +model Subject { + id String @id @default(uuid()) + name String @unique + code String @unique + description String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + classes AcademicClass[] + + @@map("subjects") +} + +model Classroom { + id String @id @default(uuid()) + name String @unique + building String? + capacity Int? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + classes AcademicClass[] + + @@map("classrooms") +} + +model Designation { + id String @id @default(uuid()) + name String @unique + code String @unique + description String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + teachers Teacher[] + + @@map("designations") +} + +model LeaveRequest { + id String @id @default(uuid()) + studentId String @map("student_id") + startDate DateTime @map("start_date") + endDate DateTime @map("end_date") + reason String + documentUrl String? @map("document_url") + status LeaveStatus @default(PENDING) + approvedBy String? @map("approved_by") + approverNote String? @map("approver_note") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + + @@map("leave_requests") +} + +model AuditLog { + id String @id @default(uuid()) + timestamp DateTime @default(now()) + eventType String @map("event_type") + severity String + actor String + target String + description String + ipAddress String? @map("ip_address") + metadata Json? + + @@map("audit_logs") +} + +model DeviceChangeRequest { + id String @id @default(uuid()) + studentId String @map("student_id") + status DeviceChangeStatus @default(PENDING) + reason String? + newDeviceUuid String @map("new_device_uuid") + approvedBy String? @map("approved_by") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + + @@map("device_change_requests") +} + +model SystemConfiguration { + id String @id @default(uuid()) + isFaceRecognitionEnabled Boolean @default(true) @map("is_face_recognition_enabled") + isGpsVerificationEnabled Boolean @default(true) @map("is_gps_verification_enabled") + isAiBackgroundValidationEnabled Boolean @default(true) @map("is_ai_background_validation_enabled") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("system_configurations") +} diff --git a/backend/prisma/seed.py b/backend/prisma/seed.py new file mode 100644 index 0000000000000000000000000000000000000000..8fc9f88c2e1e906bb3c503d64f47dfb4082c99c0 --- /dev/null +++ b/backend/prisma/seed.py @@ -0,0 +1,16 @@ +import asyncio +import sys +import os + +# Ensure the parent directory is in the path to support app/scripts imports +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from scripts.seed_db import seed_all, seed_all_pratham + +if __name__ == "__main__": + seed_type = os.getenv("SEED_TYPE", "default").strip().lower() + if seed_type == "pratham": + asyncio.run(seed_all_pratham()) + else: + asyncio.run(seed_all()) + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..284e78a3a4a7d143886770a4f9c7f4f696a1f125 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,155 @@ +# ============================================================ +# Smart Attendance System - Backend Dependencies +# ============================================================ +# Generated from full codebase analysis +# Python 3.11 | FastAPI + Prisma + TensorFlow + DeepFace +# ============================================================ + +# --- Web Framework & Server --- +fastapi==0.115.6 +uvicorn==0.34.0 +uvloop==0.21.0 +httptools==0.6.4 +websockets==14.1 +starlette==0.41.3 +python-multipart==0.0.20 +gunicorn==23.0.0 + +# --- Data Validation & Settings --- +pydantic==2.10.4 +pydantic-settings==2.7.1 +pydantic_core==2.27.2 +annotated-types==0.7.0 +typing_extensions==4.12.2 +typing-inspection==0.4.0 + +# --- Database (Prisma ORM + PostgreSQL) --- +prisma==0.15.0 +psycopg2-binary==2.9.10 + +# --- Redis (Async Cache & Token Denylist) --- +redis==5.2.1 + +# --- Authentication & Security --- +bcrypt==4.2.1 +PyJWT==2.10.1 +cryptography==44.0.0 + +# --- AI / Machine Learning (TensorFlow + DeepFace) --- +tensorflow==2.15.0 +tensorflow-estimator==2.15.0 +tensorflow-io-gcs-filesystem==0.37.1 +keras==2.15.0 +tf_keras==2.15.1 +deepface==0.0.93 +mtcnn==1.0.0 +retina-face==0.0.17 +opencv-python==4.10.0.84 +numpy==1.26.4 +h5py==3.12.1 + +# --- Data Science (Absentee Scanner / Analytics) --- +pandas==2.2.3 +scikit-learn==1.6.0 +scipy==1.14.1 +matplotlib==3.10.0 +seaborn==0.13.2 +joblib==1.4.2 + +# --- Firebase (FCM Push Notifications) --- +firebase_admin==6.6.0 + +# --- Google Cloud (Firebase dependencies) --- +google-api-core==2.24.0 +google-auth==2.37.0 +google-auth-oauthlib==1.2.1 +google-cloud-core==2.4.1 +google-cloud-firestore==2.19.0 +google-cloud-storage==2.19.0 +google-crc32c==1.6.0 +google-pasta==0.2.0 +google-resumable-media==2.7.2 +googleapis-common-protos==1.66.0 +grpcio==1.68.1 +grpcio-status==1.62.3 +proto-plus==1.25.0 +protobuf==4.25.5 + +# --- HTTP Client --- +httpx==0.28.1 +httpcore==1.0.7 +h11==0.14.0 +h2==4.1.0 +hpack==4.0.0 +hyperframe==6.0.1 +anyio==4.7.0 +certifi==2024.12.14 +idna==3.10 +urllib3==2.3.0 + +# --- Environment & Config --- +python-dotenv==1.0.1 + +# --- Utilities --- +click==8.1.7 +huggingface-hub==1.16.1 +tqdm==4.67.1 +Pillow==11.0.0 +PyYAML==6.0.2 +six==1.17.0 +packaging==24.2 +filelock==3.16.1 +requests==2.32.3 +charset-normalizer==3.4.1 + +# --- TensorBoard (TensorFlow companion) --- +tensorboard==2.15.2 +tensorboard-data-server==0.7.2 +Markdown==3.7 +Werkzeug==3.1.3 + +# --- ML Support Libraries --- +flatbuffers==24.3.25 +gast==0.6.0 +opt_einsum==3.4.0 +ml-dtypes==0.2.0 +astunparse==1.6.3 +libclang==18.1.1 +termcolor==2.5.0 +wrapt==1.14.1 +absl-py==2.1.0 + +# --- Image & Visualization Support --- +contourpy==1.3.1 +cycler==0.12.1 +fonttools==4.55.3 +kiwisolver==1.4.7 +pyparsing==3.2.0 +python-dateutil==2.9.0.post0 + +# --- Misc / Transitive --- +beautifulsoup4==4.12.3 +soupsieve==2.6 +gdown==5.2.0 +PySocks==1.7.1 +fire==0.7.0 +nodeenv==1.9.1 +tomlkit==0.13.2 +greenlet==3.1.1 +lz4==4.3.3 +msgpack==1.1.0 +oauthlib==3.2.2 +requests-oauthlib==2.0.0 +pyasn1==0.6.1 +pyasn1_modules==0.4.1 +CacheControl==0.14.1 +MarkupSafe==3.0.2 +Jinja2==3.1.4 +blinker==1.9.0 +dnspython==2.7.0 +email-validator==2.2.0 +watchfiles==1.0.3 +threadpoolctl==3.5.0 +cffi==1.17.1 +pycparser==2.22 +itsdangerous==2.2.0 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/scripts/seed_db.py b/backend/scripts/seed_db.py new file mode 100644 index 0000000000000000000000000000000000000000..b50b7a72f555481ac83f736fe63d3bc5cc9b17fb --- /dev/null +++ b/backend/scripts/seed_db.py @@ -0,0 +1,1463 @@ +""" +Smart Attendance System — Database Seed Script +================================================ + +Generates realistic Indian college data: + • 1 Admin account + • 50 Teachers with realistic profiles + • 300+ Students with enrollment numbers, departments, batches + • 9 Academic Departments + • 7 Designations + • 30+ Subjects across departments & semesters + • 20 Classrooms across multiple buildings + • 80+ Academic Classes with geofences + • ~1 month of Sessions (+Attendance records) + • Enrollments, Leaves, Device Change Requests, Audit Logs + +Idempotent: safe to re-run (clears all existing data first). + +Usage +----- + cd backend + python prisma/seed.py + + # or directly: + python -c "from scripts.seed_db import seed_all; import asyncio; asyncio.run(seed_all())" + +Pre-requisites +-------------- + pip install -r requirements.txt + prisma generate + Ensure DATABASE_URL in .env is pointing to the target PostgreSQL instance. + The `vector` extension must be enabled on the database: + CREATE EXTENSION IF NOT EXISTS vector; +""" + +from __future__ import annotations + +import asyncio +import random +import uuid +from datetime import date, datetime, timedelta, timezone +from typing import Any, TypeVar + +import bcrypt +from prisma import Json +from app.db.client import db + +T = TypeVar("T") + +# ============================================================================== +# PASSWORD HASHING (mirrors app/core/security.py to avoid app import) +# ============================================================================== + +def _hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode() + +# ============================================================================== +# REALISTIC INDIAN DATA POOLS +# ============================================================================== + +MALE_FIRST_NAMES = [ + "Aarav", "Vihaan", "Vivaan", "Advik", "Kabir", "Arjun", "Rohan", "Ishaan", + "Ayaan", "Dhruv", "Krish", "Reyansh", "Shiv", "Yash", "Dev", "Pranav", + "Manav", "Karan", "Vikram", "Rahul", "Amit", "Suresh", "Ravi", "Deepak", + "Sanjay", "Vijay", "Rajesh", "Nikhil", "Abhishek", "Harsh", "Varun", + "Aditya", "Saurabh", "Akash", "Sachin", "Pradeep", "Ganesh", "Mahesh", + "Naresh", "Rakesh", "Dinesh", "Sagar", "Lokesh", "Ajay", "Anil", "Sunil", + "Manoj", "Ashish", "Tushar", "Kunal", "Chetan", "Rishabh", "Siddharth", + "Ankur", "Pankaj", "Gaurav", "Vishal", "Shubham", "Mohit", "Rohit", + "Sumit", "Manish", "Alok", "Chandan", "Jatin", "Hitesh", "Vinod", + "Mukesh", "Rajat", "Vivek", "Lalit", "Akshay", "Sandeep", "Nitin", + "Amitabh", "Hemant", "Tarun", "Umesh", "Nilesh", "Kamlesh", "Gopal", + "Harish", "Kishore", "Mohan", "Navneet", "Om", "Prakash", "Ramesh", + "Shekhar", "Tejas", "Uday", "Vimal", "Wasim", "Yogesh", "Zubin", + "Amol", "Bhavesh", "Chirag", "Darshan", "Eknath", "Faisal", "Girish", + "Himanshu", "Iqbal", "Jagdish", "Kaushik", "Laxman", "Mithun", "Neeraj", + "Omprakash", "Parag", "Quasim", "Ranjit", "Sameer", "Tanmay", "Utkarsh", + "Vaibhav", "Waman", "Yashwant", "Anurag", "Bharat", "Chandrashekhar", + "Dhananjay", "Eshwar", "Fateh", "Gautam", "Harshad", "Ishwar", "Jitendra", + "Kartik", "Lalit", "Madhav", "Nandkishore", "Ojas", "Parth", "Raghav", +] + +FEMALE_FIRST_NAMES = [ + "Ananya", "Priya", "Aditi", "Aisha", "Diya", "Kavya", "Anjali", "Shreya", + "Neha", "Pooja", "Riya", "Meera", "Ishita", "Nandini", "Tanvi", "Sakshi", + "Vaishali", "Swati", "Divya", "Pallavi", "Shweta", "Aparna", "Deepa", + "Kavita", "Sunita", "Rekha", "Asha", "Usha", "Geeta", "Radha", "Laxmi", + "Jyoti", "Madhu", "Nidhi", "Poonam", "Rashmi", "Shilpa", "Ritu", "Anju", + "Suman", "Archana", "Bhavna", "Chitra", "Ekta", "Garima", "Hema", + "Kamala", "Lata", "Manju", "Namrata", "Pratibha", "Rajni", "Sarita", + "Tripti", "Uma", "Vandana", "Yamini", "Zara", "Aparajita", "Bindiya", + "Charulata", "Damini", "Gargi", "Harini", "Jaya", "Kirti", "Lavanya", + "Mala", "Navya", "Ojal", "Parvati", "Rukmini", "Savitri", "Tanushree", + "Ankita", "Bhavika", "Chaitali", "Devika", "Gauri", "Hansika", "Ira", + "Jhanvi", "Kiara", "Lipika", "Mitali", "Nayana", "Parnika", "Rupali", + "Samiksha", "Tulika", "Varsha", "Aarushi", "Barkha", "Charvi", "Disha", + "Esha", "Falguni", "Gomati", "Harshita", "Ipsita", "Jyotsna", "Kritika", + "Lopamudra", "Moushumi", "Nirmala", "Oindrila", "Pragya", "Roshni", + "Shikha", "Trisha", "Upasana", "Vidya", "Yoshita", "Zeenat", +] + +LAST_NAMES = [ + "Sharma", "Verma", "Patel", "Singh", "Gupta", "Reddy", "Nair", "Joshi", + "Kumar", "Das", "Sen", "Bose", "Mukherjee", "Banerjee", "Chatterjee", + "Ganguly", "Iyer", "Menon", "Pillai", "Rao", "Naidu", "Prasad", "Mishra", + "Tiwari", "Dubey", "Pandey", "Chauhan", "Yadav", "Rajput", "Thakur", + "Solanki", "Rathore", "Shekhawat", "Mehta", "Shah", "Desai", "Trivedi", + "Acharya", "Bhat", "Hegde", "Shetty", "Pai", "Nayak", "Swain", "Behera", + "Mahapatra", "Kaur", "Gill", "Dhillon", "Bedi", "Kapoor", "Khanna", + "Malhotra", "Chopra", "Bhatia", "Sethi", "Aggarwal", "Jain", "Saxena", + "Srivastava", "Sinha", "Mathur", "Bajaj", "Rana", "Biswas", "Ghosh", + "Dutta", "Majumdar", "Saha", "Acharya", "Krishnan", "Bharadwaj", "Mani", + "Subramaniam", "Venkatesh", "Kulkarni", "Deshpande", "Gokhale", "Tendulkar", + "Rajan", "Varma", "Philip", "George", "Thomas", "Jacob", "Mathew", "Cherian", +] + +# ============================================================================== +# INSTITUTIONAL DATA +# ============================================================================== + +DEPARTMENTS = [ + ("Computer Science & Engineering", "CSE", "Dr. Rajesh Sharma", "Focus on computing, algorithms, AI, and software engineering"), + ("Information Technology", "IT", "Dr. Sunita Verma", "Focus on IT infrastructure, networking, and cybersecurity"), + ("Electronics & Communication Engineering", "ECE", "Dr. Anil Kumar", "Focus on electronics, communications, and signal processing"), + ("Mechanical Engineering", "ME", "Dr. Vikram Singh", "Focus on mechanics, thermodynamics, and manufacturing"), + ("Civil Engineering", "CE", "Dr. Priya Patel", "Focus on structures, construction, and environmental engineering"), + ("Electrical Engineering", "EE", "Dr. Suresh Reddy", "Focus on power systems, machines, and renewable energy"), + ("Business Administration", "MBA", "Dr. Meera Nair", "Focus on management, finance, and organizational behavior"), + ("Pharmacy", "PHARM", "Dr. Anjali Joshi", "Focus on pharmaceutical sciences and drug discovery"), + ("Biotechnology", "BT", "Dr. Ravi Gupta", "Focus on molecular biology, genetics, and bioinformatics"), +] + +DESIGNATIONS = [ + ("Professor", "PROF", "Senior-most faculty with extensive research and teaching experience"), + ("Associate Professor", "APROF", "Mid-career faculty with significant academic contributions"), + ("Assistant Professor", "ASPROF", "Early-career faculty building their academic portfolio"), + ("Senior Lecturer", "SLECT", "Experienced lecturer with specialized domain expertise"), + ("Lecturer", "LECT", "Teaching-focused faculty member"), + ("Head of Department", "HOD", "Department head overseeing academic and administrative functions"), + ("Dean", "DEAN", "Dean of the faculty overseeing multiple departments"), +] + +# Subject definitions: department_key -> list of (name, code, semester) +SUBJECT_DEFS: dict[str, list[tuple[str, str, int]]] = { + "CSE": [ + ("Programming in C", "CSE201", 2), + ("Discrete Mathematics", "CSE202", 2), + ("Digital Logic Design", "CSE203", 2), + ("Data Structures", "CSE301", 4), + ("Database Management Systems", "CSE302", 4), + ("Computer Organization & Architecture", "CSE303", 4), + ("Operating Systems", "CSE304", 4), + ("Computer Networks", "CSE401", 6), + ("Software Engineering", "CSE402", 6), + ("Web Technologies", "CSE403", 6), + ("Design & Analysis of Algorithms", "CSE404", 6), + ("Machine Learning", "CSE501", 8), + ("Cloud Computing", "CSE502", 8), + ("Cyber Security", "CSE503", 8), + ], + "IT": [ + ("Fundamentals of IT", "IT201", 2), + ("Web Development Basics", "IT202", 2), + ("Database Systems", "IT301", 4), + ("Data Communication & Networking", "IT302", 4), + ("Object-Oriented Programming", "IT303", 4), + ("Network Security", "IT401", 6), + ("Cloud Infrastructure", "IT402", 6), + ("Mobile Application Development", "IT403", 6), + ("Big Data Analytics", "IT501", 8), + ("Blockchain Technology", "IT502", 8), + ], + "ECE": [ + ("Basic Electronics", "ECE201", 2), + ("Network Analysis & Synthesis", "ECE202", 2), + ("Analog Electronics", "ECE301", 4), + ("Digital Electronics", "ECE302", 4), + ("Signals & Systems", "ECE303", 4), + ("Analog Communication", "ECE304", 4), + ("Microprocessors & Microcontrollers", "ECE401", 6), + ("Digital Signal Processing", "ECE402", 6), + ("VLSI Design", "ECE403", 6), + ("Wireless Communication", "ECE501", 8), + ("Embedded Systems", "ECE502", 8), + ("Internet of Things", "ECE503", 8), + ], + "ME": [ + ("Engineering Mechanics", "ME201", 2), + ("Thermodynamics", "ME202", 2), + ("Fluid Mechanics & Hydraulic Machines", "ME301", 4), + ("Strength of Materials", "ME302", 4), + ("Manufacturing Processes", "ME303", 4), + ("Heat & Mass Transfer", "ME401", 6), + ("Machine Design", "ME402", 6), + ("CAD / CAM", "ME403", 6), + ("Robotics & Automation", "ME501", 8), + ("Automobile Engineering", "ME502", 8), + ("Power Plant Engineering", "ME503", 8), + ], + "CE": [ + ("Building Materials & Construction", "CE201", 2), + ("Surveying & Levelling", "CE202", 2), + ("Structural Analysis", "CE301", 4), + ("Fluid Mechanics", "CE302", 4), + ("Geotechnical Engineering", "CE303", 4), + ("Design of Steel Structures", "CE401", 6), + ("Transportation Engineering", "CE402", 6), + ("Environmental Engineering", "CE403", 6), + ("Earthquake Resistant Structures", "CE501", 8), + ("Construction Project Management", "CE502", 8), + ], + "EE": [ + ("Basic Electrical Engineering", "EE201", 2), + ("Network Theory", "EE202", 2), + ("Electrical Machines", "EE301", 4), + ("Power Systems", "EE302", 4), + ("Control Systems", "EE303", 4), + ("Power Electronics", "EE401", 6), + ("Renewable Energy Systems", "EE402", 6), + ("Switchgear & Protection", "EE403", 6), + ("Smart Grid Technology", "EE501", 8), + ("Electric Vehicle Engineering", "EE502", 8), + ], + "MBA": [ + ("Principles of Management", "MBA201", 2), + ("Financial Accounting", "MBA202", 2), + ("Marketing Management", "MBA301", 4), + ("Human Resource Management", "MBA302", 4), + ("Operations Research", "MBA303", 4), + ("Corporate Finance", "MBA401", 6), + ("Business Analytics", "MBA402", 6), + ("Organizational Behavior", "MBA403", 6), + ("Strategic Management", "MBA501", 8), + ("Entrepreneurship & Innovation", "MBA502", 8), + ], + "PHARM": [ + ("Pharmaceutical Chemistry", "PH201", 2), + ("Pharmacology I", "PH202", 2), + ("Pharmaceutics I", "PH301", 4), + ("Pharmacognosy", "PH302", 4), + ("Pharmaceutical Biochemistry", "PH303", 4), + ("Pharmaceutical Analysis", "PH401", 6), + ("Pharmacology II", "PH402", 6), + ("Medicinal Chemistry", "PH403", 6), + ("Drug Regulatory Affairs", "PH501", 8), + ("Clinical Pharmacy", "PH502", 8), + ], + "BT": [ + ("Cell Biology", "BT201", 2), + ("Biochemistry", "BT202", 2), + ("Molecular Biology", "BT301", 4), + ("Genetic Engineering", "BT302", 4), + ("Bioprocess Engineering", "BT303", 4), + ("Immunology", "BT401", 6), + ("Bioinformatics", "BT402", 6), + ("Environmental Biotechnology", "BT403", 6), + ("Pharmaceutical Biotechnology", "BT501", 8), + ("Nanobiotechnology", "BT502", 8), + ], +} + +CLASSROOMS = [ + ("A-101", "A-Block", 60), + ("A-102", "A-Block", 60), + ("A-201", "A-Block", 50), + ("A-202", "A-Block", 50), + ("B-101", "B-Block", 80), + ("B-102", "B-Block", 80), + ("B-201", "B-Block", 40), + ("B-202", "B-Block", 40), + ("C-101", "C-Block", 70), + ("C-102", "C-Block", 70), + ("C-201", "C-Block", 45), + ("C-301", "C-Block", 45), + ("D-101", "D-Block", 90), + ("D-102", "D-Block", 90), + ("D-201", "D-Block", 55), + ("Engineering Lab 1", "Engineering Block", 30), + ("Engineering Lab 2", "Engineering Block", 30), + ("Computer Lab 1", "IT Block", 40), + ("Computer Lab 2", "IT Block", 40), + ("Seminar Hall", "Admin Block", 120), +] + +# Teacher distribution: (dept_code, count, designation_indices) +TEACHER_DEPT_DIST: list[tuple[str, int]] = [ + ("CSE", 8), + ("IT", 5), + ("ECE", 7), + ("ME", 6), + ("CE", 5), + ("EE", 5), + ("MBA", 5), + ("PHARM", 5), + ("BT", 4), +] + +DESIGNATION_WEIGHTS = [0.10, 0.20, 0.35, 0.10, 0.15, 0.05, 0.05] # must sum to 1.0 + +# Student distribution per department per batch +# (dept_code, sem_2_count, sem_4_count, sem_6_count, sem_8_count) +STUDENT_DEPT_DIST: list[tuple[str, int, int, int, int]] = [ + ("CSE", 18, 17, 16, 14), # 65 + ("IT", 12, 11, 10, 9), # 42 + ("ECE", 14, 13, 12, 11), # 50 + ("ME", 12, 11, 10, 9), # 42 + ("CE", 9, 8, 8, 7), # 32 + ("EE", 9, 8, 8, 7), # 32 + ("MBA", 8, 7, 7, 6), # 28 + ("PHARM", 5, 4, 4, 4), # 17 + ("BT", 3, 3, 3, 3), # 12 +] + +BATCH_MAP: dict[int, str] = {2: "2025-2029", 4: "2024-2028", 6: "2023-2027", 8: "2022-2026"} + +# Campus GPS (approximate centre of IIT Bombay campus) +CAMPUS_LAT = 19.1334 +CAMPUS_LNG = 72.9133 + +# Attendance scoring weights (mirroring app config) +FACE_WEIGHT = 0.50 +LIVENESS_WEIGHT = 0.30 +BACKGROUND_WEIGHT = 0.20 +PASS_THRESHOLD = 0.75 + +# ============================================================================== +# HELPERS +# ============================================================================== + +def random_date(start_dt: date, end_dt: date) -> datetime: + delta = end_dt - start_dt + offset_days = random.random() * delta.days + offset_seconds = random.random() * 86400.0 + result = datetime.combine(start_dt, datetime.min.time()) + timedelta(days=offset_days, seconds=offset_seconds) + return result.replace(tzinfo=timezone.utc) + + +def _to_datetime(d: date | datetime) -> datetime: + if isinstance(d, datetime): + return d + return datetime.combine(d, datetime.min.time()).replace(tzinfo=timezone.utc) + + +def _pick(items: list[T]) -> T: + return random.choice(items) + + +def _pick_n(items: list[T], n: int) -> list[T]: + return random.sample(items, min(n, len(items))) + + +def _weighted_choice(items: list[Any], weights: list[float]) -> Any: + return random.choices(items, weights=weights, k=1)[0] + + +def jitter_gps(base_lat: float, base_lng: float, radius_deg: float = 0.002) -> tuple[float, float]: + lat = base_lat + random.uniform(-radius_deg, radius_deg) + lng = base_lng + random.uniform(-radius_deg, radius_deg) + return (round(lat, 6), round(lng, 6)) + + +def compute_final_score(face: float, liveness: float, background: float) -> float: + return round(face * FACE_WEIGHT + liveness * LIVENESS_WEIGHT + background * BACKGROUND_WEIGHT, 4) + + +def generate_present_scores() -> dict[str, float]: + face = round(random.uniform(0.82, 0.99), 4) + liveness = round(random.uniform(0.80, 0.99), 4) + background = round(random.uniform(0.78, 0.99), 4) + final = compute_final_score(face, liveness, background) + return {"face_score": face, "liveness_score": liveness, "background_score": background, "final_ai_score": final} + + +def generate_flagged_scores() -> dict[str, float]: + face = round(random.uniform(0.30, 0.70), 4) + liveness = round(random.uniform(0.25, 0.68), 4) + background = round(random.uniform(0.20, 0.65), 4) + final = compute_final_score(face, liveness, background) + return {"face_score": face, "liveness_score": liveness, "background_score": background, "final_ai_score": final} + + +def make_name_pool() -> list[tuple[str, str, str]]: + """Returns list of (first_name, last_name, gender) tuples.""" + pool: list[tuple[str, str, str]] = [] + for name in MALE_FIRST_NAMES: + pool.append((name, _pick(LAST_NAMES), "Male")) + for name in FEMALE_FIRST_NAMES: + pool.append((name, _pick(LAST_NAMES), "Female")) + random.shuffle(pool) + return pool + + +def make_phone() -> str: + return f"+91{random.randint(7000000000, 9999999999)}" + + +def make_dob_for_semester(semester: int) -> date: + if semester <= 2: + return date(random.randint(2003, 2006), random.randint(1, 12), random.randint(1, 28)) + elif semester <= 4: + return date(random.randint(2002, 2005), random.randint(1, 12), random.randint(1, 28)) + elif semester <= 6: + return date(random.randint(2001, 2004), random.randint(1, 12), random.randint(1, 28)) + else: + return date(random.randint(2000, 2003), random.randint(1, 12), random.randint(1, 28)) + +# ============================================================================== +# MAIN SEED FUNCTION +# ============================================================================== + +async def seed_all() -> None: + await db.connect() + print("=" * 72) + print(" SMART ATTENDANCE SYSTEM — DATABASE SEED") + print("=" * 72) + + try: + # ------------------------------------------------------------------ + # 1. CLEAR ALL EXISTING DATA (reverse FK dependency order) + # ------------------------------------------------------------------ + print("\n[1/16] Clearing existing data …") + await db.attendance.delete_many() + await db.devicechangerequest.delete_many() + await db.leaverequest.delete_many() + await db.enrollment.delete_many() + await db.geofence.delete_many() + await db.session.delete_many() + await db.academicclass.delete_many() + await db.teacher.delete_many() + await db.student.delete_many() + await db.user.delete_many() + await db.subject.delete_many() + await db.classroom.delete_many() + await db.designation.delete_many() + await db.department.delete_many() + await db.auditlog.delete_many() + await db.systemconfiguration.delete_many() + print(" ✓ All database tables cleared") + + # Clear Redis leaderboard cache + try: + from app.db.redis import connect_redis, disconnect_redis + redis = await connect_redis() + if redis: + await redis.delete("leaderboard:points") + print(" ✓ Redis leaderboard cache cleared") + await disconnect_redis() + except Exception as re: + print(f" [WARNING] Failed to clear Redis cache: {re}") + + # ------------------------------------------------------------------ + # 2. SYSTEM CONFIGURATION + # ------------------------------------------------------------------ + print("\n[2/16] Seeding System Configuration …") + sys_cfg = await db.systemconfiguration.create(data={ + "isFaceRecognitionEnabled": True, + "isGpsVerificationEnabled": True, + "isAiBackgroundValidationEnabled": True, + }) + print(f" ✓ System configuration (id={sys_cfg.id[:8]}…)") + + # ------------------------------------------------------------------ + # 3. DEPARTMENTS + # ------------------------------------------------------------------ + print("\n[3/16] Seeding Departments …") + dept_map: dict[str, str] = {} # code -> id + for name, code, head, desc in DEPARTMENTS: + dept = await db.department.create(data={ + "name": name, + "code": code, + "head": head, + "description": desc, + }) + dept_map[code] = dept.id + print(f" ✓ {len(DEPARTMENTS)} departments created") + + # ------------------------------------------------------------------ + # 4. DESIGNATIONS + # ------------------------------------------------------------------ + print("\n[4/16] Seeding Designations …") + desig_map: dict[str, str] = {} # code -> id + for name, code, desc in DESIGNATIONS: + desig = await db.designation.create(data={ + "name": name, + "code": code, + "description": desc, + }) + desig_map[code] = desig.id + print(f" ✓ {len(DESIGNATIONS)} designations created") + + # ------------------------------------------------------------------ + # 5. SUBJECTS + # ------------------------------------------------------------------ + print("\n[5/16] Seeding Subjects …") + subject_map: dict[str, str] = {} # code -> id + for dept_code, subjects in SUBJECT_DEFS.items(): + for name, code, sem in subjects: + subj = await db.subject.create(data={ + "name": name, + "code": code, + "description": f"{name} — {dept_code} Semester {sem}", + }) + subject_map[code] = subj.id + print(f" ✓ {len(subject_map)} subjects created") + + # ------------------------------------------------------------------ + # 6. CLASSROOMS + # ------------------------------------------------------------------ + print("\n[6/16] Seeding Classrooms …") + classroom_map: dict[str, str] = {} # name -> id + for name, building, capacity in CLASSROOMS: + cr = await db.classroom.create(data={ + "name": name, + "building": building, + "capacity": capacity, + }) + classroom_map[name] = cr.id + print(f" ✓ {len(CLASSROOMS)} classrooms created") + + # ------------------------------------------------------------------ + # 7. USERS & PROFILES + # ------------------------------------------------------------------ + print("\n[7/16] Creating user accounts …") + + admin_user = await db.user.create(data={ + "email": "admin@smartattendance.edu.in", + "hashedPassword": _hash_password("Admin@123"), + "role": "ADMIN", + }) + print(" ✓ Admin user created (admin@smartattendance.edu.in / Admin@123)") + + # Generate name pools + random.seed(42) + name_pool = make_name_pool() + random.shuffle(name_pool) + + # ----- Teachers ----- + print("\n[8/16] Seeding Teachers …") + teacher_name_pool = name_pool[:60] # extra names for teachers + teacher_ids: list[str] = [] + teacher_user_ids: list[str] = [] + teacher_dept_map: dict[str, list[dict]] = {code: [] for code, _ in TEACHER_DEPT_DIST} + teacher_counter: dict[str, int] = {} + + emp_serial = 1 + for dept_code, count in TEACHER_DEPT_DIST: + teacher_counter[dept_code] = 0 + for i in range(count): + first, last, gender = teacher_name_pool.pop(0) + emp_id = f"EMP{emp_serial:03d}" + emp_serial += 1 + email = f"{emp_id.lower()}@smartattendance.edu.in" + + user = await db.user.create(data={ + "email": email, + "hashedPassword": _hash_password("Teacher@123"), + "role": "TEACHER", + }) + teacher_user_ids.append(user.id) + + # Pick a realistic designation (weighted) + desig_code = _weighted_choice( + [d[1] for d in DESIGNATIONS], + DESIGNATION_WEIGHTS, + ) + # HOD / Dean only for senior faculty (1 per dept) + if i == 0 and count >= 3: + desig_code = "HOD" + elif i == 1 and dept_code == "CSE": + desig_code = "DEAN" + + desig_id = desig_map[desig_code] + phone = make_phone() + qual_options = ["Ph.D.", "M.Tech", "M.Sc.", "M.E.", "B.Tech + M.Tech (Dual)"] + spec = f"{_pick(['Advanced ', 'Applied ', '', 'Industrial '])}{SUBJECT_DEFS[dept_code][i % len(SUBJECT_DEFS[dept_code])][0]}" + exp = random.randint(3, 28) + join_year = 2026 - exp + join_dt = date(join_year, random.randint(6, 8), random.randint(1, 28)) + + teacher = await db.teacher.create(data={ + "userId": user.id, + "employeeId": emp_id, + "firstName": first, + "lastName": last, + "phone": phone, + "qualification": _pick(qual_options), + "specialization": spec, + "experienceYears": exp, + "joiningDate": _to_datetime(join_dt), + "departmentId": dept_map[dept_code], + "designationId": desig_id, + }) + teacher_ids.append(teacher.id) + teacher_counter[dept_code] += 1 + teacher_dept_map[dept_code].append({ + "id": teacher.id, + "first_name": first, + "last_name": last, + "email": email, + "dept_code": dept_code, + }) + + print(f" ✓ {len(teacher_ids)} teachers created") + + # ----- Students ----- + # Update name pool: add back any unused teacher names + remaining pool + remaining_names = name_pool[60:] + # If we need more names, generate additional ones + total_students_needed = sum(s2 + s4 + s6 + s8 for _, s2, s4, s6, s8 in STUDENT_DEPT_DIST) + while len(remaining_names) < total_students_needed: + remaining_names.append(( + _pick(MALE_FIRST_NAMES + FEMALE_FIRST_NAMES), + _pick(LAST_NAMES), + _pick(["Male", "Female"]), + )) + + print(f"\n[9/16] Seeding {total_students_needed} Students …") + student_ids: list[str] = [] + student_info: list[dict] = [] # for use in enrollments & attendance + + enrollment_serial: dict[str, int] = {} + for code, _, _, _, _ in STUDENT_DEPT_DIST: + enrollment_serial[code] = 1 + + def make_enrollment(dept_code: str, batch_start: str) -> str: + serial = enrollment_serial[dept_code] + enrollment_serial[dept_code] += 1 + return f"{dept_code}{batch_start}{serial:03d}" + + for dept_code, sem_2, sem_4, sem_6, sem_8 in STUDENT_DEPT_DIST: + dept_id = dept_map[dept_code] + for sem, count in [(2, sem_2), (4, sem_4), (6, sem_6), (8, sem_8)]: + batch_str = BATCH_MAP[sem] + batch_start = batch_str.split("-")[0] + for _ in range(count): + first, last, gender = remaining_names.pop(0) + enroll_no = make_enrollment(dept_code, batch_start) + email = f"{enroll_no.lower()}@smartattendance.edu.in" + + user = await db.user.create(data={ + "email": email, + "hashedPassword": _hash_password("Student@123"), + "role": "STUDENT", + }) + dob = make_dob_for_semester(sem) + phone = make_phone() + device_uuid = str(uuid.uuid4()) + + student = await db.student.create(data={ + "userId": user.id, + "enrollmentNumber": enroll_no, + "firstName": first, + "lastName": last, + "phone": phone, + "gender": gender, + "dateOfBirth": _to_datetime(dob), + "semester": sem, + "batch": batch_str, + "departmentId": dept_id, + "deviceUuid": device_uuid, + "currentStreak": random.choices([0, 1, 2, 3, 5, 7, 10, 14], weights=[30, 20, 15, 10, 10, 8, 5, 2])[0], + "highestStreak": 0, # will update below or leave as is + }) + student_ids.append(student.id) + student_info.append({ + "id": student.id, + "dept_code": dept_code, + "semester": sem, + "batch": batch_str, + "first_name": first, + "last_name": last, + "email": email, + "enroll_no": enroll_no, + }) + + # Set highest streak for some students + for s_info in random.sample(student_info, min(50, len(student_info))): + sid = s_info["id"] + hs = random.randint(3, 20) + await db.student.update(where={"id": sid}, data={"highestStreak": hs}) + + print(f" ✓ {len(student_ids)} students created") + + # ------------------------------------------------------------------ + # 10. ACADEMIC CLASSES + # ------------------------------------------------------------------ + print("\n[10/16] Seeding Academic Classes …") + class_ids: list[str] = [] + class_info: list[dict] = [] + + # Build a map: (dept_code, semester) -> list of subject codes + dept_sem_subjects: dict[tuple[str, int], list[str]] = {} + for dept_code, subjects in SUBJECT_DEFS.items(): + for name, code, sem in subjects: + dept_sem_subjects.setdefault((dept_code, sem), []).append(code) + + classroom_names = [c[0] for c in CLASSROOMS] + class_serial = 0 + + for t_info in [item for sublist in teacher_dept_map.values() for item in sublist]: + t_dept = t_info["dept_code"] + # Find available subjects for this teacher's department + # Teacher teaches across 2 semesters (pick 2 subjects from 2 different semesters) + available_sems = sorted(set(sem for _, _, sem in SUBJECT_DEFS[t_dept])) + if len(available_sems) < 2: + continue + + sem1, sem2 = _pick_n(available_sems, 2) + subs_for_sem1 = dept_sem_subjects.get((t_dept, sem1), []) + subs_for_sem2 = dept_sem_subjects.get((t_dept, sem2), []) + + chosen_subs = [] + if subs_for_sem1: + chosen_subs.append((_pick(subs_for_sem1), sem1)) + if subs_for_sem2: + chosen_subs.append((_pick(subs_for_sem2), sem2)) + + for sub_code, sem in chosen_subs: + class_serial += 1 + sub_id = subject_map[sub_code] + sub_name = next(n for n, c, _ in SUBJECT_DEFS[t_dept] if c == sub_code) + cr_name = _pick(classroom_names) + cr_id = classroom_map[cr_name] + batch_str = BATCH_MAP[sem] + class_name = f"{sub_name} ({batch_str})" + + cls = await db.academicclass.create(data={ + "name": class_name, + "subjectId": sub_id, + "classroomId": cr_id, + "teacherId": t_info["id"], + "semester": sem, + "batch": batch_str, + "maxStudents": 60, + }) + class_ids.append(cls.id) + class_info.append({ + "id": cls.id, + "name": class_name, + "subject_code": sub_code, + "dept_code": t_dept, + "semester": sem, + "batch": batch_str, + "teacher_id": t_info["id"], + "classroom_id": cr_id, + }) + + print(f" ✓ {len(class_ids)} academic classes created") + + # ------------------------------------------------------------------ + # 11. GEOFENCES (one per class) + # ------------------------------------------------------------------ + print("\n[11/16] Seeding Geofences …") + geofence_class_ids: set[str] = set() + for cl in class_info: + lat, lng = jitter_gps(CAMPUS_LAT, CAMPUS_LNG, 0.003) + radius = round(random.uniform(15.0, 50.0), 1) + await db.geofence.create(data={ + "academicClassId": cl["id"], + "latitude": lat, + "longitude": lng, + "radiusMeters": radius, + }) + geofence_class_ids.add(cl["id"]) + print(f" ✓ {len(geofence_class_ids)} geofences created") + + # ------------------------------------------------------------------ + # 12. ENROLLMENTS + # ------------------------------------------------------------------ + print("\n[12/16] Seeding Enrollments …") + enrollment_count = 0 + # For each student, enroll them in classes matching their dept & semester + # Each student gets 4-6 classes + for s_info in student_info: + dept = s_info["dept_code"] + sem = s_info["semester"] + matching_classes = [cl for cl in class_info if cl["dept_code"] == dept and cl["semester"] == sem] + available = _pick_n(matching_classes, min(len(matching_classes), random.randint(4, 6))) + for cl in available: + try: + await db.enrollment.create(data={ + "studentId": s_info["id"], + "academicClassId": cl["id"], + }) + enrollment_count += 1 + except Exception: + pass # skip duplicate + print(f" ✓ {enrollment_count} enrollments created") + + # ------------------------------------------------------------------ + # 13. SESSIONS (1 month of activity) + # ------------------------------------------------------------------ + print("\n[13/16] Seeding Sessions (~1 month: April 2026) …") + + session_ids: list[str] = [] + session_info: list[dict] = [] + + for cl in class_info: + # Each class meets 3-5 times per week over the month => ~12-20 sessions + num_sessions = random.randint(12, 20) + used_slots: set[tuple[int, int]] = set() # (day_of_month, hour) + + for _ in range(num_sessions): + for attempt in range(50): + day = random.randint(1, 30) + # Skip weekends (Saturday=5, Sunday=6 if Monday=0) + session_date = date(2026, 4, day) + wd = session_date.weekday() + if wd >= 5: + continue + hour = random.choice([8, 9, 10, 11, 14, 15, 16]) + slot = (day, hour) + if slot not in used_slots: + used_slots.add(slot) + break + else: + continue + + start_dt = datetime(2026, 4, day, hour, random.choice([0, 15, 30]), tzinfo=timezone.utc) + duration = random.choice([45, 50, 55, 60]) + end_dt = start_dt + timedelta(minutes=duration) + + sess = await db.session.create(data={ + "academicClassId": cl["id"], + "startTime": start_dt, + "endTime": end_dt, + "isActive": False, # past sessions + }) + session_ids.append(sess.id) + session_info.append({ + "id": sess.id, + "class_id": cl["id"], + "start": start_dt, + "end": end_dt, + }) + + print(f" ✓ {len(session_ids)} sessions created") + + # ------------------------------------------------------------------ + # 14. ATTENDANCE RECORDS + # ------------------------------------------------------------------ + print("\n[14/16] Seeding Attendance records …") + attendance_count = 0 + + # Pre-group enrollments by class_id for fast lookup + enrollments_by_class: dict[str, list[str]] = {} + for s_info in student_info: + sid = s_info["id"] + matching_classes = [cl for cl in class_info if cl["dept_code"] == s_info["dept_code"] and cl["semester"] == s_info["semester"]] + for cl in matching_classes: + enrollments_by_class.setdefault(cl["id"], []).append(sid) + + # Build attendance_pool per student: how likely they are to attend + # 70% regular (85-95% attendance), 20% average (65-85%), 7% irregular (40-65%), 3% very irregular (<40%) + student_attendance_pattern: dict[str, float] = {} + for s_info in student_info: + r = random.random() + if r < 0.70: + student_attendance_pattern[s_info["id"]] = random.uniform(0.85, 0.98) + elif r < 0.90: + student_attendance_pattern[s_info["id"]] = random.uniform(0.65, 0.84) + elif r < 0.97: + student_attendance_pattern[s_info["id"]] = random.uniform(0.40, 0.64) + else: + student_attendance_pattern[s_info["id"]] = random.uniform(0.10, 0.39) + + # Also pre-group students by class for attendance + for sess in session_info: + cl_id = sess["class_id"] + enrolled_students = enrollments_by_class.get(cl_id, []) + if not enrolled_students: + continue + + for sid in enrolled_students: + attend_prob = student_attendance_pattern.get(sid, 0.75) + if random.random() > attend_prob: + continue # student was absent — no record + + # Decide if present, flagged, or absent-marked + status_roll = random.random() + if status_roll < 0.82: + status = "Present" + scores = generate_present_scores() + elif status_roll < 0.95: + status = "Flagged" + scores = generate_flagged_scores() + else: + status = "Absent" + scores = {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0, "final_ai_score": 0.0} + + gps_lat, gps_lng = jitter_gps(CAMPUS_LAT, CAMPUS_LNG, 0.005) + + remarks = None + if status == "Flagged": + remarks = _pick([ + "Low face confidence", "Lighting conditions poor", + "Background mismatch detected", "Face partially occluded", + "Liveness check inconclusive", + ]) + + try: + await db.attendance.create(data={ + "studentId": sid, + "sessionId": sess["id"], + "status": status, + "faceScore": scores["face_score"], + "livenessScore": scores["liveness_score"], + "backgroundScore": scores["background_score"], + "finalAiScore": scores["final_ai_score"], + "gpsLatitude": gps_lat, + "gpsLongitude": gps_lng, + "remarks": remarks, + }) + attendance_count += 1 + except Exception: + pass # skip duplicate + + print(f" ✓ {attendance_count} attendance records created") + + # ------------------------------------------------------------------ + # 15. LEAVE REQUESTS + # ------------------------------------------------------------------ + print("\n[15/16] Seeding Leave Requests …") + leave_reasons = [ + "Medical appointment with specialist", + "Family wedding function at hometown", + "Fever and throat infection", + "Attending a technical workshop in Bangalore", + "Personal family emergency at home", + "Eye check-up and consultation", + "Higher studies counselling session", + "Participating in college sports tournament", + "Dental surgery recovery", + "Sibling's marriage ceremony", + "Preparation for competitive exam at coaching centre", + "Travel for college industrial visit", + ] + leave_count = 0 + # Create leaves for ~40 students + for s_info in random.sample(student_info, min(40, len(student_info))): + start = date(2026, 4, random.randint(5, 25)) + duration = random.randint(1, 3) + end = start + timedelta(days=duration) + if end > date(2026, 4, 30): + end = date(2026, 4, 30) + + status = _weighted_choice(["PENDING", "APPROVED", "REJECTED"], [0.25, 0.60, 0.15]) + approved_by = None + approver_note = None + if status == "APPROVED": + approved_by = _pick(teacher_ids) if teacher_ids else None + approver_note = _pick(["Approved", "Leave granted", "Ensure you catch up on missed classes"]) + elif status == "REJECTED": + approved_by = _pick(teacher_ids) if teacher_ids else None + approver_note = _pick(["Not enough supporting documents", "Attendance already below minimum"]) + + await db.leaverequest.create(data={ + "studentId": s_info["id"], + "startDate": _to_datetime(start), + "endDate": _to_datetime(end), + "reason": _pick(leave_reasons), + "status": status, + "approvedBy": approved_by, + "approverNote": approver_note, + }) + leave_count += 1 + print(f" ✓ {leave_count} leave requests created") + + # ------------------------------------------------------------------ + # 16. DEVICE CHANGE REQUESTS + # ------------------------------------------------------------------ + print("\n[16/16] Seeding Device Change Requests …") + device_count = 0 + for s_info in random.sample(student_info, min(15, len(student_info))): + new_uuid = str(uuid.uuid4()) + status = _weighted_choice(["PENDING", "APPROVED", "REJECTED"], [0.30, 0.55, 0.15]) + approved_by = None + if status in ("APPROVED", "REJECTED"): + approved_by = _pick(teacher_ids) if teacher_ids else None + + await db.devicechangerequest.create(data={ + "studentId": s_info["id"], + "newDeviceUuid": new_uuid, + "reason": _pick([ + "Phone damaged, new device", "Lost previous phone", + "Upgraded to new phone", "Battery issue in old device", + "Old device stolen", + ]), + "status": status, + "approvedBy": approved_by, + }) + device_count += 1 + print(f" ✓ {device_count} device change requests created") + + # ------------------------------------------------------------------ + # 17. AUDIT LOGS + # ------------------------------------------------------------------ + print("\n ✓ Seeding Audit Logs …") + admin_id = admin_user.id + audit_events = [ + ("USER_CREATED", "INFO", "System", f"Admin account created: {admin_user.id}", None), + ("SYSTEM_CONFIG_UPDATED", "INFO", admin_id, "Initial system configuration set", None), + ] + for s_info in student_info[:5]: # log first 5 student creations + audit_events.append(( + "STUDENT_CREATED", "INFO", admin_id, + f"Student {s_info['first_name']} {s_info['last_name']} ({s_info['enroll_no']}) registered", + {"student_id": s_info["id"]}, + )) + for t_info in teacher_dept_map["CSE"][:3]: + audit_events.append(( + "TEACHER_CREATED", "INFO", admin_id, + f"Teacher {t_info['first_name']} {t_info['last_name']} ({t_info['email']}) registered", + {"teacher_id": t_info["id"]}, + )) + + for event_type, severity, actor, description, metadata in audit_events: + log_data = { + "eventType": event_type, + "severity": severity, + "actor": actor, + "target": actor, + "description": description, + "ipAddress": "127.0.0.1", + } + if metadata: + log_data["metadata"] = Json(metadata) + await db.auditlog.create(data=log_data) + print(" ✓ Audit logs created") + + # Recalculate streaks and build Redis leaderboard + try: + from app.db.redis import connect_redis, disconnect_redis + from app.services.gamification_service import GamificationService + await connect_redis() + print("\nUpdating student streaks and Redis leaderboard...") + gamification_service = GamificationService() + for idx, s_id in enumerate(student_ids): + await gamification_service.recalculate_student_streak(s_id) + print(" ✓ Recalculated and synchronized all streaks/leaderboard scores") + except Exception as re: + print(f" [WARNING] Failed to recalculate streaks/leaderboard: {re}") + finally: + try: + await disconnect_redis() + except Exception: + pass + + # ====================================================================== + # SUMMARY + # ====================================================================== + print("\n" + "=" * 72) + print(" SEED COMPLETE — SUMMARY") + print("=" * 72) + print(f" Departments : {len(DEPARTMENTS)}") + print(f" Designations : {len(DESIGNATIONS)}") + print(f" Subjects : {len(subject_map)}") + print(f" Classrooms : {len(CLASSROOMS)}") + print(" Admins : 1") + print(f" Teachers : {len(teacher_ids)}") + print(f" Students : {len(student_ids)}") + print(f" Academic Classes : {len(class_ids)}") + print(f" Geofences : {len(geofence_class_ids)}") + print(f" Enrollments : {enrollment_count}") + print(f" Sessions : {len(session_ids)}") + print(f" Attendance : {attendance_count}") + print(f" Leaves : {leave_count}") + print(f" Device Changes : {device_count}") + print() + + # ---- CREDENTIALS ---- + sample_teacher = None + for t_list in teacher_dept_map.values(): + if t_list: + sample_teacher = t_list[0] + break + sample_student = student_info[0] if student_info else None + + print("-" * 72) + print(" LOGIN CREDENTIALS") + print("-" * 72) + print(" ADMIN → admin@smartattendance.edu.in / Admin@123") + if sample_teacher: + print(f" TEACHER → {sample_teacher['email']} / Teacher@123") + if sample_student: + print(f" STUDENT → {sample_student['email']} / Student@123") + print() + + print(" All student accounts: password = Student@123") + print(" All teacher accounts: password = Teacher@123") + print("=" * 72) + + finally: + await db.disconnect() + print("\nDatabase connection closed.") + + +# ============================================================================== +# SANDBOX SEED FOR PRATHAM RAJBHAR +# ============================================================================== + +async def seed_all_pratham() -> None: + """ + Seeds a sandbox database with exactly one student (Pratham Rajbhar) + and 30+ days of historical attendance, leave requests, and device logs. + """ + await db.connect() + print("=" * 72) + print(" SMART ATTENDANCE SYSTEM — PRATHAM RAJBHAR SEED") + print("=" * 72) + + try: + # 1. Clear tables + print("\n[1/16] Clearing existing data …") + await db.attendance.delete_many() + await db.devicechangerequest.delete_many() + await db.leaverequest.delete_many() + await db.enrollment.delete_many() + await db.geofence.delete_many() + await db.session.delete_many() + await db.academicclass.delete_many() + await db.teacher.delete_many() + await db.student.delete_many() + await db.user.delete_many() + await db.subject.delete_many() + await db.classroom.delete_many() + await db.designation.delete_many() + await db.department.delete_many() + await db.auditlog.delete_many() + await db.systemconfiguration.delete_many() + print(" ✓ All database tables cleared") + + # Clear Redis leaderboard cache + try: + from app.db.redis import connect_redis, disconnect_redis + redis = await connect_redis() + if redis: + await redis.delete("leaderboard:points") + print(" ✓ Redis leaderboard cache cleared") + await disconnect_redis() + except Exception as re: + print(f" [WARNING] Failed to clear Redis cache: {re}") + + # 2. System Configuration + print("\n[2/16] Seeding System Configuration …") + await db.systemconfiguration.create(data={ + "isFaceRecognitionEnabled": True, + "isGpsVerificationEnabled": True, + "isAiBackgroundValidationEnabled": True, + }) + print(" ✓ System configuration initialized") + + # 3. Departments (Only CSE) + print("\n[3/16] Seeding CSE Department …") + dept = await db.department.create(data={ + "name": "Computer Science & Engineering", + "code": "CSE", + "head": "Dr. Rajesh Sharma", + "description": "Department of Computer Science & Engineering", + }) + print(" ✓ CSE department created") + + # 4. Designations + print("\n[4/16] Seeding Designations …") + desig_map = {} + for name, code, desc in DESIGNATIONS: + desig = await db.designation.create(data={ + "name": name, + "code": code, + "description": desc, + }) + desig_map[code] = desig.id + print(f" ✓ {len(DESIGNATIONS)} designations created") + + # 5. Subjects (Only CSE 6th Sem subjects) + print("\n[5/16] Seeding Subjects …") + subject_map = {} + cse_subs = [ + ("Computer Networks", "CSE401"), + ("Software Engineering", "CSE402"), + ("Web Technologies", "CSE403"), + ("Design & Analysis of Algorithms", "CSE404"), + ] + for name, code in cse_subs: + subj = await db.subject.create(data={ + "name": name, + "code": code, + "description": f"{name} core course", + }) + subject_map[code] = subj.id + print(" ✓ CSE Semester 6 subjects created") + + # 6. Classrooms + print("\n[6/16] Seeding Classrooms …") + classroom_map = {} + for name, building, capacity in CLASSROOMS[:3]: # pick first 3 + cr = await db.classroom.create(data={ + "name": name, + "building": building, + "capacity": capacity, + }) + classroom_map[name] = cr.id + print(f" ✓ {len(classroom_map)} classrooms created") + + # 7. Admin User + print("\n[7/16] Creating admin user …") + admin_user = await db.user.create(data={ + "email": "admin@smartattendance.edu.in", + "hashedPassword": _hash_password("Admin@123"), + "role": "ADMIN", + }) + print(" ✓ Admin user created") + + # 8. Teachers + print("\n[8/16] Seeding Teachers …") + teacher_defs = [ + ("Amit", "Patel", "EMP001", "PROF"), + ("Sanjay", "Sharma", "EMP002", "APROF"), + ("Neha", "Gupta", "EMP003", "ASPROF"), + ] + teacher_ids = [] + for first, last, emp_id, desig_code in teacher_defs: + user = await db.user.create(data={ + "email": f"{emp_id.lower()}@smartattendance.edu.in", + "hashedPassword": _hash_password("Teacher@123"), + "role": "TEACHER", + }) + teacher = await db.teacher.create(data={ + "userId": user.id, + "employeeId": emp_id, + "firstName": first, + "lastName": last, + "phone": make_phone(), + "qualification": "Ph.D.", + "specialization": "Computer Science", + "experienceYears": 10, + "joiningDate": _to_datetime(date(2018, 7, 1)), + "departmentId": dept.id, + "designationId": desig_map[desig_code], + }) + teacher_ids.append(teacher.id) + print(f" ✓ {len(teacher_ids)} teachers created") + + # 9. Student (Pratham Rajbhar) + print("\n[9/16] Seeding Student Pratham Rajbhar …") + student_user = await db.user.create(data={ + "email": "pratham.rajbhar@smartattendance.edu.in", + "hashedPassword": _hash_password("Student@123"), + "role": "STUDENT", + }) + student = await db.student.create(data={ + "userId": student_user.id, + "enrollmentNumber": "CSE2023068", + "firstName": "Pratham", + "lastName": "Rajbhar", + "phone": "+919988776655", + "gender": "Male", + "dateOfBirth": _to_datetime(date(2004, 8, 15)), + "semester": 6, + "batch": "2023-2027", + "departmentId": dept.id, + "deviceUuid": "d8f8a1a8-c2cb-4449-b71e-3bcadfc00b68", + "currentStreak": 5, + "highestStreak": 12, + }) + print(" ✓ Student profile created") + + # 10. Academic Classes + print("\n[10/16] Seeding Academic Classes …") + cr_id = list(classroom_map.values())[0] + class_defs = [ + ("Web Technologies (2023-2027)", "CSE403", teacher_ids[0]), + ("Design & Analysis of Algorithms (2023-2027)", "CSE404", teacher_ids[1]), + ("Computer Networks (2023-2027)", "CSE401", teacher_ids[2]), + ("Software Engineering (2023-2027)", "CSE402", teacher_ids[0]), + ] + classes = [] + for name, code, t_id in class_defs: + cls = await db.academicclass.create(data={ + "name": name, + "subjectId": subject_map[code], + "classroomId": cr_id, + "teacherId": t_id, + "semester": 6, + "batch": "2023-2027", + "maxStudents": 60, + }) + classes.append({"id": cls.id, "code": code}) + print(f" ✓ {len(classes)} academic classes created") + + # 11. Geofences + print("\n[11/16] Seeding Geofences …") + for idx, cl in enumerate(classes): + cl_lat = CAMPUS_LAT + (idx * 0.0005) + cl_lng = CAMPUS_LNG - (idx * 0.0005) + await db.geofence.create(data={ + "academicClassId": cl["id"], + "latitude": cl_lat, + "longitude": cl_lng, + "radiusMeters": 50.0, + }) + print(" ✓ Geofences configured around campus") + + # 12. Enrollments + print("\n[12/16] Seeding Enrollments …") + for cl in classes: + await db.enrollment.create(data={ + "studentId": student.id, + "academicClassId": cl["id"], + }) + print(" ✓ Enrolled student in all classes") + + # 13. Sessions & Attendance (35 Days) + print("\n[13/16] Seeding 30+ Days of Sessions & Attendance …") + session_count = 0 + attendance_count = 0 + + # We loop back 35 days and seed weekday sessions + today = datetime.now(timezone.utc).date() + for offset in range(35, 0, -1): + day_date = today - timedelta(days=offset) + weekday = day_date.weekday() + if weekday >= 5: # skip weekends + continue + + # Class schedules: Mon/Wed/Fri (CSE403, CSE404), Tue/Thu (CSE401, CSE402) + scheduled_codes = ["CSE403", "CSE404"] if weekday in (0, 2, 4) else ["CSE401", "CSE402"] + for code in scheduled_codes: + cl_id = next(c["id"] for c in classes if c["code"] == code) + hour = 10 if code in ("CSE403", "CSE401") else 14 + start_dt = datetime(day_date.year, day_date.month, day_date.day, hour, 0, tzinfo=timezone.utc) + end_dt = start_dt + timedelta(hours=1) + + sess = await db.session.create(data={ + "academicClassId": cl_id, + "startTime": start_dt, + "endTime": end_dt, + "isActive": False, + }) + session_count += 1 + + # Generate status distribution: Present (85%), Flagged (8%), Absent (7%) + roll = random.random() + if roll < 0.85: + status = "Present" + scores = generate_present_scores() + elif roll < 0.93: + status = "Flagged" + scores = generate_flagged_scores() + else: + status = "Absent" + scores = {"face_score": 0.0, "liveness_score": 0.0, "background_score": 0.0, "final_ai_score": 0.0} + + gps_lat, gps_lng = jitter_gps(CAMPUS_LAT, CAMPUS_LNG, 0.0001) + + await db.attendance.create(data={ + "studentId": student.id, + "sessionId": sess.id, + "status": status, + "faceScore": scores["face_score"], + "livenessScore": scores["liveness_score"], + "backgroundScore": scores["background_score"], + "finalAiScore": scores["final_ai_score"], + "gpsLatitude": gps_lat, + "gpsLongitude": gps_lng, + "remarks": "Low face confidence" if status == "Flagged" else None, + }) + attendance_count += 1 + + print(f" ✓ {session_count} sessions created") + print(f" ✓ {attendance_count} attendance records created") + + # 14. Leave Request + print("\n[14/16] Seeding Leave Request …") + await db.leaverequest.create(data={ + "studentId": student.id, + "startDate": _to_datetime(today - timedelta(days=12)), + "endDate": _to_datetime(today - timedelta(days=10)), + "reason": "Recovering from viral fever and throat infection", + "status": "APPROVED", + "approvedBy": teacher_ids[0], + "approverNote": "Get well soon. Make sure to complete pending assignments.", + }) + print(" ✓ Approved leave request seeded") + + # 15. Device Change Request + print("\n[15/16] Seeding Device Change Request …") + await db.devicechangerequest.create(data={ + "studentId": student.id, + "reason": "Phone screen damaged, upgraded to a new device", + "newDeviceUuid": str(uuid.uuid4()), + "status": "APPROVED", + "approvedBy": teacher_ids[0], + }) + print(" ✓ Approved device change request seeded") + + # 16. Audit Logs + print("\n[16/16] Seeding Audit Logs …") + await db.auditlog.create(data={ + "eventType": "STUDENT_CREATED", + "severity": "INFO", + "actor": admin_user.id, + "target": student.id, + "description": f"Student Pratham Rajbhar ({student.enrollmentNumber}) registered by admin", + "ipAddress": "127.0.0.1", + }) + print(" ✓ Administrative audit logs created") + + # Recalculate streaks and build Redis leaderboard + try: + from app.db.redis import connect_redis, disconnect_redis + from app.services.gamification_service import GamificationService + await connect_redis() + print("\nUpdating student streaks and Redis leaderboard...") + gamification_service = GamificationService() + await gamification_service.recalculate_student_streak(student.id) + print(" ✓ Recalculated and synchronized all streaks/leaderboard scores") + except Exception as re: + print(f" [WARNING] Failed to recalculate streaks/leaderboard: {re}") + finally: + try: + await disconnect_redis() + except Exception: + pass + + # Summary Printout + print("\n" + "=" * 72) + print(" SEED COMPLETE — SUMMARY") + print("=" * 72) + print(" Departments : 1") + print(f" Designations : {len(DESIGNATIONS)}") + print(" Subjects : 4") + print(f" Classrooms : {len(classroom_map)}") + print(" Admins : 1") + print(f" Teachers : {len(teacher_ids)}") + print(" Students : 1 (Pratham Rajbhar)") + print(f" Academic Classes : {len(classes)}") + print(f" Geofences : {len(classes)}") + print(f" Enrollments : {len(classes)}") + print(f" Sessions : {session_count}") + print(f" Attendance : {attendance_count}") + print(" Leaves : 1") + print(" Device Changes : 1") + print("-" * 72) + print(" LOGIN CREDENTIALS") + print("-" * 72) + print(" ADMIN → admin@smartattendance.edu.in / Admin@123") + print(" TEACHER → emp001@smartattendance.edu.in / Teacher@123") + print(" STUDENT → pratham.rajbhar@smartattendance.edu.in / Student@123") + print("=" * 72) + + finally: + await db.disconnect() + print("\nDatabase connection closed.") + + +# ============================================================================== +# ENTRY POINT +# ============================================================================== + +if __name__ == "__main__": + asyncio.run(seed_all()) + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c6d8dbb52989d9aeb8b396ebdd5d6ac77eb9108c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ +.pnp +.pnp.* + +# Next.js +.next/ +out/ + +# Production +build/ +dist/ + +# Testing +coverage/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# OS files +.DS_Store +Thumbs.db diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7b0c50238c2413788f74ed6d6771f0a7128642e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Geist, a new font family. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..05e726d1b4201bc8c7716d2b058279676582e8c0 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9ffa3083ad279ecf95fd8eae59cb253e9a539c4 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..344889d2ebf2ee789d8263e8924b624cd490f79d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,7401 @@ +{ + "name": "frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.1.0", + "dependencies": { + "@types/papaparse": "^5.5.2", + "axios": "^1.16.1", + "leaflet": "^1.9.4", + "lucide-react": "^1.16.0", + "next": "16.2.6", + "papaparse": "^5.5.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-hot-toast": "^2.6.0", + "react-leaflet": "^5.0.0", + "recharts": "^3.8.1", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.21", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", + "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", + "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..8fc362c2ba1d4102dad48a30ca7715e293ef93c5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@types/papaparse": "^5.5.2", + "axios": "^1.16.1", + "leaflet": "^1.9.4", + "lucide-react": "^1.16.0", + "next": "16.2.6", + "papaparse": "^5.5.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-hot-toast": "^2.6.0", + "react-leaflet": "^5.0.0", + "recharts": "^3.8.1", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.21", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..61e36849cf7cfa9f1f71b4a3964a4953e3e243d3 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40e3dade25ca68c7cb61dc1f222ff537e2831d48 --- /dev/null +++ b/frontend/src/app/(auth)/login/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Mail, Lock, Shield } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import { useAuthStore } from "@/store/authStore"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassButton from "@/components/ui/GlassButton"; +import type { TokenResponse, UserProfile } from "@/types"; + +export default function LoginPage(): React.ReactElement { + const router = useRouter(); + const { login } = useAuthStore(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState<{ email?: string; password?: string }>({}); + + function validate(): boolean { + const errs: { email?: string; password?: string } = {}; + if (!email.trim()) errs.email = "Email is required"; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errs.email = "Invalid email format"; + if (!password) errs.password = "Password is required"; + else if (password.length < 8) errs.password = "Minimum 8 characters"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + + setLoading(true); + try { + const { data: tokenData } = await api.post("/auth/login", { email, password }); + const { data: profile } = await api.get("/auth/me", { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + + login(tokenData.access_token, profile); + toast.success("Welcome back!"); + + const destination = tokenData.role === "ADMIN" ? "/admin/dashboard" : "/teacher/classes"; + router.push(destination); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Login failed. Please check your credentials.")); + } finally { + setLoading(false); + } + } + + return ( +
+ {} +
+ +
+ {} +
+
+ +
+

+ Smart Attendance +

+

+ AI-Powered Multi-Layered Verification System +

+
+ + {} +
+
+

Welcome Back

+

Sign in to your administration or teacher account

+
+ +
+ setEmail(e.target.value)} + error={errors.email} + icon={} + autoComplete="email" + /> + + setPassword(e.target.value)} + error={errors.password} + icon={} + autoComplete="current-password" + /> +
+ +
+ + Sign In + +
+
+ + {} +

+ Enterprise Security Verification Suite +

+
+
+ ); +} + diff --git a/frontend/src/app/(dashboard)/admin/audit/page.tsx b/frontend/src/app/(dashboard)/admin/audit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..35e02aa0b3349c2219332a3a5eaa069aca204fe8 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/audit/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import api from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassBadge from "@/components/ui/GlassBadge"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { AuditLogResponse } from "@/types"; +import type { BadgeVariant } from "@/components/ui/GlassBadge"; + +const SEVERITY_VARIANT: Record = { + HIGH: "danger", + CRITICAL: "danger", + MEDIUM: "warning", + LOW: "info", +}; + +export default function AuditPage(): React.ReactElement { + const [logs, setLogs] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetch(): Promise { + try { const { data } = await api.get("/admin/audit"); setLogs(data); setFiltered(data); } + catch { setLogs([]); setFiltered([]); } + finally { setLoading(false); } + } + fetch(); + }, []); + + const handleSearch = useCallback((q: string) => { + if (!q.trim()) { setFiltered(logs); return; } + const lq = q.toLowerCase(); + setFiltered(logs.filter((l) => l.eventType.toLowerCase().includes(lq) || l.actor.toLowerCase().includes(lq) || l.description.toLowerCase().includes(lq))); + }, [logs]); + + const columns: TableColumn>[] = [ + { key: "timestamp", header: "Time", sortable: true, render: (r) => {new Date(String(r.timestamp)).toLocaleString()} }, + { key: "eventType", header: "Event", sortable: true }, + { key: "severity", header: "Severity", render: (r) => {String(r.severity)} }, + { key: "actor", header: "Actor" }, + { key: "target", header: "Target" }, + { key: "description", header: "Description", render: (r) => {String(r.description)} }, + ]; + + if (loading) return ; + + return ( +
+ + +
+ )[]} emptyMessage="No audit events" pageSize={15} /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/classes/[id]/assign-teacher/page.tsx b/frontend/src/app/(dashboard)/admin/classes/[id]/assign-teacher/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..82b40aa8d4ded8a831c0b7774cd864a33c2d9aa3 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/classes/[id]/assign-teacher/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { TeacherResponse } from "@/types"; + +export default function AssignTeacherPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [teachers, setTeachers] = useState([]); + const [teacherId, setTeacherId] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function fetch(): Promise { + try { const { data } = await api.get("/admin/users/teachers"); setTeachers(data); } + catch (err: unknown) { toast.error(getApiErrorMessage(err, "Failed to load teachers")); } + finally { setLoading(false); } + } + fetch(); + }, []); + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!teacherId) { toast.error("Select a teacher"); return; } + setSaving(true); + try { + await api.put(`/admin/classes/${id}/assign-teacher`, { teacher_id: teacherId }); + toast.success("Teacher assigned"); + router.push(`/admin/classes/${id}`); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Assignment failed")); + } finally { setSaving(false); } + } + + if (loading) return ; + + return ( +
+ + + +
+ ({ value: t.id, label: `${t.email} — ${t.department}` }))} + value={teacherId} onChange={setTeacherId} placeholder="Select teacher..." /> +
+ router.back()}>Cancel + Assign Teacher +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/classes/[id]/edit/page.tsx b/frontend/src/app/(dashboard)/admin/classes/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b981e81102fdbb97b8223b51a68c6c3625e78a9 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/classes/[id]/edit/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { BookOpen, Users, Save, X, Settings2 } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { ClassResponse, SubjectResponse, ClassroomResponse } from "@/types"; + +export default function EditClassPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [cls, setCls] = useState(null); + const [subjects, setSubjects] = useState([]); + const [classrooms, setClassrooms] = useState([]); + const [name, setName] = useState(""); + const [subjectId, setSubjectId] = useState(""); + const [classroomId, setClassroomId] = useState(""); + const [semester, setSemester] = useState(""); + const [batch, setBatch] = useState(""); + const [maxStudents, setMaxStudents] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const fetchData = useCallback(async (): Promise => { + try { + const [classRes, subjectsRes, classroomsRes] = await Promise.all([ + api.get(`/admin/classes/${id}`), + api.get("/admin/subjects"), + api.get("/admin/classrooms"), + ]); + const found = classRes.data; + if (found) { + setCls(found); + setName(found.name); + + const matchedSubject = subjectsRes.data.find( + (s) => s.name === found.subject_name && s.code === found.subject_code + ); + if (matchedSubject) setSubjectId(matchedSubject.id); + + const matchedClassroom = classroomsRes.data.find( + (c) => c.name === found.classroom_name + ); + if (matchedClassroom) setClassroomId(matchedClassroom.id); + + setSemester(found.semester ? String(found.semester) : ""); + setBatch(found.batch || ""); + setMaxStudents(found.max_students ? String(found.max_students) : ""); + } + setSubjects(subjectsRes.data); + setClassrooms(classroomsRes.data); + } catch { + toast.error("Could not load class data."); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + void (async () => { + await fetchData(); + })(); + }, [fetchData]); + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!name.trim()) { toast.error("Class name is required"); return; } + setSaving(true); + try { + const payload: Record = { name }; + if (subjectId) payload.subject_id = subjectId; + if (classroomId) payload.classroom_id = classroomId; + if (semester) payload.semester = Number(semester); + if (batch) payload.batch = batch; + if (maxStudents) payload.max_students = Number(maxStudents); + await api.put(`/admin/classes/${id}`, payload); + toast.success("Class updated"); + router.push(`/admin/classes/${id}`); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Update failed")); + } finally { + setSaving(false); } + } + + if (loading) return ; + if (!cls) return
Class not found
; + + const subjectOptions = [ + { value: "", label: "— Keep current —" }, + ...subjects.map((s) => ({ value: s.id, label: `${s.name} (${s.code})` })), + ]; + + const SEMESTER_OPTIONS = [ + { value: "", label: "— Keep current —" }, + ...Array.from({ length: 8 }, (_, i) => ({ + value: String(i + 1), + label: `Semester ${i + 1}`, + })), + ]; + + const classroomOptions = [ + { value: "", label: "— Keep current —" }, + ...classrooms.map((c) => ({ + value: c.id, + label: c.building ? `${c.name} — ${c.building}` : c.name, + })), + ]; + + return ( +
+ + +
+
+
+ +
+
+

+ + Core Info +

+

Primary identifiers.

+
+
+ setName(e.target.value)} + /> +
+
+ + +
+
+

+ + Capacity +

+

Enrollment limits.

+
+
+ setMaxStudents(e.target.value)} + /> +
+
+
+ +
+ +
+
+

+ + Class Configuration +

+

Spatial and academic assignments.

+
+
+ + + + setBatch(e.target.value)} + /> +
+
+
+
+ +
+ router.back()} icon={}> + Cancel + + }> + Save Changes + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/classes/[id]/enroll/page.tsx b/frontend/src/app/(dashboard)/admin/classes/[id]/enroll/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea2f5e50ca0899cda3e3dc70977dcc1992319f38 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/classes/[id]/enroll/page.tsx @@ -0,0 +1,330 @@ +"use client"; + +import React, { useEffect, useState, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Search, UserPlus, CheckCircle2, Circle, X } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassBadge from "@/components/ui/GlassBadge"; +import type { StudentResponse, ClassResponse } from "@/types"; + +export default function EnrollStudentsPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + + const [cls, setCls] = useState(null); + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedDepartment, setSelectedDepartment] = useState(""); + const [selectedSemester, setSelectedSemester] = useState(""); + const [selectedBatch, setSelectedBatch] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [enrolling, setEnrolling] = useState(false); + + useEffect(() => { + async function fetchData(): Promise { + try { + const [clsRes, studentsRes] = await Promise.all([ + api.get("/admin/classes"), + api.get("/admin/users/students") + ]); + setCls(clsRes.data.find(c => c.id === id) || null); + setStudents(studentsRes.data); + } catch { + toast.error("Failed to load data"); + } finally { + setLoading(false); + } + } + void fetchData(); + }, [id]); + + const filteredStudents = useMemo(() => { + return students.filter((s) => { + + if (selectedDepartment && s.department_name !== selectedDepartment) return false; + + if (selectedSemester && String(s.semester) !== selectedSemester) return false; + + if (selectedBatch && s.batch !== selectedBatch) return false; + + if (searchQuery.trim()) { + const lowerQ = searchQuery.toLowerCase(); + const nameMatch = `${s.first_name || ""} ${s.last_name || ""}`.toLowerCase().includes(lowerQ); + const emailMatch = s.email.toLowerCase().includes(lowerQ); + const enrollMatch = (s.enrollment_number || "").toLowerCase().includes(lowerQ); + const deptMatch = (s.department_name || "").toLowerCase().includes(lowerQ); + + if (!nameMatch && !emailMatch && !enrollMatch && !deptMatch) { + return false; + } + } + return true; + }); + }, [students, searchQuery, selectedDepartment, selectedSemester, selectedBatch]); + + const nonEnrolledFilteredStudents = useMemo(() => { + return filteredStudents.filter( + (s) => !(cls?.enrolled_student_ids?.includes(s.id) ?? false) + ); + }, [filteredStudents, cls]); + + const allVisibleSelected = nonEnrolledFilteredStudents.length > 0 && + nonEnrolledFilteredStudents.every((s) => selectedIds.has(s.id)); + + const filterOptions = useMemo(() => { + const departments = new Set(); + const semesters = new Set(); + const batches = new Set(); + for (const s of students) { + if (s.department_name) departments.add(s.department_name); + if (s.semester) semesters.add(String(s.semester)); + if (s.batch) batches.add(s.batch); + } + return { + uniqueDepartments: Array.from(departments).sort(), + uniqueSemesters: Array.from(semesters).sort(), + uniqueBatches: Array.from(batches).sort(), + }; + }, [students]); + + const toggleSelect = (studentId: string) => { + const isAlreadyEnrolled = cls?.enrolled_student_ids?.includes(studentId) || false; + if (isAlreadyEnrolled) return; + + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(studentId)) next.delete(studentId); + else next.add(studentId); + return next; + }); + }; + + const toggleSelectAll = () => { + if (allVisibleSelected) { + setSelectedIds((prev) => { + const next = new Set(prev); + nonEnrolledFilteredStudents.forEach((s) => next.delete(s.id)); + return next; + }); + } else { + setSelectedIds((prev) => { + const next = new Set(prev); + nonEnrolledFilteredStudents.forEach((s) => next.add(s.id)); + return next; + }); + } + }; + + async function handleEnroll(): Promise { + if (selectedIds.size === 0) { + toast.error("Please select at least one student"); + return; + } + setEnrolling(true); + try { + const { data } = await api.post<{ enrolled_count: number }>(`/admin/classes/${id}/enroll`, { + student_ids: Array.from(selectedIds) + }); + toast.success(`${data.enrolled_count} students enrolled successfully`); + router.push(`/admin/classes/${id}`); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Enrollment failed")); + } finally { + setEnrolling(false); + } + } + + if (loading) return ; + if (!cls) return
Class not found
; + + return ( +
+ + +
+ +
+
+

Selected

+

{selectedIds.size}

+
+
+ } + onClick={handleEnroll} + loading={enrolling} + disabled={selectedIds.size === 0} + > + Enroll Selected + +
+
+ + +
+ + {} +
+
+
+ setSearchQuery(e.target.value)} + /> + +
+ +
+ + {allVisibleSelected ? "Deselect All Visible" : "Select All Visible"} + + {selectedIds.size > 0 && ( + + )} +
+
+ + {} +
+
+ ({ value: d, label: d })) + ]} + value={selectedDepartment} + onChange={setSelectedDepartment} + /> +
+
+ ({ value: s, label: `Semester ${s}` })) + ]} + value={selectedSemester} + onChange={setSelectedSemester} + /> +
+
+ ({ value: b, label: `Batch ${b}` })) + ]} + value={selectedBatch} + onChange={setSelectedBatch} + /> +
+
+
+ + {} +
+ {filteredStudents.length === 0 ? ( +
+ No students found matching your search. +
+ ) : ( + filteredStudents.map((student) => { + const isSelected = selectedIds.has(student.id); + const isAlreadyEnrolled = cls?.enrolled_student_ids?.includes(student.id) || false; + const fullName = student.first_name || student.last_name ? `${student.first_name || ""} ${student.last_name || ""}`.trim() : "Unknown Name"; + + return ( +
toggleSelect(student.id)} + className={`group flex items-center gap-4 p-4 rounded-xl border transition-all duration-200 ${ + isAlreadyEnrolled + ? "bg-slate-900/10 border-transparent opacity-40 cursor-not-allowed" + : isSelected + ? "bg-white/5 border-white/10 cursor-pointer" + : "bg-slate-800/30 border-transparent hover:border-white/10 hover:bg-white/[0.02] cursor-pointer" + }`} + > +
+ {isAlreadyEnrolled ? : isSelected ? : } +
+ +
+
+

+ {fullName} +

+

{student.email}

+
+ +
+

+ {student.enrollment_number || "No ID"} +

+

Enrollment No.

+
+ +
+ {student.department_name ? ( + {student.department_name} + ) : ( + + )} + {isAlreadyEnrolled && ( + Enrolled + )} +
+ +
+ + {student.id.substring(0, 8)}... + +
+
+
+ ); + }) + )} +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/classes/[id]/page.tsx b/frontend/src/app/(dashboard)/admin/classes/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a55bcbd25fd2a035d6d6e6c9673d8663050124b4 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/classes/[id]/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { + Pencil, UserPlus, Users, BookOpen, GraduationCap, MapPin, + Hash, LayoutDashboard, ChevronRight +} from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassBadge from "@/components/ui/GlassBadge"; +import type { ClassResponse } from "@/types"; + +const colorVariants: Record = { + emerald: "bg-emerald-500/10 text-emerald-400 group-hover:shadow-emerald-500/20", + rose: "bg-rose-500/10 text-rose-400 group-hover:shadow-rose-500/20", + blue: "bg-blue-500/10 text-blue-400 group-hover:shadow-blue-500/20", + slate: "bg-slate-500/10 text-slate-400 group-hover:shadow-slate-500/20", +}; + +const InfoItem = ({ icon: Icon, label, value, color }: { icon: React.ElementType, label: string, value: string | React.ReactNode, color: string }) => ( +
+
+
+ +
+
+

{label}

+
{value}
+
+
+ +
+); + +export default function ClassDetailPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const [cls, setCls] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get(`/admin/classes/${id}`); + setCls(data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load class")); + setCls(null); + } finally { + setLoading(false); + } + } + void fetch(); + }, [id]); + + if (loading) return ; + if (!cls) return
Class not found
; + + return ( +
+ + +
+
+

+ {cls.name} + {cls.semester && Semester {cls.semester}} +

+

+ {cls.subject_name} ({cls.subject_code}) +

+
+
+ + }> + Edit Details + + + + }> + Enroll Students + + +
+
+ +
+
+ +
+
+

+ + Class Overview +

+
+
+ {cls.subject_name} ({cls.subject_code})} color="emerald" /> + + + +
+
+
+ +
+ +
+
+

+ + Capacity +

+
+
+
+
+

{cls.enrolled_count}

+

Enrolled

+
+
+

{cls.max_students || "∞"}

+

Capacity

+
+
+
+
+
+
+
+ + +
+
+ +
+ +
+
+

Change Teacher

+

Reassign faculty member

+
+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/classes/create/page.tsx b/frontend/src/app/(dashboard)/admin/classes/create/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..72d53f35567920a895c6d73f5b7fc0d2071c9a49 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/classes/create/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { BookOpen, Users, PlusCircle, X, Settings2 } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassButton from "@/components/ui/GlassButton"; +import type { + TeacherResponse, + SubjectResponse, + ClassroomResponse, + ClassCreate, +} from "@/types"; + +type FormErrors = Partial>; + +const SEMESTER_OPTIONS = Array.from({ length: 8 }, (_, i) => ({ + value: String(i + 1), + label: `Semester ${i + 1}`, +})); + +export default function CreateClassPage(): React.ReactElement { + const router = useRouter(); + const [teachers, setTeachers] = useState([]); + const [subjects, setSubjects] = useState([]); + const [classrooms, setClassrooms] = useState([]); + const [form, setForm] = useState({ + name: "", + subject_id: "", + teacher_id: "", + }); + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + + useEffect(() => { + async function fetchMasterData(): Promise { + try { + const [teachersRes, subjectsRes, classroomsRes] = await Promise.all([ + api.get("/admin/users/teachers"), + api.get("/admin/subjects"), + api.get("/admin/classrooms"), + ]); + setTeachers(teachersRes.data); + setSubjects(subjectsRes.data); + setClassrooms(classroomsRes.data); + } catch { + toast.error("Could not load master data."); + } + } + void fetchMasterData(); + }, []); + + function set(field: keyof ClassCreate, value: string | number | undefined): void { + setForm((prev) => ({ ...prev, [field]: value })); + if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined })); + } + + function validate(): boolean { + const errs: FormErrors = {}; + if (!form.name.trim()) errs.name = "Class name is required"; + if (!form.teacher_id) errs.teacher_id = "Teacher is required"; + if (!form.subject_id) errs.subject_id = "Subject is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await api.post("/admin/classes", form); + toast.success("Class created successfully"); + router.push("/admin/classes"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to create class")); + } finally { + setLoading(false); + } + } + + const teacherOptions = [ + { value: "", label: "— Select Teacher —" }, + ...teachers.map((t) => ({ + value: t.id, + label: `${t.first_name} ${t.last_name} — ${t.department}`, + })), + ]; + + const subjectOptions = [ + { value: "", label: "— Select Subject —" }, + ...subjects.map((s) => ({ value: s.id, label: `${s.name} (${s.code})` })), + ]; + + const classroomOptions = [ + { value: "", label: "None — no classroom" }, + ...classrooms.map((c) => ({ + value: c.id, + label: c.building ? `${c.name} — ${c.building}` : c.name, + })), + ]; + + return ( +
+ + + +
+
+
+ +
+
+

+ + Core Info +

+

Primary identifiers.

+
+
+ set("name", e.target.value)} + error={errors.name} + /> +
+
+ + +
+
+

+ + Capacity +

+

Enrollment limits.

+
+
+ + set("max_students", e.target.value ? Number(e.target.value) : undefined) + } + /> +
+
+
+ +
+ +
+
+

+ + Class Configuration +

+

Spatial and academic assignments.

+
+
+
+ set("teacher_id", v)} + error={errors.teacher_id} + /> +
+ set("subject_id", v)} + error={errors.subject_id} + /> + set("classroom_id", v || undefined)} + /> + set("semester", v ? Number(v) : undefined)} + /> + set("batch", e.target.value || undefined)} + /> +
+
+
+
+ +
+ router.back()} icon={}> + Cancel + + }> + Create Class + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/classes/page.tsx b/frontend/src/app/(dashboard)/admin/classes/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40af0f6f94f90e04941c468400e403dea07e093e --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/classes/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Eye, Pencil } from "lucide-react"; +import Link from "next/link"; +import api from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { ClassResponse } from "@/types"; + +export default function ClassesPage(): React.ReactElement { + const router = useRouter(); + const [classes, setClasses] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetch(): Promise { + try { const { data } = await api.get("/admin/classes"); setClasses(data); setFiltered(data); } + catch { setClasses([]); setFiltered([]); } + finally { setLoading(false); } + } + fetch(); + }, []); + + const handleSearch = useCallback((q: string) => { + if (!q.trim()) { setFiltered(classes); return; } + const lq = q.toLowerCase(); + setFiltered(classes.filter((c) => + c.name.toLowerCase().includes(lq) || + c.subject_name.toLowerCase().includes(lq) || + c.subject_code.toLowerCase().includes(lq) + )); + }, [classes]); + + const columns: TableColumn>[] = [ + { key: "name", header: "Class Name", sortable: true }, + { key: "subject_name", header: "Subject", sortable: true, render: (r) => {String(r.subject_name)}({String(r.subject_code)}) }, + { key: "enrolled_count", header: "Enrolled", render: (r) => {String(r.enrolled_count)}{r.max_students ? ` / ${String(r.max_students)}` : ""} }, + { key: "classroom_name", header: "Classroom", render: (r) => {String(r.classroom_name || "—")} }, + { + key: "actions", header: "Actions", + render: (row) => ( +
+ View + Edit +
+ ), + }, + ]; + + if (loading) return ; + + return ( +
+ + } onClick={() => router.push("/admin/classes/create")}>Create Class} /> +
+ )[]} emptyMessage="No classes found" /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/dashboard/page.tsx b/frontend/src/app/(dashboard)/admin/dashboard/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41ef59ec93941212797b04bac73ed0c4d7bb2f52 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/dashboard/page.tsx @@ -0,0 +1,226 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { + Users, GraduationCap, BookOpen, ShieldCheck, Activity, Cpu, + ArrowUpRight, Clock, RefreshCw, Radio +} from "lucide-react"; +import Link from "next/link"; +import api from "@/lib/api"; +import GlassStatCard from "@/components/ui/GlassStatCard"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassBadge from "@/components/ui/GlassBadge"; +import type { AdminStatsResponse } from "@/types"; + +export default function AdminDashboardPage(): React.ReactElement { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [greeting, setGreeting] = useState("Welcome"); + + async function fetchStats(): Promise { + try { + const { data } = await api.get("/admin/stats"); + setStats(data); + } catch { + setStats({ studentCount: 0, teacherCount: 0, classCount: 0 }); + } finally { + setLoading(false); + setRefreshing(false); + } + } + + useEffect(() => { + const timer = setTimeout(() => { + void fetchStats(); + const hour = new Date().getHours(); + if (hour < 12) setGreeting("Good morning"); + else if (hour < 17) setGreeting("Good afternoon"); + else setGreeting("Good evening"); + }, 0); + return () => clearTimeout(timer); + }, []); + + const handleRefresh = async () => { + setRefreshing(true); + await fetchStats(); + }; + + if (loading) return ; + + const systemNodes = [ + { name: "Face Recognition Composite", status: "Online", accuracy: "99.8% Conf.", latency: "84ms", icon: , active: true }, + { name: "Liveness Verification Classifier", status: "Active", accuracy: "99.6% Conf.", latency: "142ms", icon: , active: true }, + { name: "Geofence Spatial Services", status: "Active", accuracy: "±2m precision", latency: "18ms", icon: , active: true }, + ]; + + const recentEvents = [ + { action: "AI Scanner verification completed", detail: "Class CS-401 (96% overall liveness validation)", time: "2 mins ago", type: "success" }, + { action: "Geofence border configured", detail: "Classroom B-204 radius optimized to 40 meters", time: "45 mins ago", type: "info" }, + { action: "New instructor registered securely", detail: "User teacher@university.edu initialized", time: "2 hours ago", type: "success" }, + { action: "Database spatial nodes optimized", detail: "Prisma client optimized with spatial index updates", time: "1 day ago", type: "neutral" }, + ]; + + const quickActions = [ + { label: "Configure Verifications", href: "/admin/setup/verification-settings", detail: "Toggle verification modes", icon: }, + { label: "Audit Activity Log", href: "/admin/audit", detail: "Inspect node transactions", icon: }, + { label: "Configure Classes", href: "/admin/classes", detail: "Manage schedules & enrollments", icon: }, + { label: "Execute AI Scanner", href: "/admin/scanner", detail: "Run manual camera check-in", icon: }, + ]; + + return ( +
+ + {/* Banner */} +
+
+
+
+
+ +

System Administration

+
+

{greeting}, Admin

+

+ All core services and verification nodes are operating nominally. +

+
+ +
+
+ + {/* Quick Actions (Moved to Top for Accessibility) */} +
+ {quickActions.map((action) => ( + +
+
+ {action.icon} +
+ +
+
+ {action.label} +

{action.detail}

+
+ + ))} +
+ + {/* Stat Cards */} +
+ } + label="Total Students" + value={stats?.studentCount ?? 0} + accentColor="blue" + trend="Enrolled" + trendUp + /> + } + label="Total Teachers" + value={stats?.teacherCount ?? 0} + accentColor="emerald" + trend="Active" + trendUp + /> + } + label="Total Classes" + value={stats?.classCount ?? 0} + accentColor="purple" + trend="Configured" + trendUp + /> +
+ + {/* System Status & Activity Feed */} +
+ {/* System Nodes */} + +
+
+ +

Verification Nodes

+
+ All Active +
+
+ {systemNodes.map((node) => ( +
+
+
+ {node.icon} +
+
+

{node.name}

+

{node.accuracy} • Latency: {node.latency}

+
+
+
+ +
+
+ ))} +
+
+ + {/* Live Activity Log */} + +
+
+ +

System Events

+
+
+ + Listening +
+
+ +
+
+ + {recentEvents.map((evt, i) => ( +
+
+
+
+
+
+ +
+
+

{evt.action}

+ {evt.time} +
+

{evt.detail}

+
+
+ ))} +
+ +
+ +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/scanner/page.tsx b/frontend/src/app/(dashboard)/admin/scanner/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..70f51a46d2f91769271ea51fee2243de46048cb6 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/scanner/page.tsx @@ -0,0 +1,346 @@ +"use client"; + +import React, { useState } from "react"; +import { + ScanSearch, + AlertTriangle, + User, + Hash, + Calendar, + ShieldAlert, + Sparkles, + Copy, + Check, + Search, + Sliders, + Cpu, + BarChart4 +} from "lucide-react"; +import api, { getApiErrorMessage } from "@/lib/api"; +import toast from "react-hot-toast"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassSlider from "@/components/ui/GlassSlider"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassEmptyState from "@/components/ui/GlassEmptyState"; +import GlassBadge from "@/components/ui/GlassBadge"; +import type { AnomalyResult } from "@/types"; + +/** + * Returns a design-token hex color for card glowing border based on anomaly risk score. + */ +function getGlowColor(score: number): string { + if (score > 0.25) return "#f43f5e"; // Rose / red glow for high risk + if (score > 0.15) return "#f59e0b"; // Amber glow for medium risk + return "#10b981"; // Emerald green glow for low risk +} + +/** + * Categorizes risk level based on the anomaly score. + */ +function getRiskLevel(score: number): { label: string; variant: "danger" | "warning" | "neutral" } { + if (score > 0.25) { + return { label: "High Risk", variant: "danger" }; + } + if (score > 0.15) { + return { label: "Medium Risk", variant: "warning" }; + } + return { label: "Low Risk", variant: "neutral" }; +} + +export default function ScannerPage(): React.ReactElement { + const [contamination, setContamination] = useState(0.10); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [hasRun, setHasRun] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [copiedId, setCopiedId] = useState(null); + + async function runScan(): Promise { + setLoading(true); + try { + const { data } = await api.post(`/admin/scan-absentees?contamination=${contamination}`); + setResults(data); + setHasRun(true); + if (data.length === 0) { + toast.success("No anomalies detected"); + } else { + toast("Scan complete — anomalies flagged", { icon: "⚠️" }); + } + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Scan failed")); + } finally { + setLoading(false); + } + } + + function handleCopyId(id: string): void { + navigator.clipboard.writeText(id); + setCopiedId(id); + toast.success("Student ID copied"); + setTimeout(() => setCopiedId(null), 2000); + } + + const filteredResults = results.filter((r) => { + const name = (r.student_name || "").toLowerCase(); + const enroll = (r.enrollment_number || "").toLowerCase(); + const id = r.student_id.toLowerCase(); + const term = searchTerm.toLowerCase(); + return name.includes(term) || enroll.includes(term) || id.includes(term); + }); + + const avgScore = results.length > 0 + ? results.reduce((acc, curr) => acc + (curr.anomaly_score || 0), 0) / results.length + : 0; + + const maxScore = results.length > 0 + ? Math.max(...results.map((r) => r.anomaly_score || 0)) + : 0; + + return ( +
+ + +
+ +
+ + Machine Learning Engine Active +
+
+ +
+ + {/* Left Column: Parameter controls & ML Insights */} +
+ +
+
+ +
+
+

Scan Parameters

+

Configure sensitivity thresholds

+
+
+ + + +

+ Contamination: Estimates the proportion of outliers in the dataset. Lower values flag only extreme deviations; higher values flag more moderate patterns. +

+ +
+ } + > + {loading ? "Analyzing Datasets..." : "Run AI Anomaly Scan"} + +
+
+ + +
+
+ +
+
+

How it works

+

Isolation Forest (iForest)

+
+
+

+ Unlike normal profile clustering, Isolation Forest isolates anomalies instead of profiling normal points. +

+

+ By constructing random isolation trees, anomalous data points require significantly fewer splits to isolate, resulting in shorter path lengths and higher anomaly scores. +

+
+
+ + {/* Right Column: Scan results and flagged list */} +
+ + {loading && ( + +
+
+
+
+ +
+
+

Analyzing Attendance Matrices

+

+ Fitting Isolation Forest decision trees, evaluating structural path splits, and computing student outlier scores... +

+ + )} + + {!loading && !hasRun && ( + +
+
+ +
+

System Awaiting Analysis

+

+ Adjust the contamination factor and click "Run AI Anomaly Scan" to extract skip-patterns and generate risk profiles. +

+ + )} + + {!loading && hasRun && results.length === 0 && ( + + )} + + {!loading && hasRun && results.length > 0 && ( +
+ + {/* Stats Summary Panel */} +
+
+
+

Flagged Students

+
{results.length}
+
+
+ +
+
+
+
+

Avg Anomaly Score

+
{avgScore.toFixed(3)}
+
+
+ +
+
+
+
+

Max Anomaly Score

+
{maxScore.toFixed(3)}
+
+
+ +
+
+
+ + {/* Filter controls */} +
+
+ + ) => setSearchTerm(e.target.value)} + className="glass-input glass-input-with-icon text-sm py-2.5" + /> +
+
+ + {/* Grid of Results */} +
+ {filteredResults.map((r, i) => { + const risk = getRiskLevel(r.anomaly_score || 0); + const glow = getGlowColor(r.anomaly_score || 0); + return ( + +
+ {/* Card Header: Badges & Score */} +
+ + {risk.label} + +
+

Anomaly Score

+

+ {typeof r.anomaly_score === "number" ? r.anomaly_score.toFixed(3) : "—"} +

+
+
+ + {/* Student Main Details */} +
+

+ + {r.student_name || "Unknown Student"} +

+ +

+ + {r.enrollment_number || "No Enrollment Num"} +

+
+
+ + {/* Card Footer: Absences & ID Copy */} +
+ {typeof r.total_absences === "number" && ( +
+ + + Total Absences + + + {r.total_absences} + +
+ )} + +
+ ID: {r.student_id} + +
+
+
+ ); + })} +
+ + {filteredResults.length === 0 && ( +
+ +

No results match your search term.

+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/classrooms/[id]/edit/page.tsx b/frontend/src/app/(dashboard)/admin/setup/classrooms/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5954ed288babd937a06f884785c1581abaa96e27 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/classrooms/[id]/edit/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { ClassroomResponse } from "@/types"; + +export default function EditClassroomPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [classroom, setClassroom] = useState(null); + const [name, setName] = useState(""); + const [building, setBuilding] = useState(""); + const [capacity, setCapacity] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get(`/admin/classrooms/${id}`); + setClassroom(data); + setName(data.name); + setBuilding(data.building || ""); + setCapacity(data.capacity ? String(data.capacity) : ""); + } catch { + toast.error("Failed to load classroom"); + } finally { + setLoading(false); + } + } + fetch(); + }, [id]); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Classroom name is required"; + if (capacity.trim() && (isNaN(Number(capacity)) || Number(capacity) < 1)) { + errs.capacity = "Capacity must be a positive integer"; + } + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setSaving(true); + try { + await api.put(`/admin/classrooms/${id}`, { + name, + building: building || undefined, + capacity: capacity ? Number(capacity) : null, + }); + toast.success("Classroom updated successfully"); + router.push("/admin/setup/classrooms"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Update failed")); + } finally { + setSaving(false); + } + } + + if (loading) return ; + if (!classroom) return
Classroom not found
; + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setBuilding(e.target.value)} + /> + setCapacity(e.target.value)} + error={errors.capacity} + /> +
+ router.back()}> + Cancel + + + Save Changes + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/classrooms/create/page.tsx b/frontend/src/app/(dashboard)/admin/setup/classrooms/create/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd145c71dcc00d3ca499741db2788d01824d7730 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/classrooms/create/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassButton from "@/components/ui/GlassButton"; + +export default function CreateClassroomPage(): React.ReactElement { + const router = useRouter(); + const [name, setName] = useState(""); + const [building, setBuilding] = useState(""); + const [capacity, setCapacity] = useState(""); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Classroom name or number is required"; + if (capacity.trim() && (isNaN(Number(capacity)) || Number(capacity) < 1)) { + errs.capacity = "Capacity must be a positive integer"; + } + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await api.post("/admin/classrooms", { + name, + building: building || undefined, + capacity: capacity ? Number(capacity) : undefined, + }); + toast.success("Classroom created successfully"); + router.push("/admin/setup/classrooms"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Creation failed")); + } finally { + setLoading(false); + } + } + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setBuilding(e.target.value)} + /> + setCapacity(e.target.value)} + error={errors.capacity} + /> +
+ router.back()}> + Cancel + + + Create Classroom + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/classrooms/page.tsx b/frontend/src/app/(dashboard)/admin/setup/classrooms/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..88576faa2a7d835b3ffc4744397f03c1c972b55f --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/classrooms/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Pencil, Trash2 } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassConfirmDialog from "@/components/ui/GlassConfirmDialog"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { ClassroomResponse } from "@/types"; + +export default function ClassroomsPage(): React.ReactElement { + const router = useRouter(); + const [classrooms, setClassrooms] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const fetchClassrooms = useCallback(async (): Promise => { + try { + const { data } = await api.get("/admin/classrooms"); + setClassrooms(data); + setFiltered(data); + } catch { + setClassrooms([]); + setFiltered([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void (async () => { + await fetchClassrooms(); + })(); + }, [fetchClassrooms]); + + const handleSearch = useCallback((q: string) => { + if (!q.trim()) { + setFiltered(classrooms); + return; + } + const lq = q.toLowerCase(); + setFiltered( + classrooms.filter( + (c) => + c.name.toLowerCase().includes(lq) || + (c.building && c.building.toLowerCase().includes(lq)) + ) + ); + }, [classrooms]); + + async function handleDelete(): Promise { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/admin/classrooms/${deleteTarget}`); + toast.success("Classroom deleted successfully"); + setDeleteTarget(null); + await fetchClassrooms(); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Delete failed")); + } finally { + setDeleting(false); + } + } + + const columns: TableColumn>[] = [ + { key: "name", header: "Classroom Name", sortable: true }, + { key: "building", header: "Building", render: (r) => {String(r.building || "—")} }, + { key: "capacity", header: "Capacity", render: (r) => {r.capacity ? `${String(r.capacity)} students` : "—"} }, + { + key: "actions", + header: "Actions", + render: (row) => ( +
+ + +
+ ), + }, + ]; + + if (loading) return ; + + return ( +
+ + } + onClick={() => router.push("/admin/setup/classrooms/create")} + > + Add Classroom + + } + /> +
+ +
+ )[]} + emptyMessage="No classrooms found" + /> + setDeleteTarget(null)} + loading={deleting} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/departments/[id]/edit/page.tsx b/frontend/src/app/(dashboard)/admin/setup/departments/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ffe4efbf88b5cf9e6400407aa192c979ead37b63 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/departments/[id]/edit/page.tsx @@ -0,0 +1,125 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassTextarea from "@/components/ui/GlassTextarea"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { DepartmentResponse } from "@/types"; + +export default function EditDepartmentPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [dept, setDept] = useState(null); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [head, setHead] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get(`/admin/departments/${id}`); + setDept(data); + setName(data.name); + setCode(data.code); + setHead(data.head || ""); + setDescription(data.description || ""); + } catch { + toast.error("Failed to load department"); + } finally { + setLoading(false); + } + } + fetch(); + }, [id]); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Department name is required"; + if (!code.trim()) errs.code = "Department code is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setSaving(true); + try { + await api.put(`/admin/departments/${id}`, { + name, + code, + head: head || undefined, + description: description || undefined, + }); + toast.success("Department updated successfully"); + router.push("/admin/setup/departments"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Update failed")); + } finally { + setSaving(false); + } + } + + if (loading) return ; + if (!dept) return
Department not found
; + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setCode(e.target.value)} + error={errors.code} + /> + setHead(e.target.value)} + /> + setDescription(e.target.value)} + /> +
+ router.back()}> + Cancel + + + Save Changes + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/departments/create/page.tsx b/frontend/src/app/(dashboard)/admin/setup/departments/create/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..98aeaae1a49632c65068d7c51245c40c29bf0ea0 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/departments/create/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassTextarea from "@/components/ui/GlassTextarea"; +import GlassButton from "@/components/ui/GlassButton"; + +export default function CreateDepartmentPage(): React.ReactElement { + const router = useRouter(); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [head, setHead] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Department name is required"; + if (!code.trim()) errs.code = "Department code is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await api.post("/admin/departments", { + name, + code, + head: head || undefined, + description: description || undefined, + }); + toast.success("Department created successfully"); + router.push("/admin/setup/departments"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Creation failed")); + } finally { + setLoading(false); + } + } + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setCode(e.target.value)} + error={errors.code} + /> + setHead(e.target.value)} + /> + setDescription(e.target.value)} + /> +
+ router.back()}> + Cancel + + + Create Department + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/departments/page.tsx b/frontend/src/app/(dashboard)/admin/setup/departments/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20b790f7e1d8bd06c846c73e8321ae5615dff4cb --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/departments/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Pencil, Trash2 } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassConfirmDialog from "@/components/ui/GlassConfirmDialog"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { DepartmentResponse } from "@/types"; + +export default function DepartmentsPage(): React.ReactElement { + const router = useRouter(); + const [departments, setDepartments] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const fetchDepartments = useCallback(async (): Promise => { + try { + const { data } = await api.get("/admin/departments"); + setDepartments(data); + setFiltered(data); + } catch { + setDepartments([]); + setFiltered([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void (async () => { + await fetchDepartments(); + })(); + }, [fetchDepartments]); + + const handleSearch = useCallback((q: string) => { + if (!q.trim()) { + setFiltered(departments); + return; + } + const lq = q.toLowerCase(); + setFiltered( + departments.filter( + (d) => + d.name.toLowerCase().includes(lq) || + d.code.toLowerCase().includes(lq) + ) + ); + }, [departments]); + + async function handleDelete(): Promise { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/admin/departments/${deleteTarget}`); + toast.success("Department deleted successfully"); + setDeleteTarget(null); + await fetchDepartments(); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Delete failed")); + } finally { + setDeleting(false); + } + } + + const columns: TableColumn>[] = [ + { key: "name", header: "Name", sortable: true }, + { key: "code", header: "Code", sortable: true }, + { key: "head", header: "Head", render: (r) => {String(r.head || "—")} }, + { key: "description", header: "Description", render: (r) => {String(r.description || "—")} }, + { + key: "actions", + header: "Actions", + render: (row) => ( +
+ + +
+ ), + }, + ]; + + if (loading) return ; + + return ( +
+ + } + onClick={() => router.push("/admin/setup/departments/create")} + > + Add Department + + } + /> +
+ +
+ )[]} + emptyMessage="No departments found" + /> + setDeleteTarget(null)} + loading={deleting} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/designations/[id]/edit/page.tsx b/frontend/src/app/(dashboard)/admin/setup/designations/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9a7bc5f0ed8bb18b875b63c4adf639535dd49331 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/designations/[id]/edit/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassTextarea from "@/components/ui/GlassTextarea"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { DesignationResponse } from "@/types"; + +export default function EditDesignationPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [desig, setDesig] = useState(null); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get(`/admin/designations/${id}`); + setDesig(data); + setName(data.name); + setCode(data.code); + setDescription(data.description || ""); + } catch { + toast.error("Failed to load designation"); + } finally { + setLoading(false); + } + } + fetch(); + }, [id]); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Designation name is required"; + if (!code.trim()) errs.code = "Designation code is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setSaving(true); + try { + await api.put(`/admin/designations/${id}`, { + name, + code, + description: description || undefined, + }); + toast.success("Designation updated successfully"); + router.push("/admin/setup/designations"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Update failed")); + } finally { + setSaving(false); + } + } + + if (loading) return ; + if (!desig) return
Designation not found
; + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setCode(e.target.value)} + error={errors.code} + /> + setDescription(e.target.value)} + /> +
+ router.back()}> + Cancel + + + Save Changes + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/designations/create/page.tsx b/frontend/src/app/(dashboard)/admin/setup/designations/create/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..39dbf0eece1af5d63bf59f04b076bfdd2186d17d --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/designations/create/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassTextarea from "@/components/ui/GlassTextarea"; +import GlassButton from "@/components/ui/GlassButton"; + +export default function CreateDesignationPage(): React.ReactElement { + const router = useRouter(); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Designation name is required"; + if (!code.trim()) errs.code = "Designation code is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await api.post("/admin/designations", { + name, + code, + description: description || undefined, + }); + toast.success("Designation created successfully"); + router.push("/admin/setup/designations"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Creation failed")); + } finally { + setLoading(false); + } + } + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setCode(e.target.value)} + error={errors.code} + /> + setDescription(e.target.value)} + /> +
+ router.back()}> + Cancel + + + Create Designation + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/designations/page.tsx b/frontend/src/app/(dashboard)/admin/setup/designations/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..97e7ba6e8811e18688f923903de9edce9981acd1 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/designations/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Pencil, Trash2 } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassConfirmDialog from "@/components/ui/GlassConfirmDialog"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { DesignationResponse } from "@/types"; + +export default function DesignationsPage(): React.ReactElement { + const router = useRouter(); + const [designations, setDesignations] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const fetchDesignations = useCallback(async (): Promise => { + try { + const { data } = await api.get("/admin/designations"); + setDesignations(data); + setFiltered(data); + } catch { + setDesignations([]); + setFiltered([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void (async () => { + await fetchDesignations(); + })(); + }, [fetchDesignations]); + + const handleSearch = useCallback((q: string) => { + if (!q.trim()) { + setFiltered(designations); + return; + } + const lq = q.toLowerCase(); + setFiltered( + designations.filter( + (d) => + d.name.toLowerCase().includes(lq) || + d.code.toLowerCase().includes(lq) + ) + ); + }, [designations]); + + async function handleDelete(): Promise { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/admin/designations/${deleteTarget}`); + toast.success("Designation deleted successfully"); + setDeleteTarget(null); + await fetchDesignations(); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Delete failed")); + } finally { + setDeleting(false); + } + } + + const columns: TableColumn>[] = [ + { key: "name", header: "Designation Name", sortable: true }, + { key: "code", header: "Designation Code", sortable: true }, + { key: "description", header: "Description", render: (r) => {String(r.description || "—")} }, + { + key: "actions", + header: "Actions", + render: (row) => ( +
+ + +
+ ), + }, + ]; + + if (loading) return ; + + return ( +
+ + } + onClick={() => router.push("/admin/setup/designations/create")} + > + Add Designation + + } + /> +
+ +
+ )[]} + emptyMessage="No designations found" + /> + setDeleteTarget(null)} + loading={deleting} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/subjects/[id]/edit/page.tsx b/frontend/src/app/(dashboard)/admin/setup/subjects/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ffb594e94a0fabdb2f9afdf3656c29808830701 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/subjects/[id]/edit/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassTextarea from "@/components/ui/GlassTextarea"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { SubjectResponse } from "@/types"; + +export default function EditSubjectPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [subject, setSubject] = useState(null); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get(`/admin/subjects/${id}`); + setSubject(data); + setName(data.name); + setCode(data.code); + setDescription(data.description || ""); + } catch { + toast.error("Failed to load subject"); + } finally { + setLoading(false); + } + } + fetch(); + }, [id]); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Subject name is required"; + if (!code.trim()) errs.code = "Subject code is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setSaving(true); + try { + await api.put(`/admin/subjects/${id}`, { + name, + code, + description: description || undefined, + }); + toast.success("Subject updated successfully"); + router.push("/admin/setup/subjects"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Update failed")); + } finally { + setSaving(false); + } + } + + if (loading) return ; + if (!subject) return
Subject not found
; + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setCode(e.target.value)} + error={errors.code} + /> + setDescription(e.target.value)} + /> +
+ router.back()}> + Cancel + + + Save Changes + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/subjects/create/page.tsx b/frontend/src/app/(dashboard)/admin/setup/subjects/create/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83f96af3c8a94bc81612eb19d079c2caefa0957e --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/subjects/create/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassTextarea from "@/components/ui/GlassTextarea"; +import GlassButton from "@/components/ui/GlassButton"; + +export default function CreateSubjectPage(): React.ReactElement { + const router = useRouter(); + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [description, setDescription] = useState(""); + const [loading, setLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + function validate(): boolean { + const errs: Record = {}; + if (!name.trim()) errs.name = "Subject name is required"; + if (!code.trim()) errs.code = "Subject code is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await api.post("/admin/subjects", { + name, + code, + description: description || undefined, + }); + toast.success("Subject created successfully"); + router.push("/admin/setup/subjects"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Creation failed")); + } finally { + setLoading(false); + } + } + + return ( +
+ + + +
+ setName(e.target.value)} + error={errors.name} + /> + setCode(e.target.value)} + error={errors.code} + /> + setDescription(e.target.value)} + /> +
+ router.back()}> + Cancel + + + Create Subject + +
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/subjects/page.tsx b/frontend/src/app/(dashboard)/admin/setup/subjects/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..87c06ac030a8b0a1aa9e9ac7687a90e0e502e23d --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/subjects/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Pencil, Trash2 } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassConfirmDialog from "@/components/ui/GlassConfirmDialog"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { SubjectResponse } from "@/types"; + +export default function SubjectsPage(): React.ReactElement { + const router = useRouter(); + const [subjects, setSubjects] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const fetchSubjects = useCallback(async (): Promise => { + try { + const { data } = await api.get("/admin/subjects"); + setSubjects(data); + setFiltered(data); + } catch { + setSubjects([]); + setFiltered([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void (async () => { + await fetchSubjects(); + })(); + }, [fetchSubjects]); + + const handleSearch = useCallback((q: string) => { + if (!q.trim()) { + setFiltered(subjects); + return; + } + const lq = q.toLowerCase(); + setFiltered( + subjects.filter( + (s) => + s.name.toLowerCase().includes(lq) || + s.code.toLowerCase().includes(lq) + ) + ); + }, [subjects]); + + async function handleDelete(): Promise { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/admin/subjects/${deleteTarget}`); + toast.success("Subject deleted successfully"); + setDeleteTarget(null); + await fetchSubjects(); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Delete failed")); + } finally { + setDeleting(false); + } + } + + const columns: TableColumn>[] = [ + { key: "name", header: "Subject Name", sortable: true }, + { key: "code", header: "Subject Code", sortable: true }, + { key: "description", header: "Description", render: (r) => {String(r.description || "—")} }, + { + key: "actions", + header: "Actions", + render: (row) => ( +
+ + +
+ ), + }, + ]; + + if (loading) return ; + + return ( +
+ + } + onClick={() => router.push("/admin/setup/subjects/create")} + > + Add Subject + + } + /> +
+ +
+ )[]} + emptyMessage="No subjects found" + /> + setDeleteTarget(null)} + loading={deleting} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/setup/verification-settings/page.tsx b/frontend/src/app/(dashboard)/admin/setup/verification-settings/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c28b31ccf7bc2bcbe2956be7f17a0413fd828796 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/setup/verification-settings/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useState } from "react"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import { ShieldCheckIcon, MapPinIcon, CameraIcon, AlertTriangleIcon } from "lucide-react"; +import api, { getApiErrorMessage } from "@/lib/api"; +import { toast } from "react-hot-toast"; + +interface SystemConfig { + isFaceRecognitionEnabled: boolean; + isGpsVerificationEnabled: boolean; + isAiBackgroundValidationEnabled: boolean; +} + +export default function VerificationSettingsPage() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function fetchConfig() { + try { + setLoading(true); + setError(null); + const { data } = await api.get("/admin/config"); + setConfig(data); + } catch (err: unknown) { + setError(getApiErrorMessage(err, "Failed to fetch configuration")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + const timer = setTimeout(() => { + void fetchConfig(); + }, 0); + return () => clearTimeout(timer); + }, []); + + const handleToggle = (key: keyof SystemConfig) => { + if (!config) return; + setConfig({ ...config, [key]: !config[key] }); + }; + + const handleSave = async () => { + if (!config) return; + try { + setSaving(true); + setError(null); + await api.patch("/admin/config", config); + toast.success("Configuration saved successfully"); + } catch (err: unknown) { + setError(getApiErrorMessage(err, "Failed to save configuration")); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + {error && ( +
+ +

{error}

+
+ )} + +
+ +
+

Important Warning

+

+ Disabling a verification step will automatically mark that check as 100% successful for all users. This acts as a fallback if an AI model or external API experiences downtime. Use with caution. +

+
+
+ + {config && ( + +
+ } + title="Face Recognition" + description="Verify student identity using facial recognition AI models." + enabled={config.isFaceRecognitionEnabled} + onToggle={() => handleToggle("isFaceRecognitionEnabled")} + /> + + } + title="GPS Geofencing" + description="Verify student location against the classroom geofence." + enabled={config.isGpsVerificationEnabled} + onToggle={() => handleToggle("isGpsVerificationEnabled")} + /> + + } + title="AI Background Validation" + description="Analyze background context to ensure students are in a classroom setting." + enabled={config.isAiBackgroundValidationEnabled} + onToggle={() => handleToggle("isAiBackgroundValidationEnabled")} + /> +
+ +
+ + {saving ? "Saving..." : "Save Configuration"} + +
+
+ )} +
+ ); +} + +function ToggleOption({ + icon, + title, + description, + enabled, + onToggle +}: { + icon: React.ReactNode; + title: string; + description: string; + enabled: boolean; + onToggle: () => void; +}) { + return ( +
+
+ {icon} +
+
+

{title}

+

{description}

+
+
+ +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/students/[id]/edit/page.tsx b/frontend/src/app/(dashboard)/admin/users/students/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ecf9e648d1d7fe4a80e789b5d8078f377072129 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/students/[id]/edit/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { User, GraduationCap, Save, X } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { StudentResponse, DepartmentResponse } from "@/types"; + +const GENDER_OPTIONS = [ + { value: "Male", label: "Male" }, + { value: "Female", label: "Female" }, + { value: "Other", label: "Other" }, + { value: "Prefer not to say", label: "Prefer not to say" }, +]; + +const SEMESTER_OPTIONS = Array.from({ length: 8 }, (_, i) => ({ + value: String(i + 1), + label: `Semester ${i + 1}`, +})); + +export default function EditStudentPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [student, setStudent] = useState(null); + const [departments, setDepartments] = useState([]); + const [form, setForm] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function fetchStudentAndDeps(): Promise { + try { + const [studentsRes, depsRes] = await Promise.all([ + api.get("/admin/users/students"), + api.get("/admin/departments").catch(() => ({ data: [] })), + ]); + setDepartments(depsRes.data); + const found = studentsRes.data.find((s) => s.id === id); + if (found) { + setStudent(found); + setForm({ + enrollment_number: found.enrollment_number, + first_name: found.first_name, + last_name: found.last_name, + phone: found.phone, + gender: found.gender, + date_of_birth: found.date_of_birth ? new Date(found.date_of_birth).toISOString().split("T")[0] : undefined, + semester: found.semester, + batch: found.batch, + department_id: found.department_id, + }); + } + } catch { + toast.error("Failed to load student data"); + } finally { + setLoading(false); + } + } + + void fetchStudentAndDeps(); + }, [id]); + + function set(field: keyof StudentResponse, value: string | number | undefined): void { + setForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!form.enrollment_number?.trim()) { + toast.error("Enrollment number is required"); + return; + } + setSaving(true); + try { + await api.put(`/admin/users/students/${id}`, { + enrollment_number: form.enrollment_number, + first_name: form.first_name, + last_name: form.last_name, + phone: form.phone || undefined, + gender: form.gender || undefined, + date_of_birth: form.date_of_birth ? new Date(form.date_of_birth).toISOString() : undefined, + semester: form.semester ? Number(form.semester) : undefined, + batch: form.batch || undefined, + department_id: form.department_id || undefined, + }); + toast.success("Student updated successfully"); + router.push(`/admin/users/students/${id}`); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Update failed")); + } finally { + setSaving(false); + } + } + + const deptOptions = [ + { value: "", label: "None — assign later" }, + ...departments.map((d) => ({ value: d.id, label: `${d.name} (${d.code})` })), + ]; + + if (loading) return ; + if (!student) return
Student not found
; + + return ( +
+ + + +
+
+
+ +
+
+

+ + Account Info +

+

Core system identifiers.

+
+
+ + set("enrollment_number", e.target.value)} + /> +
+
+
+ +
+ +
+
+

+ + Student Profile +

+

Personal and academic details.

+
+
+ set("first_name", e.target.value)} + /> + set("last_name", e.target.value)} + /> + set("phone", e.target.value)} + /> + set("gender", v)} + /> + set("date_of_birth", e.target.value)} + /> + set("semester", v ? Number(v) : undefined)} + /> + set("batch", e.target.value)} + /> + set("department_id", v)} + /> +
+
+
+
+ +
+ router.back()} icon={}> + Cancel + + }> + Save Changes + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/students/[id]/page.tsx b/frontend/src/app/(dashboard)/admin/users/students/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1a6a27cdfb47e7506d165c3345c062f01db0ad87 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/students/[id]/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + Pencil, Mail, Hash, User, Phone, GraduationCap, Building2, CalendarDays, + MapPin, Clock, Award, ShieldCheck, ChevronRight, KeyRound +} from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassBadge from "@/components/ui/GlassBadge"; +import GlassResetPasswordDialog from "@/components/ui/GlassResetPasswordDialog"; +import type { StudentResponse } from "@/types"; + +const colorVariants: Record = { + blue: "bg-blue-500/10 text-blue-400 group-hover:shadow-blue-500/20", + purple: "bg-purple-500/10 text-purple-400 group-hover:shadow-purple-500/20", + pink: "bg-pink-500/10 text-pink-400 group-hover:shadow-pink-500/20", + amber: "bg-amber-500/10 text-amber-400 group-hover:shadow-amber-500/20", + emerald: "bg-emerald-500/10 text-emerald-400 group-hover:shadow-emerald-500/20", + rose: "bg-rose-500/10 text-rose-400 group-hover:shadow-rose-500/20", + cyan: "bg-cyan-500/10 text-cyan-400 group-hover:shadow-cyan-500/20", + slate: "bg-slate-500/10 text-slate-400 group-hover:shadow-slate-500/20", +}; + +const InfoItem = ({ icon: Icon, label, value, color }: { icon: React.ElementType, label: string, value: string, color: string }) => ( +
+
+
+ +
+
+

{label}

+

{value}

+
+
+ +
+); + +export default function StudentDetailPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [student, setStudent] = useState(null); + const [loading, setLoading] = useState(true); + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + + useEffect(() => { + async function fetchStudent(): Promise { + try { + const { data } = await api.get(`/admin/users/students/${id}`); + setStudent(data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load student")); + setStudent(null); + } finally { + setLoading(false); + } + } + void fetchStudent(); + }, [id]); + + const handleResetPassword = async (newPassword: string) => { + if (!student) return; + try { + await api.put(`/admin/users/${student.user_id}/reset-password`, { new_password: newPassword }); + toast.success(`Password reset for ${student.email}`); + setIsResetDialogOpen(false); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to reset password")); + } + }; + + if (loading) return ; + if (!student) return
Student not found
; + + const fullName = student.first_name && student.last_name + ? `${student.first_name} ${student.last_name}` + : student.first_name || student.last_name || "Not provided"; + + const initials = (student.first_name?.[0] || "") + (student.last_name?.[0] || ""); + + return ( +
+ + + {} +
+
+
+ +
+
+
+
+ + {initials || } + +
+
+
+ +
+
+ +
+
+

+ {fullName} +

+
+ {student.enrollment_number} + {student.department_name && {student.department_name}} + + Enrolled + +
+
+ +

+ Student profile containing full academic records, personal information, and system access details. Ensure all modifications align with institutional data policies. +

+ +
+ } onClick={() => router.push(`/admin/users/students/${id}/edit`)}> + Edit Profile + + } onClick={() => setIsResetDialogOpen(true)}> + Reset Password + +
+
+
+
+ +
+ +
+
+

+ + Personal Information +

+

Contact details and identity information.

+
+
+ + + + +
+
+ + +
+
+

+ + Academic Profile +

+

Institutional enrollment and course tracking.

+
+
+ + + + +
+
+
+ + setIsResetDialogOpen(false)} + onConfirm={handleResetPassword} + title="Force Password Reset" + description={student ? `Enter a new password for ${student.email}. They will be able to log in immediately with this new password.` : ""} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/students/add/page.tsx b/frontend/src/app/(dashboard)/admin/users/students/add/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..69e399f4a97f1e4848b73c0b36e8433b50c1ff3a --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/students/add/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { User, GraduationCap, PlusCircle, X } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassButton from "@/components/ui/GlassButton"; +import type { StudentCreate, DepartmentResponse } from "@/types"; + +type FormErrors = Partial>; + +const GENDER_OPTIONS = [ + { value: "Male", label: "Male" }, + { value: "Female", label: "Female" }, + { value: "Other", label: "Other" }, + { value: "Prefer not to say", label: "Prefer not to say" }, +]; + +const SEMESTER_OPTIONS = Array.from({ length: 8 }, (_, i) => ({ + value: String(i + 1), + label: `Semester ${i + 1}`, +})); + +export default function AddStudentPage(): React.ReactElement { + const router = useRouter(); + const [departments, setDepartments] = useState([]); + const [form, setForm] = useState({ + email: "", + password: "", + enrollment_number: "", + first_name: "", + last_name: "", + }); + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + + useEffect(() => { + async function fetchDepartments(): Promise { + try { + const { data } = await api.get("/admin/departments"); + setDepartments(data); + } catch { + + } + } + void fetchDepartments(); + }, []); + + function set(field: keyof StudentCreate, value: string | number | undefined): void { + setForm((prev) => ({ ...prev, [field]: value })); + if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined })); + } + + function validate(): boolean { + const errs: FormErrors = {}; + if (!form.email.trim()) errs.email = "Email is required"; + if (!form.password || form.password.length < 8) errs.password = "Minimum 8 characters"; + if (!form.enrollment_number.trim()) errs.enrollment_number = "Enrollment number is required"; + if (!form.first_name.trim()) errs.first_name = "First name is required"; + if (!form.last_name.trim()) errs.last_name = "Last name is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await api.post("/admin/users/student", form); + toast.success("Student created successfully"); + router.push("/admin/users/students"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to create student")); + } finally { + setLoading(false); + } + } + + const deptOptions = [ + { value: "", label: "None — assign later" }, + ...departments.map((d) => ({ value: d.id, label: `${d.name} (${d.code})` })), + ]; + + return ( +
+ + + +
+
+
+ +
+
+

+ + Account Info +

+

Core system credentials.

+
+
+ set("email", e.target.value)} + error={errors.email} + /> + set("password", e.target.value)} + error={errors.password} + /> + set("enrollment_number", e.target.value)} + error={errors.enrollment_number} + /> +
+
+
+ +
+ +
+
+

+ + Student Profile +

+

Personal and academic details.

+
+
+ set("first_name", e.target.value)} + error={errors.first_name} + /> + set("last_name", e.target.value)} + error={errors.last_name} + /> + set("phone", e.target.value || undefined)} + /> + set("gender", v || undefined)} + /> + set("date_of_birth", e.target.value || undefined)} + /> + set("semester", v ? Number(v) : undefined)} + /> + set("batch", e.target.value || undefined)} + /> + set("department_id", v || undefined)} + /> +
+
+
+
+ +
+ router.back()} icon={}> + Cancel + + }> + Create Student + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/students/page.tsx b/frontend/src/app/(dashboard)/admin/users/students/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..092df6f1f714b2aa995347067617ed75cf54041c --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/students/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Eye, Pencil } from "lucide-react"; +import Link from "next/link"; +import api from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { StudentResponse } from "@/types"; + +export default function StudentsPage(): React.ReactElement { + const router = useRouter(); + const [students, setStudents] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchStudents(): Promise { + try { + const { data } = await api.get("/admin/users/students"); + setStudents(data); + setFiltered(data); + } catch { + setStudents([]); + setFiltered([]); + } finally { + setLoading(false); + } + } + fetchStudents(); + }, []); + + const handleSearch = useCallback( + (query: string) => { + if (!query.trim()) { + setFiltered(students); + return; + } + const q = query.toLowerCase(); + setFiltered( + students.filter( + (s) => + s.email.toLowerCase().includes(q) || + s.enrollment_number.toLowerCase().includes(q) + ) + ); + }, + [students] + ); + + const columns: TableColumn>[] = [ + { key: "email", header: "Email", sortable: true }, + { key: "enrollment_number", header: "Enrollment #", sortable: true }, + { + key: "actions", + header: "Actions", + render: (row) => ( +
+ + View + + + Edit + +
+ ), + }, + ]; + + if (loading) return ; + + return ( +
+ + } onClick={() => router.push("/admin/users/students/add")}> + Add Student + + } + /> +
+ +
+ )[]} + emptyMessage="No students found" + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/teachers/[id]/edit/page.tsx b/frontend/src/app/(dashboard)/admin/users/teachers/[id]/edit/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1213437220f02eeebd4133888ad5164bdd8a1e42 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/teachers/[id]/edit/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { User, Briefcase, Save, X } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { TeacherResponse, DepartmentResponse, DesignationResponse } from "@/types"; + +export default function EditTeacherPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [teacher, setTeacher] = useState(null); + const [departments, setDepartments] = useState([]); + const [designations, setDesignations] = useState([]); + + const [form, setForm] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function fetchData(): Promise { + try { + const [teachersRes, depsRes, desigsRes] = await Promise.all([ + api.get("/admin/users/teachers"), + api.get("/admin/departments").catch(() => ({ data: [] })), + api.get("/admin/designations").catch(() => ({ data: [] })), + ]); + setDepartments(depsRes.data); + setDesignations(desigsRes.data); + + const found = teachersRes.data.find((t) => t.id === id); + if (found) { + setTeacher(found); + setForm({ + employee_id: found.employee_id, + first_name: found.first_name, + last_name: found.last_name, + phone: found.phone, + qualification: found.qualification, + specialization: found.specialization, + experience_years: found.experience_years, + joining_date: found.joining_date ? new Date(found.joining_date).toISOString().split("T")[0] : undefined, + department_id: found.department_id, + designation_id: found.designation_id, + }); + } + } catch { + toast.error("Failed to load teacher data"); + } finally { + setLoading(false); + } + } + + void fetchData(); + }, [id]); + + function set(field: keyof TeacherResponse, value: string | number | undefined): void { + setForm((prev) => ({ ...prev, [field]: value })); + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!form.employee_id?.trim() || !form.first_name?.trim() || !form.last_name?.trim() || !form.department_id || !form.designation_id) { + toast.error("Please fill all required fields"); + return; + } + setSaving(true); + try { + await api.put(`/admin/users/teachers/${id}`, { + employee_id: form.employee_id, + first_name: form.first_name, + last_name: form.last_name, + phone: form.phone || undefined, + qualification: form.qualification || undefined, + specialization: form.specialization || undefined, + experience_years: form.experience_years !== undefined && form.experience_years !== null ? Number(form.experience_years) : undefined, + joining_date: form.joining_date ? new Date(form.joining_date).toISOString() : undefined, + department_id: form.department_id, + designation_id: form.designation_id, + }); + toast.success("Teacher updated successfully"); + router.push(`/admin/users/teachers/${id}`); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Update failed")); + } finally { + setSaving(false); + } + } + + const deptOptions = departments.map((d) => ({ value: d.id, label: `${d.name} (${d.code})` })); + const desigOptions = designations.map((d) => ({ value: d.id, label: `${d.name} (${d.code})` })); + + if (loading) return ; + if (!teacher) return
Teacher not found
; + + return ( +
+ + + +
+
+
+ +
+
+

+ + Account Info +

+

Core system identifiers.

+
+
+ + set("employee_id", e.target.value)} + /> +
+
+
+ +
+ +
+
+

+ + Professional Profile +

+

Personal and academic details.

+
+
+ set("first_name", e.target.value)} + /> + set("last_name", e.target.value)} + /> + set("department_id", v)} + /> + set("designation_id", v)} + /> + set("phone", e.target.value)} + /> + set("qualification", e.target.value)} + /> + set("specialization", e.target.value)} + /> + set("experience_years", e.target.value)} + /> + set("joining_date", e.target.value)} + /> +
+
+
+
+ +
+ router.back()} icon={}> + Cancel + + }> + Save Changes + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/teachers/[id]/page.tsx b/frontend/src/app/(dashboard)/admin/users/teachers/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d1e1247b9f5f3aa38ac3109a4d4f55ceed952f4b --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/teachers/[id]/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + Pencil, Mail, Building2, Briefcase, User, Phone, GraduationCap, + Award, CalendarDays, Hash, CheckCircle, ChevronRight, KeyRound +} from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassBadge from "@/components/ui/GlassBadge"; +import GlassResetPasswordDialog from "@/components/ui/GlassResetPasswordDialog"; +import type { TeacherResponse } from "@/types"; + +const colorVariants: Record = { + blue: "bg-blue-500/10 text-blue-400 group-hover:shadow-blue-500/20", + purple: "bg-purple-500/10 text-purple-400 group-hover:shadow-purple-500/20", + amber: "bg-amber-500/10 text-amber-400 group-hover:shadow-amber-500/20", + emerald: "bg-emerald-500/10 text-emerald-400 group-hover:shadow-emerald-500/20", + rose: "bg-rose-500/10 text-rose-400 group-hover:shadow-rose-500/20", + orange: "bg-orange-500/10 text-orange-400 group-hover:shadow-orange-500/20", + cyan: "bg-cyan-500/10 text-cyan-400 group-hover:shadow-cyan-500/20", + indigo: "bg-indigo-500/10 text-indigo-400 group-hover:shadow-indigo-500/20", + slate: "bg-slate-500/10 text-slate-400 group-hover:shadow-slate-500/20", +}; + +const InfoItem = ({ icon: Icon, label, value, color }: { icon: React.ElementType, label: string, value: string, color: string }) => ( +
+
+
+ +
+
+

{label}

+

{value}

+
+
+ +
+); + +export default function TeacherDetailPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [teacher, setTeacher] = useState(null); + const [loading, setLoading] = useState(true); + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get(`/admin/users/teachers/${id}`); + setTeacher(data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load teacher")); + setTeacher(null); + } finally { + setLoading(false); + } + } + void fetch(); + }, [id]); + + const handleResetPassword = async (newPassword: string) => { + if (!teacher) return; + try { + await api.put(`/admin/users/${teacher.user_id}/reset-password`, { new_password: newPassword }); + toast.success(`Password reset for ${teacher.email}`); + setIsResetDialogOpen(false); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to reset password")); + } + }; + + if (loading) return ; + if (!teacher) return
Teacher not found
; + + const fullName = `${teacher.first_name} ${teacher.last_name}`; + const initials = (teacher.first_name?.[0] || "") + (teacher.last_name?.[0] || ""); + + return ( +
+ + + {} +
+
+
+ +
+
+
+
+ + {initials || } + +
+
+
+ +
+
+ +
+
+

+ {fullName} +

+
+ {teacher.employee_id} + {teacher.department} + + Faculty + +
+
+ +

+ Faculty profile containing professional credentials, department affiliations, and contact records. Manage administrative access and academic assignments from this panel. +

+ +
+ } onClick={() => router.push(`/admin/users/teachers/${id}/edit`)}> + Edit Profile + + } onClick={() => setIsResetDialogOpen(true)}> + Reset Password + +
+
+
+
+ +
+ +
+
+

+ + Personal Information +

+

Direct contact and identity details.

+
+
+ + + + +
+
+ + +
+
+

+ + Professional Profile +

+

Academic credentials and departmental roles.

+
+
+ + + + +
+
+
+ + setIsResetDialogOpen(false)} + onConfirm={handleResetPassword} + title="Force Password Reset" + description={teacher ? `Enter a new password for ${teacher.email}. They will be able to log in immediately with this new password.` : ""} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/teachers/add/page.tsx b/frontend/src/app/(dashboard)/admin/users/teachers/add/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d05fd45c823cb425267ee0986c7445f39e6f555b --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/teachers/add/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { User, Briefcase, PlusCircle, X } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassButton from "@/components/ui/GlassButton"; +import type { TeacherCreate, DepartmentResponse, DesignationResponse } from "@/types"; + +type FormErrors = Partial>; + +export default function AddTeacherPage(): React.ReactElement { + const router = useRouter(); + const [departments, setDepartments] = useState([]); + const [designations, setDesignations] = useState([]); + const [form, setForm] = useState({ + email: "", + password: "", + employee_id: "", + first_name: "", + last_name: "", + department_id: "", + designation_id: "", + }); + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + + useEffect(() => { + async function fetchMasterData(): Promise { + try { + const [deptsRes, desigRes] = await Promise.all([ + api.get("/admin/departments"), + api.get("/admin/designations"), + ]); + setDepartments(deptsRes.data); + setDesignations(desigRes.data); + } catch { + toast.error("Could not load departments/designations."); + } + } + void fetchMasterData(); + }, []); + + function set(field: keyof TeacherCreate, value: string | number | undefined): void { + setForm((prev) => ({ ...prev, [field]: value })); + if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined })); + } + + function validate(): boolean { + const errs: FormErrors = {}; + if (!form.email.trim()) errs.email = "Email is required"; + if (!form.password || form.password.length < 8) errs.password = "Minimum 8 characters"; + if (!form.employee_id.trim()) errs.employee_id = "Employee ID is required"; + if (!form.first_name.trim()) errs.first_name = "First name is required"; + if (!form.last_name.trim()) errs.last_name = "Last name is required"; + if (!form.department_id) errs.department_id = "Department is required"; + if (!form.designation_id) errs.designation_id = "Designation is required"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + if (!validate()) return; + setLoading(true); + try { + await api.post("/admin/users/teacher", form); + toast.success("Teacher created successfully"); + router.push("/admin/users/teachers"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to create teacher")); + } finally { + setLoading(false); + } + } + + const deptOptions = [ + { value: "", label: "— Select Department —" }, + ...departments.map((d) => ({ value: d.id, label: `${d.name} (${d.code})` })), + ]; + + const desigOptions = [ + { value: "", label: "— Select Designation —" }, + ...designations.map((d) => ({ value: d.id, label: `${d.name} (${d.code})` })), + ]; + + return ( +
+ + + +
+
+
+ +
+
+

+ + Account Info +

+

Core system credentials.

+
+
+ set("email", e.target.value)} + error={errors.email} + /> + set("password", e.target.value)} + error={errors.password} + /> + set("employee_id", e.target.value)} + error={errors.employee_id} + /> +
+
+
+ +
+ +
+
+

+ + Professional Profile +

+

Personal and academic details.

+
+
+ set("first_name", e.target.value)} + error={errors.first_name} + /> + set("last_name", e.target.value)} + error={errors.last_name} + /> + set("phone", e.target.value || undefined)} + /> + set("joining_date", e.target.value || undefined)} + /> +
+
+ + +
+
+ set("department_id", v)} + error={errors.department_id} + /> + set("designation_id", v)} + error={errors.designation_id} + /> + set("qualification", e.target.value || undefined)} + /> + set("specialization", e.target.value || undefined)} + /> +
+ + set("experience_years", e.target.value ? Number(e.target.value) : undefined) + } + /> +
+
+
+
+
+ +
+ router.back()} icon={}> + Cancel + + }> + Create Teacher + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/teachers/page.tsx b/frontend/src/app/(dashboard)/admin/users/teachers/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1628d6d07374c5b8954fa32b8d038814bb256a14 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/teachers/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Eye, Pencil } from "lucide-react"; +import Link from "next/link"; +import api from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { TeacherResponse } from "@/types"; + +export default function TeachersPage(): React.ReactElement { + const router = useRouter(); + const [teachers, setTeachers] = useState([]); + const [filtered, setFiltered] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchTeachers(): Promise { + try { + const { data } = await api.get("/admin/users/teachers"); + setTeachers(data); + setFiltered(data); + } catch { setTeachers([]); setFiltered([]); } + finally { setLoading(false); } + } + fetchTeachers(); + }, []); + + const handleSearch = useCallback((query: string) => { + if (!query.trim()) { setFiltered(teachers); return; } + const q = query.toLowerCase(); + setFiltered(teachers.filter((t) => t.email.toLowerCase().includes(q) || t.department.toLowerCase().includes(q))); + }, [teachers]); + + const columns: TableColumn>[] = [ + { key: "email", header: "Email", sortable: true }, + { key: "department", header: "Department", sortable: true }, + { key: "designation", header: "Designation", sortable: true }, + { + key: "actions", header: "Actions", + render: (row) => ( +
+ View + Edit +
+ ), + }, + ]; + + if (loading) return ; + + return ( +
+ + } onClick={() => router.push("/admin/users/teachers/add")}>Add Teacher} /> +
+ )[]} emptyMessage="No teachers found" /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/error.tsx b/frontend/src/app/(dashboard)/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e3ab9a62fdfd3bf7ccb2af9e0ab6be6b66259ac --- /dev/null +++ b/frontend/src/app/(dashboard)/error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; +import GlassButton from "@/components/ui/GlassButton"; + +export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }): React.ReactElement { + return ( +
+
+
+ +
+

Something went wrong

+

{error.message || "An unexpected error occurred while loading this page."}

+ {error.digest &&

Error ID: {error.digest}

} + } onClick={reset}> + Try Again + +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83bf2426f639b21d619ab6586a9022199adc00bf --- /dev/null +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { useAuthStore } from "@/store/authStore"; +import Sidebar from "@/components/ui/Sidebar"; +import Header from "@/components/ui/Header"; +import GlassLoader from "@/components/ui/GlassLoader"; + +export default function DashboardLayout({ children }: { children: React.ReactNode }): React.ReactElement { + const router = useRouter(); + const pathname = usePathname(); + const { isAuthenticated, isHydrated, user } = useAuthStore(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + useEffect(() => { + if (!isHydrated) return; + + if (!isAuthenticated) { + router.replace("/login"); + return; + } + + if (user?.role === "TEACHER" && pathname.startsWith("/admin")) { + router.replace("/teacher/classes"); + return; + } + if (user?.role === "ADMIN" && pathname.startsWith("/teacher")) { + router.replace("/admin/dashboard"); + return; + } + }, [isHydrated, isAuthenticated, user, pathname, router]); + + if (!isHydrated || !isAuthenticated) { + return ( +
+ +
+ ); + } + + return ( +
+ setSidebarOpen(false)} /> + +
+
setSidebarOpen(!sidebarOpen)} /> +
+ {children} +
+
+
+ ); +} + diff --git a/frontend/src/app/(dashboard)/loading.tsx b/frontend/src/app/(dashboard)/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..680e4dd1e35bb36660af9556cb88b84acd0a288a --- /dev/null +++ b/frontend/src/app/(dashboard)/loading.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +export default function DashboardLoading(): React.ReactElement { + return ( +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/analytics/page.tsx b/frontend/src/app/(dashboard)/teacher/analytics/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5955ea0e171d811c25d5ee29ea4dcf7e2355b5c0 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/analytics/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { BarChart3, Users, BookOpen, TrendingUp } from "lucide-react"; +import api from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassStatCard from "@/components/ui/GlassStatCard"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassEmptyState from "@/components/ui/GlassEmptyState"; +import type { AcademicClassWithGeofence, ClassStatsResponse } from "@/types"; + +import { + AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from "recharts"; + +export default function AnalyticsPage(): React.ReactElement { + const [classes, setClasses] = useState([]); + const [selectedClass, setSelectedClass] = useState(""); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [statsLoading, setStatsLoading] = useState(false); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get("/teacher/my-classes"); + setClasses(data); + if (data.length > 0) setSelectedClass(data[0].id); + } catch { setClasses([]); } + finally { setLoading(false); } + } + fetch(); + }, []); + + useEffect(() => { + if (!selectedClass) return; + async function fetchStats(): Promise { + setStatsLoading(true); + try { + const { data } = await api.get(`/teacher/classes/${selectedClass}/stats`); + setStats(data); + } catch { setStats(null); } + finally { setStatsLoading(false); } + } + fetchStats(); + }, [selectedClass]); + + if (loading) return ; + if (classes.length === 0) return <>; + + const trendData = stats?.history && stats.history.length > 0 + ? stats.history.map((h) => ({ + session: h.session_name, + attendance: h.attendance_percentage, + })) + : Array.from({ length: 5 }, (_, i) => ({ + session: `S${i + 1}`, + attendance: 0, + })); + + return ( +
+ + +
+ ({ value: c.id, label: `${c.name} — ${c.subject}` }))} + value={selectedClass} onChange={setSelectedClass} /> +
+ + {statsLoading ? : stats ? ( + <> +
+ } label="Total Sessions" value={stats.total_sessions} accentColor="emerald" /> + } label="Enrolled Students" value={stats.total_students} accentColor="emerald" /> + } label="Attendance Rate" value={`${stats.overall_attendance_percentage.toFixed(1)}%`} accentColor="emerald" /> +
+ +
+ +

+ Attendance Trend +

+ + + + + + + + + + + + + + + +
+ + +

Session Breakdown

+ + + + + + + + + +
+
+ + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/classes/[id]/page.tsx b/frontend/src/app/(dashboard)/teacher/classes/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b77af3dd5f630126ec440ee71057fbb3e5a512a3 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/classes/[id]/page.tsx @@ -0,0 +1,315 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; +import dynamic from "next/dynamic"; +import { useMapEvents, useMap } from "react-leaflet"; +import toast from "react-hot-toast"; +import { Save, Download, Navigation } from "lucide-react"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassSlider from "@/components/ui/GlassSlider"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { AcademicClassWithGeofence, AttendanceExportRow } from "@/types"; + +const MapContainer = dynamic(() => import("react-leaflet").then((m) => m.MapContainer), { ssr: false }); +const TileLayer = dynamic(() => import("react-leaflet").then((m) => m.TileLayer), { ssr: false }); +const Marker = dynamic(() => import("react-leaflet").then((m) => m.Marker), { ssr: false }); +const Circle = dynamic(() => import("react-leaflet").then((m) => m.Circle), { ssr: false }); + +function MapClickHandler({ onLocationSelect }: { onLocationSelect: (lat: number, lng: number) => void }): null { + useMapEvents({ click: (e: { latlng: { lat: number; lng: number } }) => onLocationSelect(e.latlng.lat, e.latlng.lng) }); + return null; +} + +function MapUpdater({ lat, lng }: { lat: number; lng: number }): null { + const map = useMap(); + useEffect(() => { + map.flyTo([lat, lng], map.getZoom()); + }, [lat, lng, map]); + return null; +} + +async function downloadCsv(rows: AttendanceExportRow[], fileName: string): Promise { + + const Papa = (await import("papaparse")).default; + const csv = Papa.unparse(rows); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + +export default function ClassDetailPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const [cls, setCls] = useState(null); + const [lat, setLat] = useState(28.6139); + const [lng, setLng] = useState(77.209); + const [radius, setRadius] = useState(100); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [mapReady, setMapReady] = useState(false); + + const [exporting, setExporting] = useState(false); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + + useEffect(() => { + async function fetchClass(): Promise { + try { + const { data } = await api.get("/teacher/my-classes"); + const found = data.find((c) => c.id === id); + if (found) { + setCls(found); + if (found.geofence) { + setLat(found.geofence.latitude); + setLng(found.geofence.longitude); + setRadius(found.geofence.radiusMeters); + } + } + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load class details")); + } finally { + setLoading(false); + } + } + void fetchClass(); + + import("leaflet/dist/leaflet.css"); + import("leaflet").then((L) => { + + interface DefaultIconPrototype { + _getIconUrl?: unknown; + } + delete (L.Icon.Default.prototype as DefaultIconPrototype)._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png", + iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png", + shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png", + }); + setMapReady(true); + }); + }, [id]); + + const handleLocationSelect = useCallback((newLat: number, newLng: number) => { + setLat(newLat); + setLng(newLng); + }, []); + + const handleGetCurrentLocation = useCallback(() => { + if (!navigator.geolocation) { + toast.error("Geolocation is not supported by your browser"); + return; + } + toast.loading("Fetching location...", { id: "geo" }); + navigator.geolocation.getCurrentPosition( + (pos) => { + setLat(pos.coords.latitude); + setLng(pos.coords.longitude); + toast.success("Location updated", { id: "geo" }); + }, + (err) => { + toast.error(`Failed to get location: ${err.message}`, { id: "geo" }); + }, + { enableHighAccuracy: true } + ); + }, []); + + async function handleSave(): Promise { + setSaving(true); + try { + await api.post(`/teacher/classes/${id}/geofence`, { + latitude: lat, + longitude: lng, + radius_meters: radius, + }); + toast.success("Geofence saved successfully"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to save geofence")); + } finally { + setSaving(false); + } + } + + async function handleExportCsv(): Promise { + setExporting(true); + try { + const params = new URLSearchParams(); + if (fromDate) params.set("from_date", new Date(fromDate).toISOString()); + if (toDate) params.set("to_date", new Date(toDate).toISOString()); + const query = params.toString() ? `?${params.toString()}` : ""; + + const { data } = await api.get( + `/teacher/classes/${id}/export-attendance${query}` + ); + + if (data.length === 0) { + toast("No attendance records found for the selected range.", { icon: "ℹ️" }); + return; + } + + const today = new Date().toISOString().slice(0, 10); + const className = cls?.name?.replace(/\s+/g, "_") ?? "class"; + await downloadCsv(data, `attendance_${className}_${today}.csv`); + toast.success(`Downloaded ${data.length} record(s)`); + } catch { + toast.error("Export failed"); + } finally { + setExporting(false); + } + } + + if (loading) return ; + if (!cls) return
Class not found
; + + return ( +
+ + {} + + +
+ {} +
+ +
+ {mapReady ? ( + <> + + + + + + + +
+ } + onClick={handleGetCurrentLocation} + > + Current Location + +
+ + ) : ( +
+ Loading map... +
+ )} +
+
+
+ + {} +
+ {} + +

+ Geofence Settings +

+
+
+

Latitude

+

{lat.toFixed(6)}

+
+
+

Longitude

+

{lng.toFixed(6)}

+
+ +
+
+ + void handleSave()} + loading={saving} + icon={} + > + Save Geofence + + + +

+ Click on the map to set the geofence center point +

+
+ + {} + +

+ Export Attendance +

+
+ setFromDate(e.target.value)} + /> + setToDate(e.target.value)} + /> + } + loading={exporting} + onClick={() => void handleExportCsv()} + > + Export CSV + +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/classes/page.tsx b/frontend/src/app/(dashboard)/teacher/classes/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd11b95e5385bf9be2b148b73d82288adc0b6035 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/classes/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { MapPin, BookOpen, Users } from "lucide-react"; +import api from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassBadge from "@/components/ui/GlassBadge"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassEmptyState from "@/components/ui/GlassEmptyState"; +import GlassButton from "@/components/ui/GlassButton"; +import type { AcademicClassWithGeofence } from "@/types"; + +export default function TeacherClassesPage(): React.ReactElement { + const router = useRouter(); + const [classes, setClasses] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetch(): Promise { + try { + const { data } = await api.get("/teacher/my-classes"); + setClasses(data); + } + catch { + setClasses([]); + } + finally { + setLoading(false); + } + } + fetch(); + }, []); + + if (loading) return ; + + return ( +
+ + + {classes.length === 0 ? ( + + ) : ( +
+ {classes.map((cls) => ( + +
router.push(`/teacher/classes/${cls.id}`)} + className="cursor-pointer flex-grow" + > +
+
+ +
+ {cls.geofence ? ( + Configured + ) : ( + No Geofence + )} +
+ +

{cls.name}

+

{cls.subject}

+
+ +
+
+ {cls.geofence ? ( + + Radius: {cls.geofence.radiusMeters}m + + ) : ( + Pending Setup + )} +
+
+ router.push(`/teacher/classes/${cls.id}`)} + icon={} + > + Geofence + + router.push(`/teacher/sessions?classId=${cls.id}`)} + icon={} + > + Manual Attendance + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/dashboard/page.tsx b/frontend/src/app/(dashboard)/teacher/dashboard/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4d0ebd64b3fbe8df84ea65bdd06945314f5c937e --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/dashboard/page.tsx @@ -0,0 +1,213 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { + BookOpen, Radio, ClipboardCheck, ArrowUpRight, + RefreshCw, PlayCircle, Eye, Clock +} from "lucide-react"; +import Link from "next/link"; +import api from "@/lib/api"; +import GlassStatCard from "@/components/ui/GlassStatCard"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassBadge from "@/components/ui/GlassBadge"; +import toast from "react-hot-toast"; +import { getApiErrorMessage } from "@/lib/api"; +import type { + AcademicClassWithGeofence, + SessionWithClassResponse, + FlaggedAttendanceResponse +} from "@/types"; + +export default function TeacherDashboardPage(): React.ReactElement { + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [classes, setClasses] = useState([]); + const [sessions, setSessions] = useState([]); + const [flagged, setFlagged] = useState([]); + const [greeting, setGreeting] = useState("Welcome"); + + async function fetchData(): Promise { + try { + const [clsRes, sessRes, flagRes] = await Promise.all([ + api.get("/teacher/my-classes"), + api.get("/teacher/sessions/all"), + api.get("/teacher/attendance/flagged") + ]); + setClasses(clsRes.data); + setSessions(sessRes.data); + setFlagged(flagRes.data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load dashboard data")); + } finally { + setLoading(false); + setRefreshing(false); + } + } + + useEffect(() => { + const timer = setTimeout(() => { + void fetchData(); + const hour = new Date().getHours(); + if (hour < 12) setGreeting("Good morning"); + else if (hour < 17) setGreeting("Good afternoon"); + else setGreeting("Good evening"); + }, 0); + return () => clearTimeout(timer); + }, []); + + const handleRefresh = async () => { + setRefreshing(true); + await fetchData(); + }; + + if (loading) return ; + + const activeSessions = sessions.filter(s => s.isActive); + const completedSessions = sessions.filter(s => !s.isActive); + + const quickActions = [ + { label: "View My Classes", href: "/teacher/classes", detail: "Manage rosters and geofences", icon: }, + { label: "Start New Session", href: "/teacher/sessions", detail: "Broadcast attendance beacon", icon: }, + { label: "Review Flagged", href: "/teacher/review", detail: "Resolve AI validation anomalies", icon: }, + { label: "Attendance History", href: "/teacher/history", detail: "View past class records", icon: }, + ]; + + return ( +
+ + {/* Banner */} +
+
+
+
+
+ +

Faculty Lecture Portal

+
+

{greeting}, Professor

+

+ Manage your subjects, broadcast sessions, and review attendance records. +

+
+ +
+
+ + {/* Active Session Monitor (Only show if active) */} + {activeSessions.length > 0 && ( +
+
+
+
+ + +
+
+
+ Broadcast Active + {new Date(activeSessions[0].startTime).toLocaleTimeString()} +
+

{activeSessions[0].class_name}

+

{activeSessions[0].subject || "Attendance Beacon Broadcast in Progress..."}

+
+
+ + + Monitor Live Roster + +
+ )} + + {/* Quick Actions (Moved to Top for Accessibility) */} +
+ {quickActions.map((action) => ( + +
+
+ {action.icon} +
+ +
+
+ {action.label} +

{action.detail}

+
+ + ))} +
+ + {/* Stat Cards */} +
+ } + label="My Classes" + value={classes.length} + accentColor="blue" + trend="Enrolled Classes" + trendUp + /> + } + label="Active Sessions" + value={activeSessions.length} + accentColor="emerald" + trend={completedSessions.length > 0 ? `${completedSessions.length} Completed` : "Ready"} + trendUp + /> + } + label="Pending Reviews" + value={flagged.length} + accentColor={flagged.length > 0 ? "rose" : "purple"} + trend={flagged.length > 0 ? "Action Required" : "All Clear"} + trendUp={flagged.length === 0} + /> +
+ + {/* Recent Sessions */} + +
+
+ +

Recent Sessions

+
+
+ +
+ {sessions.slice(0, 5).map((session, i) => ( +
+
+

{session.class_name}

+

+ {new Date(session.startTime).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} +

+
+ + {session.isActive ? "ACTIVE" : "COMPLETED"} + +
+ ))} + {sessions.length === 0 && ( +
No sessions recorded yet.
+ )} +
+
+ +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/device-changes/page.tsx b/frontend/src/app/(dashboard)/teacher/device-changes/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6e26533f762e658a9998755e3d08d0a8cd91ab6 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/device-changes/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { Smartphone, RefreshCw } from "lucide-react"; +import api from "@/lib/api"; +import DeviceChangeTable from "@/components/teacher/DeviceChangeTable"; +import { toast } from "react-hot-toast"; + +export default function DeviceChangesPage(): React.ReactElement { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchRequests = useCallback(async (): Promise => { + try { + const { data } = await api.get("/teacher/device-changes/pending"); + setRequests(data); + } catch { + toast.error("Failed to load device change requests"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchRequests(); + }, [fetchRequests]); + + return ( +
+
+
+

+
+ +
+ Device Change Requests +

+

+ Review and approve requests from students who need to change their registered device. + This prevents proxy attendance by ensuring each student uses only one authorized phone. +

+
+ +
+ +
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/history/page.tsx b/frontend/src/app/(dashboard)/teacher/history/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77b807575e5907d31f38a0aadf22e37ae7c14c37 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/history/page.tsx @@ -0,0 +1,309 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Calendar, Search, SlidersHorizontal, Trash2, ClipboardList, BookOpen, Download, Eye } from "lucide-react"; +import api from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; + +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassEmptyState from "@/components/ui/GlassEmptyState"; +import toast from "react-hot-toast"; +import { getApiErrorMessage } from "@/lib/api"; +import GlassSelect from "@/components/ui/GlassSelect"; + +interface SessionLogItem { + id: string; + academicClassId: string; + class_name: string; + subject: string; + startTime: string; + endTime: string; + isActive: boolean; +} + +interface AcademicClass { + id: string; + name: string; + subject: string; +} + +export default function HistoryPage(): React.ReactElement { + const router = useRouter(); + const [sessions, setSessions] = useState([]); + const [classes, setClasses] = useState([]); + const [loading, setLoading] = useState(true); + + const [selectedClass, setSelectedClass] = useState("all"); + const [searchTerm, setSearchTerm] = useState(""); + const [dateFilter, setDateFilter] = useState(""); + const [exportingId, setExportingId] = useState(null); + + const handleExportCSV = async ( + sessionId: string, + className: string, + subjectName: string, + startTime: string + ): Promise => { + setExportingId(sessionId); + try { + const { data } = await api.get<{ roster: { enrollment_number: string; full_name: string; email: string; status: string; final_score: number; marked_at: string | null }[] }>( + `/teacher/sessions/${sessionId}/attendance` + ); + + if (!data.roster || data.roster.length === 0) { + toast.error("No student records found to export."); + return; + } + + // Construct CSV + const headers = ["Enrollment Number", "Student Name", "Email", "Status", "AI Score", "Marked At"]; + const rows = data.roster.map((s) => [ + s.enrollment_number, + s.full_name, + s.email, + s.status, + s.final_score.toFixed(2), + s.marked_at ? new Date(s.marked_at).toLocaleString() : "N/A" + ]); + + const csvContent = [ + headers.join(","), + ...rows.map((row) => row.map((val) => `"${String(val).replace(/"/g, '""')}"`).join(",")) + ].join("\n"); + + // Trigger download + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const sanitizedClass = className.replace(/[^a-z0-9]/gi, "_").toLowerCase(); + const dateStr = new Date(startTime).toISOString().slice(0, 10); + + link.setAttribute("href", url); + link.setAttribute("download", `attendance_${sanitizedClass}_${dateStr}.csv`); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success("CSV exported successfully!"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to export CSV")); + } finally { + setExportingId(null); + } + }; + + useEffect(() => { + async function initPage(): Promise { + try { + const [sessionsRes, classesRes] = await Promise.all([ + api.get("/teacher/sessions/all"), + api.get("/teacher/my-classes") + ]); + setSessions(sessionsRes.data); + setClasses(classesRes.data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load history")); + } finally { + setLoading(false); + } + } + void initPage(); + }, []); + + function handleResetFilters(): void { + setSelectedClass("all"); + setSearchTerm(""); + setDateFilter(""); + } + + const filteredSessions = sessions.filter((session) => { + + if (selectedClass !== "all" && session.academicClassId !== selectedClass) { + return false; + } + + if (searchTerm) { + const term = searchTerm.toLowerCase(); + const matchesName = session.class_name.toLowerCase().includes(term); + const matchesSubject = session.subject.toLowerCase().includes(term); + const matchesId = session.id.toLowerCase().includes(term); + if (!matchesName && !matchesSubject && !matchesId) return false; + } + + if (dateFilter) { + const sessionDate = new Date(session.startTime).toDateString(); + const filterDate = new Date(dateFilter).toDateString(); + if (sessionDate !== filterDate) return false; + } + + return true; + }); + + if (loading) return ; + + const columns: TableColumn>[] = [ + { + key: "class_name", + header: "Class Name", + sortable: true, + render: (row) => ( +
+

{String(row.class_name)}

+

ID: {row.id.slice(0, 8)}...

+
+ ), + }, + { key: "subject", header: "Subject", sortable: true }, + { + key: "startTime", + header: "Session Window", + render: (row) => ( +
+ + + {new Date(String(row.startTime)).toLocaleDateString()} + + + {new Date(String(row.startTime)).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} – {new Date(String(row.endTime)).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} + +
+ ), + }, + { + key: "actions", + header: "Actions", + render: (row) => ( +
+ + + + +
+ ), + }, + ]; + + return ( +
+ + +
+
+ + Filter Logs & Rosters +
+ +
+
+ +
+ + setSearchTerm(e.target.value)} + className="glass-input glass-input-with-icon pr-4 py-3 w-full text-sm text-slate-200 outline-none rounded-xl border border-white/10 placeholder-slate-500 focus:border-white/10/50" + /> +
+
+ +
+ ({ value: c.id, label: `${c.name} — ${c.subject}` })) + ]} + value={selectedClass} + onChange={setSelectedClass} + /> +
+ +
+ +
+ + setDateFilter(e.target.value)} + className="glass-input glass-input-with-icon pr-4 py-3 w-full text-sm text-slate-200 outline-none rounded-xl border border-white/10 placeholder-slate-500 focus:border-white/10/50 block" + /> +
+
+
+ + {(selectedClass !== "all" || searchTerm || dateFilter) && ( +
+ +
+ )} +
+ + {filteredSessions.length > 0 ? ( + )[]} + emptyMessage="No matching sessions found for current filters" + pageSize={10} + /> + ) : ( +
+ + {(selectedClass !== "all" || searchTerm || dateFilter) && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/leaves/page.tsx b/frontend/src/app/(dashboard)/teacher/leaves/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e629f1684bbbf65698e14d0aab4b2e9719cdd0cd --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/leaves/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { CheckCircle2, XCircle, ExternalLink } from "lucide-react"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassEmptyState from "@/components/ui/GlassEmptyState"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassConfirmDialog from "@/components/ui/GlassConfirmDialog"; +import type { PendingLeaveItem } from "@/types"; + +export default function LeavesPage(): React.ReactElement { + const [leaves, setLeaves] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedLeave, setSelectedLeave] = useState(null); + const [approveAction, setApproveAction] = useState<"APPROVED" | "REJECTED" | null>(null); + const [approverNote, setApproverNote] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let mounted = true; + async function fetchLeaves(): Promise { + try { const { data } = await api.get("/teacher/leaves/pending"); if (mounted) setLeaves(data); } + catch { if (mounted) setLeaves([]); } + finally { if (mounted) setLoading(false); } + } + fetchLeaves(); + return () => { mounted = false; }; + }, []); + + async function handleSubmit(): Promise { + if (!selectedLeave || !approveAction) return; + setSubmitting(true); + try { + await api.put(`/teacher/leaves/${selectedLeave.id}/approve`, { + status: approveAction, + approver_note: approverNote || undefined, + }); + toast.success(`Leave ${approveAction === "APPROVED" ? "approved" : "rejected"}`); + setSelectedLeave(null); + setApproveAction(null); + setApproverNote(""); + const { data } = await api.get("/teacher/leaves/pending"); + setLeaves(data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to process leave request")); + } finally { + setSubmitting(false); + } + } + + if (loading) return ; + + const columns: TableColumn>[] = [ + { key: "enrollment_number", header: "Enrollment #" }, + { key: "student_name", header: "Student", sortable: true }, + { key: "start_date", header: "From", render: (r) => {new Date(r.start_date).toLocaleDateString()} }, + { key: "end_date", header: "To", render: (r) => {new Date(r.end_date).toLocaleDateString()} }, + { key: "reason", header: "Reason", render: (r) => {r.reason} }, + { key: "document_url", header: "Doc", render: (r) => r.document_url ? ( + + + + ) : }, + { key: "actions", header: "", render: (row) => ( +
+ + +
+ )}, + ]; + + const filtered = leaves.filter(r => + r.student_name.toLowerCase().includes(searchTerm.toLowerCase()) || + r.enrollment_number.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (leaves.length === 0) return <>; + + return ( +
+ + +
+
+ +
+
+
+ )[]} emptyMessage="No pending leaves match your search." /> + + { setSelectedLeave(null); setApproveAction(null); }} + loading={submitting} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/page.tsx b/frontend/src/app/(dashboard)/teacher/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e387daa498725210d3d83cff78b79df597211dfe --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function TeacherIndex() { + redirect("/teacher/dashboard"); +} diff --git a/frontend/src/app/(dashboard)/teacher/review/[id]/page.tsx b/frontend/src/app/(dashboard)/teacher/review/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..501cd150ca263a26797c95bd1fb2dfe2fad4d2d0 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/review/[id]/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { CheckCircle2, XCircle, MessageSquare } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassTextarea from "@/components/ui/GlassTextarea"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassConfirmDialog from "@/components/ui/GlassConfirmDialog"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { FlaggedAttendanceResponse } from "@/types"; + +function ScoreGauge({ label, score, color }: { label: string; score: number; color: string }): React.ReactElement { + const pct = score * 100; + const r = 40, c = 2 * Math.PI * r, offset = c - (pct / 100) * c; + return ( +
+ + + + {pct.toFixed(0)}% + +

{label}

+
+ ); +} + +export default function ReviewDetailPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [record, setRecord] = useState(null); + const [remarks, setRemarks] = useState(""); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [confirmAction, setConfirmAction] = useState<"Approved" | "Rejected" | null>(null); + + useEffect(() => { + async function fetch(): Promise { + try { const { data } = await api.get(`/teacher/attendance/${id}`); setRecord(data); } + catch (err: unknown) { toast.error(getApiErrorMessage(err, "Failed to load record")); setRecord(null); } finally { setLoading(false); } + } + fetch(); + }, [id]); + + async function handleSubmit(): Promise { + if (!confirmAction) { toast.error("Please select an action"); return; } + setSubmitting(true); + try { await api.put(`/teacher/attendance/${id}/review`, { status: confirmAction, remarks }); toast.success(`Record ${confirmAction.toLowerCase()}`); router.push("/teacher/review"); } + catch { toast.error("Review failed"); } finally { setSubmitting(false); setConfirmAction(null); } + } + + if (loading) return ; + if (!record) return
Not found
; + + return ( +
+ + +
+
+ +

AI Evidence

+
+ + + + = 0.75 ? "#10b981" : "#f43f5e"} /> +
+
+ +
+

Student

{record.student_name}

+

Enrollment

{record.enrollment_number}

+

GPS

{record.gps_latitude?.toFixed(4) ?? "N/A"}, {record.gps_longitude?.toFixed(4) ?? "N/A"}

+

Date

{new Date(record.created_at).toLocaleString()}

+
+
+ {record.student_note && ( + +

+ Student Note +

+

+ “{record.student_note}” +

+
+ )} +
+ +

Decision

+
+ setRemarks(e.target.value)} /> + } onClick={() => setConfirmAction("Approved")}>Approve + } onClick={() => setConfirmAction("Rejected")}>Reject +
+
+
+ setConfirmAction(null)} loading={submitting} /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/review/page.tsx b/frontend/src/app/(dashboard)/teacher/review/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac4473b82ae2332b4d001d881ddd9c43cac77b3b --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/review/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Eye } from "lucide-react"; +import api from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassBadge from "@/components/ui/GlassBadge"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassEmptyState from "@/components/ui/GlassEmptyState"; +import type { FlaggedAttendanceResponse } from "@/types"; + +import GlassSearch from "@/components/ui/GlassSearch"; +import GlassSelect from "@/components/ui/GlassSelect"; + +import GlassCard from "@/components/ui/GlassCard"; + +export default function ReviewQueuePage(): React.ReactElement { + const router = useRouter(); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [filterClass, setFilterClass] = useState("all"); + + useEffect(() => { + async function fetch(): Promise { + try { const { data } = await api.get("/teacher/attendance/flagged"); setRecords(data); } + catch { setRecords([]); } + finally { setLoading(false); } + } + fetch(); + }, []); + + if (loading) return ; + if (records.length === 0) return <>; + + const columns: TableColumn>[] = [ + { key: "enrollment_number", header: "Enrollment #" }, + { key: "student_name", header: "Student", sortable: true }, + { key: "class_name", header: "Class" }, + { key: "final_ai_score", header: "AI Score", render: (r) => { + const score = Number(r.final_ai_score); + return {(score * 100).toFixed(1)}%; + }}, + { key: "created_at", header: "Date", render: (r) => {new Date(String(r.created_at)).toLocaleDateString()} }, + { key: "actions", header: "", render: (row) => ( + + )}, + ]; + + const uniqueClasses = Array.from(new Set(records.map(r => r.class_name))); + const classOptions = [ + { value: "all", label: "All Classes" }, + ...uniqueClasses.map(c => ({ value: c, label: c })) + ]; + + const filteredRecords = records.filter(r => { + const matchesSearch = r.student_name.toLowerCase().includes(searchTerm.toLowerCase()) || + r.enrollment_number.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesClass = filterClass === "all" || r.class_name === filterClass; + return matchesSearch && matchesClass; + }); + + return ( +
+
+ + Requires Teacher Decision +
+ + +
+
+ +
+
+ +
+
+
+ + )[]} emptyMessage="No records match your search." /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/sessions/[id]/manual/page.tsx b/frontend/src/app/(dashboard)/teacher/sessions/[id]/manual/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0fde8a9d4bba61b45d1a7e7875218981ff66bf5e --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/sessions/[id]/manual/page.tsx @@ -0,0 +1,396 @@ +"use client"; + +import React, { useEffect, useState, useCallback, useRef, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Search, Users, AlertCircle, Check, X } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { SessionAttendanceResponse, BulkMarkRequest } from "@/types"; + +type AttendanceStatus = "Present" | "Absent"; + +export default function ManualAttendancePage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [roster, setRoster] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [statusMap, setStatusMap] = useState>(new Map()); + + // Keyboard navigation states + const [focusedIndex, setFocusedIndex] = useState(0); + const itemRefs = useRef>(new Map()); + + const fetchRoster = useCallback(async (): Promise => { + try { + setLoading(true); + const { data } = await api.get( + `/teacher/sessions/${id}/attendance` + ); + setRoster(data); + + const initMap = new Map(); + data.roster.forEach((s) => { + const isPresent = s.status === "Present" || s.status === "Flagged" || s.status === "Approved"; + initMap.set(s.student_id, isPresent ? "Present" : "Absent"); + }); + setStatusMap(initMap); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Could not load session roster")); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + void (async () => { + await fetchRoster(); + })(); + }, [fetchRoster]); + + const toggleStatus = useCallback((studentId: string): void => { + setStatusMap((prev) => { + const next = new Map(prev); + const current = prev.get(studentId) ?? "Absent"; + next.set(studentId, current === "Present" ? "Absent" : "Present"); + return next; + }); + }, []); + + function handleMarkAll(status: AttendanceStatus): void { + if (!roster) return; + setStatusMap((prev) => { + const next = new Map(prev); + roster.roster.forEach((s) => { + next.set(s.student_id, status); + }); + return next; + }); + toast.success(`Marked all students as ${status}`); + } + + function handleReset(): void { + if (!roster) return; + const next = new Map(); + roster.roster.forEach((s) => { + const isPresent = s.status === "Present" || s.status === "Flagged" || s.status === "Approved"; + next.set(s.student_id, isPresent ? "Present" : "Absent"); + }); + setStatusMap(next); + toast("Reset to original attendance status", { icon: "🔄" }); + } + + async function handleSubmit(): Promise { + setSubmitting(true); + try { + const records = Array.from(statusMap.entries()).map(([student_id, status]) => ({ + student_id, + status, + })); + const payload: BulkMarkRequest = { records }; + await api.post(`/teacher/sessions/${id}/mark-bulk`, payload); + toast.success("Attendance override applied successfully"); + router.push(`/teacher/sessions/${id}/roster`); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to submit attendance")); + } finally { + setSubmitting(false); + } + } + + const totalCount = roster ? roster.roster.length : 0; + const presentCount = Array.from(statusMap.values()).filter((s) => s === "Present").length; + const absentCount = totalCount - presentCount; + const presentPercent = totalCount > 0 ? Math.round((presentCount / totalCount) * 100) : 0; + + // SVG ring logic + const radius = 32; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference - (presentPercent / 100) * circumference; + + const filteredStudents = useMemo(() => { + return roster + ? roster.roster.filter( + (s) => + s.full_name.toLowerCase().includes(searchQuery.toLowerCase()) || + s.enrollment_number.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : []; + }, [roster, searchQuery]); + + // Clamp focused index + useEffect(() => { + const timer = setTimeout(() => { + setFocusedIndex((prev) => { + if (filteredStudents.length === 0) return 0; + return Math.min(prev, filteredStudents.length - 1); + }); + }, 0); + return () => clearTimeout(timer); + }, [filteredStudents]); + + // Scroll focused item + useEffect(() => { + if (filteredStudents.length === 0) return; + const focusedStudent = filteredStudents[focusedIndex]; + if (focusedStudent) { + const itemEl = itemRefs.current.get(focusedStudent.student_id); + itemEl?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [focusedIndex, filteredStudents]); + + // Keyboard controls + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if ( + document.activeElement?.tagName === "INPUT" || + document.activeElement?.tagName === "TEXTAREA" + ) { + return; + } + + if (filteredStudents.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusedIndex((prev) => Math.min(prev + 1, filteredStudents.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + const targetStudent = filteredStudents[focusedIndex]; + if (targetStudent) { + toggleStatus(targetStudent.student_id); + } + } + }, + [filteredStudents, focusedIndex, toggleStatus] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleKeyDown]); + + if (loading) return ; + if (!roster) return
Session not found
; + + return ( +
+ + + +
+ {/* Left Control Pane (Unified & Compact) */} +
+ +

+ Roster Health +

+ + {/* Radial SVG Widget */} +
+ + {/* Background Ring */} + + {/* Foreground Active Ring */} + + +
+ {presentPercent}% +

Present

+
+
+ + {/* Health Roster Stats */} +
+
+ Present + {presentCount} +
+
+ Absent + {absentCount} +
+
+ + {/* Search Input & Action Row */} +
+
+ setSearchQuery(e.target.value)} + className="pl-9 text-xs py-2" + /> + +
+ +
+ handleMarkAll("Present")} + > + All Present + + handleMarkAll("Absent")} + > + All Absent + + + Reset + +
+
+
+ + + void handleSubmit()} + > + Commit Changes + + router.push(`/teacher/sessions/${id}/roster`)} + > + Cancel + + +
+ + {/* Right Roster Pane */} +
+ +
+
+ + + Students ({filteredStudents.length} shown) + +
+ + Use [↑ / ↓] keys, [Space / Enter] to toggle + +
+ + {filteredStudents.length === 0 ? ( +
+ +

No students matched search

+

+ Adjust search criteria or press Escape to clear search query. +

+
+ ) : ( +
+ {filteredStudents.map((student, idx) => { + const currentStatus = statusMap.get(student.student_id) ?? "Absent"; + const isPresent = currentStatus === "Present"; + const isFocused = idx === focusedIndex; + + return ( +
{ + if (el) { + itemRefs.current.set(student.student_id, el); + } else { + itemRefs.current.delete(student.student_id); + } + }} + onClick={() => { + setFocusedIndex(idx); + toggleStatus(student.student_id); + }} + className={`flex items-center justify-between p-3.5 cursor-pointer select-none transition-all duration-200 active:scale-[0.99] border-l-4 relative ${ + isPresent + ? "bg-emerald-500/[0.02] border-l-emerald-500/70 hover:bg-emerald-500/[0.05]" + : "bg-rose-500/[0.02] border-l-rose-500/70 hover:bg-rose-500/[0.05]" + } ${isFocused ? "outline outline-2 outline-indigo-500/50 z-10 shadow-lg shadow-indigo-950/20" : ""}`} + > + {/* Name and Enrollment Number placement */} +
+ + {student.enrollment_number} + + + {student.full_name} + +
+ + {/* Override status badge */} +
+ {isPresent ? ( + + + Present + + ) : ( + + + Absent + + )} +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/sessions/[id]/page.tsx b/frontend/src/app/(dashboard)/teacher/sessions/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71d75c62e632d26f51d0b02fe00a63c22f9e0810 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/sessions/[id]/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function SessionRedirectPage(): null { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + + useEffect(() => { + router.replace(`/teacher/sessions/${id}/roster`); + }, [id, router]); + + return null; +} diff --git a/frontend/src/app/(dashboard)/teacher/sessions/[id]/preview/page.tsx b/frontend/src/app/(dashboard)/teacher/sessions/[id]/preview/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b887eaa8e5fb6243e80cc92ab5de3d74776dcb26 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/sessions/[id]/preview/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import React, { useEffect, useState, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft, Printer, Search } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassBadge, { statusToBadgeVariant } from "@/components/ui/GlassBadge"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassStatCard from "@/components/ui/GlassStatCard"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { SessionAttendanceResponse, StudentRosterItem } from "@/types"; + +export default function SessionPreviewPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [roster, setRoster] = useState(null); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + async function loadData(): Promise { + try { + const { data } = await api.get( + `/teacher/sessions/${id}/attendance` + ); + setRoster(data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load preview data")); + } finally { + setLoading(false); + } + } + void loadData(); + }, [id]); + + const handlePrint = (): void => { + if (typeof window !== "undefined") { + window.print(); + } + }; + + const counts = useMemo(() => { + if (!roster) return { present: 0, flagged: 0, absent: 0, total: 0, rate: 0 }; + const res = roster.roster.reduce( + (acc, r) => { + if (r.status === "Present" || r.status === "Approved") acc.present++; + else if (r.status === "Flagged") acc.flagged++; + else if (r.status === "Absent") acc.absent++; + return acc; + }, + { present: 0, flagged: 0, absent: 0 } + ); + const total = roster.roster.length; + const rate = total > 0 ? Math.round(((res.present + res.flagged) / total) * 100) : 0; + return { ...res, total, rate }; + }, [roster]); + + const filteredRoster = useMemo(() => { + if (!roster) return []; + if (!searchTerm) return roster.roster; + const term = searchTerm.toLowerCase(); + return roster.roster.filter( + (r) => + r.full_name.toLowerCase().includes(term) || + r.enrollment_number.toLowerCase().includes(term) || + r.email.toLowerCase().includes(term) + ); + }, [roster, searchTerm]); + + if (loading) return ; + if (!roster) return
Session not found
; + + const columns: TableColumn>[] = [ + { key: "enrollment_number", header: "Enrollment #", sortable: true }, + { key: "full_name", header: "Full Name", sortable: true }, + { key: "email", header: "Email Address", sortable: true }, + { + key: "status", + header: "Attendance Status", + sortable: true, + render: (r) => ( + + {String(r.status)} + + ), + }, + { + key: "final_score", + header: "AI Match Score", + sortable: true, + render: (r) => { + const score = Number(r.final_score); + if (score === 0 && r.status === "Absent") return ; + return ( + = 0.7 ? "text-emerald-400" : score >= 0.4 ? "text-amber-400" : "text-rose-400"}`}> + {(score * 100).toFixed(1)}% + + ); + }, + }, + { + key: "marked_at", + header: "Marked Timestamp", + sortable: true, + render: (r) => ( + + {r.marked_at ? new Date(String(r.marked_at)).toLocaleString() : "—"} + + ), + }, + ]; + + return ( +
+
+ +
+ + + } + onClick={() => router.back()} + > + Back + + } + onClick={handlePrint} + > + Print Report + +
+ } + /> + +
+ 👥} label="Total Students" value={counts.total} accentColor="neutral" /> + ✅} label="Present" value={counts.present} accentColor="emerald" /> + ❌} label="Absent" value={counts.absent} accentColor="rose" /> + 📈} label="Attendance Rate" value={`${counts.rate}%`} accentColor="emerald" /> +
+ +
+
+ + setSearchTerm(e.target.value)} + className="glass-input glass-input-with-icon pr-4 py-3 w-full text-sm text-slate-200 outline-none rounded-xl border border-white/10 placeholder-slate-500 focus:border-white/10/50" + /> +
+
+ + )[]} + emptyMessage="No students found matching your search term." + pageSize={30} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/sessions/[id]/roster/page.tsx b/frontend/src/app/(dashboard)/teacher/sessions/[id]/roster/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a4ac5dd6cb78eb4757738f3ea26d4e72fb793ec5 --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/sessions/[id]/roster/page.tsx @@ -0,0 +1,230 @@ +"use client"; + +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { RefreshCw, ClipboardList } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import { getWebSocket } from "@/lib/websocket"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassTable, { type TableColumn } from "@/components/ui/GlassTable"; +import GlassBadge, { statusToBadgeVariant } from "@/components/ui/GlassBadge"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassStatCard from "@/components/ui/GlassStatCard"; +import GlassLoader from "@/components/ui/GlassLoader"; +import GlassConfirmDialog from "@/components/ui/GlassConfirmDialog"; +import type { SessionAttendanceResponse, StudentRosterItem, BulkMarkRequest } from "@/types"; + +export default function SessionRosterPage(): React.ReactElement { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [roster, setRoster] = useState(null); + const [loading, setLoading] = useState(true); + const [markingAll, setMarkingAll] = useState(false); + const [showConfirmBulk, setShowConfirmBulk] = useState(false); + const intervalRef = useRef(null); + + const fetchRoster = useCallback(async (): Promise => { + try { + const { data } = await api.get( + `/teacher/sessions/${id}/attendance` + ); + setRoster(data); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to load roster")); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + void (async () => { + await fetchRoster(); + })(); + + const ws = getWebSocket(); + ws.connect(); + + const unsubscribe = ws.on("attendance_updated", () => { + void fetchRoster(); + }); + + intervalRef.current = setInterval(() => { + void fetchRoster(); + }, 5000); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + unsubscribe(); + }; + }, [fetchRoster]); + + async function handleOverride(studentId: string, status: string): Promise { + try { + await api.post(`/teacher/sessions/${id}/override`, { student_id: studentId, status }); + toast.success(`Marked as ${status}`); + await fetchRoster(); + } catch { + toast.error("Failed to override attendance"); + } + } + + async function handleMarkAllPresent(): Promise { + if (!roster) return; + const targets = roster.roster.filter( + (r) => r.status === "Absent" && r.marked_at === null + ); + if (targets.length === 0) { + toast("No unmarked absent students to mark present.", { icon: "ℹ️" }); + return; + } + setShowConfirmBulk(true); + } + + async function handleBulkMarkConfirm(): Promise { + if (!roster) return; + setShowConfirmBulk(false); + const targets = roster.roster.filter( + (r) => r.status === "Absent" && r.marked_at === null + ); + setMarkingAll(true); + try { + const payload: BulkMarkRequest = { + records: targets.map((r) => ({ student_id: r.student_id, status: "Present" })), + }; + await api.post(`/teacher/sessions/${id}/mark-bulk`, payload); + toast.success(`${targets.length} student(s) marked Present`); + await fetchRoster(); + } catch { + toast.error("Bulk mark failed"); + } finally { + setMarkingAll(false); + } + } + + if (loading) return ; + if (!roster) return
Session not found
; + + const counts = roster.roster.reduce( + (acc, r) => { + if (r.status === "Present" || r.status === "Approved") acc.present++; + else if (r.status === "Flagged") acc.flagged++; + else if (r.status === "Absent") acc.absent++; + return acc; + }, + { present: 0, flagged: 0, absent: 0 } + ); + + const columns: TableColumn>[] = [ + { key: "enrollment_number", header: "Enrollment #", sortable: true }, + { key: "full_name", header: "Full Name", sortable: true }, + { + key: "status", + header: "Status", + render: (r) => ( + {String(r.status)} + ), + }, + { + key: "final_score", + header: "AI Score", + render: (r) => { + const score = Number(r.final_score); + if (score === 0 && r.status === "Absent") return ; + return {(score * 100).toFixed(1)}%; + }, + }, + { + key: "marked_at", + header: "Marked At", + render: (r) => ( + + {r.marked_at ? new Date(String(r.marked_at)).toLocaleTimeString() : "—"} + + ), + }, + { + key: "actions", + header: "Override", + render: (row) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+ + + } + onClick={() => router.push(`/teacher/sessions/${id}/manual`)} + > + Manual Entry + + void handleMarkAllPresent()} + > + Mark All Absent → Present + + } + onClick={() => void fetchRoster()} + > + Refresh + +
+ } + /> + +
+ ✅} label="Present" value={counts.present} accentColor="emerald" /> + ⚠️} label="Flagged" value={counts.flagged} accentColor="amber" /> + ❌} label="Absent" value={counts.absent} accentColor="rose" /> +
+ + )[]} + emptyMessage="No students in roster" + pageSize={20} + /> + setShowConfirmBulk(false)} + loading={markingAll} + /> +
+ ); +} diff --git a/frontend/src/app/(dashboard)/teacher/sessions/page.tsx b/frontend/src/app/(dashboard)/teacher/sessions/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7f1ce476f617feb34510b9dcb14578e3d53ecd6a --- /dev/null +++ b/frontend/src/app/(dashboard)/teacher/sessions/page.tsx @@ -0,0 +1,311 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Radio, Play, Square, ClipboardList, BookOpen } from "lucide-react"; +import toast from "react-hot-toast"; +import api, { getApiErrorMessage } from "@/lib/api"; +import GlassPageHeader from "@/components/ui/GlassPageHeader"; +import GlassBreadcrumb from "@/components/ui/GlassBreadcrumb"; +import GlassCard from "@/components/ui/GlassCard"; +import GlassSelect from "@/components/ui/GlassSelect"; +import GlassInput from "@/components/ui/GlassInput"; +import GlassButton from "@/components/ui/GlassButton"; +import GlassBadge from "@/components/ui/GlassBadge"; +import GlassLoader from "@/components/ui/GlassLoader"; +import type { AcademicClassWithGeofence, SessionResponse, SessionWithClassResponse } from "@/types"; + +export default function SessionsPage(): React.ReactElement { + const router = useRouter(); + const [classes, setClasses] = useState([]); + const [selectedClass, setSelectedClass] = useState(""); + const [duration, setDuration] = useState("10"); + const [loading, setLoading] = useState(true); + const [starting, setStarting] = useState(false); + const [activeSessions, setActiveSessions] = useState([]); + const [pastSessions, setPastSessions] = useState([]); + const [classFilter, setClassFilter] = useState(""); + + const fetchData = useCallback(async (): Promise => { + try { + const [classesRes, allSessionsRes] = await Promise.all([ + api.get("/teacher/my-classes"), + api.get("/teacher/sessions/all"), + ]); + setClasses(classesRes.data); + + const now = new Date(); + setActiveSessions( + allSessionsRes.data + .filter((s) => s.isActive && new Date(s.endTime) > now) + .map(({ id, academicClassId, startTime, endTime, isActive }) => ({ + id, academicClassId, startTime, endTime, isActive, + })) + ); + setPastSessions( + allSessionsRes.data.filter((s) => !s.isActive || new Date(s.endTime) <= now) + ); + } catch { + setClasses([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void (async () => { + await fetchData(); + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const classId = params.get("classId"); + if (classId) { + setClassFilter(classId); + setSelectedClass(classId); + } + } + })(); + }, [fetchData]); + + useEffect(() => { + const timer = setInterval(() => { + const now = new Date(); + const hasExpired = activeSessions.some((s) => new Date(s.endTime) <= now); + if (hasExpired) { + void fetchData(); + } + }, 5000); + return () => clearInterval(timer); + }, [activeSessions, fetchData]); + + const handleClearFilter = useCallback((): void => { + setClassFilter(""); + router.replace("/teacher/sessions"); + }, [router]); + + async function handleStart(): Promise { + if (!selectedClass) { toast.error("Select a class first"); return; } + const mins = parseInt(duration, 10); + if (isNaN(mins) || mins < 1 || mins > 180) { toast.error("Duration must be 1–180 minutes"); return; } + setStarting(true); + try { + const { data } = await api.post("/teacher/sessions/start", { + academic_class_id: selectedClass, + duration_minutes: mins, + }); + setActiveSessions((prev) => [...prev, data]); + toast.success("Session started!"); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, "Failed to start session")); + } finally { + setStarting(false); + } + } + + async function handleStop(sessionId: string): Promise { + try { + await api.post(`/teacher/sessions/${sessionId}/stop`); + setActiveSessions((prev) => prev.filter((s) => s.id !== sessionId)); + toast.success("Session stopped"); + + await fetchData(); + } catch { + toast.error("Failed to stop session"); + } + } + + if (loading) return ; + + const filteredActiveSessions = classFilter + ? activeSessions.filter((s) => s.academicClassId === classFilter) + : activeSessions; + + const filteredPastSessions = classFilter + ? pastSessions.filter((s) => s.academicClassId === classFilter) + : pastSessions; + + const filteredClass = classes.find((c) => c.id === classFilter); + const filterLabel = filteredClass ? `${filteredClass.name} — ${filteredClass.subject}` : "Selected Class"; + + return ( +
+ + + + {classFilter && ( +
+
+ + Showing sessions for class: {filterLabel} +
+ +
+ )} + +
+ {} + +
+
+ +
+

Start New Session

+
+
+ ({ value: c.id, label: `${c.name} — ${c.subject}` }))} + value={selectedClass} + onChange={setSelectedClass} + placeholder="Select class..." + /> + setDuration(e.target.value)} + /> + void handleStart()} + loading={starting} + icon={} + > + Start Session + +
+
+ + {} + +

Active Sessions

+ {filteredActiveSessions.length === 0 ? ( +

No active sessions

+ ) : ( +
+ {filteredActiveSessions.map((session) => ( +
+
+ Live +

{session.id.slice(0, 8)}...

+

+ Ends: {new Date(session.endTime).toLocaleTimeString()} +

+
+
+ } + onClick={() => router.push(`/teacher/sessions/${session.id}/roster`)} + > + Roster + + } + onClick={() => router.push(`/teacher/sessions/${session.id}/manual`)} + > + Manual + + void handleStop(session.id)} + icon={} + > + Stop + +
+
+ ))} +
+ )} +
+
+ + {} + +

Past Sessions

+ {filteredPastSessions.length === 0 ? ( +

No past sessions yet

+ ) : ( +
+ + + + {["Class", "Subject", "Date", "Start", "End", "Actions"].map((h) => ( + + ))} + + + + {filteredPastSessions.map((s, idx) => ( + + + + + + + + + ))} + +
+ {h} +
{s.class_name}{s.subject} + {new Date(s.startTime).toLocaleDateString()} + + {new Date(s.startTime).toLocaleTimeString()} + + {new Date(s.endTime).toLocaleTimeString()} + +
+ } + onClick={() => router.push(`/teacher/sessions/${s.id}/roster`)} + > + Live View + + } + onClick={() => router.push(`/teacher/sessions/${s.id}/roster`)} + > + Roster + + } + onClick={() => router.push(`/teacher/sessions/${s.id}/manual`)} + > + Manual + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..75d00f7c753cb0bee242fd67a8280326e1c94fe8 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,767 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"); + +@import "tailwindcss"; + +:root { + /* Ultra-Premium Frosted Glassmorphism Theme Tokens */ + --glass-bg: rgba(255, 255, 255, 0.035); + --glass-bg-hover: rgba(255, 255, 255, 0.07); + --glass-border: rgba(255, 255, 255, 0.09); + --glass-border-hover: rgba(255, 255, 255, 0.20); + --glass-shadow: 0 16px 48px 0 rgba(0, 0, 0, 0.55), inset 0 1px 0 0 rgba(255, 255, 255, 0.16); + --glass-blur: 28px; + + /* Harmonized Accent Colors (Completely Blue/Indigo Free) */ + --accent-blue: #10b981; /* High-end Emerald Green */ + --accent-purple: #14b8a6; /* Clean Teal highlight */ + --accent-pink: #f43f5e; + --accent-amber: #f59e0b; + --accent-emerald: #10b981; + + /* Subtle Glowing Shadows */ + --glow-blue: 0 0 24px rgba(16, 185, 129, 0.25); + --glow-purple: 0 0 24px rgba(20, 184, 166, 0.25); + --glow-pink: 0 0 24px rgba(244, 63, 94, 0.25); + --glow-emerald: 0 0 24px rgba(16, 185, 129, 0.25); + + /* Obsidian Pitch Black backgrounds */ + --bg-primary: #000000; + --bg-secondary: #05050a; + --text-primary: #f8fafc; + --text-secondary: #cbd5e1; + --text-muted: #64748b; + + --sidebar-width: 270px; + --sidebar-collapsed: 76px; + --header-height: 72px; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-2xl: 24px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + padding: 0; + font-family: "Plus Jakarta Sans", "Outfit", "Inter", system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +h1, h2, h3, h4, h5, h6 { + font-family: "Outfit", "Plus Jakarta Sans", sans-serif; + letter-spacing: -0.025em; +} + +/* Animated Colored Gradient Dark Theme */ +.animated-bg { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + background: + radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.08) 0%, transparent 65%), + #000000; +} + +.animated-bg::before, +.animated-bg::after { + content: ""; + position: absolute; + border-radius: 50%; + filter: blur(120px); + mix-blend-mode: screen; + pointer-events: none; +} + +/* Deep Violet Orb */ +.animated-bg::before { + width: 80vw; + height: 80vh; + background: radial-gradient(circle, rgba(139, 92, 246, 0.22) 0%, transparent 65%); + top: -10%; + left: -10%; + animation: float-orb-1 28s infinite alternate ease-in-out; +} + +/* Ocean Blue Orb */ +.animated-bg::after { + width: 85vw; + height: 85vh; + background: radial-gradient(circle, rgba(56, 189, 248, 0.2) 0%, transparent 65%); + bottom: -10%; + right: -10%; + animation: float-orb-2 32s infinite alternate-reverse ease-in-out; +} + +@keyframes float-orb-1 { + 0% { transform: translate(0, 0) scale(1); } + 100% { transform: translate(15vw, 15vh) scale(1.15); } +} + +@keyframes float-orb-2 { + 0% { transform: translate(0, 0) scale(1); } + 100% { transform: translate(-15vw, -15vh) scale(1.1); } +} + +/* Tactile Specular Liquid Glass Panel */ +.glass-panel { + background: rgba(255, 255, 255, 0.015); + backdrop-filter: blur(var(--glass-blur)) saturate(150%); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(150%); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: + 0 16px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 1px rgba(255, 255, 255, 0.2), + inset 0 -1px 1px rgba(255, 255, 255, 0.03); + border-radius: var(--radius-xl); + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + overflow: hidden; +} + +.glass-panel::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid transparent; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.25) 0%, transparent 40%, rgba(255, 255, 255, 0.05) 100%) border-box; + -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask-composite: exclude; + pointer-events: none; + opacity: 0.8; + transition: opacity 0.4s ease; +} + +.glass-panel:hover { + background: rgba(255, 255, 255, 0.03); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-4px); + box-shadow: + 0 24px 48px rgba(0, 0, 0, 0.5), + inset 0 2px 2px rgba(255, 255, 255, 0.3), + inset 0 -1px 1px rgba(255, 255, 255, 0.05); +} + +.glass-panel:hover::after { + opacity: 1; +} + +.glass-panel-static { + background: rgba(255, 255, 255, 0.015); + backdrop-filter: blur(var(--glass-blur)) saturate(150%); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(150%); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: + 0 16px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 1px rgba(255, 255, 255, 0.2), + inset 0 -1px 1px rgba(255, 255, 255, 0.03); + border-radius: var(--radius-xl); + position: relative; + overflow: hidden; +} + +.glass-panel-static::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid transparent; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, transparent 50%) border-box; + -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask-composite: exclude; + pointer-events: none; +} + +/* Glass Inputs */ +.glass-input { + width: 100%; + padding: 14px 18px; + background: rgba(3, 4, 12, 0.5); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: inherit; + font-size: 16px; + line-height: 1.5; + outline: none; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glass-input-with-icon { + padding-left: 46px !important; +} + +/* Custom Webkit Calendar indicator styling */ +.glass-input[type="date"]::-webkit-calendar-picker-indicator { + cursor: pointer; + filter: invert(1) opacity(0.55); + transition: all 0.2s ease; +} + +.glass-input[type="date"]::-webkit-calendar-picker-indicator:hover { + filter: invert(1) opacity(0.85); +} + +.glass-input::placeholder { + color: var(--text-muted); +} + +.glass-input:focus { + border-color: rgba(255, 255, 255, 0.3); + box-shadow: + inset 0 2px 4px rgba(0, 0, 0, 0.6), + 0 0 0 3px rgba(255, 255, 255, 0.05), + 0 4px 15px rgba(255, 255, 255, 0.05); + background: rgba(3, 4, 12, 0.7); +} + +.glass-input:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.glass-input-error { + border-color: rgba(244, 63, 94, 0.5) !important; + box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.12) !important; +} + +/* Tactical Glass Buttons */ +.glass-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + font-family: inherit; + font-size: 15px; + font-weight: 600; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + outline: none; + text-decoration: none; + user-select: none; +} + +.glass-btn:active:not(:disabled) { + transform: scale(0.96); +} + +.glass-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +.glass-btn-primary { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.15); +} + +.glass-btn-primary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6), 0 0 0 2px rgba(255, 255, 255, 0.15); + filter: brightness(1.1); +} + +.glass-btn-secondary { + background: var(--glass-bg); + color: var(--text-primary); + border: 1px solid var(--glass-border); + backdrop-filter: blur(8px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.glass-btn-secondary:hover:not(:disabled) { + background: var(--glass-bg-hover); + border-color: var(--glass-border-hover); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.glass-btn-danger { + background: rgba(244, 63, 94, 0.15); + color: #fff; + border: 1px solid rgba(244, 63, 94, 0.4); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(244, 63, 94, 0.2); +} + +.glass-btn-danger:hover:not(:disabled) { + background: rgba(244, 63, 94, 0.25); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6), 0 0 0 2px rgba(244, 63, 94, 0.2); + filter: brightness(1.1); +} + +.glass-btn-ghost { + background: transparent; + color: var(--text-secondary); + border-color: transparent; +} + +.glass-btn-ghost:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); +} + +.glass-btn-sm { + padding: 6px 12px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.glass-btn-lg { + padding: 14px 28px; + font-size: 16px; + border-radius: var(--radius-lg); +} + +/* Animations */ +.animate-fade-in-up { + animation: fadeInUp 0.45s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-pulse-glow { + animation: pulseGlow 2.5s infinite ease-in-out; +} + +@keyframes pulseGlow { + 0%, 100% { box-shadow: 0 0 20px rgba(56, 189, 248, 0.25); } + 50% { box-shadow: 0 0 35px rgba(56, 189, 248, 0.45); } +} + +.animate-radar-pulse { + animation: radarPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes radarPulse { + 0%, 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.35); + opacity: 0.15; + } +} + +.animate-glow-green { + animation: glowGreen 1.8s infinite alternate ease-in-out; +} + +@keyframes glowGreen { + from { + box-shadow: 0 0 4px rgba(16, 185, 129, 0.4); + border-color: rgba(16, 185, 129, 0.4); + } + to { + box-shadow: 0 0 16px rgba(16, 185, 129, 0.8); + border-color: rgba(16, 185, 129, 0.8); + } +} + +.animate-shimmer { + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.01) 0%, + rgba(255, 255, 255, 0.06) 50%, + rgba(255, 255, 255, 0.01) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.8s infinite; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.16); +} + +::selection { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Floating Sidebar Navigation */ +.sidebar { + position: fixed; + left: 16px; + top: 16px; + bottom: 16px; + width: var(--sidebar-width); + background: rgba(10, 11, 28, 0.45); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + border-radius: var(--radius-2xl); + z-index: 40; + display: flex; + flex-direction: column; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid transparent; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, transparent 40%) border-box; + -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask-composite: exclude; + pointer-events: none; +} + +.sidebar-link { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + margin: 4px 16px; + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; +} + +.sidebar-link:hover { + background: rgba(255, 255, 255, 0.04); + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.03); +} + +.sidebar-link-active { + background: rgba(255, 255, 255, 0.08) !important; + color: #ffffff !important; + border: 1px solid rgba(255, 255, 255, 0.15) !important; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); +} + +/* Glass Table design */ +.glass-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.glass-table th { + padding: 14px 16px; + text-align: left; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + white-space: nowrap; +} + +.glass-table td { + padding: 14px 16px; + font-size: 14px; + color: var(--text-primary); + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + vertical-align: middle; +} + +.glass-table tbody tr { + transition: background 0.25s ease; +} + +.glass-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.badge-success { + background: rgba(52, 211, 153, 0.1); + color: #34d399; + border: 1px solid rgba(52, 211, 153, 0.22); + box-shadow: 0 0 10px rgba(52, 211, 153, 0.05); +} + +.badge-warning { + background: rgba(251, 146, 60, 0.1); + color: #fb923c; + border: 1px solid rgba(251, 146, 60, 0.22); +} + +.badge-danger { + background: rgba(244, 63, 94, 0.1); + color: #fb7185; + border: 1px solid rgba(244, 63, 94, 0.22); +} + +.badge-info { + background: rgba(56, 189, 248, 0.1); + color: #38bdf8; + border: 1px solid rgba(56, 189, 248, 0.22); + box-shadow: 0 0 10px rgba(56, 189, 248, 0.05); +} + +.badge-neutral { + background: rgba(148, 163, 184, 0.1); + color: #cbd5e1; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +/* Stat Cards */ +.stat-card { + padding: 24px; + background-color: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-xl); + box-shadow: var(--glass-shadow); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.stat-card::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid transparent; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, transparent 45%) border-box; + -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: destination-out; + mask-composite: exclude; + pointer-events: none; +} + +.stat-card:hover { + background-color: var(--glass-bg-hover); + border-color: var(--glass-border-hover); + transform: translateY(-6px); + box-shadow: + 0 24px 50px rgba(0, 0, 0, 0.65), + inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +/* Modal overlays */ +.overlay { + position: fixed; + inset: 0; + background: rgba(3, 4, 12, 0.7); + backdrop-filter: blur(6px); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + animation: overlayIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes overlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-panel { + background: rgba(10, 11, 28, 0.95); + backdrop-filter: blur(28px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-xl); + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.65), inset 0 1px 0 rgba(255, 255, 255, 0.1); + padding: 32px; + width: 100%; + max-width: 480px; + animation: modalIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes modalIn { + from { + opacity: 0; + transform: scale(0.94) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.leaflet-container { + background: #060714 !important; + border-radius: var(--radius-lg); +} + +.leaflet-control-zoom a { + background: rgba(10, 11, 28, 0.9) !important; + color: #f8fafc !important; + border-color: rgba(255, 255, 255, 0.08) !important; +} + +.leaflet-control-attribution { + background: rgba(10, 11, 28, 0.8) !important; + color: var(--text-muted) !important; + font-size: 10px !important; +} + +.leaflet-control-attribution a { + color: var(--text-secondary) !important; +} + +.recharts-default-tooltip { + background: rgba(10, 11, 28, 0.96) !important; + border: 1px solid rgba(255, 255, 255, 0.08) !important; + border-radius: var(--radius-md) !important; + box-shadow: var(--glass-shadow) !important; +} + +.recharts-tooltip-label { + color: var(--text-primary) !important; + font-weight: 600 !important; +} + +.recharts-tooltip-item { + color: var(--text-secondary) !important; +} + +.spinner { + width: 20px; + height: 20px; + border: 2.5px solid rgba(255, 255, 255, 0.15); + border-top-color: var(--accent-blue); + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.score-ring { + transition: stroke-dashoffset 0.85s cubic-bezier(0.16, 1, 0.3, 1); +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-110%); + left: 0; + top: 0; + bottom: 0; + margin: 0; + border-radius: 0; + height: 100vh; + } + + .sidebar.open { + transform: translateX(0); + } + + :root { + --sidebar-width: 260px; + } +} + +/* Premium Breadcrumbs System */ +.breadcrumb { + display: flex !important; + flex-direction: row !important; + flex-wrap: wrap !important; + align-items: center !important; + gap: 8px !important; + padding: 0 !important; + margin: 0 0 24px 0 !important; + list-style: none !important; +} + +.breadcrumb li { + display: inline-flex !important; + align-items: center !important; + list-style: none !important; +} + +.breadcrumb-item { + color: var(--text-muted) !important; + font-size: 14.5px !important; + font-weight: 550 !important; + text-decoration: none !important; + transition: color 0.25s ease !important; +} + +.breadcrumb-item:hover { + color: var(--text-primary) !important; +} + +.breadcrumb-item-active { + color: var(--text-primary) !important; + font-size: 14.5px !important; + font-weight: 650 !important; +} + +.breadcrumb-separator { + color: var(--text-muted) !important; + opacity: 0.6 !important; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1a2dc201b46300d7dee40a9f6d78799e947c0bed --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { Toaster } from "react-hot-toast"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Smart Attendance System", + description: "AI-powered multi-layered smart attendance verification dashboard", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }): React.ReactElement { + return ( + + +