8 Commits

Author SHA1 Message Date
Yamen Ahmad
8df6804dcf chore(release): sync frontend from monorepo v4 2026-04-26 03:12:52 +04:00
devops
724edea349 ci: add auto-deploy workflow to staging
All checks were successful
Deploy Frontend to Staging / Deploy frontend to staging (push) Successful in 52s
2026-04-25 09:25:47 +02:00
d6413a0496 Merge pull request 'docs: update all SRS documents to reflect implemented state' (#2) from docs/update-srs-documents into main
Reviewed-on: #2
2026-04-06 11:02:40 +02:00
4383db7fa1 docs: update all SRS documents to reflect implemented state
- ENCOACH_UNIFIED_SRS.md v2.0: updated header, added 8 new Part VIII-B
  sections (student leave, fees, lessons, gradebook, student progress,
  library, activities, facilities), extended permissions with roles CRUD
  and authority matrix, updated tech specs (93 pages, ~377 API routes,
  41 modules), added implementation traceability to all sections
- ENCOACH_ODOO19_BACKEND_SRS.md v3.0: updated status to implemented,
  added beyond-SRS features section, updated module count to 41,
  endpoint count to ~377
- ODOO_DEVELOPER_HANDOFF.md: rewritten with current repo references
  and implementation status
- ENCOACH_SYSTEM_FEATURES_GUIDE.md: added system features guide
- Superseded notices added to ODOO_BACKEND_SRS_v3.md,
  ODOO_MIGRATION_SRS_v2.md, ODOO_MIGRATION_SRS.md,
  ODOO_MIGRATION_FRONTEND_SRS.md, MATH_IT_ADAPTIVE_LEARNING_SRS.md

Made-with: Cursor
2026-04-06 12:57:52 +04:00
6543081011 Merge feature/full-frontend-v1: add Dockerfile for staging build 2026-04-01 18:32:17 +04:00
41846a6eb6 Merge feature/full-frontend-v1 into main (initial frontend codebase by Yamen)
Made-with: Cursor
2026-04-01 18:18:52 +04:00
b04db0b06c chore: verify deployment hook
Made-with: Cursor
2026-03-30 19:41:30 +04:00
3ebcec2b43 chore: add docker-compose, .gitignore and README
Infrastructure files for staging deployment pipeline.
The post-receive hook on the staging server will build and
deploy automatically when PRs are merged to main.

Made-with: Cursor
2026-03-30 19:40:44 +04:00
300 changed files with 48369 additions and 4889 deletions

View File

@@ -1,3 +0,0 @@
# Relative URL: Vite proxies /api → Odoo :8069 (avoids "Failed to fetch" from CORS).
VITE_API_BASE_URL=/api
VITE_APP_NAME=EnCoach

View File

@@ -0,0 +1,57 @@
name: Deploy Frontend to Staging
# Triggered on every push to main.
# Syncs this repo's source into the backend monorepo's frontend/
# directory and rebuilds the encoach-frontend Docker container.
on:
push:
branches: [main]
concurrency:
group: deploy-frontend
cancel-in-progress: true
jobs:
deploy:
name: Deploy frontend to staging
runs-on: self-hosted
steps:
- name: Sync frontend source to backend repo
run: |
FRONTEND_SRC="/opt/encoach/encoach_frontend_new_v2"
FRONTEND_DEST="/opt/encoach/encoach_backend_new_v2/frontend"
# Keep a clean clone of this repo on the server
if [ -d "$FRONTEND_SRC/.git" ]; then
git -C "$FRONTEND_SRC" fetch origin
git -C "$FRONTEND_SRC" reset --hard origin/main
else
git clone http://localhost:3003/devops/encoach_frontend_new_v2.git "$FRONTEND_SRC"
fi
echo "Syncing to backend frontend/ dir..."
rsync -a --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'dist' \
--exclude 'docs' \
"$FRONTEND_SRC/" "$FRONTEND_DEST/"
echo "Latest commit: $(git -C $FRONTEND_SRC log -1 --oneline)"
- name: Rebuild frontend container
run: |
cd /opt/encoach/encoach_backend_new_v2
docker compose \
-f docker-compose.yml \
-f /opt/encoach/overrides/encoach.override.yml \
up -d --build frontend
docker ps --format "table {{.Names}}\t{{.Status}}" | grep encoach-frontend
- name: Smoke test
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
echo "frontend / => HTTP $STATUS"
test "$STATUS" = "200" || exit 1
echo "Deploy successful!"

14
.gitignore vendored
View File

@@ -4,9 +4,13 @@ node_modules/
# Build output # Build output
dist/ dist/
build/ build/
.next/
out/
# Environment # Environment / secrets — never commit
.env .env
.env.*
!.env.example
.env.local .env.local
.env.production .env.production
.env.*.local .env.*.local
@@ -25,9 +29,5 @@ Thumbs.db
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log*
# Testing yarn-error.log*
coverage/
# Vite
*.local

View File

@@ -18,13 +18,25 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Default nginx body cap is 1 MB which causes HTTP 413 on:
# * resource uploads (PDF / audio / video)
# * /api/exam/generation/submit (whole exam with generated questions)
# * /api/approval-requests (attachments)
# Match Odoo's raised limit in odoo-docker.conf (128 MB).
client_max_body_size 128m;
client_body_buffer_size 1m;
location /api/ { location /api/ {
proxy_pass http://backend:8069/api/; proxy_pass http://backend:8069/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # OpenAI streaming calls + multi-module AI generation can run >3 min.
# Keep read/send timeouts above Odoo's limit_time_real (900s).
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_request_buffering off;
} }
location / { location / {

View File

@@ -1,3 +1,24 @@
# EnCoach Frontend — v2
## Branching Workflow
This repo is connected to the staging server via a Git post-receive hook.
**All deployment is automatic — but only after code review approval.**
### How to contribute
1. Never push directly to `main` — branch protection will block it.
2. Push your changes to your feature branch (e.g. `feature/full-frontend-v1`)
3. Open a Pull Request on Gitea targeting `main`
4. Request review from **devops (Talal)**
5. Once approved and merged, the staging server rebuilds and redeploys automatically.
### Environment
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/frontend-v2/.env`.
---
# EnCoach — Adaptive Learning Platform (Frontend) # EnCoach — Adaptive Learning Platform (Frontend)
The frontend application for the EnCoach Adaptive Learning Platform, serving UTAS university students and freelance learners across IELTS, Mathematics, and IT courses. The frontend application for the EnCoach Adaptive Learning Platform, serving UTAS university students and freelance learners across IELTS, Mathematics, and IT courses.
@@ -115,4 +136,4 @@ Authentication uses JWT tokens stored in `localStorage`, with automatic 401 inte
## License ## License
Proprietary — EnCoach Platform. All rights reserved. Proprietary — EnCoach Platform. All rights reserved.

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
frontend:
build: .
image: encoach-frontend:latest
container_name: encoach-frontend
restart: unless-stopped
ports:
- "3000:3000"
env_file: .env

View File

@@ -1,9 +1,26 @@
# EnCoach Platform — Odoo 19 Backend SRS # EnCoach Platform — Odoo 19 Backend SRS
**Version:** 2.0 **Version:** 3.0
**Date:** March 2026 **Date:** March 11, 2026
**Status:** Definitive **Status:** Implemented — Staging Verified
**Purpose:** Complete backend specification for an Odoo 19 developer to build all required modules, models, and REST API endpoints for the EnCoach Adaptive Learning Platform. Includes courseware, communication, notification, FAQ, enhanced approval workflows, plagiarism detection, and official exam access modes. **Supersedes:** `ODOO_BACKEND_SRS_v3.md`, `ODOO_MIGRATION_SRS_v2.md`, `ODOO_MIGRATION_SRS.md`
**Frontend Reference:** `ENCOACH_UNIFIED_SRS.md` (v2.0)
**Purpose:** Complete backend specification for the EnCoach Adaptive Learning Platform on Odoo 19. All modules, models, and REST API endpoints have been implemented and deployed to staging.
### Implementation Status
| Artifact | Location |
|----------|----------|
| **Backend Repository** | `https://git.albousalh.com/devops/encoach_backend_new_v2.git` (branch: `main`) |
| **Frontend Repository** | `https://git.albousalh.com/devops/encoach_frontend_new_v2.git` (branch: `main`) |
| **Staging Backend (Odoo 19)** | `http://5.189.151.117:8069` |
| **Staging Frontend** | `http://5.189.151.117:3000` |
| **Custom EnCoach Modules** | 27 modules in `new_project/custom_addons/` |
| **OpenEduCat Modules** | 14 community modules in `new_project/openeducat_erp-19.0/` |
| **Total API Routes** | ~377 REST endpoints across 4 controller packages |
| **Deployment** | Docker Compose (Odoo 19 + PostgreSQL 16) |
**Note:** The developer implemented all features in this SRS plus additional OpenEduCat Enterprise features documented in Section 34 (Beyond-SRS Features).
--- ---
@@ -42,6 +59,7 @@
31. [Official Exam Access](#31-official-exam-access) 31. [Official Exam Access](#31-official-exam-access)
32. [Non-Functional Requirements](#32-non-functional-requirements) 32. [Non-Functional Requirements](#32-non-functional-requirements)
33. [Implementation Priority](#33-implementation-priority) 33. [Implementation Priority](#33-implementation-priority)
34. [Beyond-SRS Features (Implemented)](#34-beyond-srs-features-implemented)
--- ---
@@ -174,7 +192,22 @@ All OpenEduCat models need `to_api_dict()` methods on the `encoach_lms_api` inhe
| 34 | `encoach_notification` | Custom | Notification engine, configurable rules, email/in-app delivery, user preferences | | 34 | `encoach_notification` | Custom | Notification engine, configurable rules, email/in-app delivery, user preferences |
| 35 | `encoach_faq` | Custom | FAQ categories and items, role-filtered, searchable | | 35 | `encoach_faq` | Custom | FAQ categories and items, role-filtered, searchable |
**Total: 8 OpenEduCat (ported) + 27 custom = 35 modules** **Original plan: 8 OpenEduCat + 27 custom = 35 modules**
### 4.2 Additional Modules (Implemented Beyond SRS)
The developer added 6 more OpenEduCat community modules:
| # | Module | Type | Description |
|---|--------|------|-------------|
| 36 | `openeducat_fees` | OpenEduCat (port v19) | Fee plans, student fees, fee terms |
| 37 | `openeducat_library` | OpenEduCat (port v19) | Media catalog, movements, library cards |
| 38 | `openeducat_activity` | OpenEduCat (port v19) | Student activities, activity types |
| 39 | `openeducat_parent` | OpenEduCat (port v19) | Parent-student relationships |
| 40 | `openeducat_erp` | OpenEduCat (port v19) | ERP connector meta-module |
| 41 | `theme_web_openeducat` | OpenEduCat (port v19) | Web theme |
**Deployed total: 14 OpenEduCat + 27 custom = 41 modules**
--- ---
@@ -1621,78 +1654,245 @@ Add to `encoach.exam`:
--- ---
## 33. Implementation Priority ## 33. Implementation Status
| Priority | Modules | Rationale | All modules have been implemented and deployed. The original priority ordering is preserved for reference.
|----------|---------|-----------|
| **P0** | `encoach_core`, `encoach_api` (auth, users, entities, permissions) | Foundation — blocks everything | | Priority | Modules | Status |
| **P0** | `openeducat_core` (port to v19), `encoach_lms_api` (courses, batches, basic serialization) | LMS foundation | |----------|---------|--------|
| **P1** | `encoach_taxonomy`, `encoach_adaptive`, `encoach_adaptive_api`, `encoach_adaptive_ai` | Core adaptive learning loop | | **P0** | `encoach_core`, `encoach_api` (auth, users, entities, permissions) | **DONE** |
| **P1** | `encoach_exam`, `encoach_ai_generation`, `encoach_ai_grading` | Exam engine | | **P0** | `openeducat_core` (port to v19), `encoach_lms_api` | **DONE** |
| **P1** | `encoach_resources` | Learning content | | **P1** | `encoach_taxonomy`, `encoach_adaptive`, `encoach_adaptive_api`, `encoach_adaptive_ai` | **DONE** |
| **P2** | `encoach_courseware` (chapters, materials, progress, AI workbench) | Structured course delivery | | **P1** | `encoach_exam`, `encoach_ai_generation`, `encoach_ai_grading` | **DONE** |
| **P2** | `encoach_communication` (discussions, announcements, messaging) | Collaboration | | **P1** | `encoach_resources` | **DONE** |
| **P2** | `openeducat_timetable`, `openeducat_attendance`, `openeducat_classroom` (port to v19) | LMS timetable/attendance | | **P2** | `encoach_courseware` (chapters, materials, progress, AI workbench) | **DONE** |
| **P2** | `openeducat_admission` (port to v19) + admission endpoints | Enrollment workflow | | **P2** | `encoach_communication` (discussions, announcements, messaging) | **DONE** |
| **P2** | Academic years/terms, departments endpoints | Institutional calendar | | **P2** | `openeducat_timetable`, `openeducat_attendance`, `openeducat_classroom` | **DONE** |
| **P2** | `encoach_ai`, `encoach_ai_media` | AI services + media | | **P2** | `openeducat_admission` + admission endpoints | **DONE** |
| **P2** | `encoach_assignment`, `encoach_classroom` | Assignments + groups | | **P2** | Academic years/terms, departments endpoints | **DONE** |
| **P3** | `encoach_notification` (rules, email delivery, preferences) | User engagement | | **P2** | `encoach_ai`, `encoach_ai_media` | **DONE** |
| **P3** | Enhanced approval workflows (stages, escalation, bypass) | Governance | | **P2** | `encoach_assignment`, `encoach_classroom` | **DONE** |
| **P3** | Plagiarism detection (GPTZero integration) | Academic integrity | | **P3** | `encoach_notification` (rules, email delivery, preferences) | **DONE** |
| **P3** | Official exam access modes (link, landing, separate login) | Exam security | | **P3** | Enhanced approval workflows (stages, escalation, bypass) | **DONE** |
| **P3** | Late submission handling, extension requests | Assignment workflow | | **P3** | Plagiarism detection (GPTZero integration) | **DONE** |
| **P3** | `openeducat_exam` (port to v19) + institutional exam endpoints | Institutional assessment | | **P3** | Official exam access modes (link, landing, separate login) | **DONE** |
| **P3** | `openeducat_assignment` (port to v19) + submission endpoints | Homework workflow | | **P3** | Late submission handling, extension requests | **DONE** |
| **P3** | Subject registration endpoints | Student subject selection | | **P3** | `openeducat_exam` + institutional exam endpoints | **DONE** |
| **P3** | `encoach_training`, analytics endpoints | Training + analytics | | **P3** | `openeducat_assignment` + submission endpoints | **DONE** |
| **P3** | `encoach_sis`, `encoach_branding` | University integration | | **P3** | Subject registration endpoints | **DONE** |
| **P4** | `encoach_faq` (categories, items, role-filtered) | User support | | **P3** | `encoach_training`, analytics endpoints | **DONE** |
| **P4** | `encoach_subscription` (Stripe, PayPal, Paymob) | Payments | | **P3** | `encoach_sis`, `encoach_branding` | **DONE** |
| **P4** | `encoach_ticket` | Support | | **P4** | `encoach_faq` (categories, items, role-filtered) | **DONE** |
| **P4** | Storage | Operational features | | **P4** | `encoach_subscription` (Stripe, PayPal, Paymob) | **DONE** |
| **P4** | `encoach_ticket` | **DONE** |
| **P4** | Storage | **DONE** |
--- ---
## Appendix A: Complete Endpoint Summary ## 34. Beyond-SRS Features (Implemented)
Total API endpoints: **280+** The developer implemented the following features beyond the original SRS scope. These leverage OpenEduCat Enterprise modules and add significant institutional management capabilities.
| Service Area | Endpoint Count | ### 34.1 Student Leave Management
|-------------|---------------|
| Auth & Users | 10 | **Module:** `openeducat_student_leave_enterprise` (via `encoach_lms_api`)
| Entities & Permissions | 9 | **Controller:** `encoach_lms_api/controllers/student_leave.py`
| LMS (Courses, Batches, Timetable, Attendance, Grades) | 16 | **Frontend:** `student-leave.service.ts` | `AdminStudentLeave.tsx`
| Academic Years, Terms, Departments | 14 |
| Admissions | 12 | | Method | Path | Description |
| Subject Registration | 6 | |--------|------|-------------|
| EnCoach Exams (AI) | 15 | | `GET` | `/api/student-leaves` | List leave requests |
| Institutional Exams | 21 | | `POST` | `/api/student-leaves` | Create leave request |
| EnCoach Assignments | 7 | | `PATCH` | `/api/student-leaves/{id}` | Update leave request |
| Course Assignments & Submissions | 11 | | `POST` | `/api/student-leaves/{id}/approve` | Approve request |
| Classrooms & Groups | 7 | | `POST` | `/api/student-leaves/{id}/reject` | Reject request |
| Taxonomy | 16 | | `GET` | `/api/student-leave-types` | List leave types |
| Adaptive Learning | 17 | | `POST` | `/api/student-leave-types` | Create leave type |
| Resources | 7 |
| AI Coaching | 6 | ### 34.2 Fees Management
| AI Evaluation | 4 |
| AI Generation | 2 | **Module:** `openeducat_fees` (via `encoach_lms_api`)
| AI Media | 3 | **Controller:** `encoach_lms_api/controllers/fees.py`
| AI Analytics | 6 | **Frontend:** `fees.service.ts` | `AdminFees.tsx`
| Training | 3 |
| Statistics | 4 | | Method | Path | Description |
| Subscriptions & Payments | 8 + 3 webhooks | |--------|------|-------------|
| Tickets | 4 | | `GET` | `/api/fees-plans` | List fee plans |
| Storage | 2 | | `GET` | `/api/fees-plans/{id}` | Fee plan detail |
| Approval Workflows (Enhanced) | 11 | | `GET` | `/api/student-fees` | Student fee details |
| **Courseware (Chapters + Materials + Workbench)** | **23** | | `GET` | `/api/fees-terms` | Fee terms |
| **Discussion Boards + Posts** | **9** |
| **Announcements** | **5** | ### 34.3 Lessons Management
| **Messaging** | **6** |
| **Notifications + Rules + Preferences** | **10** | **Module:** `openeducat_lesson` (via `encoach_lms_api`)
| **FAQ (Categories + Items)** | **8** | **Controller:** `encoach_lms_api/controllers/lessons.py`
| **Plagiarism Detection** | **3** | **Frontend:** `lesson.service.ts` | `AdminLessons.tsx`
| **Official Exam Access** | **5** |
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/lessons` | List lessons |
| `POST` | `/api/lessons` | Create lesson |
| `PATCH` | `/api/lessons/{id}` | Update lesson |
| `DELETE` | `/api/lessons/{id}` | Delete lesson |
### 34.4 Gradebook and Grading Assignments
**Module:** `openeducat_grading` (via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/gradebook.py`
**Frontend:** `gradebook.service.ts` | `AdminGradebook.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/gradebooks` | List gradebooks |
| `GET` | `/api/gradebook-lines` | Gradebook lines |
| `GET` | `/api/grading-assignments` | List grading assignments |
| `POST` | `/api/grading-assignments` | Create grading assignment |
| `PATCH` | `/api/grading-assignments/{id}` | Update grading assignment |
| `DELETE` | `/api/grading-assignments/{id}` | Delete grading assignment |
### 34.5 Student Progress Tracking
**Module:** `openeducat_student_progress_enterprise` (via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/student_progress.py`
**Frontend:** `student-progress.service.ts` | `AdminStudentProgress.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/student-progress` | List student progression records |
### 34.6 Library Management
**Module:** `openeducat_library` (via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/library.py`
**Frontend:** `library.service.ts` | `AdminLibrary.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/library/media` | List media items |
| `POST` | `/api/library/media` | Create media item |
| `DELETE` | `/api/library/media/{id}` | Delete media item |
| `GET` | `/api/library/movements` | List movements |
| `POST` | `/api/library/movements` | Create movement (issue) |
| `PATCH` | `/api/library/movements/{id}` | Update movement |
| `POST` | `/api/library/movements/{id}/return` | Return media |
| `GET` | `/api/library/cards` | List library cards |
| `POST` | `/api/library/cards` | Create library card |
### 34.7 Activity Management
**Module:** `openeducat_activity` (via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/activities.py`
**Frontend:** `activity.service.ts` | `AdminActivities.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/activities` | List activities |
| `POST` | `/api/activities` | Create activity |
| `PATCH` | `/api/activities/{id}` | Update activity |
| `DELETE` | `/api/activities/{id}` | Delete activity |
| `GET` | `/api/activity-types` | List activity types |
| `POST` | `/api/activity-types` | Create activity type |
| `DELETE` | `/api/activity-types/{id}` | Delete activity type |
### 34.8 Facility and Asset Management
**Module:** `openeducat_facility` (extended via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/facilities.py`
**Frontend:** `facility.service.ts` | `AdminFacilities.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/facilities` | List facilities |
| `POST` | `/api/facilities` | Create facility |
| `PATCH` | `/api/facilities/{id}` | Update facility |
| `DELETE` | `/api/facilities/{id}` | Delete facility |
| `GET` | `/api/assets` | List assets |
| `POST` | `/api/assets` | Create asset |
| `DELETE` | `/api/assets/{id}` | Delete asset |
### 34.9 Roles CRUD, User Role Assignment, and Authority Matrix
**Module:** `encoach_core` (extended)
**Controller:** `encoach_api/controllers/roles.py`
**Frontend:** `entities.service.ts` | `RolesPermissions.tsx`, `UserRoles.tsx`, `AuthorityMatrix.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/roles` | List all roles |
| `POST` | `/api/roles` | Create role |
| `GET` | `/api/roles/{id}` | Get role detail |
| `PATCH` | `/api/roles/{id}` | Update role |
| `DELETE` | `/api/roles/{id}` | Delete role |
| `PATCH` | `/api/roles/{id}/permissions` | Update role permissions |
| `GET` | `/api/permissions` | List all permissions |
| `POST` | `/api/permissions` | Create permission |
| `DELETE` | `/api/permissions/{id}` | Delete permission |
| `GET` | `/api/users/with-roles` | List users with roles |
| `GET/PATCH` | `/api/users/{id}/roles` | Get/update user roles |
| `POST` | `/api/users/{id}/roles/toggle` | Toggle role for user |
| `GET` | `/api/authority-matrix` | Get role-permission matrix |
| `POST` | `/api/authority-matrix/toggle` | Toggle a matrix cell |
| `GET` | `/api/user-authority-matrix` | Get user-level authority |
---
## Appendix A: Complete Endpoint Summary (Verified Against Deployment)
Total API endpoints: **~377** (deployed across 4 controller packages)
| Controller Package | Route Count | Service Areas |
|-------------------|-------------|---------------|
| `encoach_api` | ~120 | Auth, users, entities, roles, exams, assignments, classrooms, storage, stats, subscriptions, tickets, approvals, training, generation, AI analytics |
| `encoach_lms_api` | ~209 | Courses, students, teachers, batches, timetable, attendance, grades, departments, academic, admissions, subject registration, institutional exams, course assignments, student leave, fees, lessons, gradebook, student progress, library, activities, facilities, notifications, FAQ, courseware, plagiarism, communication, classrooms |
| `encoach_adaptive_api` | ~41 | Taxonomy (subjects, domains, topics), diagnostic, proficiency, learning plans, content, coaching |
| `encoach_resources` | ~7 | Learning resources (CRUD, download, complete, rate) |
**Breakdown by Service Area:**
| Service Area | Endpoint Count | Status |
|-------------|---------------|--------|
| Auth & Users | 10 | Deployed |
| Entities & Permissions | 9 | Deployed |
| Roles CRUD & Authority Matrix | 12 | **Deployed (Beyond SRS)** |
| LMS (Courses, Batches, Timetable, Attendance, Grades) | 16 | Deployed |
| Academic Years, Terms, Departments | 14 | Deployed |
| Admissions | 12 | Deployed |
| Subject Registration | 6 | Deployed |
| EnCoach Exams (AI) | 15 | Deployed |
| Institutional Exams | 21 | Deployed |
| EnCoach Assignments | 7 | Deployed |
| Course Assignments & Submissions | 11 | Deployed |
| Classrooms & Groups | 7 | Deployed |
| Taxonomy | 16 | Deployed |
| Adaptive Learning | 17 | Deployed |
| Resources | 7 | Deployed |
| AI Coaching | 6 | Deployed |
| AI Evaluation | 4 | Deployed |
| AI Generation | 2 | Deployed |
| AI Media | 3 | Deployed |
| AI Analytics | 6 | Deployed |
| Training | 3 | Deployed |
| Statistics | 4 | Deployed |
| Subscriptions & Payments | 8 + 3 webhooks | Deployed |
| Tickets | 4 | Deployed |
| Storage | 2 | Deployed |
| Approval Workflows (Enhanced) | 11 | Deployed |
| Courseware (Chapters + Materials + Workbench) | 23 | Deployed |
| Discussion Boards + Posts | 9 | Deployed |
| Announcements | 5 | Deployed |
| Messaging | 6 | Deployed |
| Notifications + Rules + Preferences | 10 | Deployed |
| FAQ (Categories + Items) | 8 | Deployed |
| Plagiarism Detection | 3 | Deployed |
| Official Exam Access | 5 | Deployed |
| Student Leave Management | 7 | **Deployed (Beyond SRS)** |
| Fees Management | 4 | **Deployed (Beyond SRS)** |
| Lessons Management | 4 | **Deployed (Beyond SRS)** |
| Gradebook & Grading | 6 | **Deployed (Beyond SRS)** |
| Student Progress | 1 | **Deployed (Beyond SRS)** |
| Library Management | 9 | **Deployed (Beyond SRS)** |
| Activity Management | 7 | **Deployed (Beyond SRS)** |
| Facility & Asset Management | 7 | **Deployed (Beyond SRS)** |
--- ---
@@ -1756,4 +1956,4 @@ res.users ──extends──> encoach.user
--- ---
*This SRS is the definitive backend specification for the EnCoach platform on Odoo 19. It covers all 280+ REST API endpoints expected by the React frontend, 35 Odoo modules (8 OpenEduCat + 27 custom), and all AI service integrations.* *This SRS is the definitive backend specification for the EnCoach platform on Odoo 19. All ~377 REST API endpoints, 41 Odoo modules (14 OpenEduCat + 27 custom), and all AI service integrations have been implemented and deployed to staging at `http://5.189.151.117:8069`. All previous backend SRS documents (`ODOO_BACKEND_SRS_v3.md`, `ODOO_MIGRATION_SRS_v2.md`, `ODOO_MIGRATION_SRS.md`) are superseded by this document.*

View File

@@ -0,0 +1,308 @@
# EnCoach Platform -- System Features Guide
**Version:** 1.0
**Date:** March 2026
**Purpose:** Describe every section and feature accessible through the platform navigation, organized by user role.
---
## Part 1: Administrator Panel
The Administrator Panel is the central management hub for the entire platform. It is accessible to users with roles: Admin, Corporate, Master Corporate, Agent, and Developer.
---
### 1. Overview
The Overview section provides high-level dashboards that give administrators an at-a-glance view of the entire platform's health and activity.
| Menu Item | Description |
|-----------|-------------|
| **Admin Dashboard** | The LMS-focused dashboard showing real-time counts of courses, students, teachers, batches, and enrollment status. Displays course capacity charts, student status distribution, and recent activity. |
| **Platform Dashboard** | The original platform dashboard focused on exam and assignment activity. Shows exam creation statistics, assignment completion rates, user activity trends, and entity-level performance summaries. |
---
### 2. LMS
The LMS (Learning Management System) section is the core of institutional course management. It handles the full lifecycle of courses, students, teachers, and class scheduling.
| Menu Item | Description |
|-----------|-------------|
| **Courses** | Create, view, and manage courses. Each course has a title, code, description, maximum capacity, status (draft/active/archived), and is linked to a department. Administrators can view enrollment counts and manage course settings. |
| **Students** | Manage student records. Create new students (which automatically creates a portal user account), view student lists with filters, export student data, and manage individual student profiles including their course enrollments. |
| **Teachers** | Manage teacher records. Create teacher profiles with specialization areas, link them to portal user accounts, assign them to courses and batches, and view their teaching schedules. |
| **Batches** | Manage class batches (sections/cohorts). Each batch belongs to a course and has a date range, capacity, and enrolled students. Administrators can view batch details, manage student enrollment per batch, and track batch capacity. |
| **Lessons** | Create and manage individual lessons linked to a course, batch, and subject. Each lesson has a faculty member, date, and duration. Used to organize the day-to-day teaching schedule within a course. |
| **Timetable** | Visual timetable management with a day/time grid. Create, view, and delete teaching sessions. Sessions are linked to courses, batches, subjects, and classrooms with specific day and time slots. |
| **Reports** | LMS-specific reporting dashboards showing course enrollment trends, batch capacity utilization, student status distributions, teacher workload, and overall LMS usage analytics. |
---
### 3. Adaptive Learning
The Adaptive Learning section manages the AI-powered personalized learning engine. It controls the knowledge structure that drives diagnostic tests, proficiency tracking, and personalized learning plans.
| Menu Item | Description |
|-----------|-------------|
| **Taxonomy** | Manage the subject knowledge hierarchy: Subjects (e.g., English, Math, IT) contain Domains, which contain Topics, which contain Learning Objectives. This taxonomy drives the adaptive engine -- diagnostic tests assess proficiency at the topic level, and learning plans are generated based on this structure. Administrators can create subjects, add domains/topics manually, or use AI to suggest topic structures. |
| **Resources** | Manage the learning resource library. Upload educational materials (PDFs, videos, links, documents, interactive content) and tag them to specific topics in the taxonomy. Resources go through a review workflow (pending, approved, rejected) and are served to students through their personalized learning plans. Track resource downloads, completion, and ratings. |
---
### 4. Institutional
The Institutional section handles university-level academic administration -- the calendar, departments, enrollment pipeline, formal exam sessions, grading, and student lifecycle.
| Menu Item | Description |
|-----------|-------------|
| **Academic Years** | Define academic years with start and end dates. Auto-generate academic terms (semesters/quarters) within each year. Academic years organize all institutional activities -- courses, batches, exams, and grades are all scoped to an academic year. |
| **Departments** | Manage the department hierarchy with parent-child relationships. Departments organize courses and faculty. Each department can have sub-departments, a head of department, and associated courses. |
| **Admissions** | View and process individual student admission applications. Each admission goes through a workflow: submitted, confirmed, admitted, or rejected. Administrators can review application details, update status, and convert admitted applicants into enrolled students. |
| **Admission Register** | Manage admission campaigns/registers. Each register defines an admission period (open/closed), linked course, available seats, and start/end dates. Registers go through a workflow: create, confirm (open for applications), close (stop accepting). |
| **Exam Sessions** | Schedule and manage institutional exam sessions. Each session is linked to a course, batch, and academic term. Sessions go through a workflow: draft, scheduled, in progress, done. Manage exam rooms, attendees, and timing within each session. |
| **Marksheets** | Generate and validate student marksheets (grade reports). Marksheets aggregate student scores across exams within a session, apply grade configurations and result templates, and compute pass/fail status. Supports validation workflow before official release. |
| **Student Leave** | Manage student leave requests. Students submit leave requests with reasons and dates; administrators or teachers can approve or reject them. Tracks leave history per student. |
| **Fees** | View fee plans, fee terms, and individual student fee records. Manage fee structures attached to courses, including installment plans and payment tracking. |
| **Gradebook** | View and manage the institutional grading system. Displays grades per student, per course, and per academic year. Supports grading assignment types, weighted grading, and grade configuration (letter grades, percentage scales). |
| **Student Progress** | Track longitudinal student progress across courses, subjects, and academic terms. Provides a historical view of student performance, showing trends and areas that need attention. |
---
### 5. Academic
The Academic section manages the AI-powered exam engine -- creating exams, defining grading rubrics, generating content with AI, and managing the approval workflow for publishing exams.
| Menu Item | Description |
|-----------|-------------|
| **Assignments** | Create and manage exam-linked assignments. Each assignment wraps an exam and assigns it to specific students or classrooms with deadlines. Supports tabs for Active, Planned, Past, and Archived assignments. Includes late submission policies, extension requests, penalty configuration, and reminder settings. |
| **Exams List** | Browse, search, and manage all exams in the system. Filter by subject, module (Reading, Writing, Listening, Speaking), and difficulty. Each exam contains exercises, passages, timer settings, and access controls (public/private). Supports both practice exercises and official exams. |
| **Exam Structures** | Define exam blueprints (templates) that specify the section layout of exams -- how many sections, what type of questions in each, difficulty distribution, and time allocation per section. Structures are reusable across multiple exams. |
| **Rubrics** | Create and manage grading rubrics that define the criteria for evaluating student responses. Rubrics specify scoring dimensions (e.g., Task Achievement, Coherence, Grammar for IELTS writing). Rubric groups organize related rubrics. Used by both human graders and the AI grading engine. |
| **Generation** | The AI exam generation interface. Select a subject, module, and topic; configure difficulty and question types; and let the AI (GPT-4o) generate complete exams with passages, questions, and answer keys. Supports generation from scratch or from existing content. |
| **Approval Workflows** | Manage the approval process for publishing exams and course materials. Configure multi-stage workflows with specific approvers, time limits per stage, auto-escalation rules, and bypass options. Track approval status (pending, approved, rejected, escalated) with comments through each stage. |
---
### 6. Management
The Management section handles platform-wide user, organization, and infrastructure management.
| Menu Item | Description |
|-----------|-------------|
| **Users** | Full user management. Create, update, search, filter, and export users. View user details including role, entity, verification status, and activity history. Supports batch user import from spreadsheets. |
| **Entities** | Manage organizations (multi-tenant). Each entity is an institution or company using the platform. Configure entity settings, manage entity-specific roles and permissions, generate invite codes for onboarding, and set per-entity branding (logo, colors). |
| **Classrooms** | Manage virtual and physical classroom groups. Create classrooms with capacity tracking, assign students to classrooms, and use classrooms for assigning exams and assignments to groups of students. |
| **Roles & Permissions** | Create and manage roles within entities. Each role has a name and a set of granular permissions. Administrators can create custom roles, assign/remove permissions from roles, and manage the permission catalog. |
| **Authority Matrix** | A cross-reference visualization of all roles and their permissions. Displayed as an interactive grid where rows are roles and columns are permissions. Administrators can toggle permissions on/off directly from the matrix view. |
| **User Roles** | Assign roles to individual users. View all users with their current role assignments, search and filter, and assign or remove roles. Supports user-level permission overrides beyond their assigned role. |
---
### 7. Reports
The Reports section provides analytical views of platform performance and student outcomes.
| Menu Item | Description |
|-----------|-------------|
| **Student Performance** | Detailed performance analytics per student. Shows exam scores, assignment completion rates, proficiency levels across subjects, and trends over time. Supports filtering by entity, course, and date range. |
| **Stats Corporate** | Entity-level statistics for corporate/institutional managers. Shows aggregated data across all students within an entity -- enrollment counts, average scores, completion rates, and comparative performance across courses. |
| **Record** | Historical activity records. Browse past exam sessions, assignment submissions, and user activity logs. Supports searching and filtering by date, user, and activity type. |
---
### 8. Configuration
The Configuration section manages platform-wide settings for notifications, FAQs, and approval processes.
| Menu Item | Description |
|-----------|-------------|
| **FAQ Manager** | Create and manage Frequently Asked Questions. Organize FAQs into categories, set target audience per item (student, entity, or both), add rich content including text, images, and video links. FAQs are displayed on a public searchable page with accordion-style expand/collapse. |
| **Notification Rules** | Configure automated notification triggers. Define rules based on event types (assignment due, exam due, chapter unlock, result release), set timing (how many days before the event), frequency (once, daily, custom intervals), and delivery channel (in-app, email, or both). |
| **Approval Config** | Configure the enhanced approval workflow system. Define multi-stage approval templates with specific approvers per stage, maximum days before auto-escalation, notification emails, and bypass permissions. Applied to exam publishing and course material approval. |
---
### 9. Training
The Training section provides reference materials for language learning support.
| Menu Item | Description |
|-----------|-------------|
| **Vocabulary** | Browse vocabulary training content. Displays categorized vocabulary lists, definitions, usage examples, and related exercises. Linked to the FAISS-based recommendation engine for contextual suggestions. |
| **Grammar** | Browse grammar training content. Displays grammar rules, examples, common mistakes, and practice exercises organized by topic and difficulty level. |
---
### 10. Support
The Support section handles financial tracking, user support, and system settings.
| Menu Item | Description |
|-----------|-------------|
| **Payment Record** | View payment history and transaction records. Shows all subscription payments, amounts, providers (Stripe/PayPal/Paymob), status, and associated packages. Supports filtering by date, user, and payment status. |
| **Tickets** | Manage support tickets. View all tickets submitted by users, assign tickets to support agents, update ticket status (open, in progress, resolved, closed), and communicate with ticket reporters. |
| **Settings** | Platform-wide settings management. Configure system parameters, AI service settings (API keys stored securely), default behaviors, and platform-level preferences. |
---
### 11. Other Admin Pages (Accessible but not in main sidebar)
| Page | Description |
|------|-------------|
| **Library** | Manage the institutional library. CRUD operations for library media (books, journals, digital resources), track media movements (issue, return), and manage student library cards. |
| **Activities** | Log and track student activities. Define activity types and record student participation in extracurricular or academic activities outside the standard course structure. |
| **Facilities** | Manage institutional facilities and assets. Track rooms, equipment, and other physical resources used for teaching and examinations. |
---
## Part 2: Teacher Panel
The Teacher Panel provides instructors with tools to manage their courses, create content, track student progress, and communicate with their classes.
---
### 1. Teaching
The Teaching section is the teacher's primary workspace for delivering courses and managing assignments.
| Menu Item | Description |
|-----------|-------------|
| **Dashboard** | The teacher's home screen showing an overview of their assigned courses, upcoming assignments, recent student submissions, and quick statistics (number of students, pending grading, upcoming sessions). |
| **Courses** | View and manage assigned courses. From here, teachers can access three key sub-pages per course: **Chapters** (organize course content into sequential chapters with materials, set unlock dates, and control access), **Chapter Detail** (upload and manage materials within a chapter -- PDFs, videos, audio, images, links -- with download permission controls), and **AI Workbench** (generate course content using AI by inputting topic, objectives, and complexity level; review and publish AI-generated chapters, materials, exercises, and rubrics). |
| **Assignments** | Create and manage assignments for classes. Define deadlines, late submission policies, penalty structures, and reminder frequencies. View assignment submissions, check plagiarism reports, grade with AI assistance, and release results. Drill into individual assignments to see per-student submission status and grading. |
---
### 2. Management
The Management section gives teachers tools to track student attendance and schedules.
| Menu Item | Description |
|-----------|-------------|
| **Attendance** | Take and manage attendance for classes. Mark students as present, absent, or late for each session. View attendance history and trends per batch. |
| **Students** | View the list of students enrolled in the teacher's courses and batches. Access individual student profiles and performance summaries. |
| **Timetable** | View the teacher's personal teaching schedule in a visual day/time grid format. Shows all assigned sessions across courses and batches. |
---
### 3. Communication
The Communication section enables teacher-student interaction and class announcements.
| Menu Item | Description |
|-----------|-------------|
| **Discussions** | View and manage discussion boards for courses. Teachers can enable/disable discussion boards per course or chapter, create discussion threads, reply to student questions, pin important posts, and mark questions as resolved. Supports file attachments. |
| **Announcements** | Create and broadcast announcements to classes. Set priority (normal, important, urgent), target specific courses or batches, optionally send via email in addition to in-app notification. Manage drafts and publish when ready. |
---
### 4. Account
| Menu Item | Description |
|-----------|-------------|
| **Profile** | View and update personal profile information (name, email, specialization, contact details). |
---
## Part 3: Student Panel
The Student Panel provides learners with access to their courses, adaptive learning engine, progress tracking, and communication tools.
---
### 1. Learning
The Learning section is the student's primary workspace for accessing courses and completing assignments.
| Menu Item | Description |
|-----------|-------------|
| **Dashboard** | The student's home screen showing enrolled subjects, upcoming deadlines (assignments, quizzes, exams), recent announcements, personal progress statistics, and AI study tips. Includes an AI coaching assistant for on-demand help. |
| **My Courses** | Browse all enrolled courses. Each course shows its chapters, completion progress, and available materials. Students can navigate into individual courses to see the chapter structure, access unlocked chapter content, download materials (when permitted by the teacher), and track their progress through the course. |
| **Subject Registration** | Register for available subjects within the current academic term. View available subjects, prerequisites, and capacity. Submit registration requests for subjects the student wants to take. |
| **Assignments** | View all active, upcoming, and past assignments. Submit completed work through the platform, view deadlines and submission status, request deadline extensions (when allowed), track submission history, and receive AI-generated feedback and plagiarism reports. |
---
### 2. Adaptive Learning
The Adaptive Learning section provides AI-powered personalized education that adapts to each student's level.
| Menu Item | Description |
|-----------|-------------|
| **My Subjects** | The entry point for the adaptive learning engine. Displays all subjects the student is studying with their overall mastery level. From here, students can: take a **Diagnostic Test** (AI-administered assessment to determine initial proficiency across topics), view their **Proficiency Profile** (detailed breakdown of mastery per topic within a subject), access their **Learning Plan** (AI-generated personalized study path with ordered topics based on proficiency gaps), and study individual **Topics** (AI-curated learning content with practice exercises and mastery quizzes that adapt difficulty based on performance). |
---
### 3. Progress
The Progress section shows academic performance, attendance, and the overall learning journey.
| Menu Item | Description |
|-----------|-------------|
| **Grades** | View grades for all courses, assignments, and exams. Shows current marks, feedback from teachers, and detailed score breakdowns by grading criteria. |
| **Attendance** | View personal attendance records across all enrolled courses. Shows attendance percentage, absent dates, and any leave requests with their approval status. |
| **Timetable** | View personal class schedule in a visual day/time grid. Shows all upcoming sessions with course, subject, teacher, and room information. |
| **My Journey** | A longitudinal progress timeline showing the student's overall academic journey. Displays enrolled courses with completion percentages, per-subject proficiency trends, and a recent activity timeline (exams taken, assignments submitted, chapters completed). Provides a holistic view of growth over time. |
---
### 4. Communication
The Communication section enables students to interact with teachers and classmates.
| Menu Item | Description |
|-----------|-------------|
| **Discussions** | Participate in course and chapter discussion boards. Create new discussion threads, reply to existing threads (nested replies), ask questions about assignments or course content, and view pinned/resolved posts. Students can see other participants within the same class. |
| **Messages** | Direct messaging between users. View inbox (with read/unread indicators and unread count badge), send messages to teachers or classmates within the same class, attach files, and optionally send an email copy. Includes a sent messages view. |
| **Announcements** | View announcements from teachers and system administrators. Announcements are displayed with priority indicators (urgent, important, normal) and sorted by date. |
---
### 5. Account
| Menu Item | Description |
|-----------|-------------|
| **Profile** | View and update personal profile information (name, email, contact details, preferences). |
---
## Part 4: Public Pages (No Login Required)
These pages are accessible without authentication.
| Page | Description |
|------|-------------|
| **Login** | User authentication with email and password. Redirects to the appropriate dashboard based on user role after successful login. |
| **Register** | New user registration. Supports open registration or invite-code-based registration to join a specific entity with a predefined role. |
| **Forgot Password** | Password recovery via email. Users enter their email to receive a verification link for password reset. |
| **Admission Application** | Public admission form for prospective students. Users can apply to an open admission register without logging in, providing personal details and required documents. They can check application status afterward. |
| **FAQ** | Public Frequently Asked Questions page. Searchable, accordion-style display of FAQ items organized by category. Content is filtered based on the user's role (student or entity) if logged in, showing all items if accessed publicly. |
| **Official Exam Access** | Special exam access pages for official/proctored exams. Supports three modes: (1) token-based link access where students open a unique URL, (2) landing page access with an exam code, and (3) a dedicated exam login screen isolated from the rest of the platform. |
---
## Part 5: AI Features (Embedded Across the Platform)
AI capabilities are embedded throughout the platform rather than being a standalone section. Here is where AI appears:
| Feature | Where It Appears | What It Does |
|---------|-----------------|-------------|
| **AI Study Coach** | Student Dashboard | Chat-based coaching assistant that provides hints, concept explanations, writing help, and study tips. |
| **AI Exam Generation** | Admin > Generation, Teacher > AI Workbench | Generates complete exams (passages, questions, answer keys) from subject taxonomy using GPT-4o. |
| **AI Grading** | Admin > Assignments, Teacher > Assignments | Automatically grades writing (rubric-based IELTS band scoring), speaking (transcription + evaluation), math (step evaluation), and IT (code evaluation) using GPT-4o. |
| **AI Course Content** | Teacher > AI Workbench | Generates course outlines, chapter content, exercises, and grading rubrics from high-level requirements. |
| **AI Diagnostics** | Student > My Subjects | Administers adaptive diagnostic tests that adjust question difficulty based on responses to accurately assess proficiency. |
| **AI Learning Plans** | Student > My Subjects | Generates personalized learning plans based on diagnostic results and proficiency gaps. |
| **AI Content Recommendations** | Student > Topic Learning | Suggests additional learning materials based on student performance and learning history using FAISS similarity search. |
| **AI Plagiarism Detection** | Teacher > Assignments, Admin > Assignments | Analyzes submitted text using GPTZero to detect AI-generated content, providing per-sentence analysis and downloadable reports. |
| **AI Search** | Admin Header Bar | Semantic search across the platform using AI. |
| **AI Analytics** | Admin > Reports | Generates narrative reports, identifies content gaps, and provides batch optimization suggestions. |
| **AI Tip Banner** | Student Dashboard | Context-aware training tips selected using FAISS-based recommendation. |
| **Text-to-Speech** | Listening Exams | Converts exam text to natural speech using AWS Polly (11 neural voices). |
| **Speech-to-Text** | Speaking Exams | Transcribes student speaking responses using local Whisper model. |
| **AI Avatar Videos** | Speaking Exams | Generates avatar-based video prompts for speaking exams using ELAI. |
---
*End of System Features Guide*

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
# Math & IT Adaptive Learning System -- Software Requirements Specification # Math & IT Adaptive Learning System -- Software Requirements Specification
> **SUPERSEDED** -- This document has been merged into `ENCOACH_UNIFIED_SRS.md` (v2.0). The adaptive learning engine for Math and IT is now part of the unified platform specification (Part II: Universal Subject Engine, sections 6-11). All features have been implemented and deployed.
**Document Version:** 1.0 **Document Version:** 1.0
**Date:** March 16, 2026 **Date:** March 16, 2026
**Status:** Draft -- Architect Review **Status:** ~~Draft -- Architect Review~~ **SUPERSEDED**
**Author:** EnCoach Engineering Team **Author:** EnCoach Engineering Team
**Audience:** Architect (initial review), then development team **Audience:** Architect (initial review), then development team

View File

@@ -1,8 +1,10 @@
# EnCoach Odoo 19 Backend -- Developer SRS v3 # EnCoach Odoo 19 Backend -- Developer SRS v3
> **SUPERSEDED** -- This document has been replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` (v3.0) and `ENCOACH_UNIFIED_SRS.md` (v2.0). All content below is historical. The developer has implemented everything and the system is deployed at `http://5.189.151.117:8069`.
**Document Version:** 3.0 **Document Version:** 3.0
**Date:** March 11, 2026 **Date:** March 11, 2026
**Status:** Active -- Ready for Development **Status:** ~~Active -- Ready for Development~~ **SUPERSEDED**
**Supersedes:** `ODOO_MIGRATION_SRS_v2.md` (v2.0) **Supersedes:** `ODOO_MIGRATION_SRS_v2.md` (v2.0)
**Master Reference:** `ENCOACH_UNIFIED_SRS.md` (v1.0) **Master Reference:** `ENCOACH_UNIFIED_SRS.md` (v1.0)
**Author:** EnCoach Architecture Team **Author:** EnCoach Architecture Team

View File

@@ -0,0 +1,100 @@
# Odoo Developer Handoff — Implementation Complete
**Status:** All deliverables implemented and deployed to staging.
**Date:** March 11, 2026
---
## 1. Current Repositories
| Repository | URL | Description |
|-----------|-----|-------------|
| **Frontend v2** | `https://git.albousalh.com/devops/encoach_frontend_new_v2.git` | React 18 SPA (93 pages, 37 services, 29 types) |
| **Backend v2** | `https://git.albousalh.com/devops/encoach_backend_new_v2.git` | Odoo 19 backend (27 custom + 14 OpenEduCat = 41 modules) |
**Note:** The older repositories (`encoach_frontend_new_v1`, `encoach_be_odoo19`, `ielts-be-v0`) are superseded. All development should continue in the v2 repositories above.
---
## 2. Staging Environment
| Service | URL |
|---------|-----|
| **Frontend** | `http://5.189.151.117:3000` |
| **Backend (Odoo 19)** | `http://5.189.151.117:8069` |
Deployment is automated via Git post-receive hooks. Push to `main` triggers rebuild.
---
## 3. Documentation
| Document | Status | Description |
|----------|--------|-------------|
| `ENCOACH_UNIFIED_SRS.md` (v2.0) | **Current** | Unified frontend + backend SRS with implementation traceability |
| `ENCOACH_ODOO19_BACKEND_SRS.md` (v3.0) | **Current** | Backend-specific SRS with all API contracts and data models |
| `ENCOACH_PRODUCT_DESCRIPTION.md` | **Current** | Non-technical product description |
| `ENCOACH_SYSTEM_FEATURES_GUIDE.md` | **Current** | System features guide by sidebar section |
| `ODOO_BACKEND_SRS_v3.md` | **Superseded** | Replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` v3.0 |
| `ODOO_MIGRATION_SRS_v2.md` | **Superseded** | Replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` v3.0 |
| `ODOO_MIGRATION_SRS.md` | **Superseded** | Replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` v3.0 |
| `ODOO_MIGRATION_FRONTEND_SRS.md` | **Superseded** | Replaced by `ENCOACH_UNIFIED_SRS.md` v2.0 |
---
## 4. What Was Implemented
### Frontend (93 page components)
- **28 root pages** -- login, register, FAQ, exam runner, etc.
- **33 admin pages** -- full LMS/platform administration
- **19 student pages** -- courses, adaptive learning, communication
- **14 teacher pages** -- course builder, AI workbench, grading
- **37 API service modules** calling ~280+ backend endpoints
- **14 AI components** wired to real backend AI services
### Backend (41 Odoo modules, ~377 API routes)
- **`encoach_api`** -- 16 controller files, ~120 routes (auth, users, entities, roles, exams, assignments, classrooms, stats, subscriptions, tickets, approvals, training, generation, AI analytics)
- **`encoach_lms_api`** -- 28 controller files, ~209 routes (courses, students, teachers, batches, timetable, attendance, grades, departments, academic, admissions, subject registration, institutional exams, course assignments, student leave, fees, lessons, gradebook, student progress, library, activities, facilities, notifications, FAQ, courseware, plagiarism, communication)
- **`encoach_adaptive_api`** -- 6 controller files, ~41 routes (taxonomy, diagnostic, proficiency, learning plans, content, coaching)
- **`encoach_resources`** -- 1 controller file, ~7 routes (resource CRUD)
### Beyond Original SRS
The developer also implemented:
- Student Leave Management (types, requests, approve/reject)
- Fees Management (plans, student fees, terms)
- Lessons Management (timetable-linked)
- Gradebook and Grading Assignments
- Student Progress Tracking (aggregate)
- Library Management (media, movements, cards)
- Activity Management (configurable types)
- Facility and Asset Management
- Roles CRUD, User Role Assignment, Authority Matrix
---
## 5. AI Service Credentials
The developer needs API keys for:
| Service | Purpose |
|---------|---------|
| **OpenAI** | GPT-4o and Whisper -- grading, coaching, content generation, speech-to-text |
| **AWS Polly** | Text-to-speech (neural voices) |
| **ELAI** | AI avatar video generation |
| **GPTZero** | AI writing detection |
These are in `06-All-Credentials-Inventory.md` locally. Keys must be provided **separately** -- never committed to the git repo. On staging, they are in `/opt/encoach/backend-v2/.env`.
---
## 6. Frontend Code Reference
For any new backend development, the frontend contract is defined in:
- **`src/services/*.ts`** (37 files) -- every URL path, request body shape, and response handling
- **`src/types/*.ts`** (29 files) -- all TypeScript interfaces the frontend expects from the API
- **`src/hooks/queries/*.ts`** (21 files) -- TanStack Query cache keys and query configurations

View File

@@ -0,0 +1,910 @@
# EnCoach Frontend API Migration SRS
> **SUPERSEDED** -- This document has been replaced by `ENCOACH_UNIFIED_SRS.md` (v2.0). The migration is complete. The old `ielts-ui` Next.js frontend has been replaced by `encoach_frontend_new_v2` (React 18 + Vite + TypeScript), deployed at `http://5.189.151.117:3000`.
## ielts-ui Migration Guide: Next.js API Routes to Odoo 19
**Version:** 1.0
**Date:** March 11, 2026
**Status:** ~~Active~~ **SUPERSEDED**
**Audience:** Frontend developer(s) maintaining ielts-ui
**Companion document:** `ODOO_MIGRATION_SRS.md` (backend Odoo specification)
---
## Table of Contents
1. [Migration Overview](#1-migration-overview)
2. [Authentication Migration](#2-authentication-migration)
3. [API Base URL Change](#3-api-base-url-change)
4. [Endpoint-by-Endpoint Migration Map](#4-endpoint-by-endpoint-migration-map)
5. [SSR Migration Strategy](#5-ssr-migration-strategy)
6. [Files to Delete](#6-files-to-delete)
7. [Migration Checklist](#7-migration-checklist)
---
## 1. Migration Overview
### 1.1 What Is Changing
The ielts-ui Next.js application currently serves two roles:
1. **Browser UI** -- React pages, components, Zustand stores, hooks
2. **BFF (Backend-for-Frontend)** -- 108 API route files in `src/pages/api/` that query MongoDB directly, manage iron-session cookies, and proxy AI requests
After migration, ielts-ui serves **only the Browser UI**. All backend logic moves to Odoo 19. The `src/pages/api/` directory and all server-side database utilities are eliminated entirely.
### 1.2 Current Architecture (what the frontend sees)
```
Browser (React)
├── axios/SWR/fetch calls to /api/* (same origin)
└── Next.js API Routes (src/pages/api/)
├── iron-session cookie auth
├── MongoDB queries (via src/utils/*.be.ts)
└── Proxy to AI backend (via BACKEND_URL)
```
### 1.3 Target Architecture (what the frontend sees)
```
Browser (React)
├── axios/SWR/fetch calls to ODOO_URL/api/* (cross-origin)
│ └── Authorization: Bearer <JWT>
└── Odoo 19 (separate service)
├── JWT auth
├── PostgreSQL (replaces MongoDB)
└── All AI/ML handled internally by Odoo
```
### 1.4 What Does NOT Change
- All React page components, layouts, and UI rendering
- Zustand stores (`src/stores/exam/`, `src/stores/preferencesStore.ts`, etc.)
- All components in `src/components/` (they still call the same API shapes -- only the base URL and auth mechanism change)
- Styling (Tailwind, DaisyUI)
- TypeScript interfaces in `src/interfaces/`
- Client-side routing
- Strapi CMS and the landing page
### 1.5 Guiding Principle
Odoo's REST API is designed to return the **same JSON shapes** the frontend currently expects. If the frontend calls `GET /api/users/list` and expects `{ users: [...], total: N }`, Odoo's `GET /api/users/list` returns the same shape. The migration is primarily a **transport change** (base URL + auth header), not a data format change.
---
## 2. Authentication Migration
This is the most significant structural change.
### 2.1 Current Auth Pattern
| Aspect | Current Implementation |
|--------|----------------------|
| **Identity provider** | Firebase Auth (`signInWithEmailAndPassword`) |
| **Session** | iron-session (encrypted httpOnly cookie named `eCrop/ielts`) |
| **Client auth** | Automatic -- browser sends cookie on same-origin requests |
| **SSR auth** | `withIronSessionSsr` + `getServerSideProps` reads `req.session.user` |
| **User hook** | `useUser()` calls `GET /api/user` via SWR; cookie sent automatically |
| **Config** | `src/lib/session.ts` (`SECRET_COOKIE_PASSWORD`, `cookieName`) |
### 2.2 Target Auth Pattern
| Aspect | Target Implementation |
|--------|----------------------|
| **Identity provider** | Odoo 19 native auth (`res.users`) |
| **Session** | JWT token issued by Odoo |
| **Client auth** | `Authorization: Bearer <token>` header on every request |
| **SSR auth** | JWT stored in httpOnly cookie; `getServerSideProps` reads cookie and calls Odoo |
| **User hook** | `useUser()` calls `GET ODOO_URL/api/user` with JWT header |
| **Config** | `NEXT_PUBLIC_API_URL` env var |
### 2.3 New Auth Infrastructure
Create a new file `src/lib/api.ts`:
```typescript
import axios from "axios";
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true,
});
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);
export default api;
```
JWT storage helpers (also in `src/lib/api.ts` or a separate `src/lib/auth.ts`):
```typescript
import Cookies from "js-cookie";
const TOKEN_KEY = "encoach_token";
export function setToken(token: string) {
Cookies.set(TOKEN_KEY, token, { secure: true, sameSite: "lax" });
}
export function getToken(): string | undefined {
return Cookies.get(TOKEN_KEY);
}
export function clearToken() {
Cookies.remove(TOKEN_KEY);
}
```
**Note:** Using a cookie for JWT storage (rather than localStorage) is recommended because `getServerSideProps` can read cookies from the request headers, enabling SSR auth. The cookie is NOT httpOnly in this approach (so JS can read it for the axios interceptor). If httpOnly is required, use a thin proxy or Next.js middleware to inject the header.
### 2.4 Files That Change for Auth
| File | Current | Target |
|------|---------|--------|
| `src/pages/login.tsx` | `axios.post("/api/login", { email, password })` | `api.post("/api/login", { email, password })` -- store JWT from response |
| `src/pages/register.tsx` | `fetch` to `/api/register` (SSR) + code lookup | `api.post("/api/register", payload)` -- store JWT from response |
| `src/pages/official-exam.tsx` | `axios.post("/api/logout")` | `api.post("/api/logout")` + `clearToken()` |
| `src/components/Sidebar.tsx` | `axios.post("/api/logout")` | `api.post("/api/logout")` + `clearToken()` |
| `src/components/MobileMenu.tsx` | `axios.post("/api/logout")` | `api.post("/api/logout")` + `clearToken()` |
| `src/utils/email.ts` | `axios.post("/api/reset/sendVerification")` | `api.post("/api/reset/sendVerification")` |
| `src/hooks/useUser.tsx` | SWR fetcher uses `axios.get("/api/user")` | SWR fetcher uses `api.get("/api/user")` |
| `src/lib/session.ts` | iron-session config | **DELETE** -- replaced by `src/lib/api.ts` |
### 2.5 Login Flow Change
**Current:**
1. User submits email + password
2. `POST /api/login` (same origin)
3. API route calls Firebase Auth, looks up user in MongoDB, saves to iron-session
4. Response: user JSON + `Set-Cookie` header
5. `mutateUser(response.data)` updates SWR cache
**Target:**
1. User submits email + password
2. `POST ODOO_URL/api/login` (cross-origin)
3. Odoo validates credentials, returns `{ user: {...}, token: "jwt..." }`
4. Frontend stores JWT via `setToken(response.data.token)`
5. `mutateUser(response.data.user)` updates SWR cache
**Login page change (`src/pages/login.tsx`):**
```typescript
// BEFORE
const response = await axios.post("/api/login", { email, password });
mutateUser(response.data);
// AFTER
import api, { setToken } from "@/lib/api";
const response = await api.post("/api/login", { email, password });
setToken(response.data.token);
mutateUser(response.data.user);
```
### 2.6 Logout Flow Change
**Current:** `POST /api/logout` destroys iron-session.
**Target:** `POST ODOO_URL/api/logout` (optional server-side invalidation) + `clearToken()` client-side.
```typescript
// BEFORE
await axios.post("/api/logout");
mutateUser(null);
router.push("/login");
// AFTER
import api, { clearToken } from "@/lib/api";
await api.post("/api/logout").catch(() => {});
clearToken();
mutateUser(null);
router.push("/login");
```
---
## 3. API Base URL Change
### 3.1 Environment Variable
Add to `.env.local`:
```
NEXT_PUBLIC_API_URL=https://api.encoach.com
```
Development:
```
NEXT_PUBLIC_API_URL=http://localhost:8069
```
### 3.2 Shared Axios Instance
Replace all direct `axios` imports with the shared `api` instance from `src/lib/api.ts` (defined in Section 2.3).
**Search and replace pattern across the codebase:**
```typescript
// BEFORE (scattered across ~40 files)
import axios from "axios";
axios.get("/api/...");
axios.post("/api/...");
axios.patch("/api/...");
axios.delete("/api/...");
// AFTER
import api from "@/lib/api";
api.get("/api/...");
api.post("/api/...");
api.patch("/api/...");
api.delete("/api/...");
```
The `/api/...` path stays the same -- the `baseURL` on the axios instance prepends the Odoo host.
### 3.3 SWR Fetcher Update
Update `src/hooks/useUser.tsx`:
```typescript
// BEFORE
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
// AFTER
import api from "@/lib/api";
const fetcher = (url: string) => api.get(url).then((res) => res.data);
```
### 3.4 fetch() Calls
A few files use `fetch()` instead of axios (primarily `src/components/ExamEditor/ImportExam/WordUploader.tsx`). These also need the base URL and auth header:
```typescript
// BEFORE
const res = await fetch(`/api/exam/${module}/import/`, { method: "POST", body: formData });
// AFTER
import { getToken } from "@/lib/api";
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/exam/${module}/import/`, {
method: "POST",
body: formData,
headers: { Authorization: `Bearer ${getToken()}` },
});
```
**Note:** `fetch()` calls to blob URLs (e.g., `fetch(blobUrl)` for audio/video previews) do NOT change. Only calls to `/api/*` endpoints change.
---
## 4. Endpoint-by-Endpoint Migration Map
For each API call the frontend makes, this section specifies the current URL, new Odoo URL, HTTP method, and every file that makes the call.
**Convention:** Where the Odoo URL is the same path as the current URL (just different host), only the base URL changes. These are marked "Path unchanged." Where the Odoo URL differs, the new path is shown.
### 4.1 Auth
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 1 | POST | `/api/login` | Path unchanged | `src/pages/login.tsx` |
| 2 | POST | `/api/logout` | Path unchanged | `src/pages/official-exam.tsx`, `src/components/Sidebar.tsx`, `src/components/MobileMenu.tsx` |
| 3 | POST | `/api/reset` | Path unchanged | `src/pages/login.tsx` |
| 4 | POST | `/api/reset/sendVerification` | Path unchanged | `src/utils/email.ts` |
**Response change for login:** Odoo returns `{ user: {...}, token: "jwt..." }` instead of just the user object. Frontend must extract and store the token.
### 4.2 User
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 5 | GET | `/api/user` | Path unchanged | `src/hooks/useUser.tsx` (SWR) |
| 6 | DELETE | `/api/user` | Path unchanged | (called from useUser context) |
| 7 | POST | `/api/users/update` | `PATCH /api/users/update` | `src/pages/profile.tsx`, `src/components/UserCard.tsx` |
| 8 | GET | `/api/users/list` | Path unchanged | `src/utils/groups.ts` |
| 9 | GET | `/api/users/{id}` | Path unchanged | `src/utils/groups.ts` |
| 10 | POST | `/api/users/controller` | Path unchanged | `src/components/Imports/StudentClassroomTransfer.tsx` |
| 11 | GET | `/api/users/balance` | Path unchanged | `src/hooks/useUserBalance.tsx` (if used) |
**Note on #7:** The current frontend uses `POST` for user updates. Odoo convention is `PATCH`. Either Odoo can accept both, or the frontend changes the method.
### 4.3 Registration / Codes
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 12 | POST | `/api/register` | Path unchanged | `src/pages/register.tsx` (SSR currently; move to client-side) |
| 13 | GET | `/api/code/{code}` | Path unchanged | `src/pages/register.tsx` |
**Note on #12:** Registration is currently handled in `getServerSideProps`. With Odoo, move registration to a client-side form submission. The register page should call `api.post("/api/register", payload)` and store the returned JWT.
### 4.4 Exams
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 14 | GET | `/api/exam` | Path unchanged | `src/hooks/useExams.tsx` |
| 15 | GET | `/api/exam/{module}?{params}` | Path unchanged | `src/utils/exams.ts` (`getExam`) |
| 16 | GET | `/api/exam/{module}/{id}` | Path unchanged | `src/utils/exams.ts` (`getExamById`) |
| 17 | POST | `/api/exam/{module}` | Path unchanged | `src/components/ExamEditor/SettingsEditor/reading/index.tsx`, `writing/index.tsx`, `listening/index.tsx`, `speaking/index.tsx`, `level.tsx` |
| 18 | PATCH | `/api/exam/{module}/{id}` | Path unchanged | `src/pages/(admin)/Lists/ExamList.tsx`, `src/pages/approval-workflows/[id]/index.tsx` |
| 19 | DELETE | `/api/exam/{module}/{id}` | Path unchanged | `src/pages/(admin)/Lists/ExamList.tsx` |
| 20 | GET | `/api/exam/avatars` | Path unchanged | `src/pages/generation.tsx` |
| 21 | GET/POST | `/api/exam/generate/{module}/{sectionId}` | Path unchanged | `src/components/ExamEditor/SettingsEditor/Shared/Generate.ts` |
| 22 | POST | `/api/exam/media/speaking` | Path unchanged | `src/components/ExamEditor/SettingsEditor/Shared/generateVideos.ts` |
| 23 | POST | `/api/exam/media/listening` | Path unchanged | `src/components/ExamEditor/SettingsEditor/listening/components.tsx` |
| 24 | POST | `/api/exam/media/instructions` | Path unchanged | `src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx` |
| 25 | POST | `/api/exam/{module}/import/` | Path unchanged | `src/components/ExamEditor/ImportExam/WordUploader.tsx` (uses `fetch`) |
### 4.5 Evaluation / Grading
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 26 | POST | `/api/evaluate/writing` | Path unchanged | `src/utils/evaluation.ts` |
| 27 | POST | `/api/evaluate/speaking` | Path unchanged | `src/utils/evaluation.ts` |
| 28 | POST | `/api/evaluate/interactiveSpeaking` | Path unchanged | `src/utils/evaluation.ts` |
| 29 | GET | `/api/evaluate/status?...` | Path unchanged | `src/utils/evaluation.ts` (polling) |
| 30 | GET | `/api/evaluate/fetchSolutions?...` | Path unchanged | `src/utils/evaluation.ts` |
| 31 | POST | `/api/grading` | Path unchanged | `src/pages/api/grading/index.ts` (if called by frontend) |
| 32 | POST | `/api/grading/multiple` | Path unchanged | (if called by frontend) |
### 4.6 Storage
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 33 | POST | `/api/storage` | Path unchanged | `src/components/ExamEditor/SettingsEditor/writing/index.tsx`, `speaking/index.tsx`, `listening/index.tsx`, `level.tsx` |
| 34 | POST | `/api/storage/delete` | `DELETE /api/storage/{id}` (or keep POST) | `src/utils/evaluation.ts` |
**Note:** Odoo will use `ir.attachment` for file storage. The response must return a publicly accessible URL for the uploaded file, matching the current behavior.
### 4.7 Speaking Audio
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 35 | POST | `/api/speaking` | Path unchanged | `src/components/Solutions/Speaking.tsx`, `src/components/Solutions/InteractiveSpeaking.tsx` |
### 4.8 Sessions
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 36 | POST | `/api/sessions` | Path unchanged | `src/stores/exam/index.ts` (Zustand `saveSession`) |
| 37 | DELETE | `/api/sessions/{id}` | Path unchanged | `src/components/Medium/SessionCard.tsx` |
### 4.9 Stats
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 38 | POST | `/api/stats` | Path unchanged | `src/stores/exam/index.ts` (Zustand `saveStats`) |
| 39 | GET | `/api/stats/{id}` | Path unchanged | `src/pages/training/[id]/index.tsx` |
| 40 | POST | `/api/statistical` | Path unchanged | `src/pages/statistical.tsx` |
### 4.10 Training
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 41 | POST | `/api/training` | Path unchanged | `src/pages/training/index.tsx` |
| 42 | GET | `/api/training/{id}` | Path unchanged | `src/pages/training/[id]/index.tsx` |
| 43 | GET | `/api/training/walkthrough` | Path unchanged | `src/pages/training/[id]/index.tsx` |
### 4.11 Groups
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 44 | GET | `/api/groups?participant={userId}` | Path unchanged | `src/utils/groups.ts` |
| 45 | POST | `/api/groups` | Path unchanged | `src/pages/classrooms/create.tsx` |
| 46 | PATCH | `/api/groups/{id}` | Path unchanged | `src/pages/classrooms/[id].tsx` |
| 47 | DELETE | `/api/groups/{id}` | Path unchanged | `src/pages/classrooms/[id].tsx` |
### 4.12 Entities
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 48 | POST | `/api/entities` | Path unchanged | `src/pages/entities/create.tsx` |
| 49 | PATCH | `/api/entities/{id}` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
| 50 | DELETE | `/api/entities/{id}` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
| 51 | PATCH | `/api/entities/{id}/users` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
### 4.13 Roles
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 52 | POST | `/api/roles` | Path unchanged | `src/pages/entities/[id]/roles/index.tsx` |
| 53 | PATCH | `/api/roles/{id}` | Path unchanged | `src/pages/entities/[id]/roles/[role].tsx` |
| 54 | DELETE | `/api/roles/{id}` | Path unchanged | `src/pages/entities/[id]/roles/[role].tsx` |
| 55 | POST | `/api/roles/{id}/users` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
### 4.14 Permissions
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 56 | PATCH | `/api/permissions/{id}` | Path unchanged | `src/pages/permissions/[id].tsx` |
### 4.15 Payments
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 57 | POST | `/api/payments` | Path unchanged | `src/pages/payment-record.tsx` |
| 58 | PATCH | `/api/payments/{id}` | Path unchanged | `src/pages/payment-record.tsx` |
| 59 | DELETE | `/api/payments/{id}` | Path unchanged | `src/pages/payment-record.tsx` |
| 60 | GET/POST/DELETE | `/api/payments/files/{type}/{paymentId}` | Path unchanged | `src/components/PaymentAssetManager.tsx` |
| 61 | POST | `/api/paymob` | Path unchanged | `src/components/PaymobPayment.tsx` |
**Bug fix opportunity:** In `src/pages/payment-record.tsx`, the PATCH call uses `api/payments/${id}` without a leading `/`. When switching to the shared axios instance with a `baseURL`, this will work correctly. But verify during testing.
### 4.16 Assignments
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 62 | POST | `/api/assignments` | Path unchanged | `src/pages/assignments/creator/index.tsx` |
| 63 | PATCH | `/api/assignments/{id}` | Path unchanged | `src/pages/assignments/creator/[id].tsx`, `src/pages/assignments/[id].tsx` |
| 64 | DELETE | `/api/assignments/{id}` | Path unchanged | `src/pages/assignments/creator/[id].tsx`, `src/pages/assignments/[id].tsx` |
| 65 | POST | `/api/assignments/{id}/start` | Path unchanged | `src/pages/assignments/creator/[id].tsx`, `src/pages/assignments/[id].tsx` |
### 4.17 Invites
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 66 | GET | `/api/invites/{decision}/{id}` | `POST /api/invites/{decision}/{id}` | `src/components/Medium/InviteCard.tsx`, `src/components/Medium/InviteWithUserCard.tsx` |
**Note:** Currently uses GET for accept/decline actions (side-effectful GET). Odoo should accept POST. Consider updating the frontend to use POST -- it's a more correct HTTP method for state-changing operations.
### 4.18 Approval Workflows
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 67 | POST | `/api/approval-workflows/create` | Path unchanged | `src/pages/approval-workflows/create.tsx` |
| 68 | PUT | `/api/approval-workflows/{id}` | Path unchanged | `src/pages/approval-workflows/[id]/index.tsx` |
| 69 | PUT | `/api/approval-workflows/{id}/edit` | Path unchanged | `src/pages/approval-workflows/[id]/edit.tsx` |
| 70 | DELETE | `/api/approval-workflows/{id}` | Path unchanged | `src/pages/approval-workflows/index.tsx` |
### 4.19 Transcription
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 71 | POST | `/api/transcribe` | Path unchanged | (called from evaluation utils if needed) |
### 4.20 Batch Users
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 72 | POST | `/api/batch_users` | Path unchanged | (admin import page) |
### 4.21 Make User (Admin)
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 73 | POST | `/api/make_user` | Path unchanged | (admin user creation) |
---
## 5. SSR Migration Strategy
### 5.1 Current SSR Auth Pattern
~35 pages use this pattern:
```typescript
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login");
return { props: { user } };
}, sessionOptions);
```
### 5.2 Recommended: JWT Cookie + Server-Side Validation (Option A)
Store the JWT in a regular cookie (set by `js-cookie` on login). In `getServerSideProps`, read the cookie from the request and call Odoo to validate.
**New helper `src/lib/ssr-auth.ts`:**
```typescript
import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const TOKEN_COOKIE = "encoach_token";
export function withAuth<P extends Record<string, unknown>>(
handler: (ctx: GetServerSidePropsContext, user: User) => Promise<GetServerSidePropsResult<P>>
) {
return async (ctx: GetServerSidePropsContext): Promise<GetServerSidePropsResult<P>> => {
const token = ctx.req.cookies[TOKEN_COOKIE];
if (!token) {
return { redirect: { destination: "/login", permanent: false } };
}
try {
const res = await fetch(`${API_URL}/api/user`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Unauthorized");
const user = await res.json();
if (!user.isVerified) {
return { redirect: { destination: "/login", permanent: false } };
}
return handler(ctx, user);
} catch {
return { redirect: { destination: "/login", permanent: false } };
}
};
}
```
**Page migration example:**
```typescript
// BEFORE
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
// ... fetch additional data using user ...
return { props: { user, someData } };
}, sessionOptions);
// AFTER
export const getServerSideProps = withAuth(async (ctx, user) => {
// ... fetch additional data using user ...
return { props: { user, someData } };
});
```
### 5.3 Alternative: Client-Only Auth (Option B)
Remove all `getServerSideProps` auth checks. Every page uses `useUser({ redirectTo: "/login" })` to protect itself.
**Pros:** Simpler; no SSR auth at all.
**Cons:** Brief flash of unauthenticated content; pages that fetch data server-side need to move data fetching to client-side.
### 5.4 Pages Requiring SSR Migration
Every page below currently uses `withIronSessionSsr` and needs to switch to `withAuth` (Option A) or remove SSR auth (Option B):
| Page | SSR Data Fetching |
|------|-------------------|
| `src/pages/index.tsx` | User only (redirects to dashboard) |
| `src/pages/login.tsx` | Reverse guard (redirect if already logged in) |
| `src/pages/dashboard/index.tsx` | User only |
| `src/pages/dashboard/teacher.tsx` | User + groups + assignments + stats |
| `src/pages/dashboard/student.tsx` | User + assignments + stats |
| `src/pages/dashboard/corporate.tsx` | User + entity data |
| `src/pages/dashboard/mastercorporate.tsx` | User + multi-entity data |
| `src/pages/dashboard/admin.tsx` | User + platform stats |
| `src/pages/dashboard/developer.tsx` | User |
| `src/pages/users/index.tsx` | User + user list |
| `src/pages/users/performance.tsx` | User + performance data |
| `src/pages/training/index.tsx` | User |
| `src/pages/training/[id]/index.tsx` | User + training data |
| `src/pages/tickets.tsx` | User + tickets |
| `src/pages/stats.tsx` | User + stats |
| `src/pages/statistical.tsx` | User |
| `src/pages/settings.tsx` | User |
| `src/pages/record.tsx` | User |
| `src/pages/profile.tsx` | User |
| `src/pages/popout.tsx` | User (session-based) |
| `src/pages/permissions/index.tsx` | User + permissions |
| `src/pages/permissions/[id].tsx` | User + permission detail |
| `src/pages/payment.tsx` | User |
| `src/pages/payment-record.tsx` | User + payments |
| `src/pages/official-exam.tsx` | User |
| `src/pages/generation.tsx` | User |
| `src/pages/exercises.tsx` | User + exam data |
| `src/pages/exam.tsx` | User + exam + session data |
| `src/pages/entities/index.tsx` | User + entities |
| `src/pages/entities/create.tsx` | User |
| `src/pages/entities/[id]/index.tsx` | User + entity detail |
| `src/pages/entities/[id]/roles/index.tsx` | User + roles |
| `src/pages/entities/[id]/roles/[role].tsx` | User + role detail |
| `src/pages/classrooms/*.tsx` | User + groups |
| `src/pages/assignments/*.tsx` | User + assignments |
| `src/pages/approval-workflows/*.tsx` | User + workflows |
**For pages that only need user auth (no server-side data):** Replace `getServerSideProps` with client-side `useUser({ redirectTo: "/login" })`.
**For pages that fetch additional data server-side:** Use `withAuth` and make the data-fetching calls to Odoo using the JWT token from the cookie.
---
## 6. Files to Delete
After migration, the following files are no longer needed. They represent the BFF layer being replaced by Odoo.
### 6.1 API Route Files (108 files)
Delete the entire `src/pages/api/` directory:
**Auth & User:**
- `src/pages/api/login.ts`
- `src/pages/api/logout.ts`
- `src/pages/api/register.ts`
- `src/pages/api/user.ts`
- `src/pages/api/make_user.ts`
- `src/pages/api/batch_users.ts`
- `src/pages/api/reset/index.ts`
- `src/pages/api/reset/confirm.ts`
- `src/pages/api/reset/sendVerification.ts`
- `src/pages/api/reset/verify.ts`
- `src/pages/api/users/update.ts`
- `src/pages/api/users/search.ts`
- `src/pages/api/users/list.ts`
- `src/pages/api/users/controller.ts`
- `src/pages/api/users/balance.ts`
- `src/pages/api/users/agents/index.ts`
- `src/pages/api/users/agents/[code].ts`
- `src/pages/api/users/[id].ts`
**Exams:**
- `src/pages/api/exam/index.ts`
- `src/pages/api/exam/upload.ts`
- `src/pages/api/exam/avatars.ts`
- `src/pages/api/exam/generate/[...module].ts`
- `src/pages/api/exam/media/[...module].ts`
- `src/pages/api/exam/media/poll.ts`
- `src/pages/api/exam/media/instructions.ts`
- `src/pages/api/exam/[module]/index.ts`
- `src/pages/api/exam/[module]/import.ts`
- `src/pages/api/exam/[module]/[id].ts`
**Evaluation:**
- `src/pages/api/evaluate/writing.ts`
- `src/pages/api/evaluate/speaking.ts`
- `src/pages/api/evaluate/interactiveSpeaking.ts`
- `src/pages/api/evaluate/status.ts`
- `src/pages/api/evaluate/fetchSolutions.ts`
**Grading:**
- `src/pages/api/grading/index.ts`
- `src/pages/api/grading/multiple.ts`
**Stats & Sessions:**
- `src/pages/api/stats/index.ts`
- `src/pages/api/stats/update.ts`
- `src/pages/api/stats/disabled.ts`
- `src/pages/api/stats/user/[user].ts`
- `src/pages/api/stats/session/[session].ts`
- `src/pages/api/stats/[id]/index.ts`
- `src/pages/api/stats/[id]/[export]/pdf.tsx`
- `src/pages/api/statistical.ts`
- `src/pages/api/sessions/index.ts`
- `src/pages/api/sessions/[id].ts`
**Training:**
- `src/pages/api/training/index.ts`
- `src/pages/api/training/[id].ts`
- `src/pages/api/training/user/[user].ts`
- `src/pages/api/training/walkthrough/index.ts`
**Entities & Groups:**
- `src/pages/api/entities/index.ts`
- `src/pages/api/entities/users.ts`
- `src/pages/api/entities/groups.ts`
- `src/pages/api/entities/[id]/index.ts`
- `src/pages/api/entities/[id]/users.ts`
- `src/pages/api/groups/index.ts`
- `src/pages/api/groups/controller.ts`
- `src/pages/api/groups/[id].ts`
**Roles & Permissions:**
- `src/pages/api/roles/index.ts`
- `src/pages/api/roles/[id]/index.ts`
- `src/pages/api/roles/[id]/users.ts`
- `src/pages/api/permissions/index.ts`
- `src/pages/api/permissions/bootstrap.ts`
- `src/pages/api/permissions/[id].ts`
**Invites & Codes:**
- `src/pages/api/invites/index.ts`
- `src/pages/api/invites/[id].ts`
- `src/pages/api/invites/accept/[id].ts`
- `src/pages/api/invites/decline/[id].ts`
- `src/pages/api/code/index.ts`
- `src/pages/api/code/entities.ts`
- `src/pages/api/code/[id].ts`
**Payments:**
- `src/pages/api/stripe.ts`
- `src/pages/api/paypal/index.ts`
- `src/pages/api/paypal/approve.ts`
- `src/pages/api/paypal/raas.ts`
- `src/pages/api/paymob/index.ts`
- `src/pages/api/paymob/webhook.ts`
- `src/pages/api/payments/index.ts`
- `src/pages/api/payments/[id].ts`
- `src/pages/api/payments/assigned.ts`
- `src/pages/api/payments/paypal.ts`
- `src/pages/api/payments/files/[type]/[paymentId].ts`
- `src/pages/api/packages/index.ts`
- `src/pages/api/packages/[id].ts`
- `src/pages/api/discounts/index.ts`
- `src/pages/api/discounts/[id].ts`
**Assignments:**
- `src/pages/api/assignments/index.ts`
- `src/pages/api/assignments/corporate/index.ts`
- `src/pages/api/assignments/corporate/[id].ts`
- `src/pages/api/assignments/[id]/index.ts`
- `src/pages/api/assignments/[id]/start.ts`
- `src/pages/api/assignments/[id]/release.ts`
- `src/pages/api/assignments/[id]/archive.tsx`
- `src/pages/api/assignments/[id]/unarchive.tsx`
- `src/pages/api/assignments/[id]/[export]/pdf.tsx`
- `src/pages/api/assignments/[id]/[export]/excel.ts`
**Approval Workflows:**
- `src/pages/api/approval-workflows/index.ts`
- `src/pages/api/approval-workflows/create.ts`
- `src/pages/api/approval-workflows/[id]/index.ts`
- `src/pages/api/approval-workflows/[id]/edit.ts`
**Other:**
- `src/pages/api/storage/index.ts`
- `src/pages/api/storage/insert.ts`
- `src/pages/api/storage/delete.ts`
- `src/pages/api/speaking.ts`
- `src/pages/api/transcribe/index.ts`
- `src/pages/api/tickets/index.ts`
- `src/pages/api/tickets/[id].ts`
- `src/pages/api/tickets/assignedToUser/index.ts`
- `src/pages/api/hello.ts`
### 6.2 Backend Utility Files (14 files)
These files contain MongoDB query logic used by API routes. Delete all:
- `src/utils/approval.workflows.be.ts`
- `src/utils/assignments.be.ts`
- `src/utils/codes.be.ts`
- `src/utils/disabled.be.ts`
- `src/utils/entities.be.ts`
- `src/utils/exams.be.ts`
- `src/utils/grading.be.ts`
- `src/utils/groups.be.ts`
- `src/utils/invites.be.ts`
- `src/utils/permissions.be.ts`
- `src/utils/roles.be.ts`
- `src/utils/sessions.be.ts`
- `src/utils/stats.be.ts`
- `src/utils/users.be.ts`
### 6.3 Library Files (3 files)
- `src/lib/session.ts` -- iron-session config (replaced by `src/lib/api.ts`)
- `src/lib/mongodb.ts` -- MongoDB connection (no longer needed)
- `src/lib/createWorkflowsOnExamCreation.ts` -- server-side workflow logic (moved to Odoo)
**Keep:**
- `src/lib/utils.ts` -- general client-side utilities
- `src/lib/formidable-serverless.d.ts` -- only needed if any remaining file upload logic stays; likely deletable
### 6.4 Firebase Files
If Odoo replaces Firebase Auth entirely, also remove:
- `src/firebase/` directory (Firebase client config)
- `src/constants/platform.json` (Firebase service account -- server-side only)
- `src/constants/staging.json` (Firebase service account -- server-side only)
### 6.5 Dependencies to Remove from package.json
```
iron-session
firebase
firebase-admin
mongodb
@beam-australia/react-env (if no longer needed for client env)
bcrypt
formidable
```
### 6.6 Dependencies to Add
```
js-cookie (JWT cookie storage)
@types/js-cookie (TypeScript types)
```
---
## 7. Migration Checklist
A step-by-step checklist for the frontend developer. Complete in order.
### Phase 1: Infrastructure Setup
- [ ] **1.1** Add `NEXT_PUBLIC_API_URL` to `.env.local` (point to Odoo dev instance)
- [ ] **1.2** Create `src/lib/api.ts` with shared axios instance, base URL, and JWT interceptor (Section 2.3)
- [ ] **1.3** Create `src/lib/auth.ts` with `setToken()`, `getToken()`, `clearToken()` helpers
- [ ] **1.4** Add `js-cookie` dependency: `yarn add js-cookie @types/js-cookie`
- [ ] **1.5** Create `src/lib/ssr-auth.ts` with `withAuth()` helper for SSR (Section 5.2)
### Phase 2: Auth Migration
- [ ] **2.1** Update `src/pages/login.tsx`: use `api.post()`, store JWT, extract user from response
- [ ] **2.2** Update `src/pages/register.tsx`: move registration to client-side, use `api.post()`
- [ ] **2.3** Update logout calls in `Sidebar.tsx`, `MobileMenu.tsx`, `official-exam.tsx`: use `api.post()` + `clearToken()`
- [ ] **2.4** Update `src/hooks/useUser.tsx`: use `api` instance for SWR fetcher
- [ ] **2.5** Update `src/utils/email.ts`: use `api` instance
- [ ] **2.6** Verify login/logout/register work end-to-end with Odoo
### Phase 3: SSR Migration
- [ ] **3.1** For each page in the SSR page list (Section 5.4), replace `withIronSessionSsr` with `withAuth` or client-side `useUser({ redirectTo: "/login" })`
- [ ] **3.2** Update pages that fetch server-side data to call Odoo with the JWT token
- [ ] **3.3** Verify all ~35 protected pages redirect correctly when not authenticated
- [ ] **3.4** Verify the login page redirects correctly when already authenticated
### Phase 4: API Call Migration (domain by domain)
For each domain, replace `axios` with `api` from `@/lib/api`:
- [ ] **4.1** User endpoints (`src/pages/profile.tsx`, `src/components/UserCard.tsx`, `src/utils/groups.ts`, `src/components/Imports/StudentClassroomTransfer.tsx`)
- [ ] **4.2** Exam endpoints (`src/utils/exams.ts`, `src/hooks/useExams.tsx`, `src/pages/generation.tsx`, all `ExamEditor` files, `ExamList.tsx`)
- [ ] **4.3** Evaluation endpoints (`src/utils/evaluation.ts`)
- [ ] **4.4** Storage endpoints (all `ExamEditor/SettingsEditor` files, `src/utils/evaluation.ts`)
- [ ] **4.5** Speaking audio (`src/components/Solutions/Speaking.tsx`, `InteractiveSpeaking.tsx`)
- [ ] **4.6** Sessions (`src/stores/exam/index.ts`, `src/components/Medium/SessionCard.tsx`)
- [ ] **4.7** Stats (`src/stores/exam/index.ts`, `src/pages/training/[id]/index.tsx`, `src/pages/statistical.tsx`)
- [ ] **4.8** Training (`src/pages/training/index.tsx`, `src/pages/training/[id]/index.tsx`)
- [ ] **4.9** Groups (`src/utils/groups.ts`, `src/pages/classrooms/create.tsx`, `src/pages/classrooms/[id].tsx`)
- [ ] **4.10** Entities (`src/pages/entities/create.tsx`, `src/pages/entities/[id]/index.tsx`)
- [ ] **4.11** Roles (`src/pages/entities/[id]/roles/index.tsx`, `[role].tsx`)
- [ ] **4.12** Permissions (`src/pages/permissions/[id].tsx`)
- [ ] **4.13** Payments (`src/pages/payment-record.tsx`, `src/components/PaymentAssetManager.tsx`, `src/components/PaymobPayment.tsx`)
- [ ] **4.14** Assignments (`src/pages/assignments/creator/index.tsx`, `[id].tsx`, `src/pages/assignments/[id].tsx`)
- [ ] **4.15** Invites (`src/components/Medium/InviteCard.tsx`, `InviteWithUserCard.tsx`)
- [ ] **4.16** Approval workflows (`src/pages/approval-workflows/*.tsx`)
- [ ] **4.17** Codes (`src/pages/register.tsx`)
- [ ] **4.18** Remaining: transcribe, batch_users, make_user, statistical
- [ ] **4.19** Update `WordUploader.tsx` `fetch()` call with base URL and auth header
### Phase 5: Cleanup
- [ ] **5.1** Delete all 108 files in `src/pages/api/`
- [ ] **5.2** Delete all 14 `src/utils/*.be.ts` files
- [ ] **5.3** Delete `src/lib/session.ts`, `src/lib/mongodb.ts`, `src/lib/createWorkflowsOnExamCreation.ts`
- [ ] **5.4** Delete `src/firebase/` directory (if Firebase Auth fully removed)
- [ ] **5.5** Delete `src/constants/platform.json`, `src/constants/staging.json`
- [ ] **5.6** Remove unused dependencies from `package.json`: `iron-session`, `firebase`, `firebase-admin`, `mongodb`, `bcrypt`, `formidable`
- [ ] **5.7** Run `yarn install` to update lockfile
- [ ] **5.8** Run TypeScript compiler (`yarn tsc --noEmit`) and fix any remaining type errors
- [ ] **5.9** Run linter (`yarn lint`) and fix issues
### Phase 6: Testing
- [ ] **6.1** Test login flow (email + password -> JWT stored -> user loaded)
- [ ] **6.2** Test registration flow (individual and corporate)
- [ ] **6.3** Test logout (token cleared, redirect to login)
- [ ] **6.4** Test SSR protection (unauthenticated access to protected page -> redirect)
- [ ] **6.5** Test exam taking flow (load exam, answer questions, submit, save stats)
- [ ] **6.6** Test writing/speaking grading (submit, poll, receive result)
- [ ] **6.7** Test entity/group/classroom management (CRUD operations)
- [ ] **6.8** Test assignment lifecycle (create, start, release, archive)
- [ ] **6.9** Test payment flows (Stripe, PayPal, Paymob)
- [ ] **6.10** Test file uploads (exam media, profile pictures)
- [ ] **6.11** Test training content generation
- [ ] **6.12** Test ticket creation (from platform and landing page)
- [ ] **6.13** Test admin user management (create, update, batch import)
- [ ] **6.14** Cross-browser test (Chrome, Firefox, Safari)

2337
docs/ODOO_MIGRATION_SRS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,12 @@
# EnCoach Platform -- Odoo 19 Full Migration SRS (v2) # EnCoach Platform -- Odoo 19 Full Migration SRS (v2)
> **SUPERSEDED** -- This document has been replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` (v3.0) and `ENCOACH_UNIFIED_SRS.md` (v2.0). All content below is historical. The migration is complete and the system is deployed at `http://5.189.151.117:8069`.
## Software Requirements Specification ## Software Requirements Specification
**Version:** 2.0 **Version:** 2.0
**Date:** March 11, 2026 **Date:** March 11, 2026
**Status:** Draft **Status:** ~~Draft~~ **SUPERSEDED**
**Supersedes:** ODOO_MIGRATION_SRS.md (v1) **Supersedes:** ODOO_MIGRATION_SRS.md (v1)
**Key change from v1:** The ielts-be (FastAPI) microservice is fully eliminated. All AI/ML functionality is absorbed into custom Odoo modules. **Key change from v1:** The ielts-be (FastAPI) microservice is fully eliminated. All AI/ML functionality is absorbed into custom Odoo modules.

27
e2e/login.spec.ts Normal file
View File

@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
/**
* Smoke test: the app loads, serves the login page, and the main form
* controls are present and reachable. We deliberately avoid hitting the
* real backend — this is a lightweight sanity check that catches broken
* routing / bundle / asset problems before they reach production.
*/
test("login page renders the sign-in form", async ({ page }) => {
await page.goto("/login");
await expect(
page.getByRole("heading", { name: /sign in|login|welcome/i }),
).toBeVisible({ timeout: 10_000 });
const email = page.getByLabel(/email/i);
const password = page.getByLabel(/password/i);
await expect(email).toBeVisible();
await expect(password).toBeVisible();
await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
});
test("root redirects to login for anonymous users", async ({ page }) => {
const response = await page.goto("/");
expect(response?.ok()).toBeTruthy();
await page.waitForURL(/\/login/i, { timeout: 10_000 });
});

View File

@@ -6,12 +6,18 @@
<title>EnCoach — IELTS & English Learning Platform</title> <title>EnCoach — IELTS & English Learning Platform</title>
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." /> <meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
<meta name="author" content="EnCoach" /> <meta name="author" content="EnCoach" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#4A4068" />
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" /> <meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." /> <meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
<meta property="og:image" content="/logo-icon.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

3704
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,9 @@
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps chromium"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
@@ -47,6 +49,8 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@@ -54,6 +58,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.61.1", "react-hook-form": "^7.61.1",
"react-i18next": "^17.0.4",
"react-resizable-panels": "^2.1.9", "react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
@@ -65,9 +70,10 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.32.0", "@eslint/js": "^9.32.0",
"@playwright/test": "^1.59.1",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0", "@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5", "@types/node": "^22.16.5",
"@types/react": "^18.3.23", "@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
@@ -81,6 +87,7 @@
"lovable-tagger": "^1.1.13", "lovable-tagger": "^1.1.13",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.38.0", "typescript-eslint": "^8.38.0",
"vite": "^5.4.19", "vite": "^5.4.19",

42
playwright.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright configuration for EnCoach frontend smoke tests.
*
* By default we run against a Vite preview server on localhost:4173. In CI
* this is started by the `playwright` step in .github/workflows/ci.yml after
* `npm run build`.
*
* Override the base URL with `PLAYWRIGHT_BASE_URL=https://... npx playwright
* test` to run against a deployed environment.
*/
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:4173";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["github"], ["list"]] : [["list"]],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
? undefined
: {
command: "npm run preview -- --host 127.0.0.1 --port 4173",
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 554 B

BIN
public/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

12
public/logo.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="EnCoach">
<defs>
<linearGradient id="eg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4f46e5"/>
<stop offset="50%" stop-color="#7c3aed"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#eg)"/>
<path d="M20 20h22a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H20a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2Z" fill="#ffffff"/>
<circle cx="48" cy="48" r="5" fill="#facc15" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

View File

@@ -0,0 +1,2 @@
name,email
Jane Doe,jane@example.com
1 name email
2 Jane Doe jane@example.com

View File

@@ -1,126 +1,233 @@
import { lazy, Suspense } from "react";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner"; import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
import { AuthProvider } from "@/contexts/AuthContext"; import { AuthProvider } from "@/contexts/AuthContext";
import ProtectedRoute from "@/components/ProtectedRoute"; import ProtectedRoute from "@/components/ProtectedRoute";
import StudentLayout from "@/components/StudentLayout"; import StudentLayout from "@/components/StudentLayout";
import TeacherLayout from "@/components/TeacherLayout"; import TeacherLayout from "@/components/TeacherLayout";
import AdminLmsLayout from "@/components/AdminLmsLayout"; import AdminLmsLayout from "@/components/AdminLmsLayout";
import Login from "@/pages/Login";
import Register from "@/pages/Register";
import ForgotPassword from "@/pages/ForgotPassword";
// Original platform pages
import AdminDashboard from "@/pages/AdminDashboard";
import UsersPage from "@/pages/UsersPage";
import EntitiesPage from "@/pages/EntitiesPage";
import AssignmentsPage from "@/pages/AssignmentsPage";
import ExamsListPage from "@/pages/ExamsListPage";
import ExamStructuresPage from "@/pages/ExamStructuresPage";
import RubricsPage from "@/pages/RubricsPage";
import GenerationPage from "@/pages/GenerationPage";
import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
import ClassroomsPage from "@/pages/ClassroomsPage";
import StudentPerformancePage from "@/pages/StudentPerformancePage";
import StatsCorporatePage from "@/pages/StatsCorporatePage";
import RecordPage from "@/pages/RecordPage";
import VocabularyPage from "@/pages/VocabularyPage";
import GrammarPage from "@/pages/GrammarPage";
import PaymentRecordPage from "@/pages/PaymentRecordPage";
import TicketsPage from "@/pages/TicketsPage";
import SettingsPage from "@/pages/SettingsPage";
import ProfilePage from "@/pages/ProfilePage";
import ExamPage from "@/pages/ExamPage";
// Student pages
import StudentDashboard from "@/pages/student/StudentDashboard";
import StudentCourses from "@/pages/student/StudentCourses";
import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
import StudentAssignments from "@/pages/student/StudentAssignments";
import StudentGrades from "@/pages/student/StudentGrades";
import StudentAttendance from "@/pages/student/StudentAttendance";
import StudentTimetable from "@/pages/student/StudentTimetable";
import StudentProfile from "@/pages/student/StudentProfile";
// Adaptive learning pages
import SubjectSelection from "@/pages/student/SubjectSelection";
import DiagnosticTest from "@/pages/student/DiagnosticTest";
import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
import LearningPlanPage from "@/pages/student/LearningPlan";
import TopicLearning from "@/pages/student/TopicLearning";
// Teacher pages
import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
import TeacherCourses from "@/pages/teacher/TeacherCourses";
import CourseBuilder from "@/pages/teacher/CourseBuilder";
import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
import TeacherStudents from "@/pages/teacher/TeacherStudents";
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
import TeacherProfile from "@/pages/teacher/TeacherProfile";
// Admin LMS pages
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
import AdminCourses from "@/pages/admin/AdminCourses";
import AdminStudents from "@/pages/admin/AdminStudents";
import AdminTeachers from "@/pages/admin/AdminTeachers";
import AdminBatches from "@/pages/admin/AdminBatches";
import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
import AdminTimetable from "@/pages/admin/AdminTimetable";
import AdminReports from "@/pages/admin/AdminReports";
import AdminSettings from "@/pages/admin/AdminSettings";
import AdminProfileLms from "@/pages/admin/AdminProfile";
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
import ResourceManager from "@/pages/admin/ResourceManager";
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
import DepartmentManager from "@/pages/admin/DepartmentManager";
import AdmissionList from "@/pages/admin/AdmissionList";
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
import MarksheetManager from "@/pages/admin/MarksheetManager";
import AdminStudentLeave from "@/pages/admin/AdminStudentLeave";
import AdminFees from "@/pages/admin/AdminFees";
import AdminLessons from "@/pages/admin/AdminLessons";
import AdminGradebook from "@/pages/admin/AdminGradebook";
import AdminStudentProgress from "@/pages/admin/AdminStudentProgress";
import AdminLibrary from "@/pages/admin/AdminLibrary";
import AdminActivities from "@/pages/admin/AdminActivities";
import AdminFacilities from "@/pages/admin/AdminFacilities";
import AdmissionApplication from "@/pages/AdmissionApplication";
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
// Courseware pages
import CourseChapters from "@/pages/teacher/CourseChapters";
import ChapterDetail from "@/pages/teacher/ChapterDetail";
import AiWorkbench from "@/pages/teacher/AiWorkbench";
import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard";
import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements";
import StudentChapterView from "@/pages/student/StudentChapterView";
import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard";
import StudentAnnouncements from "@/pages/student/StudentAnnouncements";
import StudentMessages from "@/pages/student/StudentMessages";
import StudentJourney from "@/pages/student/StudentJourney";
import FaqManager from "@/pages/admin/FaqManager";
import NotificationRules from "@/pages/admin/NotificationRules";
import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig";
import RolesPermissions from "@/pages/admin/RolesPermissions";
import AuthorityMatrix from "@/pages/admin/AuthorityMatrix";
import UserRoles from "@/pages/admin/UserRoles";
import OfficialExamAccess from "@/pages/OfficialExamAccess";
import FaqPage from "@/pages/FaqPage";
import NotFound from "@/pages/NotFound";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { Button } from "@/components/ui/button";
import { ErrorBoundary } from "@/components/ErrorBoundary";
// -----------------------------------------------------------------------------
// Lazy-loaded route pages
// -----------------------------------------------------------------------------
// Keeping the initial bundle small matters on slow networks / modest hardware
// (many teachers/students open the app from school Wi-Fi). Each page below is
// split into its own chunk and only fetched when the user hits the matching
// route. The shell (layouts, providers, router) is still eagerly imported so
// the first paint stays snappy.
// Auth
const Login = lazy(() => import("@/pages/Login"));
const Register = lazy(() => import("@/pages/Register"));
const EmailVerification = lazy(() => import("@/pages/EmailVerification"));
const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard"));
const ForgotPassword = lazy(() => import("@/pages/ForgotPassword"));
const ResetPassword = lazy(() => import("@/pages/ResetPassword"));
const ScoreVerification = lazy(() => import("@/pages/ScoreVerification"));
// Original platform pages
const AdminDashboard = lazy(() => import("@/pages/AdminDashboard"));
const UsersPage = lazy(() => import("@/pages/UsersPage"));
const EntitiesPage = lazy(() => import("@/pages/EntitiesPage"));
const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage"));
const ExamsListPage = lazy(() => import("@/pages/ExamsListPage"));
const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage"));
const RubricsPage = lazy(() => import("@/pages/RubricsPage"));
const GenerationPage = lazy(() => import("@/pages/GenerationPage"));
const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage"));
const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage"));
const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage"));
const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage"));
const RecordPage = lazy(() => import("@/pages/RecordPage"));
const VocabularyPage = lazy(() => import("@/pages/VocabularyPage"));
const GrammarPage = lazy(() => import("@/pages/GrammarPage"));
const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage"));
const TicketsPage = lazy(() => import("@/pages/TicketsPage"));
const SettingsPage = lazy(() => import("@/pages/SettingsPage"));
// Student pages
const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard"));
const StudentCourses = lazy(() => import("@/pages/student/StudentCourses"));
const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail"));
const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments"));
const StudentGrades = lazy(() => import("@/pages/student/StudentGrades"));
const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance"));
const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable"));
const StudentProfile = lazy(() => import("@/pages/student/StudentProfile"));
// Adaptive learning pages
const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection"));
const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest"));
const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile"));
const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan"));
const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
// Teacher pages
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
const TeacherQuickSetup = lazy(() => import("@/pages/teacher/TeacherQuickSetup"));
const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
const TeacherAssignmentDetail = lazy(() => import("@/pages/teacher/TeacherAssignmentDetail"));
const TeacherAttendance = lazy(() => import("@/pages/teacher/TeacherAttendance"));
const TeacherStudents = lazy(() => import("@/pages/teacher/TeacherStudents"));
const TeacherTimetable = lazy(() => import("@/pages/teacher/TeacherTimetable"));
const TeacherProfile = lazy(() => import("@/pages/teacher/TeacherProfile"));
const TeacherLibrary = lazy(() => import("@/pages/teacher/TeacherLibrary"));
const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
// Admin LMS pages
const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard"));
const AdminQuickSetup = lazy(() => import("@/pages/admin/AdminQuickSetup"));
const SmartWizardHub = lazy(() => import("@/pages/admin/SmartWizardHub"));
const RubricWizard = lazy(() => import("@/pages/admin/wizards/RubricWizard"));
const ExamStructureWizard = lazy(() => import("@/pages/admin/wizards/ExamStructureWizard"));
const CourseWizard = lazy(() => import("@/pages/admin/wizards/CourseWizard"));
const CoursePlanWizard = lazy(() => import("@/pages/admin/wizards/CoursePlanWizard"));
const AdminCoursePlans = lazy(() => import("@/pages/admin/AdminCoursePlans"));
const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDetail"));
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
// Courseware pages
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
const FaqPage = lazy(() => import("@/pages/FaqPage"));
const NotFound = lazy(() => import("@/pages/NotFound"));
function StudentSubscriptionPlaceholder() {
const navigate = useNavigate();
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 p-8">
<p className="text-muted-foreground text-center max-w-sm">Subscription checkout will be available here.</p>
<Button onClick={() => navigate("/student/dashboard")}>Back to dashboard</Button>
</div>
);
}
function RouteFallback() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const App = () => ( const App = () => (
<ErrorBoundary>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="encoach-theme"
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Sonner /> <Sonner />
<BrowserRouter> <BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<AuthProvider> <AuthProvider>
<Suspense fallback={<RouteFallback />}>
<Routes> <Routes>
{/* Auth routes */} {/* Auth routes */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<EmailVerification />} />
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/verify/:verificationHash" element={<ScoreVerification />} />
{/* Public pages */} {/* Public pages */}
<Route path="/apply" element={<AdmissionApplication />} /> <Route path="/apply" element={<AdmissionApplication />} />
<Route path="/exam/access/:token" element={<OfficialExamAccess />} /> <Route path="/exam/access/:token" element={<OfficialExamAccess />} />
@@ -128,8 +235,16 @@ const App = () => (
<Route path="/exam/login/:examId" element={<OfficialExamAccess />} /> <Route path="/exam/login/:examId" element={<OfficialExamAccess />} />
<Route path="/faq" element={<FaqPage />} /> <Route path="/faq" element={<FaqPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/onboarding" element={<OnboardingWizard />} />
</Route>
{/* Student routes */} {/* Student routes */}
<Route element={<ProtectedRoute allowedRoles={["student"]} />}> <Route element={<ProtectedRoute allowedRoles={["student"]} />}>
<Route path="/student/exam/:examId/session" element={<ExamSession />} />
<Route path="/student/exam/:examId/status" element={<ExamStatus />} />
<Route path="/student/placement/test" element={<PlacementTest />} />
<Route path="/student/subscription" element={<StudentSubscriptionPlaceholder />} />
<Route element={<StudentLayout />}> <Route element={<StudentLayout />}>
<Route path="/student/dashboard" element={<StudentDashboard />} /> <Route path="/student/dashboard" element={<StudentDashboard />} />
<Route path="/student/courses" element={<StudentCourses />} /> <Route path="/student/courses" element={<StudentCourses />} />
@@ -139,6 +254,7 @@ const App = () => (
<Route path="/student/attendance" element={<StudentAttendance />} /> <Route path="/student/attendance" element={<StudentAttendance />} />
<Route path="/student/timetable" element={<StudentTimetable />} /> <Route path="/student/timetable" element={<StudentTimetable />} />
<Route path="/student/profile" element={<StudentProfile />} /> <Route path="/student/profile" element={<StudentProfile />} />
<Route path="/student/privacy" element={<PrivacyCenter />} />
<Route path="/student/subjects" element={<SubjectSelection />} /> <Route path="/student/subjects" element={<SubjectSelection />} />
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} /> <Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} /> <Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
@@ -150,14 +266,26 @@ const App = () => (
<Route path="/student/announcements" element={<StudentAnnouncements />} /> <Route path="/student/announcements" element={<StudentAnnouncements />} />
<Route path="/student/messages" element={<StudentMessages />} /> <Route path="/student/messages" element={<StudentMessages />} />
<Route path="/student/journey" element={<StudentJourney />} /> <Route path="/student/journey" element={<StudentJourney />} />
<Route path="/student/placement" element={<PlacementBriefing />} />
<Route path="/student/placement/results" element={<PlacementResults />} />
<Route path="/student/placement/access" element={<PlacementAccess />} />
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
<Route path="/student/course/generate" element={<GapAnalysis />} />
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
</Route> </Route>
</Route> </Route>
{/* Teacher routes */} {/* Teacher routes */}
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}> <Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
<Route element={<TeacherLayout />}> <Route element={<TeacherLayout />}>
<Route path="/teacher/dashboard" element={<TeacherDashboard />} /> <Route path="/teacher/dashboard" element={<TeacherDashboard />} />
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
<Route path="/teacher/courses" element={<TeacherCourses />} /> <Route path="/teacher/courses" element={<TeacherCourses />} />
<Route path="/teacher/library" element={<TeacherLibrary />} />
<Route path="/teacher/courses/new" element={<CourseBuilder />} /> <Route path="/teacher/courses/new" element={<CourseBuilder />} />
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} /> <Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
<Route path="/teacher/assignments" element={<TeacherAssignments />} /> <Route path="/teacher/assignments" element={<TeacherAssignments />} />
@@ -165,12 +293,15 @@ const App = () => (
<Route path="/teacher/attendance" element={<TeacherAttendance />} /> <Route path="/teacher/attendance" element={<TeacherAttendance />} />
<Route path="/teacher/students" element={<TeacherStudents />} /> <Route path="/teacher/students" element={<TeacherStudents />} />
<Route path="/teacher/timetable" element={<TeacherTimetable />} /> <Route path="/teacher/timetable" element={<TeacherTimetable />} />
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} /> <Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} /> <Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} /> <Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} /> <Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} /> <Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} /> <Route path="/teacher/profile" element={<TeacherProfile />} />
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
</Route> </Route>
</Route> </Route>
@@ -179,6 +310,14 @@ const App = () => (
<Route element={<AdminLmsLayout />}> <Route element={<AdminLmsLayout />}>
{/* LMS Dashboard */} {/* LMS Dashboard */}
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} /> <Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
<Route path="/admin/quick-setup" element={<AdminQuickSetup />} />
<Route path="/admin/smart-wizard" element={<SmartWizardHub />} />
<Route path="/admin/smart-wizard/rubric" element={<RubricWizard />} />
<Route path="/admin/smart-wizard/exam-structure" element={<ExamStructureWizard />} />
<Route path="/admin/smart-wizard/course" element={<CourseWizard />} />
<Route path="/admin/smart-wizard/course-plan" element={<CoursePlanWizard />} />
<Route path="/admin/course-plans" element={<AdminCoursePlans />} />
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
{/* Original platform dashboard */} {/* Original platform dashboard */}
<Route path="/admin/platform" element={<AdminDashboard />} /> <Route path="/admin/platform" element={<AdminDashboard />} />
{/* LMS pages */} {/* LMS pages */}
@@ -191,6 +330,7 @@ const App = () => (
<Route path="/admin/reports" element={<AdminReports />} /> <Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} /> <Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} /> <Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */} {/* Original academic pages */}
<Route path="/admin/assignments" element={<AssignmentsPage />} /> <Route path="/admin/assignments" element={<AssignmentsPage />} />
<Route path="/admin/examsList" element={<ExamsListPage />} /> <Route path="/admin/examsList" element={<ExamsListPage />} />
@@ -213,7 +353,16 @@ const App = () => (
<Route path="/admin/payment-record" element={<PaymentRecordPage />} /> <Route path="/admin/payment-record" element={<PaymentRecordPage />} />
<Route path="/admin/tickets" element={<TicketsPage />} /> <Route path="/admin/tickets" element={<TicketsPage />} />
<Route path="/admin/settings-platform" element={<SettingsPage />} /> <Route path="/admin/settings-platform" element={<SettingsPage />} />
<Route path="/admin/exam" element={<ExamPage />} /> <Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
<Route path="/admin/exam/review-queue" element={<ExamReviewQueue />} />
<Route path="/admin/exam/review/:examId" element={<ExamReviewDetail />} />
<Route path="/admin/ai/prompts" element={<AIPromptEditor />} />
<Route path="/admin/ai/feedback" element={<AIFeedbackTriage />} />
<Route path="/admin/taxonomy" element={<TaxonomyManager />} /> <Route path="/admin/taxonomy" element={<TaxonomyManager />} />
<Route path="/admin/resources" element={<ResourceManager />} /> <Route path="/admin/resources" element={<ResourceManager />} />
{/* Institutional LMS pages */} {/* Institutional LMS pages */}
@@ -239,6 +388,19 @@ const App = () => (
<Route path="/admin/roles-permissions" element={<RolesPermissions />} /> <Route path="/admin/roles-permissions" element={<RolesPermissions />} />
<Route path="/admin/authority-matrix" element={<AuthorityMatrix />} /> <Route path="/admin/authority-matrix" element={<AuthorityMatrix />} />
<Route path="/admin/user-roles" element={<UserRoles />} /> <Route path="/admin/user-roles" element={<UserRoles />} />
<Route path="/admin/exam/:examId/grading" element={<GradingQueue />} />
<Route path="/admin/course/configure/:courseId" element={<CourseConfig />} />
<Route path="/admin/course/:courseId/modules" element={<ModuleBuilder />} />
<Route path="/admin/entity/students/upload" element={<BulkStudentUpload />} />
<Route path="/admin/entity/students/credentials" element={<CredentialDashboard />} />
<Route path="/admin/entity/:entityId/level-mapping" element={<LevelMappingConfig />} />
<Route path="/admin/entity/:entityId/branding" element={<WhiteLabelBranding />} />
<Route path="/admin/ai-course/english/:courseId/quality" element={<AiEnglishQuality />} />
<Route path="/admin/ai-course/english/taxonomy" element={<AiEnglishTaxonomy />} />
<Route path="/admin/ai-course/ielts/:courseId/validation" element={<AiIeltsValidation />} />
<Route path="/admin/adaptive/dashboard" element={<AdaptiveDashboard />} />
<Route path="/admin/adaptive/student/:studentId" element={<AdaptiveStudentDetail />} />
<Route path="/admin/scores/pending" element={<ScoreApprovalQueue />} />
</Route> </Route>
</Route> </Route>
@@ -249,10 +411,13 @@ const App = () => (
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} /> <Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</Suspense>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider>
</ErrorBoundary>
); );
export default App; export default App;

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import {
useAIFeedbackSummary,
useSubmitAIFeedback,
} from "@/hooks/queries/useAIFeedback";
import { cn } from "@/lib/utils";
import type {
AIFeedbackRating,
AIFeedbackSubjectType,
} from "@/types/ai-feedback";
export interface AIFeedbackButtonsProps {
subjectType: AIFeedbackSubjectType;
subjectId: number;
promptKey?: string;
promptVersion?: number;
aiLogId?: number;
entityId?: number;
courseId?: number;
size?: "sm" | "md";
className?: string;
}
/** Thumbs up/down widget for any AI-generated artefact.
*
* Always renders the same UI regardless of whether the user has rated the
* artefact before — a previous rating will show as highlighted. Clicking the
* same button again is a no-op; clicking the opposite button updates the
* server-side row in place. A thumbs-down always prompts the user for a short
* comment (required by the backend).
*/
export function AIFeedbackButtons({
subjectType,
subjectId,
promptKey,
promptVersion,
aiLogId,
entityId,
courseId,
size = "sm",
className,
}: AIFeedbackButtonsProps) {
const summary = useAIFeedbackSummary(subjectType, subjectId);
const submit = useSubmitAIFeedback();
const [downOpen, setDownOpen] = useState(false);
const [comment, setComment] = useState("");
useEffect(() => {
if (downOpen) {
setComment(summary.data?.my_comment ?? "");
}
}, [downOpen, summary.data?.my_comment]);
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
const submitUp = async () => {
if (myRating === "up") return;
try {
await submit.mutateAsync({
subject_type: subjectType,
subject_id: subjectId,
rating: "up",
prompt_key: promptKey,
prompt_version: promptVersion,
ai_log_id: aiLogId,
entity_id: entityId,
course_id: courseId,
});
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit");
}
};
const submitDown = async () => {
if (!comment.trim()) {
toast.error("Please tell us what was wrong.");
return;
}
try {
await submit.mutateAsync({
subject_type: subjectType,
subject_id: subjectId,
rating: "down",
comment: comment.trim(),
prompt_key: promptKey,
prompt_version: promptVersion,
ai_log_id: aiLogId,
entity_id: entityId,
course_id: courseId,
});
setDownOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit");
}
};
const btnSize = size === "sm" ? "sm" : "default";
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
return (
<div className={cn("flex items-center gap-1", className)}>
<Button
variant={myRating === "up" ? "default" : "outline"}
size={btnSize}
onClick={submitUp}
disabled={submit.isPending}
aria-label="Thumbs up"
>
<ThumbsUp className={iconCls} />
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
</Button>
<Button
variant={myRating === "down" ? "default" : "outline"}
size={btnSize}
onClick={() => setDownOpen(true)}
disabled={submit.isPending}
aria-label="Thumbs down"
>
<ThumbsDown className={iconCls} />
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
</Button>
<Dialog open={downOpen} onOpenChange={setDownOpen}>
<DialogContent
className="max-w-md"
description="Tell us what was wrong so we can improve this AI output."
>
<DialogHeader>
<DialogTitle>What went wrong?</DialogTitle>
</DialogHeader>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setDownOpen(false)}>
Cancel
</Button>
<Button onClick={submitDown} disabled={submit.isPending}>
Submit feedback
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AIFeedbackButtons;

View File

@@ -1,4 +1,5 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom"; import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -9,10 +10,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { NavLink } from "@/components/NavLink"; import { NavLink } from "@/components/NavLink";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown"; import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer"; import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar"; import AiSearchBar from "@/components/ai/AiSearchBar";
import { usePermissions } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
@@ -29,115 +33,128 @@ import {
CalendarDays, Landmark, UserPlus, ScrollText, Award, CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow, HelpCircle as FaqIcon, Bell, Workflow,
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp, CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
Library, Activity, Warehouse, UserCog, Library, Activity, Warehouse, UserCog, Sparkles, Compass,
} from "lucide-react"; } from "lucide-react";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
// ============= Navigation Config ============= // ============= Navigation Config =============
interface NavItem { title: string; url: string; icon: LucideIcon } // Items store i18n keys (`titleKey`) rather than literal strings so Arabic
// language switches update the sidebar without the component having to
// re-mount.
interface NavItem { titleKey: string; url: string; icon: LucideIcon }
const overviewItems: NavItem[] = [ const overviewItems: NavItem[] = [
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard }, { titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 }, { titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
]; ];
const lmsItems: NavItem[] = [ const lmsItems: NavItem[] = [
{ title: "Courses", url: "/admin/courses", icon: BookOpen }, { titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ title: "Students", url: "/admin/students", icon: Users }, { titleKey: "nav.students", url: "/admin/students", icon: Users },
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap }, { titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
{ title: "Batches", url: "/admin/batches", icon: Layers }, { titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ title: "Timetable", url: "/admin/timetable", icon: Calendar }, { titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ title: "Reports", url: "/admin/reports", icon: BarChart3 }, { titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
]; ];
const academicItems: NavItem[] = [ const academicItems: NavItem[] = [
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList }, { titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
{ title: "Exams List", url: "/admin/examsList", icon: FileText }, { titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers }, { titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen }, { titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
{ title: "Generation", url: "/admin/generation", icon: Wand2 }, { titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch }, { titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
{ titleKey: "nav.coursePlans", url: "/admin/course-plans", icon: Compass },
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
]; ];
const adaptiveItems: NavItem[] = [ const adaptiveItems: NavItem[] = [
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target }, { titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
{ title: "Resources", url: "/admin/resources", icon: FolderOpen }, { titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
]; ];
const institutionalItems: NavItem[] = [ const institutionalItems: NavItem[] = [
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays }, { titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
{ title: "Departments", url: "/admin/departments", icon: Landmark }, { titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus }, { titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText }, { titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText }, { titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
{ title: "Marksheets", url: "/admin/marksheets", icon: Award }, { titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff }, { titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign }, { titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked }, { titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal }, { titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp }, { titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
{ title: "Library", url: "/admin/library", icon: Library }, { titleKey: "nav.library", url: "/admin/library", icon: Library },
{ title: "Activities", url: "/admin/activities", icon: Activity }, { titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse }, { titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
]; ];
const managementItems: NavItem[] = [ const managementItems: NavItem[] = [
{ title: "Users", url: "/admin/users", icon: Users }, { titleKey: "nav.users", url: "/admin/users", icon: Users },
{ title: "Entities", url: "/admin/entities", icon: Building2 }, { titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap }, { titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog }, { titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings }, { titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow }, { titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
]; ];
const reportItems: NavItem[] = [ const reportItems: NavItem[] = [
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 }, { titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 }, { titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
{ title: "Record", url: "/admin/record", icon: History }, { titleKey: "nav.record", url: "/admin/record", icon: History },
]; ];
const trainingItems: NavItem[] = [ const trainingItems: NavItem[] = [
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA }, { titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool }, { titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
]; ];
const configItems: NavItem[] = [ const configItems: NavItem[] = [
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon }, { titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell }, { titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow }, { titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
]; ];
const supportItems: NavItem[] = [ const supportItems: NavItem[] = [
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard }, { titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ title: "Tickets", url: "/admin/tickets", icon: Ticket }, { titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ title: "Settings", url: "/admin/settings-platform", icon: Settings }, { titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
]; ];
// ============= Reusable sidebar group ============= // ============= Reusable sidebar group =============
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) { function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
const { state } = useSidebar(); const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel> <SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => {
<SidebarMenuItem key={item.url}> const label = t(item.titleKey);
<SidebarMenuButton asChild tooltip={item.title}> return (
<NavLink <SidebarMenuItem key={item.url}>
to={item.url} <SidebarMenuButton asChild tooltip={label}>
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")} <NavLink
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors" to={item.url}
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium" end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
> className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
<item.icon className="h-4 w-4 shrink-0" /> activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
{!collapsed && <span>{item.title}</span>} >
</NavLink> <item.icon className="h-4 w-4 shrink-0" />
</SidebarMenuButton> {!collapsed && <span>{label}</span>}
</SidebarMenuItem> </NavLink>
))} </SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@@ -145,40 +162,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
} }
// ============= Breadcrumbs ============= // ============= Breadcrumbs =============
const routeLabels: Record<string, string> = { // Map URL segments to i18n keys so breadcrumbs follow the active locale.
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses", // Segments without an entry fall back to a Title-cased version of the slug.
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable", const routeLabelKeys: Record<string, string> = {
reports: "Reports", assignments: "Assignments", examsList: "Exams List", admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation", platform: "breadcrumb.platform", courses: "nav.courses",
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities", students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
classrooms: "Classrooms", "student-performance": "Student Performance", timetable: "nav.timetable", reports: "nav.reports",
"stats-corporate": "Stats Corporate", record: "Record", training: "Training", assignments: "nav.assignments", examsList: "nav.examsList",
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record", "exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings", generation: "nav.generation", "review-queue": "nav.reviewQueue",
profile: "Profile", exam: "Exam", review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
"academic-years": "Academic Years", departments: "Departments", feedback: "breadcrumb.feedback",
admissions: "Admissions", "admission-register": "Admission Register", "approval-workflows": "nav.approvalWorkflows", users: "nav.users",
"exam-sessions": "Exam Sessions", marksheets: "Marksheets", entities: "nav.entities", classrooms: "nav.classrooms",
faq: "FAQ Manager", "notification-rules": "Notification Rules", "student-performance": "nav.studentPerformance",
"approval-config": "Approval Config", "stats-corporate": "nav.statsCorporate", record: "nav.record",
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons", training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
gradebook: "Gradebook", "student-progress": "Student Progress", grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
library: "Library", activities: "Activities", facilities: "Facilities", tickets: "nav.tickets", "settings-platform": "nav.settings",
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
"academic-years": "nav.academicYears", departments: "nav.departments",
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
"approval-config": "nav.approvalConfig",
"student-leave": "nav.studentLeave", fees: "nav.fees",
lessons: "nav.lessons", gradebook: "nav.gradebook",
"student-progress": "nav.studentProgress", library: "nav.library",
activities: "nav.activities", facilities: "nav.facilities",
}; };
function AppBreadcrumbs() { function AppBreadcrumbs() {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation();
const segments = location.pathname.split("/").filter(Boolean); const segments = location.pathname.split("/").filter(Boolean);
return ( return (
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink> <BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
{segments.map((seg, i) => { {segments.map((seg, i) => {
const path = "/" + segments.slice(0, i + 1).join("/"); const path = "/" + segments.slice(0, i + 1).join("/");
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1); const key = routeLabelKeys[seg];
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
const isLast = i === segments.length - 1; const isLast = i === segments.length - 1;
return ( return (
<React.Fragment key={path}> <React.Fragment key={path}>
@@ -194,10 +223,32 @@ function AppBreadcrumbs() {
); );
} }
// ============= Route content fallback =============
// Shown only inside the main content area while a lazy-loaded route chunk
// is fetching. Keeping the fallback local means the sidebar and header
// stay mounted during navigation — previously the page felt like a full
// browser reload because the outer App-level Suspense replaced everything
// with a full-viewport spinner.
function RouteContentFallback() {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
);
}
// ============= Main Layout ============= // ============= Main Layout =============
export default function AdminLmsLayout() { export default function AdminLmsLayout() {
const { user, logout } = useAuth(); const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
// on the wizard's own Next/Finish footer (real bug repro: trying to
// click "Next" in the Course-plan wizard at the standard 1024×768
// viewport hits the pill instead). The AI Assistant orb also at the
// bottom-right is preserved — it's smaller and useful in-wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"; const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -213,13 +264,53 @@ export default function AdminLmsLayout() {
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0"> <header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SidebarTrigger /> <SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<AppBreadcrumbs /> <AppBreadcrumbs />
</div> </div>
<AiSearchBar /> <AiSearchBar />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t("nav.entities")}
</div>
<DropdownMenuSeparator />
{user.entities.map((entity) => (
<DropdownMenuItem
key={entity.id}
onClick={() => {
setSelectedEntityId(entity.id);
// Force page data to refresh against the newly
// selected entity scope.
window.location.reload();
}}
className="flex items-center justify-between gap-2"
>
<span className="truncate">{entity.name}</span>
{selectedEntity?.id === entity.id && (
<Badge variant="secondary" className="text-[10px]">
{t("common.active", "Active")}
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown /> <NotificationDropdown />
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground"> <Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" /> <Ticket className="h-4 w-4" />
</Button> </Button>
<DropdownMenu> <DropdownMenu>
@@ -232,37 +323,41 @@ export default function AdminLmsLayout() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name ?? "User"}</p> <p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p> <p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/admin/profile")}> <DropdownMenuItem onClick={() => navigate("/admin/profile")}>
<User className="mr-2 h-4 w-4" /> Profile <User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}> <DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
<Settings className="mr-2 h-4 w-4" /> Settings <Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}> <DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout <LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main> </main>
</div> </div>
</div> </div>
<AiAssistantDrawer /> <AiAssistantDrawer />
<Link {!isWizardRoute && (
to="/admin/tickets" <Link
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" to="/admin/tickets"
> className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
<HelpCircle className="h-4 w-4" /> >
<span className="text-sm font-medium">Need help?</span> <HelpCircle className="h-4 w-4" />
</Link> <span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link>
)}
</SidebarProvider> </SidebarProvider>
); );
} }
@@ -273,67 +368,78 @@ function AdminSidebar() {
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
const { user } = useAuth(); const { user } = useAuth();
const { hasAnyPermission, isAdmin } = usePermissions(); const { hasAnyPermission, isAdmin } = usePermissions();
const { t, i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]); const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]); const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]); const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
return ( return (
<Sidebar collapsible="icon"> <Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4"> <SidebarHeader className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" /> {collapsed ? (
{!collapsed && ( <img
<span className="text-lg font-bold tracking-tight text-sidebar-foreground"> src="/logo.png"
<span className="text-[hsl(42,40%,62%)]">En</span> alt={t("chrome.adminAlt")}
<span>Coach</span> className="h-9 w-9 shrink-0 rounded-lg object-contain"
</span> />
) : (
<img
src="/logo.png"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain"
/>
)} )}
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator /> <SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarNavGroup label="Overview" items={overviewItems} /> <SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
<SidebarNavGroup label="LMS" items={lmsItems} /> <SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} /> <SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
<SidebarNavGroup label="Institutional" items={institutionalItems} /> <SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
<SidebarNavGroup label="Academic" items={academicItems} /> <SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />} {showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />} {showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
<SidebarNavGroup label="Configuration" items={configItems} /> <SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
<SidebarGroup> <SidebarGroup>
<Collapsible defaultOpen className="group/collapsible"> <Collapsible defaultOpen className="group/collapsible">
<SidebarGroupLabel asChild> <SidebarGroupLabel asChild>
<CollapsibleTrigger className="flex w-full items-center justify-between"> <CollapsibleTrigger className="flex w-full items-center justify-between">
Training {t("sidebarGroup.training")}
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />} {!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
</CollapsibleTrigger> </CollapsibleTrigger>
</SidebarGroupLabel> </SidebarGroupLabel>
<CollapsibleContent> <CollapsibleContent>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{trainingItems.map((item) => ( {trainingItems.map((item) => {
<SidebarMenuItem key={item.url}> const label = t(item.titleKey);
<SidebarMenuButton asChild tooltip={item.title}> return (
<NavLink <SidebarMenuItem key={item.url}>
to={item.url} <SidebarMenuButton asChild tooltip={label}>
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors" <NavLink
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium" to={item.url}
> className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
<item.icon className="h-4 w-4 shrink-0" /> activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
{!collapsed && <span>{item.title}</span>} >
</NavLink> <item.icon className="h-4 w-4 shrink-0" />
</SidebarMenuButton> {!collapsed && <span>{label}</span>}
</SidebarMenuItem> </NavLink>
))} </SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</SidebarGroup> </SidebarGroup>
<SidebarNavGroup label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} /> <SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
</SidebarContent> </SidebarContent>
<SidebarSeparator /> <SidebarSeparator />
<SidebarFooter className="p-3"> <SidebarFooter className="p-3">
@@ -343,7 +449,7 @@ function AdminSidebar() {
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span> <span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span> <span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
</div> </div>
)} )}

View File

@@ -1,4 +1,5 @@
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom"; import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/AppSidebar"; import { AppSidebar } from "@/components/AppSidebar";
import { import {
@@ -6,14 +7,16 @@ import {
BreadcrumbPage, BreadcrumbSeparator, BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react"; import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
import React from "react"; import React from "react";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer"; import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar"; import AiSearchBar from "@/components/ai/AiSearchBar";
import { useAuth } from "@/contexts/AuthContext";
const routeLabels: Record<string, string> = { const routeLabels: Record<string, string> = {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -77,8 +80,26 @@ function AppBreadcrumbs() {
); );
} }
// Local Suspense fallback so the sidebar/header keep rendering while a
// lazy route chunk loads. See AdminLmsLayout for the full rationale.
function RouteContentFallback() {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
);
}
export default function AppLayout() { export default function AppLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, selectedEntity, setSelectedEntityId } = useAuth();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
// intercepting clicks on the wizard's own Next/Finish footer at most
// viewport heights, blocking users from finishing the flow. The AI
// Assistant orb (also bottom-right) stays — it's smaller and
// genuinely useful inside the wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
return ( return (
<SidebarProvider> <SidebarProvider>
@@ -92,6 +113,34 @@ export default function AppLayout() {
</div> </div>
<AiSearchBar /> <AiSearchBar />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{user.entities.map((entity) => {
const active = selectedEntity?.id === entity.id;
return (
<DropdownMenuItem
key={entity.id}
onClick={() => setSelectedEntityId(entity.id)}
className="flex items-center justify-between gap-3"
>
<span className="truncate">{entity.name}</span>
{active ? <Badge variant="secondary">Current</Badge> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground"> <Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" /> <Ticket className="h-4 w-4" />
</Button> </Button>
@@ -127,19 +176,22 @@ export default function AppLayout() {
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main> </main>
</div> </div>
</div> </div>
<AiAssistantDrawer /> <AiAssistantDrawer />
{/* Floating help button */} {!isWizardRoute && (
<Link <Link
to="/tickets" to="/tickets"
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span> <span className="text-sm font-medium">Need help?</span>
</Link> </Link>
)}
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@@ -5,6 +5,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { NavLink } from "@/components/NavLink"; import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
@@ -81,9 +82,11 @@ export function AppSidebar() {
const { state } = useSidebar(); const { state } = useSidebar();
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
const location = useLocation(); const location = useLocation();
const { i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
return ( return (
<Sidebar collapsible="icon"> <Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4"> <SidebarHeader className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0"> <div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">

View File

@@ -0,0 +1,71 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
interface Props extends WithTranslation {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* Error boundary is a classical React class component, so we bridge it to
* i18next via the `withTranslation` HOC. That keeps the tree-shaking of
* `react-i18next`'s hook path intact while giving us a `t` prop here.
*/
class ErrorBoundaryInner extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
const { t } = this.props;
return (
<div className="min-h-screen flex items-center justify-center bg-background p-6">
<div className="max-w-md w-full text-center space-y-4">
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
<p className="text-muted-foreground text-sm">
{t("errors.unexpectedDescription")}
</p>
{this.state.error && (
<details className="text-start text-xs text-muted-foreground bg-muted rounded-lg p-3">
<summary className="cursor-pointer font-medium">{t("errors.errorDetails")}</summary>
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
</details>
)}
<button
onClick={() => window.location.reload()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t("errors.refreshPage")}
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);

View File

@@ -0,0 +1,48 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
export function LanguageToggle() {
const { i18n, t } = useTranslation();
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("language.change")}
title={t("language.change")}
>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("language.label")}</DropdownMenuLabel>
{SUPPORTED_LANGS.map((lang) => (
<DropdownMenuCheckboxItem
key={lang.code}
checked={current === lang.code}
onCheckedChange={(checked) => {
if (checked) void i18n.changeLanguage(lang.code);
}}
>
{lang.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LanguageToggle;

View File

@@ -0,0 +1,235 @@
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
import { API_BASE_URL } from "@/lib/api-client";
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
interface MaterialViewerProps {
material: ChapterMaterial | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onDownload?: (materialId: number, name: string) => void;
}
const typeLabels: Record<MaterialType, string> = {
pdf: "PDF Document",
document: "Document",
video: "Video",
audio: "Audio",
image: "Image",
link: "External Link",
article: "Article",
};
const typeIcons: Record<MaterialType, React.ReactNode> = {
pdf: <FileText className="h-4 w-4" />,
document: <FileText className="h-4 w-4" />,
video: <Video className="h-4 w-4" />,
audio: <Music className="h-4 w-4" />,
image: <Image className="h-4 w-4" />,
link: <Link2 className="h-4 w-4" />,
article: <FileText className="h-4 w-4" />,
};
function getContentUrl(materialId: number): string {
const token = localStorage.getItem("encoach_token") ?? "";
return `${API_BASE_URL}/materials/${materialId}/content?token=${token}`;
}
function isYouTubeUrl(url: string): boolean {
return /(?:youtube\.com\/(?:watch|embed)|youtu\.be\/)/.test(url);
}
function getYouTubeEmbedUrl(url: string): string {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
);
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
}
function PdfViewer({ material }: { material: ChapterMaterial }) {
if (material.file_url) {
return (
<iframe
src={getContentUrl(material.id)}
className="w-full h-full min-h-[600px] rounded-lg border"
title={material.name}
/>
);
}
if (material.url) {
return (
<iframe
src={material.url}
className="w-full h-full min-h-[600px] rounded-lg border"
title={material.name}
/>
);
}
return <EmptyState message="No PDF file available." />;
}
function VideoViewer({ material }: { material: ChapterMaterial }) {
if (material.url && isYouTubeUrl(material.url)) {
return (
<iframe
src={getYouTubeEmbedUrl(material.url)}
className="w-full aspect-video rounded-lg border"
title={material.name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
);
}
if (material.url) {
return (
<video
src={material.url}
controls
className="w-full rounded-lg border"
title={material.name}
>
Your browser does not support the video tag.
</video>
);
}
if (material.file_url) {
return (
<video
src={getContentUrl(material.id)}
controls
className="w-full rounded-lg border"
title={material.name}
>
Your browser does not support the video tag.
</video>
);
}
return <EmptyState message="No video source available." />;
}
function AudioViewer({ material }: { material: ChapterMaterial }) {
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
if (!src) return <EmptyState message="No audio source available." />;
return (
<div className="flex items-center justify-center py-12">
<audio src={src} controls className="w-full max-w-lg">
Your browser does not support the audio element.
</audio>
</div>
);
}
function ImageViewer({ material }: { material: ChapterMaterial }) {
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
if (!src) return <EmptyState message="No image available." />;
return (
<div className="flex items-center justify-center">
<img
src={src}
alt={material.name}
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
/>
</div>
);
}
function ArticleViewer({ material }: { material: ChapterMaterial }) {
if (!material.description) return <EmptyState message="No article content available." />;
return (
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
<div className="whitespace-pre-wrap">{material.description}</div>
</div>
);
}
function LinkViewer({ material }: { material: ChapterMaterial }) {
if (!material.url) return <EmptyState message="No URL available." />;
return (
<div className="space-y-4">
<div className="flex items-center gap-2 p-4 rounded-lg border bg-muted/50">
<Link2 className="h-5 w-5 text-primary shrink-0" />
<a
href={material.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline break-all flex-1"
>
{material.url}
</a>
<Button variant="outline" size="sm" asChild>
<a href={material.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1" /> Open
</a>
</Button>
</div>
<iframe
src={material.url}
className="w-full min-h-[500px] rounded-lg border"
title={material.name}
sandbox="allow-scripts allow-same-origin"
/>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<FileText className="h-12 w-12 mb-3 opacity-40" />
<p>{message}</p>
</div>
);
}
const viewers: Record<MaterialType, React.FC<{ material: ChapterMaterial }>> = {
pdf: PdfViewer,
document: PdfViewer,
video: VideoViewer,
audio: AudioViewer,
image: ImageViewer,
link: LinkViewer,
article: ArticleViewer,
};
export default function MaterialViewer({ material, open, onOpenChange, onDownload }: MaterialViewerProps) {
if (!material) return null;
const Viewer = viewers[material.type] ?? ArticleViewer;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
<SheetHeader className="space-y-3 pb-4 border-b">
<div className="flex items-center gap-2">
{typeIcons[material.type]}
<Badge variant="outline">{typeLabels[material.type]}</Badge>
</div>
<SheetTitle className="text-lg">{material.name}</SheetTitle>
{material.description && material.type !== "article" && (
<p className="text-sm text-muted-foreground">{material.description}</p>
)}
<div className="flex gap-2">
{material.allow_download && material.file_url && onDownload && (
<Button variant="outline" size="sm" onClick={() => onDownload(material.id, material.name)}>
<Download className="h-4 w-4 mr-1" /> Download
</Button>
)}
{material.url && (
<Button variant="outline" size="sm" asChild>
<a href={material.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1" /> Open Link
</a>
</Button>
)}
</div>
</SheetHeader>
<div className="mt-6">
<Viewer material={material} />
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -1,4 +1,4 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom"; import { NavLink as RouterNavLink, NavLinkProps, useNavigate } from "react-router-dom";
import { forwardRef } from "react"; import { forwardRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
} }
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>( const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => { ({ className, activeClassName, pendingClassName, to, onClick, ...props }, ref) => {
const navigate = useNavigate();
const isExternalTo = (value: NavLinkProps["to"]): boolean => {
if (typeof value !== "string") return false;
return /^(https?:)?\/\//.test(value) || value.startsWith("mailto:") || value.startsWith("tel:");
};
return ( return (
<RouterNavLink <RouterNavLink
ref={ref} ref={ref}
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
className={({ isActive, isPending }) => className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName) cn(className, isActive && activeClassName, isPending && pendingClassName)
} }
onClick={(event) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.altKey ||
event.ctrlKey ||
event.shiftKey ||
props.target === "_blank" ||
isExternalTo(to)
) {
return;
}
// Force client-side route transitions for app menu links.
event.preventDefault();
navigate(to, {
replace: props.replace,
state: props.state,
relative: props.relative,
preventScrollReset: props.preventScrollReset,
viewTransition: props.viewTransition,
});
}}
{...props} {...props}
/> />
); );

View File

@@ -1,35 +1,50 @@
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
export default function NotificationDropdown() { export default function NotificationDropdown() {
const { t } = useTranslation();
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = []; const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
const unread = notifications.filter((n) => !n.read).length; const unread = notifications.filter((n) => !n.read).length;
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground"> <Button
variant="ghost"
size="icon"
aria-label={t("notifications.ariaLabel")}
title={t("notifications.ariaLabel")}
className="relative text-muted-foreground hover:text-foreground"
>
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
{unread > 0 && ( {unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center"> <span className="absolute -top-0.5 -end-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
{unread} {unread}
</span> </span>
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80"> <DropdownMenuContent align="end" className="w-80">
<div className="px-3 py-2 font-semibold text-sm">Notifications</div> <div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{notifications.slice(0, 4).map((n) => ( {notifications.length === 0 ? (
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer"> <div className="px-3 py-6 text-center text-xs text-muted-foreground">
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span> {t("notifications.empty")}
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span> </div>
<span className="text-[10px] text-muted-foreground/70">{n.time}</span> ) : (
</DropdownMenuItem> notifications.slice(0, 4).map((n) => (
))} <DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@@ -0,0 +1,250 @@
import { Link } from "react-router-dom";
import { useQueries } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { LucideIcon, Check, ChevronRight, Sparkles } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* Smart-setup wizard primitives.
*
* The wizard is purposefully thin: each step / quick-create card is a deep
* link into an existing creation page that already knows how to talk to the
* backend. We don't re-implement forms here — the value of this screen is
* the guided sequence, the visual "what's next" hint, and the completion
* ticks that show which parts of the setup still need attention.
*
* Completion detection is optional and client-only. A caller can supply a
* `check` function that returns a Promise<boolean>; the wizard runs it via
* react-query so the UI refreshes automatically when the user returns from
* a sub-page without any extra plumbing.
*/
export interface WizardStep {
/** Stable key used for react-query cache. */
id: string;
titleKey: string;
descriptionKey: string;
/** Destination page that performs the actual creation. */
to: string;
icon: LucideIcon;
/** Optional completion check. When omitted the step is never auto-ticked. */
check?: () => Promise<boolean>;
/** Optional i18n key for a longer help tooltip. */
helpKey?: string;
}
export interface QuickCreate {
id: string;
titleKey: string;
descriptionKey: string;
to: string;
icon: LucideIcon;
}
export interface QuickSetupWizardProps {
/** Page heading i18n key, e.g. "quickSetup.adminTitle". */
titleKey: string;
/** Short lead paragraph i18n key. */
subtitleKey: string;
/** Ordered "Recommended flow" steps. */
steps: WizardStep[];
/** Side-grid of one-click creates that don't belong to the main flow. */
quickCreates: QuickCreate[];
}
export function QuickSetupWizard({
titleKey,
subtitleKey,
steps,
quickCreates,
}: QuickSetupWizardProps) {
const { t } = useTranslation();
// Run all completion checks in parallel. Each step with a `check` gets its
// own cached query keyed by the step id. Steps without a `check` just
// resolve to `undefined` and render as neutral.
const queries = useQueries({
queries: steps.map((step) => ({
queryKey: ["quick-setup-check", step.id],
queryFn: step.check ?? (async () => undefined),
enabled: Boolean(step.check),
// Re-check when the user comes back from a sub-page — that's the most
// common path to "un-greying" a step after they've created a rubric /
// structure / exam.
refetchOnWindowFocus: true,
staleTime: 30_000,
})),
});
const completedCount = queries.filter((q) => q.data === true).length;
const totalCheckable = steps.filter((s) => s.check).length;
const progress = totalCheckable > 0 ? Math.round((completedCount / totalCheckable) * 100) : 0;
return (
<TooltipProvider delayDuration={200}>
<div className="space-y-6">
{/* Heading + progress */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
</div>
<p className="text-muted-foreground max-w-2xl">{t(subtitleKey)}</p>
</div>
{totalCheckable > 0 && (
<div className="shrink-0 text-right">
<div className="text-sm text-muted-foreground">
{t("quickSetup.progressLabel")}
</div>
<div className="text-2xl font-semibold tabular-nums">
{completedCount}/{totalCheckable}
</div>
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
{/* Recommended flow */}
<section aria-labelledby="quick-setup-flow">
<h2
id="quick-setup-flow"
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
>
{t("quickSetup.recommendedFlow")}
</h2>
<ol className="space-y-3">
{steps.map((step, index) => {
const query = queries[index];
const Icon = step.icon;
const isDone = query?.data === true;
return (
<li key={step.id}>
<Card
className={cn(
"transition-shadow hover:shadow-md",
isDone && "border-green-500/40 bg-green-500/5",
)}
>
<CardContent className="flex items-center gap-4 p-4">
{/* Step number / done tick */}
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-semibold",
isDone
? "bg-green-500 text-white"
: "bg-primary/10 text-primary",
)}
aria-hidden
>
{isDone ? <Check className="h-5 w-5" /> : index + 1}
</div>
{/* Title + description */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<h3 className="font-medium">{t(step.titleKey)}</h3>
{isDone && (
<Badge variant="outline" className="border-green-500/50 text-green-600 text-[10px]">
{t("quickSetup.ready")}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{t(step.descriptionKey)}
</p>
</div>
{/* CTA */}
<div className="flex items-center gap-2 shrink-0">
{step.helpKey && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
aria-label={t("quickSetup.helpAria")}
>
?
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">
{t(step.helpKey)}
</TooltipContent>
</Tooltip>
)}
<Button asChild size="sm" variant={isDone ? "outline" : "default"}>
<Link to={step.to} className="flex items-center gap-1">
{isDone ? t("quickSetup.review") : t("quickSetup.start")}
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</li>
);
})}
</ol>
</section>
{/* Quick creates */}
{quickCreates.length > 0 && (
<section aria-labelledby="quick-setup-other" className="pt-2">
<h2
id="quick-setup-other"
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
>
{t("quickSetup.otherQuickCreates")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{quickCreates.map((item) => {
const Icon = item.icon;
return (
<Card key={item.id} className="transition-all hover:shadow-md hover:-translate-y-0.5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<CardTitle className="text-base">{t(item.titleKey)}</CardTitle>
</div>
<CardDescription className="line-clamp-2">
{t(item.descriptionKey)}
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="secondary" size="sm" className="w-full">
<Link to={item.to} className="flex items-center justify-center gap-1">
{t("quickSetup.open")}
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</Button>
</CardContent>
</Card>
);
})}
</div>
</section>
)}
</div>
</TooltipProvider>
);
}
export default QuickSetupWizard;

View File

@@ -1,4 +1,6 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -8,22 +10,28 @@ import {
import { NavLink } from "@/components/NavLink"; import { NavLink } from "@/components/NavLink";
import { useAuth, UserRole } from "@/contexts/AuthContext"; import { useAuth, UserRole } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown"; import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { LogOut, User, Settings, LucideIcon } from "lucide-react"; import { LogOut, User, LucideIcon } from "lucide-react";
import React from "react";
/**
* Non-admin portal shell (student / teacher / etc.). Nav items hold i18n
* keys (`titleKey` / `labelKey`) rather than literal strings so they react
* to language changes without re-mounting the layout.
*/
export interface NavItem { export interface NavItem {
title: string; titleKey: string;
url: string; url: string;
icon: LucideIcon; icon: LucideIcon;
} }
export interface NavGroup { export interface NavGroup {
label: string; labelKey: string;
items: NavItem[]; items: NavItem[];
} }
@@ -34,30 +42,34 @@ interface RoleLayoutProps {
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) { function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
const { state } = useSidebar(); const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed"; const collapsed = state === "collapsed";
return ( return (
<> <>
{navGroups.map((group) => ( {navGroups.map((group) => (
<SidebarGroup key={group.label}> <SidebarGroup key={group.labelKey}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel> <SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => ( {group.items.map((item) => {
<SidebarMenuItem key={item.url}> const label = t(item.titleKey);
<SidebarMenuButton asChild tooltip={item.title}> return (
<NavLink <SidebarMenuItem key={item.url}>
to={item.url} <SidebarMenuButton asChild tooltip={label}>
end={item.url.endsWith("/dashboard")} <NavLink
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors" to={item.url}
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium" end={item.url.endsWith("/dashboard")}
> className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
<item.icon className="h-4 w-4 shrink-0" /> activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
{!collapsed && <span>{item.title}</span>} >
</NavLink> <item.icon className="h-4 w-4 shrink-0" />
</SidebarMenuButton> {!collapsed && <span>{label}</span>}
</SidebarMenuItem> </NavLink>
))} </SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
@@ -66,9 +78,21 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
); );
} }
// Keeps the student/teacher sidebar + header mounted while the lazy route
// chunk is fetching. See AdminLmsLayout for the rationale.
function RouteContentFallback() {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
);
}
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) { export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t, i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"; const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -77,17 +101,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
navigate("/login"); navigate("/login");
}; };
const roleLabel = t(`roles.${role}`, { defaultValue: role });
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
return ( return (
<SidebarProvider> <SidebarProvider>
<div className="min-h-screen flex w-full"> <div className="min-h-screen flex w-full">
<Sidebar collapsible="icon"> <Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4"> <SidebarHeader className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" /> <img
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden"> src="/logo.png"
<span className="text-[hsl(42,40%,62%)]">En</span> alt={t("chrome.adminAlt")}
<span>Coach</span> className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
</span> />
<img
src="/logo.png"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
/>
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarSeparator /> <SidebarSeparator />
@@ -101,7 +133,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<span className="text-xs font-semibold text-primary">{initials}</span> <span className="text-xs font-semibold text-primary">{initials}</span>
</div> </div>
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden"> <div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name}</span> <span className="text-sm font-medium truncate text-sidebar-foreground">
{user?.name ?? t("userMenu.userFallback")}
</span>
<span className="text-xs text-muted-foreground truncate">{user?.email}</span> <span className="text-xs text-muted-foreground truncate">{user?.email}</span>
</div> </div>
</div> </div>
@@ -111,10 +145,12 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0"> <header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SidebarTrigger /> <SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span> <span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown /> <NotificationDropdown />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -126,23 +162,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name}</p> <p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p> <p className="text-xs text-muted-foreground">{user?.email}</p>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}> <DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
<User className="mr-2 h-4 w-4" /> Profile <User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}> <DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout <LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -1,51 +1,63 @@
import RoleLayout, { NavGroup } from "./RoleLayout"; import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup";
import { import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3, LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks, CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
} from "lucide-react"; } from "lucide-react";
/**
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
* resolves via `useTranslation`, so switching language does not require
* re-mounting the layout.
*/
const navGroups: NavGroup[] = [ const navGroups: NavGroup[] = [
{ {
label: "Learning", labelKey: "sidebarGroup.learning",
items: [ items: [
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard }, { titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ title: "My Courses", url: "/student/courses", icon: BookOpen }, { titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks }, { titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList }, { titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
], ],
}, },
{ {
label: "Adaptive Learning", labelKey: "sidebarGroup.adaptiveLearning",
items: [ items: [
{ title: "My Subjects", url: "/student/subjects", icon: Target }, { titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
], ],
}, },
{ {
label: "Progress", labelKey: "sidebarGroup.progress",
items: [ items: [
{ title: "Grades", url: "/student/grades", icon: BarChart3 }, { titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck }, { titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
{ title: "Timetable", url: "/student/timetable", icon: Calendar }, { titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
{ title: "My Journey", url: "/student/journey", icon: TrendingUp }, { titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
], ],
}, },
{ {
label: "Communication", labelKey: "sidebarGroup.communication",
items: [ items: [
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare }, { titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
{ title: "Messages", url: "/student/messages", icon: Mail }, { titleKey: "nav.messages", url: "/student/messages", icon: Mail },
{ title: "Announcements", url: "/student/announcements", icon: Megaphone }, { titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
], ],
}, },
{ {
label: "Account", labelKey: "sidebarGroup.account",
items: [ items: [
{ title: "Profile", url: "/student/profile", icon: User }, { titleKey: "nav.profile", url: "/student/profile", icon: User },
], ],
}, },
]; ];
export default function StudentLayout() { export default function StudentLayout() {
return <RoleLayout navGroups={navGroups} role="student" />; return (
<>
<RoleLayout navGroups={navGroups} role="student" />
<ExamPopup />
</>
);
} }

View File

@@ -0,0 +1,143 @@
import { useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { useQuery } from "@tanstack/react-query";
import { taxonomyService } from "@/services/taxonomy.service";
import { lmsService } from "@/services/lms.service";
interface TaxonomyCascadeProps {
subjectId: string;
onSubjectChange: (id: string) => void;
domainId: string;
onDomainChange: (id: string) => void;
topicIds: number[];
onTopicIdsChange: (ids: number[]) => void;
objectiveIds: number[];
onObjectiveIdsChange: (ids: number[]) => void;
}
export function TaxonomyCascade({
subjectId, onSubjectChange,
domainId, onDomainChange,
topicIds, onTopicIdsChange,
objectiveIds, onObjectiveIdsChange,
}: TaxonomyCascadeProps) {
const { data: subjects } = useQuery({
queryKey: ["taxonomy", "subjects"],
queryFn: () => taxonomyService.listSubjects(),
});
const numericSubjectId = subjectId !== "none" ? Number(subjectId) : undefined;
const numericDomainId = domainId !== "none" ? Number(domainId) : undefined;
const { data: domains } = useQuery({
queryKey: ["taxonomy", "domains", numericSubjectId],
queryFn: () => taxonomyService.listDomains({ subject_id: numericSubjectId }),
enabled: !!numericSubjectId,
});
const { data: topics } = useQuery({
queryKey: ["taxonomy", "topics", numericDomainId],
queryFn: () => taxonomyService.listTopics({ domain_id: numericDomainId }),
enabled: !!numericDomainId,
});
const { data: objectivesData } = useQuery({
queryKey: ["learning-objectives", topicIds],
queryFn: () => lmsService.listLearningObjectives({ topic_ids: topicIds.join(",") }),
enabled: topicIds.length > 0,
});
const objectives = objectivesData?.items ?? [];
useEffect(() => {
if (subjectId === "none") {
if (domainId !== "none") onDomainChange("none");
if (topicIds.length > 0) onTopicIdsChange([]);
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
}
}, [subjectId]);
useEffect(() => {
if (domainId === "none") {
if (topicIds.length > 0) onTopicIdsChange([]);
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
}
}, [domainId]);
const toggleTopic = (id: number) => {
const next = topicIds.includes(id) ? topicIds.filter(t => t !== id) : [...topicIds, id];
onTopicIdsChange(next);
if (next.length === 0 && objectiveIds.length > 0) onObjectiveIdsChange([]);
};
const toggleObjective = (id: number) => {
onObjectiveIdsChange(
objectiveIds.includes(id) ? objectiveIds.filter(o => o !== id) : [...objectiveIds, id],
);
};
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Subject</Label>
<Select value={subjectId} onValueChange={(v) => { onSubjectChange(v); if (v === "none") { onDomainChange("none"); } }}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="Select subject" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{(subjects ?? []).map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Domain</Label>
<Select value={domainId} onValueChange={onDomainChange} disabled={!numericSubjectId}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder={numericSubjectId ? "Select domain" : "Select subject first"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{(domains ?? []).map(d => <SelectItem key={d.id} value={String(d.id)}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{numericDomainId && (topics ?? []).length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Topics</Label>
<div className="flex flex-wrap gap-1.5 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
{(topics ?? []).map(t => (
<Badge
key={t.id}
variant={topicIds.includes(t.id) ? "default" : "outline"}
className="cursor-pointer select-none text-xs"
onClick={() => toggleTopic(t.id)}
>
{t.name}
</Badge>
))}
</div>
</div>
)}
{topicIds.length > 0 && objectives.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Learning Objectives</Label>
<div className="space-y-1 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
{objectives.map(o => (
<label key={o.id} className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={objectiveIds.includes(o.id)}
onCheckedChange={() => toggleObjective(o.id)}
/>
<span>{o.name}</span>
{o.bloom_level && <Badge variant="outline" className="text-[10px] h-4">{o.bloom_level}</Badge>}
</label>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,38 +1,41 @@
import RoleLayout, { NavGroup } from "./RoleLayout"; import RoleLayout, { NavGroup } from "./RoleLayout";
import { import {
LayoutDashboard, BookOpen, ClipboardList, FileText, LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare, CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2, Megaphone, Library, Sparkles,
} from "lucide-react"; } from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
const navGroups: NavGroup[] = [ const navGroups: NavGroup[] = [
{ {
label: "Teaching", labelKey: "sidebarGroup.teaching",
items: [ items: [
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard }, { titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
{ title: "Courses", url: "/teacher/courses", icon: BookOpen }, { titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList }, { titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
], ],
}, },
{ {
label: "Management", labelKey: "sidebarGroup.management",
items: [ items: [
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck }, { titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
{ title: "Students", url: "/teacher/students", icon: Users }, { titleKey: "nav.students", url: "/teacher/students", icon: Users },
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar }, { titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
], ],
}, },
{ {
label: "Communication", labelKey: "sidebarGroup.communication",
items: [ items: [
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare }, { titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone }, { titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
], ],
}, },
{ {
label: "Account", labelKey: "sidebarGroup.account",
items: [ items: [
{ title: "Profile", url: "/teacher/profile", icon: User }, { titleKey: "nav.profile", url: "/teacher/profile", icon: User },
], ],
}, },
]; ];

View File

@@ -0,0 +1,71 @@
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
/**
* Light/dark/system theme toggle.
*
* We intentionally render a neutral placeholder until `next-themes` has
* hydrated — otherwise the first client render disagrees with the server-less
* pre-render and the icon briefly flashes the wrong state.
*/
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const { t } = useTranslation();
useEffect(() => {
setMounted(true);
}, []);
const isDark = mounted && (resolvedTheme ?? theme) === "dark";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("theme.toggleLabel")}
title={t("theme.toggleLabel")}
className="text-muted-foreground hover:text-foreground"
>
{mounted ? (
isDark ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)
) : (
<Sun className="h-4 w-4 opacity-0" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="me-2 h-4 w-4" /> {t("theme.light")}
{theme === "light" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="me-2 h-4 w-4" /> {t("theme.dark")}
{theme === "dark" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="me-2 h-4 w-4" /> {t("theme.system")}
{theme === "system" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default ThemeToggle;

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react"; import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { analyticsService } from "@/services/analytics.service"; import { analyticsService } from "@/services/analytics.service";
@@ -7,32 +8,34 @@ import { analyticsService } from "@/services/analytics.service";
export default function AiAlertBanner() { export default function AiAlertBanner() {
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set()); const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
const [errorDismissed, setErrorDismissed] = useState(false); const [errorDismissed, setErrorDismissed] = useState(false);
const { t } = useTranslation();
const { data: alerts, isLoading, isError, error } = useQuery({ const { data: resp, isLoading, isError, error } = useQuery({
queryKey: ["ai", "alerts"], queryKey: ["ai", "alerts"],
queryFn: () => analyticsService.getAlerts(), queryFn: () => analyticsService.getAlerts(),
}); });
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? []; const alerts = resp?.alerts ?? [];
const visible = alerts.filter((a, i) => !dismissedIds.has(String(i)));
if (isLoading) { if (isLoading) {
return ( return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3"> <div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" /> <Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
<p className="text-sm text-muted-foreground">Loading alerts</p> <p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
</div> </div>
); );
} }
if (isError && !errorDismissed) { if (isError && !errorDismissed) {
return ( return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3"> <div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium flex items-center gap-1"> <p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable <Sparkles className="h-3 w-3 text-primary shrink-0" /> {t("ai.alertsUnavailable")}
</p> </p>
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p> <p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : t("ai.couldNotLoadAlerts")}</p>
</div> </div>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}> <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -43,11 +46,11 @@ export default function AiAlertBanner() {
if (isError && errorDismissed) return null; if (isError && errorDismissed) return null;
if (!alerts?.length) { if (!alerts.length) {
return ( return (
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3"> <div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" /> <Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">No AI alerts right now.</p> <p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
</div> </div>
); );
} }
@@ -56,12 +59,12 @@ export default function AiAlertBanner() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{visible.map((alert) => ( {visible.map((alert, idx) => (
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3"> <div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium flex items-center gap-1"> <p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary" /> {alert.title} <Sparkles className="h-3 w-3 text-primary shrink-0" /> {alert.title}
</p> </p>
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p> <p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
</div> </div>
@@ -69,7 +72,7 @@ export default function AiAlertBanner() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 shrink-0" className="h-7 w-7 shrink-0"
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))} onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -8,38 +9,43 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service"; import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const quickActions = [
"Platform health summary",
"Show dropout risks",
"Compare course performance",
"Suggest batch improvements",
];
export default function AiAssistantDrawer() { export default function AiAssistantDrawer() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]); const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const location = useLocation(); const location = useLocation();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
// Re-compute when language switches so quick-action chips show translated
// labels immediately. Chips are stored by label (what we send to the
// backend), so localizing the label is safe here — the backend treats
// them as free-form prompts.
const quickActions = useMemo(
() => [
t("ai.quickHealth"),
t("ai.quickDropouts"),
t("ai.quickCompare"),
t("ai.quickBatch"),
],
[t],
);
const chatMutation = useMutation({ const chatMutation = useMutation({
mutationFn: (message: string) => mutationFn: (message: string) =>
coachingService.chat({ message, context: { page: location.pathname } }), coachingService.chat({ message, context: { page: location.pathname } }),
onSuccess: (data) => { onSuccess: (data) => {
setMessages((prev) => [...prev, { role: "ai", text: data.message }]); setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Could not get a reply", title: t("ai.replyErrorTitle"),
description: err.message || "Something went wrong. Try again.", description: err.message || t("ai.replyErrorDesc"),
}); });
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ { role: "ai", text: t("ai.fallbackReply") },
role: "ai",
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
},
]); ]);
}, },
}); });
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
<> <>
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
aria-label="AI Assistant" aria-label={t("ai.assistantLabel")}
title={t("ai.assistantLabel")}
> >
<Sparkles className="h-5 w-5" /> <Sparkles className="h-5 w-5" />
</button> </button>
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
<SheetHeader> <SheetHeader>
<SheetTitle className="flex items-center gap-2"> <SheetTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> <Sparkles className="h-5 w-5 text-primary" />
EnCoach AI Assistant {t("ai.assistantTitle")}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
{messages.length === 0 && ( {messages.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8"> <div className="text-center text-muted-foreground text-sm py-8">
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" /> <Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
<p>Ask me anything about the platform,</p> <p>{t("ai.emptyLine1")}</p>
<p>or click a quick action above.</p> <p>{t("ai.emptyLine2")}</p>
</div> </div>
)} )}
{messages.map((msg, i) => ( {messages.map((msg, i) => (
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
key={i} key={i}
className={`rounded-lg p-3 text-sm ${ className={`rounded-lg p-3 text-sm ${
msg.role === "user" msg.role === "user"
? "bg-primary text-primary-foreground ml-8" ? "bg-primary text-primary-foreground ms-8"
: "bg-muted mr-8" : "bg-muted me-8"
}`} }`}
> >
{msg.role === "ai" && ( {msg.role === "ai" && (
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" /> <Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
)} )}
{msg.text} {msg.text}
</div> </div>
))} ))}
{chatMutation.isPending && ( {chatMutation.isPending && (
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2"> <div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> <Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground">Thinking...</span> <span className="text-muted-foreground">{t("ai.thinking")}</span>
</div> </div>
)} )}
</div> </div>
<div className="flex gap-2 pt-3 border-t mt-auto"> <div className="flex gap-2 pt-3 border-t mt-auto">
<Input <Input
placeholder="Ask anything..." placeholder={t("ai.placeholder")}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend(input)} onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
size="icon" size="icon"
onClick={() => handleSend(input)} onClick={() => handleSend(input)}
disabled={!input.trim() || chatMutation.isPending} disabled={!input.trim() || chatMutation.isPending}
aria-label={t("ai.send")}
title={t("ai.send")}
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>

View File

@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
}, },
}); });
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
const handleOpen = () => { const handleOpen = () => {
if (batchId == null) { if (batchId == null) {
toast({ toast({
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
mutation.mutate(batchId); mutation.mutate(batchId);
}; };
const applyMutation = useMutation({
mutationFn: () => analyticsService.applyBatchOptimization(batchId!, mutation.data?.optimized ?? []),
onSuccess: (res) => {
toast({ title: "Suggestion Applied", description: `${res.applied} optimization(s) saved successfully.` });
setOpen(false);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Apply failed",
description: err.message || "Could not apply batch optimization.",
});
},
});
const handleApply = () => { const handleApply = () => {
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." }); applyMutation.mutate();
setOpen(false);
}; };
const onOpenChange = (next: boolean) => { const onOpenChange = (next: boolean) => {
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
if (!next) mutation.reset(); if (!next) mutation.reset();
}; };
const suggestions = mutation.data ?? []; const optData = mutation.data as OptResult | undefined;
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0; const hasSuggestions = !!optData?.summary;
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0; const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
return ( return (
<> <>
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
</div> </div>
) : mutation.isError ? ( ) : mutation.isError ? (
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p> <p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
) : showResults ? ( ) : showResults && optData ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3 max-h-[50vh] overflow-y-auto"> <div className="rounded-lg bg-muted/30 p-4 border border-border/60">
{suggestions.map((s, i) => ( <p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60"> <p className="text-sm font-medium">{optData.summary}</p>
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{s.impact} impact</p>
<p className="text-sm font-medium">{s.suggestion}</p>
{s.details ? <p className="text-sm text-muted-foreground mt-2 leading-relaxed">{s.details}</p> : null}
</div>
))}
</div> </div>
{Array.isArray(optData.optimized) && optData.optimized.length > 0 && (
<div className="space-y-2 max-h-[40vh] overflow-y-auto">
{optData.optimized.map((item, i) => (
<div key={i} className="rounded-lg bg-muted/20 p-3 border text-sm">
{typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)}
</div>
))}
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<Button className="flex-1" onClick={handleApply}> <Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
Apply Suggestion {applyMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
) : (
"Apply Suggestion"
)}
</Button> </Button>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss Dismiss

View File

@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
difficulty, difficulty,
count, count,
}), }),
onSuccess: (res) => { onSuccess: (res: Record<string, unknown>) => {
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []); const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
setLocalExercises(items);
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
generateMutation.mutate(); generateMutation.mutate();
}; };
const saveMutation = useMutation({
mutationFn: () => generationService.saveGenerated(moduleType, localExercises ?? []),
onSuccess: (res) => {
toast({ title: "Saved", description: `${res.saved} assignments saved successfully.` });
setOpen(false);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Save failed",
description: err.message || "Could not save generated assignments.",
});
},
});
const generated = localExercises; const generated = localExercises;
const handleRemove = (index: number) => { const handleRemove = (index: number) => {
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
); );
})} })}
<div className="flex gap-2"> <div className="flex gap-2">
<Button className="flex-1">Save All</Button> <Button
className="flex-1"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !generated?.length}
>
{saveMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : (
"Save All"
)}
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {

View File

@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
const explainMutation = useMutation({ const explainMutation = useMutation({
mutationFn: () => mutationFn: () =>
coachingService.explain({ coachingService.explain({
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`, score_data: scores ?? {},
scores, student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
}), }),
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({

View File

@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
const gradeMutation = useMutation({ const gradeMutation = useMutation({
mutationFn: () => mutationFn: () =>
analyticsService.getGradingSuggestion({ analyticsService.getGradingSuggestion({
submission_id: submissionId, submission_text: submissionText,
text: submissionText, skill: "writing",
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
}), }),
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
}, [submissionId, submissionText, rubricId]); }, [submissionId, submissionText, rubricId]);
const data = gradeMutation.data; const data = gradeMutation.data;
const marks = data ? Math.round(data.overall_score) : 0; const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
const feedbackBlock = data const feedbackBlock = data
? [ ? [
data.feedback, data.feedback,

View File

@@ -1,32 +1,32 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react"; import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service"; import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
import type { AiInsight } from "@/types";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const EMPTY_PAYLOAD: Record<string, unknown> = {}; const EMPTY_PAYLOAD: Record<string, unknown> = {};
function insightIcon(type: AiInsight["type"]) { function insightIcon(severity: AiInsightItem["severity"]) {
switch (type) { switch (severity) {
case "positive": case "critical":
return Trophy;
case "warning":
return AlertTriangle; return AlertTriangle;
default: case "warning":
return TrendingUp; return TrendingUp;
default:
return Info;
} }
} }
function insightColor(type: AiInsight["type"]) { function insightColor(severity: AiInsightItem["severity"]) {
switch (type) { switch (severity) {
case "positive": case "critical":
return "text-primary"; return "text-destructive";
case "warning": case "warning":
return "text-warning"; return "text-warning";
default: default:
return "text-success"; return "text-primary";
} }
} }
@@ -36,14 +36,15 @@ interface Props {
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) { export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
const payloadKey = useMemo(() => JSON.stringify(data), [data]); const payloadKey = useMemo(() => JSON.stringify(data), [data]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload), mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
title: "Insights unavailable", title: t("ai.insightsUnavailable"),
description: err.message || "Could not load AI insights.", description: err.message || t("ai.couldNotLoadInsights"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -51,47 +52,47 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
useEffect(() => { useEffect(() => {
mutation.mutate(data); mutation.mutate(data);
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes // eslint-disable-next-line react-hooks/exhaustive-deps
}, [payloadKey]); }, [payloadKey]);
const items = mutation.data ?? []; const items = mutation.data?.insights ?? [];
return ( return (
<Card className="border-0 shadow-sm"> <Card className="border-0 shadow-sm">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2"> <CardTitle className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> <Sparkles className="h-4 w-4 text-primary" />
AI Platform Insights {t("ai.platformInsightsTitle")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{mutation.isPending && ( {mutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center"> <div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
Loading insights {t("ai.loadingInsights")}
</div> </div>
)} )}
{mutation.isError && !mutation.isPending && ( {mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p> <p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
)} )}
{mutation.isSuccess && items.length === 0 && ( {mutation.isSuccess && items.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p> <p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
)} )}
{!mutation.isPending && items.length > 0 && ( {!mutation.isPending && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{items.map((item) => { {items.map((item, idx) => {
const Icon = insightIcon(item.type); const Icon = insightIcon(item.severity);
const color = insightColor(item.type); const color = insightColor(item.severity);
return ( return (
<div key={item.id} className="rounded-lg border bg-muted/30 p-4"> <div key={idx} className="rounded-lg border bg-muted/30 p-4" dir="auto">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Icon className={`h-4 w-4 ${color}`} /> <Icon className={`h-4 w-4 shrink-0 ${color}`} />
<span className="text-sm font-semibold">{item.title}</span> <span className="text-sm font-semibold min-w-0">{item.title}</span>
</div> </div>
<p className="text-sm text-muted-foreground">{item.description}</p> <p className="text-sm text-muted-foreground">{item.description}</p>
{item.metric != null && item.value != null && ( {item.recommendation && (
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2 italic">
{item.metric}: {item.value} {item.recommendation}
</p> </p>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Sparkles, Search, Loader2, X } from "lucide-react"; import { Sparkles, Search, Loader2, X } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation();
const searchMutation = useMutation({ const searchMutation = useMutation({
mutationFn: (q: string) => analyticsService.search(q), mutationFn: (q: string) => analyticsService.search(q),
onError: (err: Error) => { onError: (err: Error) => {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Search failed", title: t("chrome.aiSearchFailedTitle"),
description: err.message || "Could not complete AI search.", description: err.message || t("chrome.aiSearchFailedDesc"),
}); });
}, },
}); });
@@ -27,16 +29,16 @@ export default function AiSearchBar() {
searchMutation.mutate(query.trim()); searchMutation.mutate(query.trim());
}; };
const results = searchMutation.data; const result = searchMutation.data;
return ( return (
<div className="relative max-w-md w-full"> <div className="relative max-w-md w-full">
<div className="relative"> <div className="relative">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" /> <Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Ask anything... e.g. 'Show students with low attendance'" placeholder={t("chrome.aiSearchPlaceholder")}
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1" className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
@@ -50,50 +52,56 @@ export default function AiSearchBar() {
setQuery(""); setQuery("");
searchMutation.reset(); searchMutation.reset();
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
)} )}
</div> </div>
{(searchMutation.isPending || results !== undefined) && ( {(searchMutation.isPending || result !== undefined) && (
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md"> <div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
{searchMutation.isPending ? ( {searchMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> <Loader2 className="h-4 w-4 animate-spin text-primary" />
AI is searching... {t("chrome.aiSearching")}
</div> </div>
) : results && results.length > 0 ? ( ) : result?.answer ? (
<div className="text-sm space-y-2 max-h-64 overflow-y-auto"> <div className="text-sm space-y-2 max-h-64 overflow-y-auto">
{results.map((r, i) => ( <div className="flex items-start gap-2 pb-2">
<div <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
key={`${r.title}-${i}`} <p className="text-muted-foreground">{result.answer}</p>
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0" </div>
> {result.suggestions?.length > 0 && (
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <div className="border-t pt-2 space-y-1">
<div className="min-w-0"> <p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
<p className="font-medium">{r.title}</p> {result.suggestions.map((s, i) => (
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p> <button
{r.url && ( key={i}
<button type="button"
type="button" className="block text-xs text-primary hover:underline"
className="text-xs text-primary mt-1 hover:underline" onClick={() => { setQuery(s); searchMutation.mutate(s); }}
onClick={() => navigate(r.url!)} >
> {s}
Go to {r.url} </button>
</button> ))}
)}
</div>
</div> </div>
)}
{result.related_actions?.map((a, i) => (
<button
key={i}
type="button"
className="text-xs text-primary hover:underline"
onClick={() => navigate(a.action)}
>
{a.label}
</button>
))} ))}
</div> </div>
) : ( ) : (
<div className="text-sm flex items-start gap-2"> <div className="text-sm flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<p> <p>{t("chrome.aiNoResults", { q: query })}</p>
No results for &quot;{query}&quot;. Try a different question or keyword.
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
suggestMutation.mutate(); suggestMutation.mutate();
}; };
const suggestions = suggestMutation.data?.suggestions ?? []; const d = suggestMutation.data;
const planTips = suggestMutation.data?.study_plan_tips ?? []; const suggestions = d ? [d.suggestion, ...(d.focus_areas ?? []).map((a: string) => `Focus area: ${a}`)].filter(Boolean) : [];
const planTips = d?.daily_plan?.length
? d.daily_plan.map((p: { activity: string; duration_min: number; skill: string }) => `${p.activity} (${p.duration_min}min — ${p.skill})`)
: d?.motivation ? [d.motivation] : [];
return ( return (
<Card className="border-0 shadow-sm bg-primary/5"> <Card className="border-0 shadow-sm bg-primary/5">

View File

@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Sparkles, X, Loader2 } from "lucide-react"; import { Sparkles, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { coachingService } from "@/services/coaching.service"; import { coachingService } from "@/services/coaching.service";
@@ -12,6 +13,7 @@ interface Props {
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) { export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ["ai", "tip", context], queryKey: ["ai", "tip", context],
@@ -22,23 +24,30 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10"; const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
const variantLabel =
variant === "insight"
? t("ai.insightLabel")
: variant === "recommendation"
? t("ai.recommendationLabel")
: t("ai.tipLabel");
if (isLoading) { if (isLoading) {
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" /> <Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
<span className="text-sm text-muted-foreground">Loading AI tip</span> <span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary">AI Tip</span> <span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."} {isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
</p> </p>
</div> </div>
{dismissible && ( {dismissible && (
@@ -50,26 +59,28 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
); );
} }
if (!data.content?.trim() && !data.title?.trim()) { if (!data.tip?.trim()) {
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span> <span className="block text-xs font-semibold text-primary">{variantLabel}</span>
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p> <p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
</div> </div>
</div> </div>
); );
} }
const label = data.category && data.category !== "general"
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
: variantLabel;
return ( return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}> <div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" /> <Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary"> <span className="block text-xs font-semibold text-primary">{label}</span>
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`} <p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
</span>
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
</div> </div>
{dismissible && ( {dismissible && (
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}> <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>

View File

@@ -22,8 +22,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (mode: NonNullable<Mode>) => mutationFn: (mode: NonNullable<Mode>) =>
coachingService.writingHelp({ coachingService.writingHelp({
text: text.trim(), task: task_type,
task_type: `${task_type}:${mode}`, draft: text.trim(),
help_type: mode,
}), }),
onSuccess: () => setShowResult(true), onSuccess: () => setShowResult(true),
onError: (err: Error) => { onError: (err: Error) => {
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
{showResult && !loading && mutation.data && activeMode === "improve" && ( {showResult && !loading && mutation.data && activeMode === "improve" && (
<div className="space-y-3"> <div className="space-y-3">
{mutation.data.feedback && ( {mutation.data.tips?.length > 0 && (
<div className="rounded-lg border bg-muted/30 p-3"> <div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1"> <p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Feedback <Sparkles className="h-3 w-3" /> Feedback
</p> </p>
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p> <p className="text-sm text-muted-foreground">{mutation.data.tips.join(" ")}</p>
</div> </div>
)} )}
{mutation.data.improved && ( {mutation.data.improved_text && (
<div className="rounded-lg border bg-muted/30 p-3"> <div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1"> <p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Improved Version <Sparkles className="h-3 w-3" /> Improved Version
</p> </p>
<p className="text-sm">{mutation.data.improved}</p> <p className="text-sm">{mutation.data.improved_text}</p>
</div> </div>
)} )}
</div> </div>
@@ -108,17 +109,17 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1"> <p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Grammar notes <Sparkles className="h-3 w-3" /> Grammar notes
</p> </p>
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? ( {(mutation.data.changes?.length ?? 0) > 0 ? (
mutation.data.grammar_notes!.map((note, i) => ( mutation.data.changes.map((c, i) => (
<div key={i} className="text-sm border-l-2 border-warning pl-2"> <div key={i} className="text-sm border-l-2 border-warning pl-2">
<p className="text-muted-foreground">{note}</p> <p className="text-muted-foreground"><strong>{c.original}</strong> {c.revised} {c.reason}</p>
</div> </div>
)) ))
) : ( ) : (
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p> <p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
)} )}
{mutation.data.feedback ? ( {mutation.data.tips?.length > 0 ? (
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p> <p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
) : null} ) : null}
</div> </div>
)} )}
@@ -128,9 +129,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1"> <p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Estimated band / assessment <Sparkles className="h-3 w-3" /> Estimated band / assessment
</p> </p>
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p> <p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
{mutation.data.improved ? ( {mutation.data.improved_text ? (
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p> <p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
) : null} ) : null}
</div> </div>
)} )}

View File

@@ -0,0 +1,214 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Library, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { resourcesService } from "@/services/resources.service";
import type { Resource } from "@/types";
/**
* Generic, reusable picker over `/api/resources`.
*
* Two callers wire this up today:
*
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
* the picked resources to `coursePlanService.attachResources` in the
* `onConfirm` handler and invalidates the sources query.
*
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
* so the wizard simply pushes the picks into its draft state and
* attaches them in one batch after the plan is created.
*
* The dialog itself is purposefully agnostic: it only reports the
* selected `Resource` rows; the parent decides what to do.
*/
export function LibraryPickerDialog({
open,
onOpenChange,
alreadyLinkedIds,
onConfirm,
isPending,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Resources whose ids are in this set render disabled + checked. */
alreadyLinkedIds?: Set<number>;
/** Called when the admin clicks "Attach selected". */
onConfirm: (resources: Resource[]) => void | Promise<void>;
/** Optional spinner state from the parent's mutation. */
isPending?: boolean;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selected, setSelected] = useState<Set<number>>(new Set());
// Reset every time the dialog re-opens so a previous session's
// selection doesn't bleed into the next attach.
useEffect(() => {
if (open) {
setSelected(new Set());
setSearch("");
setTypeFilter("all");
}
}, [open]);
const { data, isLoading } = useQuery({
queryKey: ["library-resources", { search, type: typeFilter }],
queryFn: () =>
resourcesService.list({
search: search || undefined,
resource_type: typeFilter === "all" ? undefined : typeFilter,
review_status: "approved",
}),
enabled: open,
});
const resources: Resource[] = data?.items ?? [];
const toggle = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleConfirm = async () => {
const picked = resources.filter((r) => selected.has(r.id));
if (!picked.length) return;
await onConfirm(picked);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Library className="h-5 w-5 text-primary" />
{t("coursePlan.sources.libraryTitle")}
</DialogTitle>
<DialogDescription>
{t("coursePlan.sources.libraryDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 pt-2">
<Input
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("coursePlan.sources.libraryTypeAll")}
</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="document">DOCX</SelectItem>
<SelectItem value="link">URL</SelectItem>
<SelectItem value="article">Article</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
{isLoading && <Skeleton className="h-32 w-full" />}
{!isLoading && resources.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-8">
{t("coursePlan.sources.libraryEmpty")}
</p>
)}
{!isLoading &&
resources.map((r) => {
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
const isSelected = selected.has(r.id);
return (
<label
key={r.id}
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
isLinked
? "opacity-60 cursor-not-allowed bg-muted/40"
: "cursor-pointer hover:bg-accent/50"
}`}
>
<Checkbox
checked={isLinked || isSelected}
disabled={isLinked}
onCheckedChange={() => !isLinked && toggle(r.id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium truncate">{r.name}</span>
<Badge
variant="outline"
className="text-[10px] capitalize"
>
{r.resource_type || r.type || "resource"}
</Badge>
{isLinked && (
<Badge variant="secondary" className="text-[10px]">
{t("coursePlan.sources.libraryAlreadyLinked")}
</Badge>
)}
</div>
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
<p className="text-xs text-muted-foreground truncate">
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
.filter(Boolean)
.join(" ")}
</p>
)}
</div>
</label>
);
})}
</div>
<DialogFooter className="border-t pt-3">
<span className="mr-auto text-xs text-muted-foreground self-center">
{t("coursePlan.sources.librarySelectedCount", {
count: selected.size,
})}
</span>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={!selected.size || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("coursePlan.sources.libraryAttach")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { CoursePlanMaterial } from "@/types";
export const SKILL_STYLE: Record<string, string> = {
reading: "bg-blue-100 text-blue-800 border-blue-200",
writing: "bg-purple-100 text-purple-800 border-purple-200",
listening: "bg-emerald-100 text-emerald-800 border-emerald-200",
speaking: "bg-amber-100 text-amber-800 border-amber-200",
grammar: "bg-rose-100 text-rose-800 border-rose-200",
vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200",
integrated: "bg-slate-100 text-slate-800 border-slate-200",
};
export function SkillBadge({ skill }: { skill: string }) {
return (
<Badge
variant="outline"
className={cn("capitalize border", SKILL_STYLE[skill] ?? SKILL_STYLE.integrated)}
>
{skill || "integrated"}
</Badge>
);
}
function renderAny(value: unknown): React.ReactNode {
if (value == null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return <p className="leading-7">{String(value)}</p>;
}
if (Array.isArray(value)) {
if (value.length === 0) return null;
return (
<ul className="list-disc ps-5 space-y-1">
{value.map((item, idx) => (
<li key={idx}>{renderAny(item)}</li>
))}
</ul>
);
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
return (
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k}>
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
{k.replace(/_/g, " ")}
</h5>
<div className="text-sm">{renderAny(v)}</div>
</div>
))}
</div>
);
}
return null;
}
export default function MaterialBookView({
material,
}: {
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
}) {
const body = material.body ?? {};
const hasStructured = Object.keys(body).length > 0;
return (
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
{hasStructured ? (
renderAny(body)
) : (
<p className="text-sm whitespace-pre-wrap leading-7">
{material.body_text || "No content available yet."}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react";
import { assignmentsService } from "@/services/assignments.service";
import type { StudentExamAssignment } from "@/types";
export default function ExamPopup() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
const { data } = useQuery({
queryKey: ["student-my-exams"],
queryFn: () => assignmentsService.getStudentExams(),
refetchInterval: 30000,
});
const exams = (data?.items ?? []) as StudentExamAssignment[];
const pendingExams = exams.filter((e) => !dismissed.has(e.id));
useEffect(() => {
if (pendingExams.length > 0 && !open) {
setOpen(true);
}
}, [pendingExams.length]);
if (pendingExams.length === 0) return null;
// Localize dates with the active language so Arabic users see ar-EG
// month names instead of "Apr 19, 2026". The locale is taken from i18n.
const dateLocale = i18n.language?.startsWith("ar") ? "ar-EG" : "en-US";
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
};
const handleStart = (exam: StudentExamAssignment) => {
setOpen(false);
navigate(`/student/exam/${exam.exam_id}/session`);
};
const handleDismiss = (id: number) => {
setDismissed((prev) => new Set([...prev, id]));
const remaining = pendingExams.filter((e) => e.id !== id);
if (remaining.length === 0) setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-primary" />
{t("examPopup.title", { n: pendingExams.length })}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pe-1">
{pendingExams.map((exam) => (
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
<Badge
variant={exam.schedule_state === "active" ? "default" : "secondary"}
className="capitalize text-xs"
>
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
</Badge>
</div>
{exam.schedule_name && exam.exam_title && (
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{t("examPopup.from")} {formatDate(exam.start_date)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{t("examPopup.to")} {formatDate(exam.end_date)}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!exam.can_start}
onClick={() => handleStart(exam)}
className="gap-1.5"
>
<PlayCircle className="h-3.5 w-3.5" />
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismiss(exam.id)}
>
{t("common.dismiss")}
</Button>
{!exam.can_start && (
<span className="text-[11px] text-muted-foreground italic ms-auto">
{exam.schedule_state === "planned"
? t("examPopup.willBeAvailable")
: t("examPopup.notActive")}
</span>
)}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
)); ));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
function hasAlertDialogDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === AlertDialogPrimitive.Description ||
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasAlertDialogDescription(nested)) {
found = true;
}
});
return found;
}
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
typeof AlertDialogPrimitive.Content
> & {
/**
* Optional accessible description rendered visually-hidden. When omitted and
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
* emitted so screen readers still have something to announce.
*/
description?: React.ReactNode;
};
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> AlertDialogContentProps
>(({ className, ...props }, ref) => ( >(({ className, children, description, ...props }, ref) => {
<AlertDialogPortal> const needsFallback =
<AlertDialogOverlay /> !props["aria-describedby"] &&
<AlertDialogPrimitive.Content description === undefined &&
ref={ref} !hasAlertDialogDescription(children);
className={cn( return (
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", <AlertDialogPortal>
className, <AlertDialogOverlay />
)} <AlertDialogPrimitive.Content
{...props} ref={ref}
/> className={cn(
</AlertDialogPortal> "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
)); className,
)}
{...props}
>
{description !== undefined && (
<AlertDialogPrimitive.Description className="sr-only">
{description}
</AlertDialogPrimitive.Description>
)}
{needsFallback && (
<AlertDialogPrimitive.Description className="sr-only">
Alert dialog content
</AlertDialogPrimitive.Description>
)}
{children}
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
);
});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@@ -22,8 +22,11 @@ const badgeVariants = cva(
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {} export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
return <div className={cn(badgeVariants({ variant }), className)} {...props} />; ({ className, variant, ...props }, ref) => (
} <div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
),
);
Badge.displayName = "Badge";
export { Badge, badgeVariants }; export { Badge, badgeVariants };

View File

@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
BreadcrumbPage.displayName = "BreadcrumbPage"; BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => ( const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}> <li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
{...props}
>
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
); );

View File

@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />, IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />, IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
}} }}
{...props} {...props}
/> />

View File

@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
onClick={scrollPrev} onClick={scrollPrev}
{...props} {...props}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
); );
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
onClick={scrollNext} onClick={scrollNext}
{...props} {...props}
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
); );

View File

@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
)); ));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;

View File

@@ -27,28 +27,86 @@ const DialogOverlay = React.forwardRef<
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/**
* Walks `children` looking for any element whose component is
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
* Radix emits an a11y warning when a `DialogContent` has neither a
* `Description` nor an explicit `aria-describedby`, so we use this to decide
* whether to inject a visually-hidden fallback description.
*/
function hasDialogDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === DialogPrimitive.Description ||
(type && type.displayName === DialogPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasDialogDescription(nested)) {
found = true;
}
});
return found;
}
type DialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> & {
/**
* Optional accessible description. When supplied, a visually-hidden
* `DialogDescription` is rendered so screen readers announce it without
* affecting layout. Prefer this for simple dialogs; for richer descriptions
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
*/
description?: React.ReactNode;
};
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> DialogContentProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, description, ...props }, ref) => {
<DialogPortal> const hasExplicitDescribedBy = !!props["aria-describedby"];
<DialogOverlay /> const needsFallback =
<DialogPrimitive.Content !hasExplicitDescribedBy &&
ref={ref} description === undefined &&
className={cn( !hasDialogDescription(children);
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className, return (
)} <DialogPortal>
{...props} <DialogOverlay />
> <DialogPrimitive.Content
{children} ref={ref}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"> className={cn(
<X className="h-4 w-4" /> "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
<span className="sr-only">Close</span> className,
</DialogPrimitive.Close> )}
</DialogPrimitive.Content> {...props}
</DialogPortal> >
)); {description !== undefined && (
<DialogPrimitive.Description className="sr-only">
{description}
</DialogPrimitive.Description>
)}
{needsFallback && (
<DialogPrimitive.Description className="sr-only">
Dialog content
</DialogPrimitive.Description>
)}
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)); ));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;

View File

@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</MenubarPrimitive.SubTrigger> </MenubarPrimitive.SubTrigger>
)); ));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;

View File

@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
PaginationLink.displayName = "PaginationLink"; PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}> <PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4 rtl:rotate-180" />
<span>Previous</span> <span>Previous</span>
</PaginationLink> </PaginationLink>
); );
PaginationPrevious.displayName = "PaginationPrevious"; PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}> <PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pe-2.5", className)} {...props}>
<span>Next</span> <span>Next</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4 rtl:rotate-180" />
</PaginationLink> </PaginationLink>
); );
PaginationNext.displayName = "PaginationNext"; PaginationNext.displayName = "PaginationNext";

View File

@@ -49,21 +49,61 @@ const sheetVariants = cva(
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {} VariantProps<typeof sheetVariants> {
/**
* Optional accessible description rendered visually-hidden. Falls back to a
* generic string when no `SheetDescription` is found in the subtree, which
* keeps Radix happy without forcing every call-site to add one.
*/
description?: React.ReactNode;
}
function hasSheetDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === SheetPrimitive.Description ||
(type && type.displayName === SheetPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasSheetDescription(nested)) {
found = true;
}
});
return found;
}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => ( ({ side = "right", className, children, description, ...props }, ref) => {
<SheetPortal> const needsFallback =
<SheetOverlay /> !props["aria-describedby"] &&
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> description === undefined &&
{children} !hasSheetDescription(children);
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"> return (
<X className="h-4 w-4" /> <SheetPortal>
<span className="sr-only">Close</span> <SheetOverlay />
</SheetPrimitive.Close> <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
</SheetPrimitive.Content> {description !== undefined && (
</SheetPortal> <SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
), )}
{needsFallback && (
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
)}
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
},
); );
SheetContent.displayName = SheetPrimitive.Content.displayName; SheetContent.displayName = SheetPrimitive.Content.displayName;

View File

@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
Sidebar.displayName = "Sidebar"; Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>( const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => { ({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => {
const { toggleSidebar } = useSidebar(); const { toggleSidebar } = useSidebar();
return ( return (
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
data-sidebar="trigger" data-sidebar="trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={ariaLabel ?? "Toggle sidebar"}
className={cn("h-7 w-7", className)} className={cn("h-7 w-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event); onClick?.(event);
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
}} }}
{...props} {...props}
> >
<PanelLeft /> <PanelLeft className="rtl:rotate-180" />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
</Button> </Button>
); );
}, },

View File

@@ -0,0 +1,234 @@
import { ReactNode, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, Check, ChevronLeft } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/**
* Generic multi-step wizard shell.
*
* A wizard is defined as an ordered array of {@link WizardStepDef} items.
* Each step owns its own piece of the accumulated state and returns a node
* that renders the form. The shell handles:
*
* - rendering the numbered stepper (with `completed` / `active` styles)
* - Back / Next navigation
* - a final "Finish" button that calls `onFinish(state)`
* - per-step validation via `step.validate(state)` → return an error
* message or `null` when valid
* - a persistent progress bar
*
* Keeping this shell free of domain logic means every scenario wizard
* (rubric, course, structure, …) is just a tiny file that defines steps
* and wires the final submit to the right service.
*/
export interface WizardStepDef<TState> {
id: string;
/** i18n key for the step title. */
titleKey: string;
/** Optional i18n key for a short description shown under the title. */
descriptionKey?: string;
/**
* Render the step's form. Call `update(patch)` to merge fields into the
* shared state. Do **not** call `onNext` directly — the shell handles
* Next/Back; the render function should just update local fields.
*/
render: (props: StepRenderProps<TState>) => ReactNode;
/**
* Synchronous validator. Return a human-readable error message that will
* be displayed under the form and block Next, or `null` when valid.
*/
validate?: (state: TState) => string | null;
}
export interface StepRenderProps<TState> {
state: TState;
update: (patch: Partial<TState>) => void;
error: string | null;
}
export interface StepWizardProps<TState> {
/** i18n key for the wizard heading. */
titleKey: string;
/** i18n key for the short lead paragraph shown under the heading. */
subtitleKey?: string;
/** Optional back-link to the hub. */
backTo?: string;
backLabelKey?: string;
steps: WizardStepDef<TState>[];
initialState: TState;
/** Called once the user clicks Finish on the last step. */
onFinish: (state: TState) => Promise<void> | void;
/** i18n key for the Finish button label. Defaults to "wizard.finish". */
finishLabelKey?: string;
/** Disable all form controls (used by consumers while submitting). */
submitting?: boolean;
}
export function StepWizard<TState>({
titleKey,
subtitleKey,
backTo,
backLabelKey = "wizard.backToHub",
steps,
initialState,
onFinish,
finishLabelKey = "wizard.finish",
submitting = false,
}: StepWizardProps<TState>) {
const { t } = useTranslation();
const [index, setIndex] = useState(0);
const [state, setState] = useState<TState>(initialState);
const [error, setError] = useState<string | null>(null);
const [submittingInternal, setSubmittingInternal] = useState(false);
const busy = submitting || submittingInternal;
const current = steps[index];
const isLast = index === steps.length - 1;
const progress = Math.round(((index + 1) / steps.length) * 100);
const update = (patch: Partial<TState>) => {
setState((prev) => ({ ...prev, ...patch }));
// Clear validation error as soon as the user starts typing again.
if (error) setError(null);
};
const validateAndAdvance = async () => {
const msg = current.validate?.(state) ?? null;
if (msg) {
setError(msg);
return;
}
setError(null);
if (!isLast) {
setIndex((i) => i + 1);
return;
}
// Last step: submit.
try {
setSubmittingInternal(true);
await onFinish(state);
} finally {
setSubmittingInternal(false);
}
};
const stepStatus = useMemo(
() =>
steps.map((_, i) => {
if (i < index) return "done" as const;
if (i === index) return "active" as const;
return "upcoming" as const;
}),
[steps, index],
);
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* Heading + back link */}
<div className="space-y-2">
{backTo && (
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<Link to={backTo} className="flex items-center gap-1">
<ChevronLeft className="h-4 w-4" />
{t(backLabelKey)}
</Link>
</Button>
)}
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
{subtitleKey && <p className="text-muted-foreground">{t(subtitleKey)}</p>}
</div>
{/* Stepper */}
<ol className="flex items-center gap-2 overflow-x-auto pb-2" role="list">
{steps.map((step, i) => {
const s = stepStatus[i];
return (
<li key={step.id} className="flex items-center gap-2 shrink-0">
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold",
s === "done" && "bg-primary text-primary-foreground",
s === "active" && "bg-primary/10 text-primary ring-2 ring-primary",
s === "upcoming" && "bg-muted text-muted-foreground",
)}
aria-current={s === "active" ? "step" : undefined}
>
{s === "done" ? <Check className="h-4 w-4" /> : i + 1}
</div>
<span
className={cn(
"text-sm whitespace-nowrap",
s === "active" ? "font-medium" : "text-muted-foreground",
)}
>
{t(step.titleKey)}
</span>
{i < steps.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
</li>
);
})}
</ol>
{/* Progress bar */}
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
aria-hidden
/>
</div>
{/* Active step */}
<Card>
<CardHeader className="pb-2">
<h2 className="text-lg font-semibold">{t(current.titleKey)}</h2>
{current.descriptionKey && (
<p className="text-sm text-muted-foreground">{t(current.descriptionKey)}</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{current.render({ state, update, error })}
{error && (
<div
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</div>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={busy || index === 0}
>
<ArrowLeft className="mr-1 h-4 w-4" />
{t("wizard.back")}
</Button>
<div className="text-xs text-muted-foreground tabular-nums">
{t("wizard.stepOf", { current: index + 1, total: steps.length })}
</div>
<Button onClick={validateAndAdvance} disabled={busy}>
{isLast ? t(finishLabelKey) : t("wizard.next")}
{!isLast && <ArrowRight className="ml-1 h-4 w-4" />}
</Button>
</div>
</div>
);
}
export default StepWizard;

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { clearToken } from "@/lib/api-client"; import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client";
import type { User, UserRole } from "@/types/auth"; import type { User, UserRole } from "@/types/auth";
export type { UserRole }; export type { UserRole };
@@ -9,6 +9,9 @@ interface AuthContextType {
user: User | null; user: User | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
selectedEntityId: number | null;
selectedEntity: User["entities"][number] | null;
setSelectedEntityId: (entityId: number | null) => void;
login: (email: string, password: string) => Promise<User>; login: (email: string, password: string) => Promise<User>;
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const syncEntitySelection = useCallback((nextUser: User | null) => {
const available = nextUser?.entities ?? [];
if (available.length === 0) {
setSelectedEntityIdState(null);
setActiveEntityId(null);
return;
}
const stored = getActiveEntityId();
const validStored = stored && available.some((e) => e.id === stored) ? stored : null;
const next = validStored ?? available[0].id;
setSelectedEntityIdState(next);
setActiveEntityId(next);
}, []);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem("encoach_token"); const token = localStorage.getItem("encoach_token");
if (!token) { if (!token) {
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authService authService
.getUser() .getUser()
.then(setUser) .then((u) => {
setUser(u);
syncEntitySelection(u);
})
.catch(() => { .catch(() => {
clearToken(); clearToken();
setActiveEntityId(null);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, []); }, [syncEntitySelection]);
const login = useCallback(async (email: string, password: string): Promise<User> => { const login = useCallback(async (email: string, password: string): Promise<User> => {
const res = await authService.login({ login: email, password }); const res = await authService.login({ login: email, password });
setUser(res.user); setUser(res.user);
syncEntitySelection(res.user);
return res.user; return res.user;
}, []); }, [syncEntitySelection]);
const setSelectedEntityId = useCallback((entityId: number | null) => {
if (!user) {
setSelectedEntityIdState(null);
setActiveEntityId(null);
return;
}
if (entityId == null) {
setSelectedEntityIdState(null);
setActiveEntityId(null);
return;
}
if (!user.entities.some((e) => e.id === entityId)) return;
setSelectedEntityIdState(entityId);
setActiveEntityId(entityId);
}, [user]);
const logout = useCallback(async () => { const logout = useCallback(async () => {
await authService.logout(); await authService.logout();
setUser(null); setUser(null);
setSelectedEntityIdState(null);
setActiveEntityId(null);
}, []); }, []);
const selectedEntity =
user?.entities.find((e) => e.id === selectedEntityId) ??
user?.entities[0] ??
null;
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
isAuthenticated: !!user, isAuthenticated: !!user,
isLoading, isLoading,
selectedEntityId,
selectedEntity,
setSelectedEntityId,
login, login,
logout, logout,
}} }}

View File

@@ -20,4 +20,9 @@ export * from "./useStudentProgress";
export * from "./useLibrary"; export * from "./useLibrary";
export * from "./useActivities"; export * from "./useActivities";
export * from "./useFacilities"; export * from "./useFacilities";
export * from "./useExamTemplates";
export * from "./useEntityOnboarding";
export * from "./useAiCourse";
export * from "./useAdaptiveEngine";
export * from "./useScoreRelease";
export { usePermissions } from "../usePermissions"; export { usePermissions } from "../usePermissions";

View File

@@ -4,240 +4,306 @@ export const queryKeys = {
}, },
users: { users: {
all: ["users"] as const, all: ["users"] as const,
list: (params: Record<string, unknown>) => ["users", "list", params] as const, list: (params: object) => ["users", "list", params] as const,
detail: (id: number) => ["users", id] as const, detail: (id: number) => ["users", id] as const,
}, },
entities: { entities: {
all: ["entities"] as const, all: ["entities"] as const,
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const, list: (params?: object) => ["entities", "list", params] as const,
detail: (id: number) => ["entities", id] as const, detail: (id: number) => ["entities", id] as const,
roles: (entityId: number) => ["entities", entityId, "roles"] as const, roles: (entityId: number) => ["entities", entityId, "roles"] as const,
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const, permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
}, },
exams: { exams: {
all: ["exams"] as const, all: ["exams"] as const,
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const, list: (module: string, params?: object) => ["exams", module, params] as const,
detail: (module: string, id: number) => ["exams", module, id] as const, detail: (module: string, id: number) => ["exams", module, id] as const,
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const, rubrics: (params?: object) => ["exams", "rubrics", params] as const,
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const, rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const, structures: (params?: object) => ["exams", "structures", params] as const,
avatars: ["exams", "avatars"] as const, avatars: ["exams", "avatars"] as const,
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
}, },
assignments: { assignments: {
all: ["assignments"] as const, all: ["assignments"] as const,
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const, list: (params?: object) => ["assignments", "list", params] as const,
detail: (id: number) => ["assignments", id] as const, detail: (id: number) => ["assignments", id] as const,
}, },
classrooms: { classrooms: {
all: ["classrooms"] as const, all: ["classrooms"] as const,
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const, list: (params?: object) => ["classrooms", "list", params] as const,
detail: (id: number) => ["classrooms", id] as const, detail: (id: number) => ["classrooms", id] as const,
}, },
stats: { stats: {
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const, sessions: (params?: object) => ["stats", "sessions", params] as const,
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const, stats: (params?: object) => ["stats", "stats", params] as const,
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const, statistical: (params?: object) => ["stats", "statistical", params] as const,
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const, performance: (params?: object) => ["stats", "performance", params] as const,
}, },
training: { training: {
all: ["training"] as const, all: ["training"] as const,
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const, tips: (params?: object) => ["training", "tips", params] as const,
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const, walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
}, },
subscriptions: { subscriptions: {
packages: ["subscriptions", "packages"] as const, packages: ["subscriptions", "packages"] as const,
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const, payments: (params?: object) => ["subscriptions", "payments", params] as const,
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const, discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
}, },
tickets: { tickets: {
all: ["tickets"] as const, all: ["tickets"] as const,
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const, list: (params?: object) => ["tickets", "list", params] as const,
assigned: ["tickets", "assigned"] as const, assigned: ["tickets", "assigned"] as const,
}, },
approvals: { approvals: {
all: ["approvals"] as const, all: ["approvals"] as const,
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const, list: (params?: object) => ["approvals", "list", params] as const,
}, },
taxonomy: { taxonomy: {
subjects: ["taxonomy", "subjects"] as const, subjects: ["taxonomy", "subjects"] as const,
subject: (id: number) => ["taxonomy", "subjects", id] as const, subject: (id: number) => ["taxonomy", "subjects", id] as const,
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const, tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const, domains: (params?: object) => ["taxonomy", "domains", params] as const,
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const, topics: (params?: object) => ["taxonomy", "topics", params] as const,
}, },
adaptive: { adaptive: {
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const, proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
summary: ["adaptive", "proficiency", "summary"] as const, summary: ["adaptive", "proficiency", "summary"] as const,
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const, plan: (params?: object) => ["adaptive", "plan", params] as const,
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const, topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const, diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
}, },
resources: { resources: {
all: ["resources"] as const, all: ["resources"] as const,
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const, list: (params?: object) => ["resources", "list", params] as const,
}, },
lms: { lms: {
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const, courses: (params?: object) => ["lms", "courses", params] as const,
course: (id: number) => ["lms", "courses", id] as const, course: (id: number) => ["lms", "courses", id] as const,
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const, myCourses: ["lms", "my-courses"] as const,
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const, students: (params?: object) => ["lms", "students", params] as const,
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const, teachers: (params?: object) => ["lms", "teachers", params] as const,
batches: (params?: object) => ["lms", "batches", params] as const,
batch: (id: number) => ["lms", "batches", id] as const, batch: (id: number) => ["lms", "batches", id] as const,
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const, timetable: (params?: object) => ["lms", "timetable", params] as const,
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const, attendance: (params?: object) => ["lms", "attendance", params] as const,
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const, grades: (params?: object) => ["lms", "grades", params] as const,
}, },
analytics: { analytics: {
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const, student: (params?: object) => ["analytics", "student", params] as const,
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const, class: (params?: object) => ["analytics", "class", params] as const,
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const, subject: (params?: object) => ["analytics", "subject", params] as const,
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const, contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
alerts: ["analytics", "alerts"] as const, alerts: ["analytics", "alerts"] as const,
}, },
academicYears: { academicYears: {
all: ["academic-years"] as const, all: ["academic-years"] as const,
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const, list: (params?: object) => ["academic-years", "list", params] as const,
detail: (id: number) => ["academic-years", id] as const, detail: (id: number) => ["academic-years", id] as const,
}, },
academicTerms: { academicTerms: {
all: ["academic-terms"] as const, all: ["academic-terms"] as const,
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const, list: (params?: object) => ["academic-terms", "list", params] as const,
detail: (id: number) => ["academic-terms", id] as const, detail: (id: number) => ["academic-terms", id] as const,
}, },
departments: { departments: {
all: ["departments"] as const, all: ["departments"] as const,
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const, list: (params?: object) => ["departments", "list", params] as const,
detail: (id: number) => ["departments", id] as const, detail: (id: number) => ["departments", id] as const,
}, },
admissionRegisters: { admissionRegisters: {
all: ["admission-registers"] as const, all: ["admission-registers"] as const,
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const, list: (params?: object) => ["admission-registers", "list", params] as const,
detail: (id: number) => ["admission-registers", id] as const, detail: (id: number) => ["admission-registers", id] as const,
}, },
admissions: { admissions: {
all: ["admissions"] as const, all: ["admissions"] as const,
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const, list: (params?: object) => ["admissions", "list", params] as const,
detail: (id: number) => ["admissions", id] as const, detail: (id: number) => ["admissions", id] as const,
}, },
instExamSessions: { instExamSessions: {
all: ["inst-exam-sessions"] as const, all: ["inst-exam-sessions"] as const,
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const, list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
detail: (id: number) => ["inst-exam-sessions", id] as const, detail: (id: number) => ["inst-exam-sessions", id] as const,
}, },
examTypes: { examTypes: {
all: ["exam-types"] as const, all: ["exam-types"] as const,
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const, list: (params?: object) => ["exam-types", "list", params] as const,
}, },
gradeConfigs: { gradeConfigs: {
all: ["grade-configs"] as const, all: ["grade-configs"] as const,
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const, list: (params?: object) => ["grade-configs", "list", params] as const,
}, },
resultTemplates: { resultTemplates: {
all: ["result-templates"] as const, all: ["result-templates"] as const,
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const, list: (params?: object) => ["result-templates", "list", params] as const,
}, },
marksheets: { marksheets: {
all: ["marksheets"] as const, all: ["marksheets"] as const,
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const, list: (params?: object) => ["marksheets", "list", params] as const,
detail: (id: number) => ["marksheets", id] as const, detail: (id: number) => ["marksheets", id] as const,
}, },
courseAssignments: { courseAssignments: {
all: ["course-assignments"] as const, all: ["course-assignments"] as const,
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const, list: (params?: object) => ["course-assignments", "list", params] as const,
detail: (id: number) => ["course-assignments", id] as const, detail: (id: number) => ["course-assignments", id] as const,
}, },
assignmentTypes: { assignmentTypes: {
all: ["assignment-types"] as const, all: ["assignment-types"] as const,
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const, list: (params?: object) => ["assignment-types", "list", params] as const,
}, },
subjectRegistrations: { subjectRegistrations: {
all: ["subject-registrations"] as const, all: ["subject-registrations"] as const,
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const, list: (params?: object) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const, available: (params?: object) => ["subject-registrations", "available", params] as const,
}, },
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
chapters: (courseId: number) => ["chapters", "list", courseId] as const, chapters: (courseId: number) => ["chapters", "list", courseId] as const,
chapter: (id: number) => ["chapters", "detail", id] as const, chapter: (id: number) => ["chapters", "detail", id] as const,
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const, chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const, chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const, discussionBoards: (params?: object) => ["discussion-boards", params] as const,
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const, posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const, announcements: (params?: object) => ["announcements", params] as const,
messages: (params?: Record<string, unknown>) => ["messages", params] as const, messages: (params?: object) => ["messages", params] as const,
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const, sentMessages: (params?: object) => ["messages", "sent", params] as const,
unreadMessages: ["messages", "unread-count"] as const, unreadMessages: ["messages", "unread-count"] as const,
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const, notifications: (params?: object) => ["notifications", params] as const,
unreadNotifications: ["notifications", "unread-count"] as const, unreadNotifications: ["notifications", "unread-count"] as const,
notificationRules: ["notification-rules"] as const, notificationRules: ["notification-rules"] as const,
notificationPreferences: ["notification-preferences"] as const, notificationPreferences: ["notification-preferences"] as const,
faqCategories: (audience?: string) => ["faq", "categories", audience] as const, faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const, faqItems: (params?: object) => ["faq", "items", params] as const,
studentLeaves: { studentLeaves: {
all: ["student-leaves"] as const, all: ["student-leaves"] as const,
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const, list: (params?: object) => ["student-leaves", "list", params] as const,
}, },
studentLeaveTypes: { studentLeaveTypes: {
all: ["student-leave-types"] as const, all: ["student-leave-types"] as const,
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const, list: (params?: object) => ["student-leave-types", "list", params] as const,
}, },
feesPlans: { feesPlans: {
all: ["fees-plans"] as const, all: ["fees-plans"] as const,
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const, list: (params?: object) => ["fees-plans", "list", params] as const,
detail: (id: number) => ["fees-plans", id] as const, detail: (id: number) => ["fees-plans", id] as const,
}, },
studentFees: { studentFees: {
all: ["student-fees"] as const, all: ["student-fees"] as const,
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const, list: (params?: object) => ["student-fees", "list", params] as const,
}, },
feesTerms: { feesTerms: {
all: ["fees-terms"] as const, all: ["fees-terms"] as const,
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const, list: (params?: object) => ["fees-terms", "list", params] as const,
}, },
lessons: { lessons: {
all: ["lessons"] as const, all: ["lessons"] as const,
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const, list: (params?: object) => ["lessons", "list", params] as const,
}, },
gradebooks: { gradebooks: {
all: ["gradebooks"] as const, all: ["gradebooks"] as const,
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const, list: (params?: object) => ["gradebooks", "list", params] as const,
}, },
gradebookLines: { gradebookLines: {
all: ["gradebook-lines"] as const, all: ["gradebook-lines"] as const,
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const, list: (params?: object) => ["gradebook-lines", "list", params] as const,
}, },
gradingAssignments: { gradingAssignments: {
all: ["grading-assignments"] as const, all: ["grading-assignments"] as const,
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const, list: (params?: object) => ["grading-assignments", "list", params] as const,
}, },
studentProgress: { studentProgress: {
all: ["student-progress"] as const, all: ["student-progress"] as const,
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const, list: (params?: object) => ["student-progress", "list", params] as const,
detail: (id: number) => ["student-progress", "detail", id] as const,
}, },
libraryMedia: { libraryMedia: {
all: ["library-media"] as const, all: ["library-media"] as const,
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const, list: (params?: object) => ["library-media", "list", params] as const,
}, },
libraryMovements: { libraryMovements: {
all: ["library-movements"] as const, all: ["library-movements"] as const,
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const, list: (params?: object) => ["library-movements", "list", params] as const,
}, },
libraryCards: { libraryCards: {
all: ["library-cards"] as const, all: ["library-cards"] as const,
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const, list: (params?: object) => ["library-cards", "list", params] as const,
}, },
activities: { activities: {
all: ["activities"] as const, all: ["activities"] as const,
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const, list: (params?: object) => ["activities", "list", params] as const,
}, },
activityTypes: { activityTypes: {
all: ["activity-types"] as const, all: ["activity-types"] as const,
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const, list: (params?: object) => ["activity-types", "list", params] as const,
}, },
facilities: { facilities: {
all: ["facilities"] as const, all: ["facilities"] as const,
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const, list: (params?: object) => ["facilities", "list", params] as const,
}, },
assets: { assets: {
all: ["assets"] as const, all: ["assets"] as const,
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const, list: (params?: object) => ["assets", "list", params] as const,
},
signup: {
goals: ["signup", "goals"] as const,
},
placement: {
results: (sessionId: string) => ["placement", "results", sessionId] as const,
speakingStatus: (sessionId: string) => ["placement", "speaking-status", sessionId] as const,
learningPath: (sessionId: string) => ["placement", "learning-path", sessionId] as const,
},
examTemplates: {
international: ["exam-templates", "international"] as const,
custom: ["exam-templates", "custom"] as const,
detail: (id: number) => ["exam-templates", id] as const,
},
ieltsExam: {
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
},
examSession: {
session: (examId: number) => ["exam-session", examId] as const,
status: (examId: number) => ["exam-session", examId, "status"] as const,
},
grading: {
queue: (params?: object) => ["grading", "queue", params] as const,
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
},
courseGeneration: {
gapAnalysis: (source: string, sourceId: number) => ["course-gen", "gap", source, sourceId] as const,
course: (id: number) => ["course-gen", "course", id] as const,
},
aiCourse: {
course: (id: number) => ["ai-course", id] as const,
tracks: (id: number) => ["ai-course", id, "tracks"] as const,
quality: (id: number) => ["ai-course", id, "quality"] as const,
ieltsValidation: (id: number) => ["ai-course", id, "ielts-validation"] as const,
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
},
entityOnboarding: {
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
},
adaptiveEngine: {
dashboard: ["adaptive-engine", "dashboard"] as const,
students: (params?: object) => ["adaptive-engine", "students", params] as const,
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
settings: ["adaptive-engine", "settings"] as const,
},
levelMapping: {
get: (entityId: number) => ["level-mapping", entityId] as const,
},
branding: {
get: (entityId: number) => ["branding", entityId] as const,
current: ["branding", "current"] as const,
},
scoreRelease: {
pending: (params?: object) => ["score-release", "pending", params] as const,
},
verification: {
verify: (hash: string) => ["verification", hash] as const,
}, },
}; };

View File

@@ -0,0 +1,70 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiFeedbackService } from "@/services/ai-feedback.service";
import type {
AIFeedbackStatus,
AIFeedbackSubjectType,
AIFeedbackSubmitInput,
} from "@/types/ai-feedback";
const KEY_ROOT = ["ai", "feedback"] as const;
export function useAIFeedbackSummary(
subjectType: AIFeedbackSubjectType | undefined,
subjectId: number | undefined,
) {
return useQuery({
enabled: !!subjectType && !!subjectId,
queryKey: [...KEY_ROOT, "summary", subjectType, subjectId] as const,
queryFn: () =>
aiFeedbackService.summary(
subjectType as AIFeedbackSubjectType,
subjectId as number,
),
});
}
export function useSubmitAIFeedback() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: AIFeedbackSubmitInput) => aiFeedbackService.submit(input),
onSuccess: (_res, vars) => {
qc.invalidateQueries({
queryKey: [...KEY_ROOT, "summary", vars.subject_type, vars.subject_id],
});
qc.invalidateQueries({ queryKey: [...KEY_ROOT, "list"] });
},
});
}
export function useAIFeedbackList(
params: {
status?: AIFeedbackStatus;
rating?: "up" | "down";
subject_type?: AIFeedbackSubjectType;
prompt_key?: string;
page?: number;
size?: number;
} = {},
) {
return useQuery({
queryKey: [...KEY_ROOT, "list", params] as const,
queryFn: () => aiFeedbackService.list(params),
});
}
export function useResolveAIFeedback() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
status,
notes,
}: {
id: number;
status: Exclude<AIFeedbackStatus, "open">;
notes?: string;
}) => aiFeedbackService.resolve(id, status, notes),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}

View File

@@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiPromptService } from "@/services/ai-prompt.service";
import type { AIPromptCreateInput } from "@/types/ai-prompt";
const KEY_ROOT = ["ai", "prompts"] as const;
export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
return useQuery({
queryKey: [...KEY_ROOT, "keys", params] as const,
queryFn: () => aiPromptService.listKeys(params),
});
}
export function useAIPromptVersions(key: string | undefined) {
return useQuery({
enabled: !!key,
queryKey: [...KEY_ROOT, "versions", key] as const,
queryFn: () => aiPromptService.listVersions(key as string),
});
}
export function useAIPrompt(id: number | undefined) {
return useQuery({
enabled: !!id,
queryKey: [...KEY_ROOT, "detail", id] as const,
queryFn: () => aiPromptService.get(id as number),
});
}
export function useCreateAIPrompt() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}
export function useActivateAIPrompt() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => aiPromptService.activate(id),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}
export function useRenderAIPrompt() {
return useMutation({
mutationFn: ({ id, variables }: { id: number; variables: Record<string, string> }) =>
aiPromptService.render(id, variables),
});
}

View File

@@ -66,7 +66,10 @@ export function useGenerateTerms() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (yearId: number) => academicService.generateTerms(yearId), mutationFn: (yearId: number) => academicService.generateTerms(yearId),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }), onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all });
qc.invalidateQueries({ queryKey: queryKeys.academicYears.all });
},
}); });
} }

View File

@@ -0,0 +1,51 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { adaptiveEngineService } from "@/services/adaptive-engine.service";
import type { AdaptiveThresholdSettings } from "@/types";
export function useAdaptiveDashboard() {
return useQuery({
queryKey: queryKeys.adaptiveEngine.dashboard,
queryFn: () => adaptiveEngineService.getDashboard(),
});
}
export function useAdaptiveStudents(params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: queryKeys.adaptiveEngine.students(params as Record<string, unknown> | undefined),
queryFn: () => adaptiveEngineService.getStudents(params),
});
}
export function useStudentSignals(studentId: number | undefined) {
return useQuery({
queryKey: queryKeys.adaptiveEngine.signals(studentId ?? 0),
queryFn: () => adaptiveEngineService.getStudentSignals(studentId!),
enabled: !!studentId && studentId > 0,
});
}
export function useStudentAbility(studentId: number | undefined) {
return useQuery({
queryKey: queryKeys.adaptiveEngine.ability(studentId ?? 0),
queryFn: () => adaptiveEngineService.getStudentAbility(studentId!),
enabled: !!studentId && studentId > 0,
});
}
export function useAdaptiveSettings() {
return useQuery({
queryKey: queryKeys.adaptiveEngine.settings,
queryFn: () => adaptiveEngineService.getSettings(),
});
}
export function useUpdateAdaptiveSettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AdaptiveThresholdSettings) => adaptiveEngineService.updateSettings(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.adaptiveEngine.settings });
},
});
}

View File

@@ -0,0 +1,101 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import {
aiCourseService,
type AiCourseCreateEnglishRequest,
type AiCourseCreateIeltsRequest,
} from "@/services/ai-course.service";
export function useAiCourse(courseId: number | undefined) {
return useQuery({
queryKey: queryKeys.aiCourse.course(courseId ?? 0),
queryFn: () => aiCourseService.getCourse(courseId!),
enabled: !!courseId && courseId > 0,
});
}
export function useAiCourseTracks(courseId: number | undefined) {
return useQuery({
queryKey: queryKeys.aiCourse.tracks(courseId ?? 0),
queryFn: () => aiCourseService.getTracks(courseId!),
enabled: !!courseId && courseId > 0,
});
}
export function useCreateEnglishCourse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AiCourseCreateEnglishRequest) =>
aiCourseService.createEnglish(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-course"] });
},
});
}
export function useCreateIeltsCourse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AiCourseCreateIeltsRequest) =>
aiCourseService.createIelts(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-course"] });
},
});
}
export function useQualityGate(courseId: number | undefined) {
return useQuery({
queryKey: queryKeys.aiCourse.quality(courseId ?? 0),
queryFn: () => aiCourseService.getQualityGate(courseId!),
enabled: !!courseId && courseId > 0,
});
}
export function useApproveQuality() {
const qc = useQueryClient();
return useMutation({
mutationFn: (courseId: number) => aiCourseService.approveQuality(courseId),
onSuccess: (_d, courseId) => {
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
},
});
}
export function useRejectQuality() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
aiCourseService.rejectQuality(courseId, reason),
onSuccess: (_d, { courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
},
});
}
export function useEnglishTaxonomy() {
return useQuery({
queryKey: queryKeys.aiCourse.englishTaxonomy,
queryFn: () => aiCourseService.getEnglishTaxonomy(),
});
}
export function useIeltsValidation(courseId: number | undefined) {
return useQuery({
queryKey: queryKeys.aiCourse.ieltsValidation(courseId ?? 0),
queryFn: () => aiCourseService.getIeltsValidation(courseId!),
enabled: !!courseId && courseId > 0,
});
}
export function useSubmitExaminerReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { logId: number; action: string; examiner_notes?: string }) =>
aiCourseService.submitExaminerReview(data.logId, { action: data.action, examiner_notes: data.examiner_notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-course"] });
},
});
}

View File

@@ -0,0 +1,48 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { courseGenerationService } from "@/services/course-generation.service";
import type { CourseConfig, CourseProgress } from "@/types";
export function useGapAnalysis(source: "exam" | "placement", sourceId: number) {
return useQuery({
queryKey: queryKeys.courseGeneration.gapAnalysis(source, sourceId),
queryFn: () => courseGenerationService.getGapAnalysis(source, sourceId),
enabled: !!sourceId,
});
}
export function useAutoGenerateCourse() {
return useMutation({
mutationFn: (sourceId: number) => courseGenerationService.autoGenerate(sourceId),
});
}
export function useCreateCourse() {
return useMutation({
mutationFn: (data: Partial<CourseConfig>) => courseGenerationService.create(data),
});
}
export function useCourseDetail(courseId: number) {
return useQuery({
queryKey: queryKeys.courseGeneration.course(courseId),
queryFn: () => courseGenerationService.getCourse(courseId),
enabled: !!courseId,
});
}
export function useUpdateCourse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { courseId: number; payload: Partial<CourseConfig> }) =>
courseGenerationService.updateCourse(data.courseId, data.payload),
onSuccess: (_, vars) => qc.invalidateQueries({ queryKey: queryKeys.courseGeneration.course(vars.courseId) }),
});
}
export function useTrackProgress() {
return useMutation({
mutationFn: (data: { courseId: number; progress: CourseProgress }) =>
courseGenerationService.trackProgress(data.courseId, data.progress),
});
}

View File

@@ -120,6 +120,19 @@ export function useUploadMaterial() {
}); });
} }
export function useCreateMaterialFromResource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ chapterId, resourceId, name }: { chapterId: number; resourceId: number; name?: string }) =>
coursewareService.createMaterialFromResource(chapterId, resourceId, name),
onSuccess: (_, { chapterId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
},
});
}
export function useDeleteMaterial() { export function useDeleteMaterial() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
@@ -145,13 +158,24 @@ export function useMarkMaterialViewed() {
}); });
} }
export function useCourseCompletion(courseId: number) {
return useQuery({
queryKey: queryKeys.courseCompletion(courseId),
queryFn: () => coursewareService.getCourseCompletion(courseId),
enabled: !!courseId,
});
}
export function useCompleteChapter() { export function useCompleteChapter() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId), mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
onSuccess: (_, chapterId) => { coursewareService.markChapterComplete(chapterId),
onSuccess: (_, { chapterId, courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) }); qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) }); qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.courseCompletion(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
}, },
}); });
} }

View File

@@ -0,0 +1,57 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { entityOnboardingService } from "@/services/entity-onboarding.service";
import type { CredentialFilters } from "@/types";
export function useValidateCSV() {
return useMutation({
mutationFn: (file: File) => entityOnboardingService.validateCsv(file),
});
}
export function useBulkCreate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { validate_session_id?: string }) => entityOnboardingService.bulkCreate(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entity-onboarding"] });
},
});
}
export function useSendCredentials() {
const qc = useQueryClient();
return useMutation({
mutationFn: (studentIds: number[]) => entityOnboardingService.sendCredentials(studentIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.entityOnboarding.credentials() });
},
});
}
export function useCredentialStatuses(filters?: CredentialFilters & { page?: number; limit?: number }) {
return useQuery({
queryKey: queryKeys.entityOnboarding.credentials(filters as Record<string, unknown> | undefined),
queryFn: () => entityOnboardingService.getCredentialStatuses(filters),
});
}
export function useResendCredential() {
const qc = useQueryClient();
return useMutation({
mutationFn: (studentId: number) => entityOnboardingService.resendCredential(studentId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entity-onboarding", "credentials"] });
},
});
}
export function useResendAllPendingCredentials() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => entityOnboardingService.resendAllPending(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entity-onboarding", "credentials"] });
},
});
}

View File

@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
examReviewService,
type ExamReviewQueueParams,
} from "@/services/exam-review.service";
import { queryKeys } from "./keys";
export function useExamReviewQueue(params: ExamReviewQueueParams = {}) {
return useQuery({
queryKey: queryKeys.exams.reviewQueue(params),
queryFn: () => examReviewService.queue(params),
});
}
export function useExamReviewDetail(examId: number | undefined) {
return useQuery({
enabled: !!examId,
queryKey: queryKeys.exams.reviewDetail(examId ?? -1),
queryFn: () => examReviewService.detail(examId as number),
});
}
export function useApproveExamReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, notes }: { examId: number; notes?: string }) =>
examReviewService.approve(examId, notes),
onSuccess: (_data, { examId }) => {
qc.invalidateQueries({ queryKey: ["exams", "review"] });
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
},
});
}
export function useRejectExamReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, notes }: { examId: number; notes: string }) =>
examReviewService.reject(examId, notes),
onSuccess: (_data, { examId }) => {
qc.invalidateQueries({ queryKey: ["exams", "review"] });
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
},
});
}

View File

@@ -0,0 +1,35 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { examSessionService } from "@/services/exam-session.service";
import type { ExamAutoSave } from "@/types";
export function useExamSession(examId: number) {
return useQuery({
queryKey: queryKeys.examSession.session(examId),
queryFn: () => examSessionService.getSession(examId),
enabled: !!examId,
});
}
export function useExamStatus(examId: number, enabled: boolean) {
return useQuery({
queryKey: queryKeys.examSession.status(examId),
queryFn: () => examSessionService.getStatus(examId),
enabled: !!examId && enabled,
refetchInterval: 10000,
});
}
export function useExamAutoSave() {
return useMutation({
mutationFn: (data: { examId: number; payload: ExamAutoSave }) =>
examSessionService.autoSave(data.examId, data.payload),
});
}
export function useExamSubmit() {
return useMutation({
mutationFn: (data: { examId: number; attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
examSessionService.submit(data.examId, { attempt_id: data.attempt_id, answers: data.answers }),
});
}

View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { examTemplateService } from "@/services/exam-template.service";
import { ieltsExamService } from "@/services/ielts-exam.service";
import { customExamService } from "@/services/custom-exam.service";
import type { IELTSExamConfig, ContentPoolFilters, ExamAssignRequest, CustomExamCreateRequest, SaveAsTemplateRequest } from "@/types";
export function useInternationalTemplates() {
return useQuery({
queryKey: queryKeys.examTemplates.international,
queryFn: examTemplateService.listInternational,
});
}
export function useCustomTemplates() {
return useQuery({
queryKey: queryKeys.examTemplates.custom,
queryFn: examTemplateService.listCustom,
});
}
export function useCreateIeltsExam() {
return useMutation({
mutationFn: (data: IELTSExamConfig) => ieltsExamService.create(data),
});
}
export function useIeltsSkills(examId: number) {
return useQuery({
queryKey: queryKeys.ieltsExam.skills(examId),
queryFn: () => ieltsExamService.getSkills(examId),
enabled: !!examId,
});
}
export function useIeltsContentPool(examId: number, filters: ContentPoolFilters) {
return useQuery({
queryKey: queryKeys.ieltsExam.contentPool(examId, filters),
queryFn: () => ieltsExamService.getContentPool(examId, filters),
enabled: !!examId,
});
}
export function useIeltsAutoAssemble() {
return useMutation({
mutationFn: (examId: number) => ieltsExamService.autoAssemble(examId),
});
}
export function useIeltsExamValidation(examId: number) {
return useQuery({
queryKey: queryKeys.ieltsExam.validation(examId),
queryFn: () => ieltsExamService.validate(examId),
enabled: !!examId,
});
}
export function usePublishIeltsExam() {
const qc = useQueryClient();
return useMutation({
mutationFn: (examId: number) => ieltsExamService.update(examId, { status: "published" }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["ielts-exam"] }),
});
}
export function useAssignIeltsExam() {
return useMutation({
mutationFn: (data: { examId: number; assignment: ExamAssignRequest }) =>
ieltsExamService.assign(data.examId, data.assignment),
});
}
export function useCreateCustomExam() {
return useMutation({
mutationFn: (data: CustomExamCreateRequest) => customExamService.create(data),
});
}
export function useSaveAsTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: SaveAsTemplateRequest) => examTemplateService.saveCustomTemplate(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.examTemplates.custom }),
});
}

View File

@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "./keys"; import { queryKeys } from "./keys";
import { feesService } from "@/services/fees.service"; import { feesService, type FeesPlanFilters } from "@/services/fees.service";
import type { PaginationParams } from "@/types"; import type { PaginationParams } from "@/types";
export function useFeesPlans(params?: PaginationParams) { export function useFeesPlans(params?: FeesPlanFilters) {
return useQuery({ return useQuery({
queryKey: queryKeys.feesPlans.list(params), queryKey: queryKeys.feesPlans.list(params),
queryFn: () => feesService.listFeesPlans(params), queryFn: () => feesService.listFeesPlans(params),
@@ -18,7 +18,7 @@ export function useFeesPlan(id: number) {
}); });
} }
export function useStudentFees(params?: PaginationParams) { export function useStudentFees(params?: FeesPlanFilters) {
return useQuery({ return useQuery({
queryKey: queryKeys.studentFees.list(params), queryKey: queryKeys.studentFees.list(params),
queryFn: () => feesService.listStudentFees(params), queryFn: () => feesService.listStudentFees(params),

View File

@@ -11,10 +11,11 @@ export function useGradebooks(params?: PaginationParams) {
}); });
} }
export function useGradebookLines(params?: PaginationParams) { export function useGradebookLines(params?: PaginationParams & { gradebook_id?: number }) {
return useQuery({ return useQuery({
queryKey: queryKeys.gradebookLines.list(params), queryKey: queryKeys.gradebookLines.list(params),
queryFn: () => gradebookService.listGradebookLines(params), queryFn: () => gradebookService.listGradebookLines(params),
enabled: params?.gradebook_id ? !!params.gradebook_id : true,
}); });
} }

View File

@@ -0,0 +1,42 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { gradingService } from "@/services/grading.service";
import type { GradeSubmission } from "@/types";
export function useGradingQueue(params?: { skill?: string; status?: string; exam_id?: number }) {
return useQuery({
queryKey: queryKeys.grading.queue(params),
queryFn: () => gradingService.getQueue(params),
});
}
export function useStudentResponse(attemptId: number, skill: string) {
return useQuery({
queryKey: queryKeys.grading.response(attemptId, skill),
queryFn: () => gradingService.getStudentResponse(attemptId, skill),
enabled: !!attemptId,
});
}
export function useGradingRubric(attemptId: number, skill: string) {
return useQuery({
queryKey: queryKeys.grading.rubric(attemptId, skill),
queryFn: () => gradingService.getRubric(attemptId, skill),
enabled: !!attemptId,
});
}
export function useAIGradeSuggestion() {
return useMutation({
mutationFn: (data: { attemptId: number; skill: string }) =>
gradingService.getAISuggestion(data.attemptId, data.skill),
});
}
export function useSubmitGrade() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: GradeSubmission) => gradingService.submitGrade(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["grading"] }),
});
}

View File

@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys"; import { queryKeys } from "./keys";
import { lmsService } from "@/services"; import { lmsService } from "@/services";
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest } from "@/types"; import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest, EnrollStudentRequest, BulkEnrollRequest } from "@/types";
export function useCourses(params?: PaginationParams & { status?: string }) { export function useCourses(params?: PaginationParams & { status?: string }) {
return useQuery({ return useQuery({
@@ -26,6 +26,38 @@ export function useCreateCourse() {
}); });
} }
export function useMyEnrolledCourses() {
return useQuery({
queryKey: queryKeys.lms.myCourses,
queryFn: () => lmsService.getMyEnrolledCourses(),
});
}
export function useEnrollStudent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ studentId, data }: { studentId: number; data: EnrollStudentRequest }) =>
lmsService.enrollStudent(studentId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "students"] });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}
export function useBulkEnroll() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ courseId, data }: { courseId: number; data: BulkEnrollRequest }) =>
lmsService.bulkEnrollInCourse(courseId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "students"] });
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) { export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
return useQuery({ return useQuery({
queryKey: queryKeys.lms.students(params), queryKey: queryKeys.lms.students(params),

View File

@@ -0,0 +1,54 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { placementService } from "@/services/placement.service";
import type { PlacementSubject, CATAnswer, CATAutoSave } from "@/types";
export function usePlacementStart() {
return useMutation({
mutationFn: (subject: PlacementSubject) => placementService.start(subject),
});
}
export function usePlacementAnswer() {
return useMutation({
mutationFn: (data: CATAnswer) => placementService.submitAnswer(data),
});
}
export function usePlacementAutoSave() {
return useMutation({
mutationFn: (data: CATAutoSave) => placementService.autoSave(data),
});
}
export function usePlacementResults(sessionId: string) {
return useQuery({
queryKey: queryKeys.placement.results(sessionId),
queryFn: () => placementService.getResults(sessionId),
enabled: !!sessionId,
});
}
export function useSpeakingStatus(sessionId: string, enabled: boolean) {
return useQuery({
queryKey: queryKeys.placement.speakingStatus(sessionId),
queryFn: () => placementService.getSpeakingStatus(sessionId),
enabled: !!sessionId && enabled,
refetchInterval: 30000,
});
}
export function useLearningPath(sessionId: string) {
return useQuery({
queryKey: queryKeys.placement.learningPath(sessionId),
queryFn: () => placementService.getLearningPath(sessionId),
enabled: !!sessionId,
});
}
export function useSpeakingUpload() {
return useMutation({
mutationFn: (data: { sessionId: string; promptId: number; audioFile: File }) =>
placementService.uploadSpeaking(data.sessionId, data.promptId, data.audioFile),
});
}

View File

@@ -0,0 +1,41 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { scoreReleaseService } from "@/services/score-release.service";
export function usePendingScores(params?: { page?: number; limit?: number }) {
return useQuery({
queryKey: queryKeys.scoreRelease.pending(params as Record<string, unknown> | undefined),
queryFn: () => scoreReleaseService.getPending(params),
});
}
export function useReleaseScore() {
const qc = useQueryClient();
return useMutation({
mutationFn: (attemptId: number) => scoreReleaseService.release(attemptId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["score-release", "pending"] });
},
});
}
export function useRejectScore() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ attemptId, reason }: { attemptId: number; reason: string }) =>
scoreReleaseService.reject(attemptId, reason),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["score-release", "pending"] });
},
});
}
export function useBulkRelease() {
const qc = useQueryClient();
return useMutation({
mutationFn: (attemptIds: number[]) => scoreReleaseService.bulkRelease(attemptIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["score-release", "pending"] });
},
});
}

View File

@@ -0,0 +1,41 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { signupService } from "@/services/signup.service";
import type { RegisterRequest, VerifyEmailRequest, OnboardingData } from "@/types";
export function useGoals() {
return useQuery({
queryKey: queryKeys.signup.goals,
queryFn: signupService.getGoals,
});
}
export function useRegister() {
return useMutation({
mutationFn: (data: RegisterRequest) => signupService.register(data),
});
}
export function useVerifyEmail() {
return useMutation({
mutationFn: (data: VerifyEmailRequest) => signupService.verifyEmail(data),
});
}
export function useResendOtp() {
return useMutation({
mutationFn: (email: string) => signupService.resendOtp({ email }),
});
}
export function useCheckEmail() {
return useMutation({
mutationFn: (email: string) => signupService.checkEmail(email),
});
}
export function useCompleteOnboarding() {
return useMutation({
mutationFn: (data: OnboardingData) => signupService.completeOnboarding(data),
});
}

View File

@@ -1,11 +1,18 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "./keys"; import { queryKeys } from "./keys";
import { studentProgressService } from "@/services/student-progress.service"; import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
import type { PaginationParams } from "@/types";
export function useStudentProgressList(params?: PaginationParams) { export function useStudentProgressList(params?: StudentProgressFilters) {
return useQuery({ return useQuery({
queryKey: queryKeys.studentProgress.list(params), queryKey: queryKeys.studentProgress.list(params),
queryFn: () => studentProgressService.listProgress(params), queryFn: () => studentProgressService.listProgress(params),
}); });
} }
export function useStudentProgressDetail(id: number | null | undefined) {
return useQuery({
queryKey: queryKeys.studentProgress.detail(id ?? 0),
queryFn: () => studentProgressService.getProgress(id as number),
enabled: !!id,
});
}

80
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* EnCoach i18n bootstrap.
*
* We keep the translation strings in TypeScript modules (one per locale) so
* they go through type-checking and tree-shaking, and so non-translators
* can't ship a broken JSON file that blocks the build.
*
* Language selection order (product decision: English is the default UI
* language at startup; Arabic is opt-in via the language toggle):
* 1. localStorage key ``encoach-lang`` (explicit user pick, persisted)
* 2. ``en`` — fallback used on first visit, regardless of browser locale
*
* We intentionally do **not** read ``navigator.language`` anymore: an
* Arabic-locale browser should still land on the English UI the first time
* a user visits, because the QA team and the product manager both sign off
* against the English baseline. Users that actively want Arabic flip the
* toggle once and the preference is remembered from then on.
*
* RTL handling: whenever the active language switches to one of RTL_LANGS,
* we flip ``document.documentElement.dir`` so Tailwind's RTL utilities +
* Radix primitives pick up the change automatically.
*/
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import ar from "./locales/ar";
import en from "./locales/en";
const RTL_LANGS = new Set(["ar", "he", "fa", "ur"]);
export const SUPPORTED_LANGS = [
{ code: "en", label: "English" },
{ code: "ar", label: "العربية" },
] as const;
export type SupportedLang = (typeof SUPPORTED_LANGS)[number]["code"];
export const DEFAULT_LANG: SupportedLang = "en";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
ar: { translation: ar },
},
// Explicit default — english is the baseline UI language at startup.
lng: DEFAULT_LANG,
fallbackLng: DEFAULT_LANG,
supportedLngs: SUPPORTED_LANGS.map((l) => l.code),
interpolation: { escapeValue: false },
detection: {
// Only honour the user's saved pick. Navigator/htmlTag detection was
// removed so Arabic-locale browsers don't silently boot the UI in
// Arabic before the user has chosen.
order: ["localStorage"],
caches: ["localStorage"],
lookupLocalStorage: "encoach-lang",
},
returnEmptyString: false,
});
export function applyDirectionForLang(lang: string) {
const base = lang.split("-")[0];
const dir = RTL_LANGS.has(base) ? "rtl" : "ltr";
if (typeof document !== "undefined") {
document.documentElement.dir = dir;
document.documentElement.lang = base;
}
}
if (typeof window !== "undefined") {
applyDirectionForLang(i18n.language || "en");
i18n.on("languageChanged", applyDirectionForLang);
}
export default i18n;

1131
src/i18n/locales/ar.ts Normal file

File diff suppressed because it is too large Load Diff

1193
src/i18n/locales/en.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -51,6 +51,14 @@
--sidebar-accent-foreground: 8 40% 78%; --sidebar-accent-foreground: 8 40% 78%;
--sidebar-border: 240 18% 20%; --sidebar-border: 240 18% 20%;
--sidebar-ring: 8 50% 58%; --sidebar-ring: 8 50% 58%;
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
warm/cool axis so pairs read well together when stacked. */
--chart-1: 8 50% 54%;
--chart-2: 220 70% 52%;
--chart-3: 152 60% 42%;
--chart-4: 38 92% 50%;
--chart-5: 280 55% 55%;
} }
.dark { .dark {
@@ -99,6 +107,12 @@
--sidebar-accent-foreground: 8 40% 75%; --sidebar-accent-foreground: 8 40% 75%;
--sidebar-border: 240 18% 13%; --sidebar-border: 240 18% 13%;
--sidebar-ring: 8 50% 55%; --sidebar-ring: 8 50% 55%;
--chart-1: 8 52% 62%;
--chart-2: 220 65% 65%;
--chart-3: 152 50% 55%;
--chart-4: 38 75% 60%;
--chart-5: 280 55% 68%;
} }
} }
@@ -120,3 +134,103 @@
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
} }
} }
/* -------------------------------------------------------------------------
* RTL / Arabic support
*
* `tailwindcss-rtl` handles the bulk of the work: physical margin / padding /
* position / border / rounded / text-align / space-x utilities are mirrored
* automatically when `html[dir="rtl"]` is active.
*
* Below we cover the things Tailwind can't: the webfont (Inter has weak
* Arabic glyphs, so swap in Cairo), recharts tooltips/axes which have their
* own DOM, and a few icons that should NOT mirror (arrows used as pure
* visual affordance e.g. ChevronRight in a dropdown).
* ------------------------------------------------------------------------- */
html[dir="rtl"] body,
html[dir="rtl"] h1,
html[dir="rtl"] h2,
html[dir="rtl"] h3,
html[dir="rtl"] h4,
html[dir="rtl"] h5,
html[dir="rtl"] h6,
html[dir="rtl"] input,
html[dir="rtl"] textarea,
html[dir="rtl"] button,
html[dir="rtl"] select {
font-family: 'Cairo', 'Inter', system-ui, sans-serif;
}
html[dir="rtl"] code,
html[dir="rtl"] pre {
font-family: 'JetBrains Mono', monospace;
}
html[dir="rtl"] .recharts-wrapper,
html[dir="rtl"] .recharts-legend-wrapper {
direction: ltr;
}
/* Breadcrumb and submenu chevrons: always pointing "forward" in reading
direction. Lucide's ChevronRight is ">" which is forward in LTR, but in
RTL the same arrow must become "<". We flip via CSS so no component has
to know about direction. */
html[dir="rtl"] nav[aria-label="breadcrumb"] li[role="presentation"] > svg,
html[dir="rtl"] [data-radix-menu-content] [role="menuitem"] > svg:last-child,
html[dir="rtl"] [data-radix-popper-content-wrapper] [role="menuitem"] > svg:last-child {
transform: rotate(180deg);
}
/* Numbers, percentages, emails, URLs, dates should stay LTR-isolated even
when embedded in Arabic paragraphs, otherwise slashes / percent signs /
dashes drift to the wrong side and the value becomes unreadable.
<bdi> already gives us this per-element; the helper classes below are
for places where adding a wrapper element is awkward. */
.ltr-nums,
.dir-ltr {
direction: ltr;
unicode-bidi: isolate;
}
/* Pure-number cells & badges align to the end (right in LTR, left in RTL)
but the digits themselves still read left-to-right. */
html[dir="rtl"] .numeric {
text-align: end;
direction: ltr;
unicode-bidi: isolate;
}
/* Some lucide icons are pure affordances (play, external-link, send) and
should NOT be mirrored even if tailwindcss-rtl would otherwise flip the
button that contains them. Authors opt-in with data-no-flip. */
html[dir="rtl"] [data-no-flip] {
transform: none !important;
}
/* Keep code snippets, identifiers, and file paths readable in RTL. */
html[dir="rtl"] code,
html[dir="rtl"] pre,
html[dir="rtl"] kbd,
html[dir="rtl"] samp,
html[dir="rtl"] [data-monospace] {
direction: ltr;
unicode-bidi: isolate;
text-align: start;
}
/* Scrollbars inside horizontally-scrollable tables already flip naturally
in RTL, but the shadow Radix portals (dropdowns, tooltips, dialogs) use
`right-0` / `left-0` positioning computed in JS. For safety, make sure
they inherit the document direction. */
html[dir="rtl"] [data-radix-popper-content-wrapper] {
direction: rtl;
}
/* Inputs with dir="ltr" (email, password, URL) override but keep the
placeholder aligned to the visual start (right in RTL, left in LTR). */
html[dir="rtl"] input[dir="ltr"]::placeholder,
html[dir="rtl"] textarea[dir="ltr"]::placeholder {
text-align: right;
}

View File

@@ -1,4 +1,23 @@
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */ /**
* Odoo REST client with transparent access-token rotation.
*
* Tokens live in ``localStorage`` under three keys:
* - ``encoach_token`` — short-lived access JWT (1h)
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
* - ``encoach_token_exp`` — epoch seconds for the access token
*
* When a request receives ``401`` *and* a refresh token is present, the client
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
* original request exactly once. Multiple concurrent 401s coalesce onto the
* same refresh promise so we never fire more than one rotation in flight.
*
* Motivation: before this change, every expired access token forced a full
* re-login, which interrupted long admin dashboards (stats, reports) multiple
* times per hour. With the refresh loop, access tokens can be short-lived
* (1h) without hurting UX, giving us the security benefit of short access
* windows without an eager logout.
*/
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, ""); export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
const BASE_URL = API_BASE_URL; const BASE_URL = API_BASE_URL;
@@ -21,120 +40,387 @@ export class ApiError extends Error {
} }
} }
function getToken(): string | null { /**
return localStorage.getItem("encoach_token"); * Turn an arbitrary thrown value from an API mutation into a human-readable,
* actionable toast description. Deployment QA kept reporting generic
* "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
* status and, when we recognise the code, a hint about what to do next
* (payload too big → ask ops to raise nginx, timeout → retry, etc.).
*
* @param err the error thrown by an `api.*` call (usually an ApiError)
* @param fallback text to show if we can't classify the error
* @param context short context word used in the 413/504 hints
* ("file", "request", "payload" …)
*/
export function describeApiError(
err: unknown,
fallback = "Something went wrong",
context = "request",
): string {
const e = err as { status?: number; message?: string; data?: unknown } | null;
const status = e?.status;
// Try to extract a server-side error body even if the generic ApiError
// message wasn't populated (nginx 413/504 respond with an HTML page so
// `data.error` is empty and `message` looks like "413 ").
const serverMsg =
e?.data && typeof e.data === "object" && "error" in (e.data as object)
? String((e.data as { error: unknown }).error || "").trim()
: "";
if (status === 413) {
return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
}
if (status === 504) {
return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
}
if (status === 502) {
return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
}
if (status === 401) {
return "Your session expired — please sign in again.";
}
if (status === 403) {
return serverMsg || "You don't have permission to do that (HTTP 403).";
}
if (status === 404) {
return serverMsg || "The requested item no longer exists (HTTP 404).";
}
if (status === 422 || status === 400) {
return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
}
if (status && status >= 500) {
return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
}
// Network failure / fetch aborted / CORS — ApiError not thrown at all.
if (!status && e?.message) return e.message;
return fallback;
}
const ACCESS_KEY = "encoach_token";
const REFRESH_KEY = "encoach_refresh_token";
const EXP_KEY = "encoach_token_exp";
const ENTITY_KEY = "encoach_entity_id";
function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_KEY);
}
export function getActiveEntityId(): number | null {
try {
const raw = localStorage.getItem(ENTITY_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
}
}
export function setActiveEntityId(entityId: number | null | undefined): void {
try {
if (entityId && Number.isFinite(entityId)) {
localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
} else {
localStorage.removeItem(ENTITY_KEY);
}
} catch {
// ignore storage errors
}
}
/**
* Build a URL to a media-streaming endpoint with the JWT attached as a
* query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
* (which can't attach custom Authorization headers) and by ``<a download>``
* tags so the browser can fetch the binary directly without going through
* a fetch + blob URL dance.
*
* Returns the original path unchanged when no token is stored, which lets
* callers render a placeholder rather than crashing on ``null``.
*/
export function withAuthQuery(path: string): string {
if (!path) return path;
const token = getAccessToken();
if (!token) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}token=${encodeURIComponent(token)}`;
}
function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_KEY);
} }
export function setToken(token: string): void { export function setToken(token: string): void {
localStorage.setItem("encoach_token", token); localStorage.setItem(ACCESS_KEY, token);
}
export function setRefreshToken(token: string | null | undefined): void {
if (token) {
localStorage.setItem(REFRESH_KEY, token);
} else {
localStorage.removeItem(REFRESH_KEY);
}
}
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
if (epochSeconds && Number.isFinite(epochSeconds)) {
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
} else {
localStorage.removeItem(EXP_KEY);
}
}
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
export function persistTokenBundle(bundle: {
access_token?: string;
token?: string;
refresh_token?: string;
expires_in?: number;
}): void {
const access = bundle.access_token || bundle.token;
if (access) setToken(access);
setRefreshToken(bundle.refresh_token || null);
if (bundle.expires_in) {
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
}
} }
export function clearToken(): void { export function clearToken(): void {
localStorage.removeItem("encoach_token"); localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(EXP_KEY);
} }
async function handleResponse<T>(response: Response): Promise<T> { // Shared refresh promise — any 401 that arrives while a refresh is in flight
const data = await response.json().catch(() => null); // waits on the existing request instead of firing a duplicate.
let refreshPromise: Promise<boolean> | null = null;
if (response.status === 401) { async function performRefresh(): Promise<boolean> {
const hadToken = !!getToken(); const refreshToken = getRefreshToken();
clearToken(); if (!refreshToken) return false;
// Login failure is also 401 — do not hard-redirect when no session existed. try {
if (hadToken) { const res = await fetch(`${BASE_URL}/auth/refresh`, {
window.location.href = "/login"; method: "POST",
} headers: { "Content-Type": "application/json" },
throw new ApiError(401, response.statusText, data); body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) return false;
const data: {
access_token?: string;
token?: string;
refresh_token?: string;
expires_in?: number;
} = await res.json().catch(() => ({} as never));
if (!data.access_token && !data.token) return false;
persistTokenBundle(data);
return true;
} catch {
return false;
} }
if (!response.ok) {
throw new ApiError(response.status, response.statusText, data);
}
return data as T;
} }
function buildHeaders(extra?: Record<string, string>): Record<string, string> { function refreshOnce(): Promise<boolean> {
const headers: Record<string, string> = { if (!refreshPromise) {
"Content-Type": "application/json", refreshPromise = performRefresh().finally(() => {
...extra, refreshPromise = null;
}; });
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
} }
return refreshPromise;
}
function getCurrentLanguage(): string {
// Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit
// pick wins; everything else falls back to English. We intentionally do
// not consult navigator.language here so AI-generated content stays in
// English on first visit (matching the UI default) until the user flips
// the language toggle.
try {
const stored = localStorage.getItem("encoach-lang");
if (stored) return stored;
} catch {
// localStorage unavailable (SSR, sandboxing, etc.)
}
return "en";
}
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
const headers: Record<string, string> = { ...extra };
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
const token = getAccessToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
// Tell the backend which UI language the user is on so AI-generated content
// (coaching tips, insights, alerts, narratives) can be returned in the same
// language instead of always defaulting to English.
if (!headers["Accept-Language"]) {
headers["Accept-Language"] = getCurrentLanguage();
}
return headers; return headers;
} }
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string { /**
* Query param values we know how to serialise into a URL. Arrays are joined
* with commas because that's what our Odoo controllers expect for multi-value
* filters (e.g. `?state=draft,confirmed`).
*/
export type QueryParamValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean>;
/**
* Accept any object-shaped bag of query params. We intentionally use `object`
* here rather than `Record<string, QueryParamValue>` because typed interfaces
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
* default, which would force every call-site to cast.
*/
export type QueryParams = object;
const ENTITY_QUERY_SCOPE_RE =
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
const ENTITY_BODY_SCOPE_RE =
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
function buildUrl(path: string, params?: QueryParams): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin); const url = new URL(`${BASE_URL}${path}`, window.location.origin);
if (params) { if (params) {
Object.entries(params).forEach(([key, value]) => { for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
if (value !== undefined) { if (rawValue === undefined || rawValue === null) continue;
url.searchParams.set(key, String(value)); if (Array.isArray(rawValue)) {
if (rawValue.length === 0) continue;
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
continue;
} }
}); url.searchParams.set(key, String(rawValue));
}
}
// Multi-entity LMS scope: when the user has selected an active entity
// in the UI, append it to entity-scoped endpoints unless the caller
// already provided an explicit entity_id.
const activeEntityId = getActiveEntityId();
if (
activeEntityId &&
!url.searchParams.has("entity_id") &&
ENTITY_QUERY_SCOPE_RE.test(path)
) {
url.searchParams.set("entity_id", String(activeEntityId));
} }
return url.toString(); return url.toString();
} }
function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
const activeEntityId = getActiveEntityId();
if (!activeEntityId) return body;
if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const rec = body as Record<string, unknown>;
if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
return { ...rec, entity_id: activeEntityId };
}
async function parseResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
return data as T;
}
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
const response = await fetch(url, init);
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
// refresh token would recurse forever.
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
const hadRefresh = !!getRefreshToken();
if (hadRefresh) {
const rotated = await refreshOnce();
if (rotated) {
// Rebuild headers so the new access token is attached.
const newInit: RequestInitWithSkip = {
...init,
_skipRetry: true,
headers: {
...(init.headers as Record<string, string>),
Authorization: `Bearer ${getAccessToken()}`,
},
};
return performRequest<T>(url, newInit);
}
}
const hadAccess = !!getAccessToken();
clearToken();
if (hadAccess || hadRefresh) {
// Use SPA-style navigation when possible; fall back to a hard nav only
// when we're inside a worker / non-browser context. A full document
// reload here used to feel like "the browser refreshes on every click"
// whenever an access token silently expired.
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
window.history.pushState({}, "", "/login");
window.dispatchEvent(new PopStateEvent("popstate"));
}
}
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
}
return parseResponse<T>(response);
}
export const api = { export const api = {
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> { async get<T>(path: string, params?: QueryParams): Promise<T> {
const res = await fetch(buildUrl(path, params), { return performRequest<T>(buildUrl(path, params), {
method: "GET", method: "GET",
headers: buildHeaders(), headers: buildHeaders(),
}); });
return handleResponse<T>(res);
}, },
async post<T>(path: string, body?: unknown): Promise<T> { async post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), { const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), {
method: "POST", method: "POST",
headers: buildHeaders(), headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined, body: payload ? JSON.stringify(payload) : undefined,
}); });
return handleResponse<T>(res);
}, },
async patch<T>(path: string, body?: unknown): Promise<T> { async patch<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), { const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), {
method: "PATCH", method: "PATCH",
headers: buildHeaders(), headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined, body: payload ? JSON.stringify(payload) : undefined,
}); });
return handleResponse<T>(res);
}, },
async put<T>(path: string, body?: unknown): Promise<T> { async put<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), { const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), {
method: "PUT", method: "PUT",
headers: buildHeaders(), headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined, body: payload ? JSON.stringify(payload) : undefined,
}); });
return handleResponse<T>(res);
}, },
async delete<T>(path: string): Promise<T> { async delete<T>(path: string): Promise<T> {
const res = await fetch(buildUrl(path), { return performRequest<T>(buildUrl(path), {
method: "DELETE", method: "DELETE",
headers: buildHeaders(), headers: buildHeaders(),
}); });
return handleResponse<T>(res);
}, },
async upload<T>(path: string, formData: FormData): Promise<T> { async upload<T>(path: string, formData: FormData): Promise<T> {
const token = getToken(); return performRequest<T>(buildUrl(path), {
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(buildUrl(path), {
method: "POST", method: "POST",
headers, headers: buildHeaders(undefined, false),
body: formData, body: formData,
}); });
return handleResponse<T>(res);
}, },
}; };

View File

@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App.tsx"; import App from "./App.tsx";
import "./index.css"; import "./index.css";
import "./i18n";
createRoot(document.getElementById("root")!).render(<App />); createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -31,9 +31,10 @@ interface PlatformStats {
export default function AdminDashboard() { export default function AdminDashboard() {
const { data: stats } = useQuery<PlatformStats>({ const { data: stats } = useQuery<PlatformStats>({
queryKey: ["platform", "stats"], queryKey: ["platform", "stats"],
queryFn: async () => { queryFn: async (): Promise<PlatformStats> => {
const res = await api.get<{ data: PlatformStats }>("/stats"); const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
return (res as { data: PlatformStats }).data ?? res; const wrapped = res as { data?: PlatformStats };
return wrapped.data ?? (res as PlatformStats);
}, },
staleTime: 30_000, staleTime: 30_000,
}); });

View File

@@ -1,119 +1,556 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea";
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react"; import {
import AiGradingAssistant from "@/components/ai/AiGradingAssistant"; Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
CheckCircle,
XCircle,
Clock,
Loader2,
Trash2,
ChevronRight,
ShieldCheck,
FileText,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api-client";
const workflows = [ interface WorkflowStep {
{ id: number;
id: 1, name: "Exam Content Review", status: "In Progress", sequence: number;
steps: [ approver_id: number | null;
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" }, approver_name: string;
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" }, status: string;
{ name: "Final Approval", assignee: "Admin", status: "Waiting" }, comment: string;
], auto_escalate: boolean;
}, max_days: number;
{ acted_at: string | null;
id: 2, name: "Rubric Approval", status: "Completed", }
steps: [
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" }, interface Workflow {
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" }, id: number;
], name: string;
}, type: string;
{ entity_id: number | null;
id: 3, name: "New Exam Structure", status: "Rejected", entity_name: string | null;
steps: [ allow_bypass: boolean;
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" }, active: boolean;
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" }, steps: WorkflowStep[];
], created: string;
}, }
];
interface ApprovalRequest {
id: number;
workflow_id: number | null;
workflow_name: string;
res_model: string;
res_id: number;
state: string;
requester_id: number | null;
requester_name: string;
current_stage_id: number | null;
current_stage_sequence: number | null;
bypass_reason: string;
created_at: string | null;
}
interface UserItem {
id: number;
name: string;
login: string;
}
const statusIcon = (s: string) => { const statusIcon = (s: string) => {
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />; if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />; if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-warning" />; return <Clock className="h-4 w-4 text-amber-500" />;
};
const stateBadge = (state: string) => {
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
approved: "default",
in_progress: "secondary",
rejected: "destructive",
draft: "outline",
};
return (
<Badge variant={map[state] || "outline"} className="capitalize">
{state.replace(/_/g, " ")}
</Badge>
);
}; };
export default function ApprovalWorkflowsPage() { export default function ApprovalWorkflowsPage() {
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [formName, setFormName] = useState("");
const [formType, setFormType] = useState("custom");
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
{ approver_id: "" },
{ approver_id: "" },
]);
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
const [rejectComment, setRejectComment] = useState("");
const workflowsQ = useQuery({
queryKey: ["approval-workflows"],
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
});
const requestsQ = useQuery({
queryKey: ["approval-requests"],
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
});
const usersQ = useQuery({
queryKey: ["approval-users"],
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
});
const workflows = workflowsQ.data?.items ?? [];
const requests = requestsQ.data?.items ?? [];
const users = usersQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
setCreateOpen(false);
resetForm();
toast({ title: "Workflow created" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
toast({ title: "Workflow deleted" });
},
});
const approveMut = useMutation({
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-requests"] });
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
toast({ title: "Request approved" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
});
const rejectMut = useMutation({
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
api.post(`/approval-requests/${id}/reject`, { comment }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-requests"] });
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
setRejectDialog(null);
setRejectComment("");
toast({ title: "Request rejected" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
});
const resetForm = () => {
setFormName("");
setFormType("custom");
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
};
const handleCreate = () => {
createMut.mutate({
name: formName.trim(),
type: formType,
steps: formSteps
.filter((s) => s.approver_id)
.map((s) => ({ approver_id: Number(s.approver_id) })),
});
};
const pendingRequests = requests.filter((r) => r.state === "in_progress");
const completedRequests = requests.filter((r) => r.state !== "in_progress");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1> <h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p> <p className="text-muted-foreground">
Manage multi-step approval processes for exam content.
</p>
</div> </div>
<Dialog> <Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetForm();
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button> <Button size="sm">
<Plus className="h-4 w-4 mr-1" /> Create Workflow
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>Create Workflow</DialogTitle>
<DialogDescription>Define a multi-step approval process.</DialogDescription>
</DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Step 1 Assignee</Label> <Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger> Workflow Name <span className="text-destructive">*</span>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent> </Label>
</Select> <Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Exam Content Review"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Step 2 Assignee</Label> <Label>Type</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger> <Select value={formType} onValueChange={setFormType}>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent> <SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="exam_publish">Exam Publication</SelectItem>
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
<SelectItem value="content_publish">Content Publication</SelectItem>
</SelectContent>
</Select> </Select>
</div> </div>
<Button className="w-full">Create</Button>
{formSteps.map((step, i) => (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between">
<Label>Step {i + 1} Approver</Label>
{formSteps.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setFormSteps((s) => s.filter((_, idx) => idx !== i))
}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<Select
value={step.approver_id}
onValueChange={(v) =>
setFormSteps((s) =>
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select approver" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
>
<Plus className="h-3 w-3 mr-1" /> Add Step
</Button>
</div> </div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={!formName.trim() || createMut.isPending}
onClick={handleCreate}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
<div className="space-y-4"> {/* Loading */}
{(workflowsQ.isLoading || requestsQ.isLoading) && (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Pending Requests */}
{pendingRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
Pending Approval ({pendingRequests.length})
</h2>
{pendingRequests.map((req) => {
const wf = workflows.find((w) => w.id === req.workflow_id);
return (
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-semibold">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
Requested by {req.requester_name} &middot;{" "}
{req.res_model.replace("encoach.", "")} #{req.res_id}
</p>
</div>
{stateBadge(req.state)}
</div>
{wf && (
<div className="flex items-center gap-2 flex-wrap mb-3">
{wf.steps.map((step, i) => {
const isActive = step.id === req.current_stage_id;
return (
<div key={step.id} className="flex items-center gap-2">
<div
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
isActive
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
: step.status === "approved"
? "border-green-200 bg-green-50/50"
: step.status === "rejected"
? "border-red-200 bg-red-50/50"
: "bg-muted/30"
}`}
>
{statusIcon(step.status)}
<div>
<p className="text-sm font-medium">Step {i + 1}</p>
<p className="text-xs text-muted-foreground">
{step.approver_name}
</p>
</div>
</div>
{i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
);
})}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
disabled={approveMut.isPending}
onClick={() => approveMut.mutate(req.id)}
>
{approveMut.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
)}
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRejectDialog(req.id)}
>
<XCircle className="h-3.5 w-3.5 mr-1" />
Reject
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Workflow Templates */}
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Workflow Templates ({workflows.length})
</h2>
{workflows.length === 0 && !workflowsQ.isLoading && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
No workflows defined yet. Create one to get started.
</CardContent>
</Card>
)}
{workflows.map((wf) => ( {workflows.map((wf) => (
<Card key={wf.id} className="border-0 shadow-sm"> <Card key={wf.id} className="border-0 shadow-sm">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle> <div className="flex items-center gap-3">
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge> <CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant="outline" className="text-xs capitalize">
{wf.type.replace(/_/g, " ")}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{wf.steps.map((step, i) => ( {wf.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2"> <div key={step.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]"> <div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
{statusIcon(step.status)} <div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{i + 1}
</div>
<div> <div>
<p className="text-sm font-medium">{step.name}</p> <p className="text-sm font-medium">{step.approver_name}</p>
<p className="text-xs text-muted-foreground">{step.assignee}</p> <p className="text-xs text-muted-foreground">
{step.max_days}d limit
{step.auto_escalate ? " · auto-escalate" : ""}
</p>
</div> </div>
</div> </div>
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />} {i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div> </div>
))} ))}
</div> </div>
{wf.status === "In Progress" && (
<div className="space-y-4 mt-4">
<AiGradingAssistant onAccept={(marks, feedback) => {
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
}} />
<div className="flex gap-2">
<Button size="sm" variant="default">Approve</Button>
<Button size="sm" variant="outline">Reject</Button>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
{/* Completed Requests */}
{completedRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
Recent Decisions ({completedRequests.length})
</h2>
{completedRequests.map((req) => (
<Card key={req.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
{req.requester_name} &middot; {req.res_model.replace("encoach.", "")} #
{req.res_id}
{req.created_at && (
<>
{" "}
&middot; {new Date(req.created_at).toLocaleDateString()}
</>
)}
</p>
</div>
{stateBadge(req.state)}
</CardContent>
</Card>
))}
</div>
)}
{/* Reject Dialog */}
<Dialog
open={rejectDialog !== null}
onOpenChange={(open) => {
if (!open) {
setRejectDialog(null);
setRejectComment("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Request</DialogTitle>
<DialogDescription>Provide a reason for rejection.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
placeholder="Reason for rejection..."
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectDialog(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={rejectMut.isPending}
onClick={() => {
if (rejectDialog !== null) {
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
}
}}
>
{rejectMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Reject
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

Some files were not shown because too many files have changed in this diff Show More