5 Commits

Author SHA1 Message Date
c9bf12ad8e Merge pull request 'chore(release): sync frontend from monorepo v4' (#6) from release-v4-sync-20260426 into main
All checks were successful
Deploy Frontend to Staging / Deploy frontend to staging (push) Successful in 55s
Reviewed-on: #6
2026-04-26 01:14:24 +02:00
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
297 changed files with 48331 additions and 4881 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!"

View File

@@ -18,13 +18,25 @@ server {
root /usr/share/nginx/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/ {
proxy_pass http://backend:8069/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
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 / {

View File

@@ -1,9 +1,26 @@
# EnCoach Platform — Odoo 19 Backend SRS
**Version:** 2.0
**Date:** March 2026
**Status:** Definitive
**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.
**Version:** 3.0
**Date:** March 11, 2026
**Status:** Implemented — Staging Verified
**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)
32. [Non-Functional Requirements](#32-non-functional-requirements)
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 |
| 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 |
|----------|---------|-----------|
| **P0** | `encoach_core`, `encoach_api` (auth, users, entities, permissions) | Foundation — blocks everything |
| **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 |
| **P1** | `encoach_exam`, `encoach_ai_generation`, `encoach_ai_grading` | Exam engine |
| **P1** | `encoach_resources` | Learning content |
| **P2** | `encoach_courseware` (chapters, materials, progress, AI workbench) | Structured course delivery |
| **P2** | `encoach_communication` (discussions, announcements, messaging) | Collaboration |
| **P2** | `openeducat_timetable`, `openeducat_attendance`, `openeducat_classroom` (port to v19) | LMS timetable/attendance |
| **P2** | `openeducat_admission` (port to v19) + admission endpoints | Enrollment workflow |
| **P2** | Academic years/terms, departments endpoints | Institutional calendar |
| **P2** | `encoach_ai`, `encoach_ai_media` | AI services + media |
| **P2** | `encoach_assignment`, `encoach_classroom` | Assignments + groups |
| **P3** | `encoach_notification` (rules, email delivery, preferences) | User engagement |
| **P3** | Enhanced approval workflows (stages, escalation, bypass) | Governance |
| **P3** | Plagiarism detection (GPTZero integration) | Academic integrity |
| **P3** | Official exam access modes (link, landing, separate login) | Exam security |
| **P3** | Late submission handling, extension requests | Assignment workflow |
| **P3** | `openeducat_exam` (port to v19) + institutional exam endpoints | Institutional assessment |
| **P3** | `openeducat_assignment` (port to v19) + submission endpoints | Homework workflow |
| **P3** | Subject registration endpoints | Student subject selection |
| **P3** | `encoach_training`, analytics endpoints | Training + analytics |
| **P3** | `encoach_sis`, `encoach_branding` | University integration |
| **P4** | `encoach_faq` (categories, items, role-filtered) | User support |
| **P4** | `encoach_subscription` (Stripe, PayPal, Paymob) | Payments |
| **P4** | `encoach_ticket` | Support |
| **P4** | Storage | Operational features |
All modules have been implemented and deployed. The original priority ordering is preserved for reference.
| Priority | Modules | Status |
|----------|---------|--------|
| **P0** | `encoach_core`, `encoach_api` (auth, users, entities, permissions) | **DONE** |
| **P0** | `openeducat_core` (port to v19), `encoach_lms_api` | **DONE** |
| **P1** | `encoach_taxonomy`, `encoach_adaptive`, `encoach_adaptive_api`, `encoach_adaptive_ai` | **DONE** |
| **P1** | `encoach_exam`, `encoach_ai_generation`, `encoach_ai_grading` | **DONE** |
| **P1** | `encoach_resources` | **DONE** |
| **P2** | `encoach_courseware` (chapters, materials, progress, AI workbench) | **DONE** |
| **P2** | `encoach_communication` (discussions, announcements, messaging) | **DONE** |
| **P2** | `openeducat_timetable`, `openeducat_attendance`, `openeducat_classroom` | **DONE** |
| **P2** | `openeducat_admission` + admission endpoints | **DONE** |
| **P2** | Academic years/terms, departments endpoints | **DONE** |
| **P2** | `encoach_ai`, `encoach_ai_media` | **DONE** |
| **P2** | `encoach_assignment`, `encoach_classroom` | **DONE** |
| **P3** | `encoach_notification` (rules, email delivery, preferences) | **DONE** |
| **P3** | Enhanced approval workflows (stages, escalation, bypass) | **DONE** |
| **P3** | Plagiarism detection (GPTZero integration) | **DONE** |
| **P3** | Official exam access modes (link, landing, separate login) | **DONE** |
| **P3** | Late submission handling, extension requests | **DONE** |
| **P3** | `openeducat_exam` + institutional exam endpoints | **DONE** |
| **P3** | `openeducat_assignment` + submission endpoints | **DONE** |
| **P3** | Subject registration endpoints | **DONE** |
| **P3** | `encoach_training`, analytics endpoints | **DONE** |
| **P3** | `encoach_sis`, `encoach_branding` | **DONE** |
| **P4** | `encoach_faq` (categories, items, role-filtered) | **DONE** |
| **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 |
|-------------|---------------|
| Auth & Users | 10 |
| Entities & Permissions | 9 |
| LMS (Courses, Batches, Timetable, Attendance, Grades) | 16 |
| Academic Years, Terms, Departments | 14 |
| Admissions | 12 |
| Subject Registration | 6 |
| EnCoach Exams (AI) | 15 |
| Institutional Exams | 21 |
| EnCoach Assignments | 7 |
| Course Assignments & Submissions | 11 |
| Classrooms & Groups | 7 |
| Taxonomy | 16 |
| Adaptive Learning | 17 |
| Resources | 7 |
| AI Coaching | 6 |
| AI Evaluation | 4 |
| AI Generation | 2 |
| AI Media | 3 |
| AI Analytics | 6 |
| Training | 3 |
| Statistics | 4 |
| Subscriptions & Payments | 8 + 3 webhooks |
| Tickets | 4 |
| Storage | 2 |
| Approval Workflows (Enhanced) | 11 |
| **Courseware (Chapters + Materials + Workbench)** | **23** |
| **Discussion Boards + Posts** | **9** |
| **Announcements** | **5** |
| **Messaging** | **6** |
| **Notifications + Rules + Preferences** | **10** |
| **FAQ (Categories + Items)** | **8** |
| **Plagiarism Detection** | **3** |
| **Official Exam Access** | **5** |
### 34.1 Student Leave Management
**Module:** `openeducat_student_leave_enterprise` (via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/student_leave.py`
**Frontend:** `student-leave.service.ts` | `AdminStudentLeave.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/student-leaves` | List leave requests |
| `POST` | `/api/student-leaves` | Create leave request |
| `PATCH` | `/api/student-leaves/{id}` | Update leave request |
| `POST` | `/api/student-leaves/{id}/approve` | Approve request |
| `POST` | `/api/student-leaves/{id}/reject` | Reject request |
| `GET` | `/api/student-leave-types` | List leave types |
| `POST` | `/api/student-leave-types` | Create leave type |
### 34.2 Fees Management
**Module:** `openeducat_fees` (via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/fees.py`
**Frontend:** `fees.service.ts` | `AdminFees.tsx`
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/fees-plans` | List fee plans |
| `GET` | `/api/fees-plans/{id}` | Fee plan detail |
| `GET` | `/api/student-fees` | Student fee details |
| `GET` | `/api/fees-terms` | Fee terms |
### 34.3 Lessons Management
**Module:** `openeducat_lesson` (via `encoach_lms_api`)
**Controller:** `encoach_lms_api/controllers/lessons.py`
**Frontend:** `lesson.service.ts` | `AdminLessons.tsx`
| 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
> **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
**Date:** March 16, 2026
**Status:** Draft -- Architect Review
**Status:** ~~Draft -- Architect Review~~ **SUPERSEDED**
**Author:** EnCoach Engineering Team
**Audience:** Architect (initial review), then development team

View File

@@ -1,8 +1,10 @@
# 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
**Date:** March 11, 2026
**Status:** Active -- Ready for Development
**Status:** ~~Active -- Ready for Development~~ **SUPERSEDED**
**Supersedes:** `ODOO_MIGRATION_SRS_v2.md` (v2.0)
**Master Reference:** `ENCOACH_UNIFIED_SRS.md` (v1.0)
**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)
> **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
**Version:** 2.0
**Date:** March 11, 2026
**Status:** Draft
**Status:** ~~Draft~~ **SUPERSEDED**
**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.

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>
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
<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:description" content="Comprehensive IELTS preparation and English learning platform." />
<meta property="og:image" content="/logo-icon.png" />
<meta property="og:type" content="website" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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>
<body>
<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 .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
@@ -47,6 +49,8 @@
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
@@ -54,6 +58,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-i18next": "^17.0.4",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
@@ -65,9 +70,10 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.59.1",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -81,6 +87,7 @@
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"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 as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
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 ProtectedRoute from "@/components/ProtectedRoute";
import StudentLayout from "@/components/StudentLayout";
import TeacherLayout from "@/components/TeacherLayout";
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 { 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 = () => (
<ErrorBoundary>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="encoach-theme"
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<AuthProvider>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Auth routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<EmailVerification />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/verify/:verificationHash" element={<ScoreVerification />} />
{/* Public pages */}
<Route path="/apply" element={<AdmissionApplication />} />
<Route path="/exam/access/:token" element={<OfficialExamAccess />} />
@@ -128,8 +235,16 @@ const App = () => (
<Route path="/exam/login/:examId" element={<OfficialExamAccess />} />
<Route path="/faq" element={<FaqPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/onboarding" element={<OnboardingWizard />} />
</Route>
{/* Student routes */}
<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 path="/student/dashboard" element={<StudentDashboard />} />
<Route path="/student/courses" element={<StudentCourses />} />
@@ -139,6 +254,7 @@ const App = () => (
<Route path="/student/attendance" element={<StudentAttendance />} />
<Route path="/student/timetable" element={<StudentTimetable />} />
<Route path="/student/profile" element={<StudentProfile />} />
<Route path="/student/privacy" element={<PrivacyCenter />} />
<Route path="/student/subjects" element={<SubjectSelection />} />
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
@@ -150,14 +266,26 @@ const App = () => (
<Route path="/student/announcements" element={<StudentAnnouncements />} />
<Route path="/student/messages" element={<StudentMessages />} />
<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>
{/* Teacher routes */}
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
<Route element={<TeacherLayout />}>
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
<Route path="/teacher/courses" element={<TeacherCourses />} />
<Route path="/teacher/library" element={<TeacherLibrary />} />
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
@@ -165,12 +293,15 @@ const App = () => (
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
<Route path="/teacher/students" element={<TeacherStudents />} />
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} />
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<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>
@@ -179,6 +310,14 @@ const App = () => (
<Route element={<AdminLmsLayout />}>
{/* LMS Dashboard */}
<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 */}
<Route path="/admin/platform" element={<AdminDashboard />} />
{/* LMS pages */}
@@ -191,6 +330,7 @@ const App = () => (
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */}
<Route path="/admin/assignments" element={<AssignmentsPage />} />
<Route path="/admin/examsList" element={<ExamsListPage />} />
@@ -213,7 +353,16 @@ const App = () => (
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
<Route path="/admin/tickets" element={<TicketsPage />} />
<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/resources" element={<ResourceManager />} />
{/* Institutional LMS pages */}
@@ -239,6 +388,19 @@ const App = () => (
<Route path="/admin/roles-permissions" element={<RolesPermissions />} />
<Route path="/admin/authority-matrix" element={<AuthorityMatrix />} />
<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>
@@ -249,10 +411,13 @@ const App = () => (
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</AuthProvider>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
</ErrorBoundary>
);
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 {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -9,10 +10,13 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { NavLink } from "@/components/NavLink";
import { useAuth } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar";
import { usePermissions } from "@/hooks/usePermissions";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
@@ -29,115 +33,128 @@ import {
CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow,
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
Library, Activity, Warehouse, UserCog,
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
} from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
// ============= 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[] = [
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
];
const lmsItems: NavItem[] = [
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
{ title: "Students", url: "/admin/students", icon: Users },
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
{ title: "Batches", url: "/admin/batches", icon: Layers },
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
];
const academicItems: NavItem[] = [
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
{ 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[] = [
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
];
const institutionalItems: NavItem[] = [
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
{ title: "Departments", url: "/admin/departments", icon: Landmark },
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
{ title: "Library", url: "/admin/library", icon: Library },
{ title: "Activities", url: "/admin/activities", icon: Activity },
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
];
const managementItems: NavItem[] = [
{ title: "Users", url: "/admin/users", icon: Users },
{ title: "Entities", url: "/admin/entities", icon: Building2 },
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
];
const reportItems: NavItem[] = [
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
{ title: "Record", url: "/admin/record", icon: History },
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
{ titleKey: "nav.record", url: "/admin/record", icon: History },
];
const trainingItems: NavItem[] = [
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
];
const configItems: NavItem[] = [
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
];
const supportItems: NavItem[] = [
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
];
// ============= Reusable sidebar group =============
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
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"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{items.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
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"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -145,40 +162,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
}
// ============= Breadcrumbs =============
const routeLabels: Record<string, string> = {
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
classrooms: "Classrooms", "student-performance": "Student Performance",
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
profile: "Profile", exam: "Exam",
"academic-years": "Academic Years", departments: "Departments",
admissions: "Admissions", "admission-register": "Admission Register",
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
faq: "FAQ Manager", "notification-rules": "Notification Rules",
"approval-config": "Approval Config",
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
gradebook: "Gradebook", "student-progress": "Student Progress",
library: "Library", activities: "Activities", facilities: "Facilities",
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
// Segments without an entry fall back to a Title-cased version of the slug.
const routeLabelKeys: Record<string, string> = {
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
platform: "breadcrumb.platform", courses: "nav.courses",
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
timetable: "nav.timetable", reports: "nav.reports",
assignments: "nav.assignments", examsList: "nav.examsList",
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
generation: "nav.generation", "review-queue": "nav.reviewQueue",
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
feedback: "breadcrumb.feedback",
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
entities: "nav.entities", classrooms: "nav.classrooms",
"student-performance": "nav.studentPerformance",
"stats-corporate": "nav.statsCorporate", record: "nav.record",
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
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() {
const location = useLocation();
const { t } = useTranslation();
const segments = location.pathname.split("/").filter(Boolean);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
</BreadcrumbItem>
{segments.map((seg, i) => {
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;
return (
<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 =============
export default function AdminLmsLayout() {
const { user, logout } = useAuth();
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
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() ?? "??";
@@ -213,13 +264,53 @@ export default function AdminLmsLayout() {
<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">
<div className="flex items-center gap-3">
<SidebarTrigger />
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<AppBreadcrumbs />
</div>
<AiSearchBar />
<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 />
<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" />
</Button>
<DropdownMenu>
@@ -232,37 +323,41 @@ export default function AdminLmsLayout() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<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>
</div>
<DropdownMenuSeparator />
<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 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>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
<AiAssistantDrawer />
<Link
to="/admin/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"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
</Link>
{!isWizardRoute && (
<Link
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">{t("chrome.needHelp")}</span>
</Link>
)}
</SidebarProvider>
);
}
@@ -273,67 +368,78 @@ function AdminSidebar() {
const collapsed = state === "collapsed";
const { user } = useAuth();
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 showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
return (
<Sidebar collapsible="icon">
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<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 && (
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span>Coach</span>
</span>
{collapsed ? (
<img
src="/logo.png"
alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain"
/>
) : (
<img
src="/logo.png"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain"
/>
)}
</div>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarNavGroup label="Overview" items={overviewItems} />
<SidebarNavGroup label="LMS" items={lmsItems} />
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
<SidebarNavGroup label="Institutional" items={institutionalItems} />
<SidebarNavGroup label="Academic" items={academicItems} />
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
<SidebarNavGroup label="Configuration" items={configItems} />
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
<SidebarGroup>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroupLabel asChild>
<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" />}
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{trainingItems.map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
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"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{trainingItems.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
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"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</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>
<SidebarSeparator />
<SidebarFooter className="p-3">
@@ -343,7 +449,7 @@ function AdminSidebar() {
</div>
{!collapsed && (
<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>
</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 { AppSidebar } from "@/components/AppSidebar";
import {
@@ -6,14 +7,16 @@ import {
BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} 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 AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar";
import { useAuth } from "@/contexts/AuthContext";
const routeLabels: Record<string, string> = {
"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() {
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 (
<SidebarProvider>
@@ -92,6 +113,34 @@ export default function AppLayout() {
</div>
<AiSearchBar />
<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">
<Ticket className="h-4 w-4" />
</Button>
@@ -127,19 +176,22 @@ export default function AppLayout() {
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
<AiAssistantDrawer />
{/* Floating help button */}
<Link
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"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
</Link>
{!isWizardRoute && (
<Link
to="/tickets"
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" />
<span className="text-sm font-medium">Need help?</span>
</Link>
)}
</SidebarProvider>
);
}

View File

@@ -5,6 +5,7 @@ import {
} from "lucide-react";
import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
@@ -81,9 +82,11 @@ export function AppSidebar() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const location = useLocation();
const { i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
return (
<Sidebar collapsible="icon">
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<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 { cn } from "@/lib/utils";
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
}
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 (
<RouterNavLink
ref={ref}
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
className={({ isActive, isPending }) =>
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}
/>
);

View File

@@ -1,35 +1,50 @@
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function NotificationDropdown() {
const { t } = useTranslation();
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
const unread = notifications.filter((n) => !n.read).length;
return (
<DropdownMenu>
<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" />
{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}
</span>
)}
</Button>
</DropdownMenuTrigger>
<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 />
{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>
))}
{notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{t("notifications.empty")}
</div>
) : (
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>
</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 {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -8,22 +10,28 @@ import {
import { NavLink } from "@/components/NavLink";
import { useAuth, UserRole } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, Settings, LucideIcon } from "lucide-react";
import React from "react";
import { LogOut, User, LucideIcon } from "lucide-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 {
title: string;
titleKey: string;
url: string;
icon: LucideIcon;
}
export interface NavGroup {
label: string;
labelKey: string;
items: NavItem[];
}
@@ -34,30 +42,34 @@ interface RoleLayoutProps {
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<>
{navGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroup key={group.labelKey}>
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
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"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{group.items.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
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"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</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) {
const { user, logout } = useAuth();
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() ?? "??";
@@ -77,17 +101,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
navigate("/login");
};
const roleLabel = t(`roles.${role}`, { defaultValue: role });
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<Sidebar collapsible="icon">
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span>Coach</span>
</span>
<img
src="/logo.png"
alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
/>
<img
src="/logo.png"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
/>
</div>
</SidebarHeader>
<SidebarSeparator />
@@ -101,7 +133,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<span className="text-xs font-semibold text-primary">{initials}</span>
</div>
<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>
</div>
</div>
@@ -111,10 +145,12 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<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">
<div className="flex items-center gap-3">
<SidebarTrigger />
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
</div>
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -126,23 +162,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<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>
</div>
<DropdownMenuSeparator />
<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>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>

View File

@@ -1,51 +1,63 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
} 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[] = [
{
label: "Learning",
labelKey: "sidebarGroup.learning",
items: [
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
],
},
{
label: "Adaptive Learning",
labelKey: "sidebarGroup.adaptiveLearning",
items: [
{ title: "My Subjects", url: "/student/subjects", icon: Target },
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
],
},
{
label: "Progress",
labelKey: "sidebarGroup.progress",
items: [
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
],
},
{
label: "Communication",
labelKey: "sidebarGroup.communication",
items: [
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
{ title: "Messages", url: "/student/messages", icon: Mail },
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
],
},
{
label: "Account",
labelKey: "sidebarGroup.account",
items: [
{ title: "Profile", url: "/student/profile", icon: User },
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
],
},
];
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 {
LayoutDashboard, BookOpen, ClipboardList, FileText,
LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2,
Megaphone, Library, Sparkles,
} from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
const navGroups: NavGroup[] = [
{
label: "Teaching",
labelKey: "sidebarGroup.teaching",
items: [
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ 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: [
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
{ title: "Students", url: "/teacher/students", icon: Users },
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
],
},
{
label: "Communication",
labelKey: "sidebarGroup.communication",
items: [
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
],
},
{
label: "Account",
labelKey: "sidebarGroup.account",
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 { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { analyticsService } from "@/services/analytics.service";
@@ -7,32 +8,34 @@ import { analyticsService } from "@/services/analytics.service";
export default function AiAlertBanner() {
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
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"],
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) {
return (
<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" />
<p className="text-sm text-muted-foreground">Loading alerts</p>
<p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
</div>
);
}
if (isError && !errorDismissed) {
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" />
<div className="flex-1">
<div className="flex-1 min-w-0">
<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 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>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
<X className="h-4 w-4" />
@@ -43,11 +46,11 @@ export default function AiAlertBanner() {
if (isError && errorDismissed) return null;
if (!alerts?.length) {
if (!alerts.length) {
return (
<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" />
<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>
);
}
@@ -56,12 +59,12 @@ export default function AiAlertBanner() {
return (
<div className="space-y-3">
{visible.map((alert) => (
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
{visible.map((alert, idx) => (
<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" />
<div className="flex-1">
<div className="flex-1 min-w-0">
<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 className="text-sm text-muted-foreground mt-1">{alert.description}</p>
</div>
@@ -69,7 +72,7 @@ export default function AiAlertBanner() {
variant="ghost"
size="icon"
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" />
</Button>

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,38 +9,43 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
const quickActions = [
"Platform health summary",
"Show dropout risks",
"Compare course performance",
"Suggest batch improvements",
];
export default function AiAssistantDrawer() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
const [input, setInput] = useState("");
const location = useLocation();
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({
mutationFn: (message: string) =>
coachingService.chat({ message, context: { page: location.pathname } }),
onSuccess: (data) => {
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not get a reply",
description: err.message || "Something went wrong. Try again.",
title: t("ai.replyErrorTitle"),
description: err.message || t("ai.replyErrorDesc"),
});
setMessages((prev) => [
...prev,
{
role: "ai",
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
},
{ role: "ai", text: t("ai.fallbackReply") },
]);
},
});
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
<>
<button
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"
aria-label="AI Assistant"
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={t("ai.assistantLabel")}
title={t("ai.assistantLabel")}
>
<Sparkles className="h-5 w-5" />
</button>
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
EnCoach AI Assistant
{t("ai.assistantTitle")}
</SheetTitle>
</SheetHeader>
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
{messages.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
<p>Ask me anything about the platform,</p>
<p>or click a quick action above.</p>
<p>{t("ai.emptyLine1")}</p>
<p>{t("ai.emptyLine2")}</p>
</div>
)}
{messages.map((msg, i) => (
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
key={i}
className={`rounded-lg p-3 text-sm ${
msg.role === "user"
? "bg-primary text-primary-foreground ml-8"
: "bg-muted mr-8"
? "bg-primary text-primary-foreground ms-8"
: "bg-muted me-8"
}`}
>
{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}
</div>
))}
{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" />
<span className="text-muted-foreground">Thinking...</span>
<span className="text-muted-foreground">{t("ai.thinking")}</span>
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t mt-auto">
<Input
placeholder="Ask anything..."
placeholder={t("ai.placeholder")}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
size="icon"
onClick={() => handleSend(input)}
disabled={!input.trim() || chatMutation.isPending}
aria-label={t("ai.send")}
title={t("ai.send")}
>
<Send className="h-4 w-4" />
</Button>

View File

@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
},
});
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
const handleOpen = () => {
if (batchId == null) {
toast({
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
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 = () => {
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
setOpen(false);
applyMutation.mutate();
};
const onOpenChange = (next: boolean) => {
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
if (!next) mutation.reset();
};
const suggestions = mutation.data ?? [];
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
const optData = mutation.data as OptResult | undefined;
const hasSuggestions = !!optData?.summary;
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
return (
<>
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
</div>
) : mutation.isError ? (
<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-3 max-h-[50vh] overflow-y-auto">
{suggestions.map((s, i) => (
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
<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 className="rounded-lg bg-muted/30 p-4 border border-border/60">
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
<p className="text-sm font-medium">{optData.summary}</p>
</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">
<Button className="flex-1" onClick={handleApply}>
Apply Suggestion
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
{applyMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
) : (
"Apply Suggestion"
)}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss

View File

@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
difficulty,
count,
}),
onSuccess: (res) => {
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
onSuccess: (res: Record<string, unknown>) => {
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
setLocalExercises(items);
},
onError: (err: Error) => {
toast({
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
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 handleRemove = (index: number) => {
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
);
})}
<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
variant="outline"
onClick={() => {

View File

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

View File

@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
const gradeMutation = useMutation({
mutationFn: () =>
analyticsService.getGradingSuggestion({
submission_id: submissionId,
text: submissionText,
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
submission_text: submissionText,
skill: "writing",
}),
onError: (err: Error) => {
toast({
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
}, [submissionId, submissionText, rubricId]);
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
? [
data.feedback,

View File

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

View File

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

View File

@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
suggestMutation.mutate();
};
const suggestions = suggestMutation.data?.suggestions ?? [];
const planTips = suggestMutation.data?.study_plan_tips ?? [];
const d = suggestMutation.data;
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 (
<Card className="border-0 shadow-sm bg-primary/5">

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Sparkles, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { coachingService } from "@/services/coaching.service";
@@ -12,6 +13,7 @@ interface Props {
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
const [dismissed, setDismissed] = useState(false);
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
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 variantLabel =
variant === "insight"
? t("ai.insightLabel")
: variant === "recommendation"
? t("ai.recommendationLabel")
: t("ai.tipLabel");
if (isLoading) {
return (
<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" />
<span className="text-sm text-muted-foreground">Loading AI tip</span>
<span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
</div>
);
}
if (isError || !data) {
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" />
<div className="flex-1">
<span className="text-xs font-semibold text-primary">AI Tip</span>
<div className="flex-1 min-w-0">
<span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
<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>
</div>
{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 (
<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" />
<div className="flex-1">
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
<div className="flex-1 min-w-0">
<span className="block text-xs font-semibold text-primary">{variantLabel}</span>
<p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
</div>
</div>
);
}
const label = data.category && data.category !== "general"
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
: variantLabel;
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" />
<div className="flex-1">
<span className="text-xs font-semibold text-primary">
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
</span>
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
<div className="flex-1 min-w-0">
<span className="block text-xs font-semibold text-primary">{label}</span>
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
</div>
{dismissible && (
<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({
mutationFn: (mode: NonNullable<Mode>) =>
coachingService.writingHelp({
text: text.trim(),
task_type: `${task_type}:${mode}`,
task: task_type,
draft: text.trim(),
help_type: mode,
}),
onSuccess: () => setShowResult(true),
onError: (err: Error) => {
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
{showResult && !loading && mutation.data && activeMode === "improve" && (
<div className="space-y-3">
{mutation.data.feedback && (
{mutation.data.tips?.length > 0 && (
<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">
<Sparkles className="h-3 w-3" /> Feedback
</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>
)}
{mutation.data.improved && (
{mutation.data.improved_text && (
<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">
<Sparkles className="h-3 w-3" /> Improved Version
</p>
<p className="text-sm">{mutation.data.improved}</p>
<p className="text-sm">{mutation.data.improved_text}</p>
</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">
<Sparkles className="h-3 w-3" /> Grammar notes
</p>
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
mutation.data.grammar_notes!.map((note, i) => (
{(mutation.data.changes?.length ?? 0) > 0 ? (
mutation.data.changes.map((c, i) => (
<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>
))
) : (
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
)}
{mutation.data.feedback ? (
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
{mutation.data.tips?.length > 0 ? (
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
) : null}
</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">
<Sparkles className="h-3 w-3" /> Estimated band / assessment
</p>
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
{mutation.data.improved ? (
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
{mutation.data.improved_text ? (
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
) : null}
</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;
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<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"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}
/>
</AlertDialogPortal>
));
AlertDialogContentProps
>(({ className, children, description, ...props }, ref) => {
const needsFallback =
!props["aria-describedby"] &&
description === undefined &&
!hasAlertDialogDescription(children);
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"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;
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> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
),
);
Badge.displayName = "Badge";
export { Badge, badgeVariants };

View File

@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
BreadcrumbPage.displayName = "BreadcrumbPage";
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 />}
</li>
);

View File

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

View File

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

View File

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

View File

@@ -27,28 +27,86 @@ const DialogOverlay = React.forwardRef<
));
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<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"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}
>
{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>
));
DialogContentProps
>(({ className, children, description, ...props }, ref) => {
const hasExplicitDescribedBy = !!props["aria-describedby"];
const needsFallback =
!hasExplicitDescribedBy &&
description === undefined &&
!hasDialogDescription(children);
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"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 && (
<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;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

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

View File

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

View File

@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
PaginationLink.displayName = "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}>
<ChevronLeft className="h-4 w-4" />
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
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>
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";

View File

@@ -49,21 +49,61 @@ const sheetVariants = cva(
interface SheetContentProps
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>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{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>
),
({ side = "right", className, children, description, ...props }, ref) => {
const needsFallback =
!props["aria-describedby"] &&
description === undefined &&
!hasSheetDescription(children);
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{description !== undefined && (
<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;

View File

@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
Sidebar.displayName = "Sidebar";
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();
return (
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
data-sidebar="trigger"
variant="ghost"
size="icon"
aria-label={ariaLabel ?? "Toggle sidebar"}
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
<PanelLeft className="rtl:rotate-180" />
<span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
</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 { 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";
export type { UserRole };
@@ -9,6 +9,9 @@ interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
selectedEntityId: number | null;
selectedEntity: User["entities"][number] | null;
setSelectedEntityId: (entityId: number | null) => void;
login: (email: string, password: string) => Promise<User>;
logout: () => Promise<void>;
}
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
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(() => {
const token = localStorage.getItem("encoach_token");
if (!token) {
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authService
.getUser()
.then(setUser)
.then((u) => {
setUser(u);
syncEntitySelection(u);
})
.catch(() => {
clearToken();
setActiveEntityId(null);
})
.finally(() => setIsLoading(false));
}, []);
}, [syncEntitySelection]);
const login = useCallback(async (email: string, password: string): Promise<User> => {
const res = await authService.login({ login: email, password });
setUser(res.user);
syncEntitySelection(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 () => {
await authService.logout();
setUser(null);
setSelectedEntityIdState(null);
setActiveEntityId(null);
}, []);
const selectedEntity =
user?.entities.find((e) => e.id === selectedEntityId) ??
user?.entities[0] ??
null;
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
selectedEntityId,
selectedEntity,
setSelectedEntityId,
login,
logout,
}}

View File

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

View File

@@ -4,240 +4,306 @@ export const queryKeys = {
},
users: {
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,
},
entities: {
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,
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
},
exams: {
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,
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
structures: (params?: object) => ["exams", "structures", params] 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: {
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,
},
classrooms: {
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,
},
stats: {
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
sessions: (params?: object) => ["stats", "sessions", params] as const,
stats: (params?: object) => ["stats", "stats", params] as const,
statistical: (params?: object) => ["stats", "statistical", params] as const,
performance: (params?: object) => ["stats", "performance", params] as const,
},
training: {
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,
},
subscriptions: {
packages: ["subscriptions", "packages"] as const,
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
payments: (params?: object) => ["subscriptions", "payments", params] as const,
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
},
tickets: {
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,
},
approvals: {
all: ["approvals"] as const,
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
list: (params?: object) => ["approvals", "list", params] as const,
},
taxonomy: {
subjects: ["taxonomy", "subjects"] as const,
subject: (id: number) => ["taxonomy", "subjects", id] as const,
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
domains: (params?: object) => ["taxonomy", "domains", params] as const,
topics: (params?: object) => ["taxonomy", "topics", params] as const,
},
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,
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,
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
},
resources: {
all: ["resources"] as const,
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
list: (params?: object) => ["resources", "list", params] as const,
},
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,
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
myCourses: ["lms", "my-courses"] as const,
students: (params?: object) => ["lms", "students", 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,
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
timetable: (params?: object) => ["lms", "timetable", params] as const,
attendance: (params?: object) => ["lms", "attendance", params] as const,
grades: (params?: object) => ["lms", "grades", params] as const,
},
analytics: {
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
student: (params?: object) => ["analytics", "student", params] as const,
class: (params?: object) => ["analytics", "class", params] as const,
subject: (params?: object) => ["analytics", "subject", params] as const,
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
alerts: ["analytics", "alerts"] as const,
},
academicYears: {
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,
},
academicTerms: {
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,
},
departments: {
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,
},
admissionRegisters: {
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,
},
admissions: {
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,
},
instExamSessions: {
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,
},
examTypes: {
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: {
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: {
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: {
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,
},
courseAssignments: {
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,
},
assignmentTypes: {
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: {
all: ["subject-registrations"] as const,
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
list: (params?: object) => ["subject-registrations", "list", 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,
chapter: (id: number) => ["chapters", "detail", id] as const,
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
announcements: (params?: object) => ["announcements", params] as const,
messages: (params?: object) => ["messages", params] as const,
sentMessages: (params?: object) => ["messages", "sent", params] 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,
notificationRules: ["notification-rules"] as const,
notificationPreferences: ["notification-preferences"] 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: {
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: {
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: {
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,
},
studentFees: {
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: {
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: {
all: ["lessons"] as const,
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
list: (params?: object) => ["lessons", "list", params] as const,
},
gradebooks: {
all: ["gradebooks"] as const,
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
list: (params?: object) => ["gradebooks", "list", params] as const,
},
gradebookLines: {
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: {
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: {
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: {
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: {
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: {
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: {
all: ["activities"] as const,
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
list: (params?: object) => ["activities", "list", params] as const,
},
activityTypes: {
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: {
all: ["facilities"] as const,
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
list: (params?: object) => ["facilities", "list", params] as const,
},
assets: {
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();
return useMutation({
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() {
const qc = useQueryClient();
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() {
const qc = useQueryClient();
return useMutation({
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
onSuccess: (_, chapterId) => {
mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
coursewareService.markChapterComplete(chapterId),
onSuccess: (_, { chapterId, courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(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 { queryKeys } from "./keys";
import { feesService } from "@/services/fees.service";
import { feesService, type FeesPlanFilters } from "@/services/fees.service";
import type { PaginationParams } from "@/types";
export function useFeesPlans(params?: PaginationParams) {
export function useFeesPlans(params?: FeesPlanFilters) {
return useQuery({
queryKey: queryKeys.feesPlans.list(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({
queryKey: queryKeys.studentFees.list(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({
queryKey: queryKeys.gradebookLines.list(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 { queryKeys } from "./keys";
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 }) {
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 }) {
return useQuery({
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 { queryKeys } from "./keys";
import { studentProgressService } from "@/services/student-progress.service";
import type { PaginationParams } from "@/types";
import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
export function useStudentProgressList(params?: PaginationParams) {
export function useStudentProgressList(params?: StudentProgressFilters) {
return useQuery({
queryKey: queryKeys.studentProgress.list(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-border: 240 18% 20%;
--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 {
@@ -99,6 +107,12 @@
--sidebar-accent-foreground: 8 40% 75%;
--sidebar-border: 240 18% 13%;
--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;
}
}
/* -------------------------------------------------------------------------
* 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(/\/$/, "");
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 {
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 {
localStorage.removeItem("encoach_token");
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(EXP_KEY);
}
async function handleResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
// Shared refresh promise — any 401 that arrives while a refresh is in flight
// waits on the existing request instead of firing a duplicate.
let refreshPromise: Promise<boolean> | null = null;
if (response.status === 401) {
const hadToken = !!getToken();
clearToken();
// Login failure is also 401 — do not hard-redirect when no session existed.
if (hadToken) {
window.location.href = "/login";
}
throw new ApiError(401, response.statusText, data);
async function performRefresh(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
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> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
function refreshOnce(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = performRefresh().finally(() => {
refreshPromise = null;
});
}
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;
}
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);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value));
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
if (rawValue === undefined || rawValue === null) continue;
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();
}
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 = {
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const res = await fetch(buildUrl(path, params), {
async get<T>(path: string, params?: QueryParams): Promise<T> {
return performRequest<T>(buildUrl(path, params), {
method: "GET",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
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",
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> {
const res = await fetch(buildUrl(path), {
const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), {
method: "PATCH",
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> {
const res = await fetch(buildUrl(path), {
const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), {
method: "PUT",
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> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "DELETE",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
async upload<T>(path: string, formData: FormData): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "POST",
headers,
headers: buildHeaders(undefined, false),
body: formData,
});
return handleResponse<T>(res);
},
};

View File

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

View File

@@ -31,9 +31,10 @@ interface PlatformStats {
export default function AdminDashboard() {
const { data: stats } = useQuery<PlatformStats>({
queryKey: ["platform", "stats"],
queryFn: async () => {
const res = await api.get<{ data: PlatformStats }>("/stats");
return (res as { data: PlatformStats }).data ?? res;
queryFn: async (): Promise<PlatformStats> => {
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
const wrapped = res as { data?: PlatformStats };
return wrapped.data ?? (res as PlatformStats);
},
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 { Button } from "@/components/ui/button";
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 { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
import { Textarea } from "@/components/ui/textarea";
import {
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 { api } from "@/lib/api-client";
const workflows = [
{
id: 1, name: "Exam Content Review", status: "In Progress",
steps: [
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
],
},
{
id: 2, name: "Rubric Approval", status: "Completed",
steps: [
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
],
},
{
id: 3, name: "New Exam Structure", status: "Rejected",
steps: [
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
],
},
];
interface WorkflowStep {
id: number;
sequence: number;
approver_id: number | null;
approver_name: string;
status: string;
comment: string;
auto_escalate: boolean;
max_days: number;
acted_at: string | null;
}
interface Workflow {
id: number;
name: string;
type: string;
entity_id: number | null;
entity_name: string | null;
allow_bypass: boolean;
active: boolean;
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) => {
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-warning" />;
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" />;
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() {
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<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>
<Dialog>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetForm();
}}
>
<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>
<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-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
<div className="space-y-2">
<Label>Step 1 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
</Select>
<Label>
Workflow Name <span className="text-destructive">*</span>
</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Exam Content Review"
/>
</div>
<div className="space-y-2">
<Label>Step 2 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
<Label>Type</Label>
<Select value={formType} onValueChange={setFormType}>
<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>
</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>
<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>
</Dialog>
</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) => (
<Card key={wf.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
<div className="flex items-center gap-3">
<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>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{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]">
{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>
<p className="text-sm font-medium">{step.name}</p>
<p className="text-xs text-muted-foreground">{step.assignee}</p>
<p className="text-sm font-medium">{step.approver_name}</p>
<p className="text-xs text-muted-foreground">
{step.max_days}d limit
{step.auto_escalate ? " · auto-escalate" : ""}
</p>
</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>
{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>
</Card>
))}
</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>
);
}

View File

@@ -1,91 +1,341 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2 } from "lucide-react";
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
import {
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
} from "lucide-react";
import { assignmentsService } from "@/services/assignments.service";
import { api } from "@/lib/api-client";
import { useBatches, useStudents } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
interface CustomExam {
id: number;
title: string;
status: string;
}
interface Entity {
id: number;
name: string;
}
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
];
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
{ key: "full_length", label: "Full length exams" },
{ key: "generate_different", label: "Generate different exams" },
{ key: "auto_release_results", label: "Auto release results" },
{ key: "auto_start", label: "Auto start exam" },
{ key: "official_exam", label: "Official Exam" },
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
];
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
switch (s) {
case "active": return "default";
case "planned": return "secondary";
case "past": return "outline";
case "start_expired": return "destructive";
case "archived": return "outline";
default: return "secondary";
}
};
type FormState = {
name: string;
exam_id: string;
entity_id: string;
start_date: string;
start_time: string;
end_date: string;
end_time: string;
assign_mode: "entity" | "batch" | "individual";
batch_ids: Set<number>;
student_ids: Set<number>;
full_length: boolean;
generate_different: boolean;
auto_release_results: boolean;
auto_start: boolean;
official_exam: boolean;
hide_assignee_details: boolean;
};
const _iso = (d: Date) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const emptyForm = (): FormState => {
const today = new Date();
const inOneWeek = new Date();
inOneWeek.setDate(today.getDate() + 7);
return {
name: "",
exam_id: "",
entity_id: "",
start_date: _iso(today),
start_time: "09:00",
end_date: _iso(inOneWeek),
end_time: "17:00",
assign_mode: "batch",
batch_ids: new Set(),
student_ids: new Set(),
full_length: true,
generate_different: false,
auto_release_results: false,
auto_start: false,
official_exam: false,
hide_assignee_details: false,
};
};
export default function AssignmentsPage() {
const [search, setSearch] = useState("");
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
const [form, setForm] = useState<FormState>(emptyForm());
const [studentQuery, setStudentQuery] = useState("");
const { toast } = useToast();
const qc = useQueryClient();
const assignmentsQ = useAssignments({ size: 200 });
const createMut = useCreateAssignment();
const schedulesQ = useQuery({
queryKey: ["exam-schedules", stateFilter],
queryFn: () => assignmentsService.listSchedules(
stateFilter === "all" ? {} : { state: stateFilter }
),
});
const schedules = schedulesQ.data?.items ?? [];
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
const assignments = Array.isArray(items) ? items : [];
const q = search.toLowerCase();
const filtered = assignments.filter(a =>
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
const customExamsQ = useQuery({
queryKey: ["custom-exams-for-assign"],
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
});
const publishedExams = (customExamsQ.data?.items ?? []).filter(
(e) => e.status === "published" || e.status === "draft"
);
const loading = assignmentsQ.isLoading;
const entitiesQ = useQuery({
queryKey: ["entities-for-assign"],
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
});
const entities = entitiesQ.data?.items ?? [];
const batchesQ = useBatches({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const studentsQ = useStudents({ size: 200 });
const students = studentsQ.data?.items ?? [];
const filteredStudents = useMemo(() => {
const q = studentQuery.trim().toLowerCase();
if (!q) return students;
return students.filter((s) =>
(s.name || "").toLowerCase().includes(q) ||
(s.email || "").toLowerCase().includes(q),
);
}, [students, studentQuery]);
const stateCounts = useMemo(() => {
const all = schedulesQ.data?.items ?? [];
const counts: Record<string, number> = { all: all.length };
for (const s of all) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [schedulesQ.data]);
const allSchedulesQ = useQuery({
queryKey: ["exam-schedules", "all"],
queryFn: () => assignmentsService.listSchedules({}),
enabled: stateFilter !== "all",
});
const totalCounts = useMemo(() => {
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
const counts: Record<string, number> = { all: items.length };
for (const s of items) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [stateFilter, schedules, allSchedulesQ.data]);
const createMut = useMutation({
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
setCreateOpen(false);
setForm(emptyForm());
toast({ title: "Exam scheduled successfully" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const archiveMut = useMutation({
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule archived" });
},
});
const q = search.toLowerCase();
const filtered = schedules.filter(
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
);
function handleCreate() {
createMut.mutate(
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
{
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
if (!form.name || !form.exam_id || !startDt || !endDt) {
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
return;
}
createMut.mutate({
name: form.name,
exam_id: Number(form.exam_id),
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
start_date: startDt,
end_date: endDt,
assign_mode: form.assign_mode,
batch_ids: [...form.batch_ids],
student_ids: [...form.student_ids],
full_length: form.full_length,
generate_different: form.generate_different,
auto_release_results: form.auto_release_results,
auto_start: form.auto_start,
official_exam: form.official_exam,
hide_assignee_details: form.hide_assignee_details,
});
}
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
<p className="text-muted-foreground">Create and manage assignments.</p>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" /> Assignments
</h1>
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Assignment
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
</Button>
</div>
{/* State tabs */}
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
{STATE_TABS.map((tab) => (
<button
key={tab.key}
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
stateFilter === tab.key
? "bg-white text-primary shadow-sm"
: "text-primary/70 hover:text-primary"
}`}
onClick={() => setStateFilter(tab.key)}
>
{tab.icon}
{tab.label}
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
({totalCounts[tab.key] ?? 0})
</span>
</button>
))}
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
{schedulesQ.isLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : filtered.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center text-muted-foreground">
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p className="font-medium">No exam schedules found</p>
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
</CardContent>
</Card>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Schedule Name</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
<TableHead>Assignees</TableHead>
<TableHead>Completed</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
)}
{filtered.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>{a.entity_name || "—"}</TableCell>
<TableCell>{a.start_date || "—"}</TableCell>
<TableCell>{a.end_date || "—"}</TableCell>
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
<TableCell>{a.assignee_count ?? 0}</TableCell>
<TableCell>{a.completed_count ?? 0}</TableCell>
{filtered.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.exam_title}</TableCell>
<TableCell>{s.entity_name || "—"}</TableCell>
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
</TableCell>
<TableCell>{s.completed_count}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{s.state !== "archived" && (
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
onClick={() => archiveMut.mutate(s.id)}>
<Archive className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -94,21 +344,188 @@ export default function AssignmentsPage() {
</Card>
)}
{/* Create Schedule Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Schedule Exam</DialogTitle>
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Basic info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Schedule Name <span className="text-destructive">*</span></Label>
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
</div>
<div className="space-y-1">
<Label>Select Exam <span className="text-destructive">*</span></Label>
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
<SelectContent>
{publishedExams.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
</SelectItem>
))}
{publishedExams.length === 0 && (
<SelectItem value="_none" disabled>No exams available</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Date/Time */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-1">
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Start Time</Label>
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Time</Label>
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
</div>
</div>
</div>
{/* Options checkboxes */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Exam Options</Label>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{OPTION_FIELDS.map((opt) => (
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={form[opt.key] as boolean}
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
className="h-4 w-4"
/>
{opt.label}
</label>
))}
</div>
</div>
{/* Assignment target */}
<div className="space-y-3">
<Label className="text-sm font-semibold">Assign To</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs">Assignment Mode</Label>
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="entity">Entire Entity</SelectItem>
<SelectItem value="batch">Class / Batch</SelectItem>
<SelectItem value="individual">Individual Students</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Entity</Label>
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
<SelectContent>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
{entities.length === 0 && (
<SelectItem value="_none" disabled>No entities</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Batch selection */}
{form.assign_mode === "batch" && (
<div className="space-y-1">
<Label className="text-xs">Select Classes</Label>
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
{batches.map((b) => (
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.batch_ids.has(b.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.batch_ids);
checked ? next.add(b.id) : next.delete(b.id);
return { ...p, batch_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{b.name}
</label>
))}
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
</div>
</div>
)}
{/* Individual student selection */}
{form.assign_mode === "individual" && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Select Students</Label>
<span className="text-[11px] text-muted-foreground">
{form.student_ids.size} selected · {students.length} total
</span>
</div>
{/* Search box — needed because QA reported long student lists
made bottom entries unreachable inside the inner scroll
container. Filtering keeps the list short and predictable. */}
<Input
placeholder="Search students by name or email…"
value={studentQuery}
onChange={(e) => setStudentQuery(e.target.value)}
className="h-8 text-xs"
/>
<div className="max-h-64 overflow-y-auto border rounded-md p-2 space-y-1">
{filteredStudents.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.student_ids.has(s.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.student_ids);
checked ? next.add(s.id) : next.delete(s.id);
return { ...p, student_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{s.name}
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
</label>
))}
{filteredStudents.length === 0 && students.length > 0 && (
<p className="text-xs text-muted-foreground italic text-center py-2">
No students match {studentQuery}.
</p>
)}
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
{createMut.isPending ? "Creating..." : "Create"}
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -3,11 +3,12 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2, Users } from "lucide-react";
import { useBatches, useCourses } from "@/hooks/queries";
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
@@ -17,49 +18,124 @@ export default function ClassroomsPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
const [editOpen, setEditOpen] = useState(false);
const [editBatch, setEditBatch] = useState<{ id: number; name: string; course_id: string; start_date: string; end_date: string } | null>(null);
const [studentsOpen, setStudentsOpen] = useState(false);
const [studentsBatchId, setStudentsBatchId] = useState<number | null>(null);
const [addStudentsOpen, setAddStudentsOpen] = useState(false);
const [selectedAddIds, setSelectedAddIds] = useState<number[]>([]);
const { toast } = useToast();
const qc = useQueryClient();
const batchesQ = useBatches({ size: 200 });
const coursesQ = useCourses({ size: 200 });
const studentsQ = useStudents({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const courses = coursesQ.data?.items ?? [];
const allStudents = studentsQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" }); toast({ title: "Classroom created" }); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setCreateOpen(false);
setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
toast({ title: "Classroom created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setEditOpen(false);
toast({ title: "Classroom updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => lmsService.deleteBatch(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); toast({ title: "Deleted" }); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({ title: "Deleted" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const addStudentsMut = useMutation({
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
lmsService.addStudentsToBatch(batchId, studentIds),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setAddStudentsOpen(false);
setSelectedAddIds([]);
toast({ title: `Added ${res.total} student(s)` });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const removeStudentMut = useMutation({
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
lmsService.removeStudentsFromBatch(batchId, studentIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({ title: "Student removed from classroom" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const q = search.toLowerCase();
const filtered = batches.filter(b => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q));
const filtered = batches.filter(
(b) => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
);
const loading = batchesQ.isLoading;
const currentBatch = batches.find((b) => b.id === studentsBatchId);
const batchStudentIds = new Set(currentBatch?.student_ids ?? []);
const availableStudents = allStudents.filter((s) => !batchStudentIds.has(s.id));
function openStudents(batchId: number) {
setStudentsBatchId(batchId);
setStudentsOpen(true);
}
function openEdit(b: typeof batches[0]) {
setEditBatch({
id: b.id,
name: b.name,
course_id: String(b.course_id || ""),
start_date: b.start_date,
end_date: b.end_date,
});
setEditOpen(true);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
<p className="text-muted-foreground">Create and manage batches (classrooms).</p>
<p className="text-muted-foreground">Create and manage classrooms with students.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Classroom
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Badge variant="secondary">{batches.length} total</Badge>
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
@@ -69,29 +145,67 @@ export default function ClassroomsPage() {
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Students</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-10" />
<TableHead className="w-[140px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No classrooms found.</TableCell></TableRow>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No classrooms found.
</TableCell>
</TableRow>
)}
{filtered.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell>{b.course_name}</TableCell>
<TableCell><Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{b.student_count}</Badge></TableCell>
<TableCell>{b.capacity}</TableCell>
<TableCell className="text-sm">{b.start_date} {b.end_date}</TableCell>
<TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell>
<TableCell>{b.course_name || "—"}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id); }}>
<Trash2 className="h-4 w-4" />
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-7 px-2"
onClick={() => openStudents(b.id)}
>
<Users className="h-3.5 w-3.5" />
{b.student_count} students
</Button>
</TableCell>
<TableCell className="text-sm">
{b.start_date && b.end_date
? `${b.start_date}${b.end_date}`
: "—"}
</TableCell>
<TableCell>
<Badge
variant={b.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{b.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage students" onClick={() => openStudents(b.id)}>
<UserPlus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit" onClick={() => openEdit(b)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => {
if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -100,34 +214,234 @@ export default function ClassroomsPage() {
</Card>
)}
{/* Create Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" /></div>
<div className="space-y-2">
<Label>Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id} onValueChange={(v) => setForm(f => ({ ...f, course_id: v }))}>
<Select value={form.course_id} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>)}
{courses.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
</div>
</div>
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.name} onClick={() => createMut.mutate({ name: form.name, course_id: Number(form.course_id) || undefined, start_date: form.start_date || undefined, end_date: form.end_date || undefined, capacity: Number(form.max_students) || undefined } as never)}>
<Button
disabled={createMut.isPending || !form.name}
onClick={() =>
createMut.mutate({
name: form.name,
course_id: Number(form.course_id) || undefined,
start_date: form.start_date || undefined,
end_date: form.end_date || undefined,
} as never)
}
>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Classroom</DialogTitle></DialogHeader>
{editBatch && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editBatch.name}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, name: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select
value={editBatch.course_id}
onValueChange={(v) => setEditBatch((prev) => prev ? { ...prev, course_id: v } : prev)}
>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Start Date</Label>
<Input
type="date"
value={editBatch.start_date}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, start_date: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input
type="date"
value={editBatch.end_date}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, end_date: e.target.value } : prev)}
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editBatch?.name}
onClick={() => {
if (!editBatch) return;
updateMut.mutate({
id: editBatch.id,
data: {
name: editBatch.name,
course_id: Number(editBatch.course_id) || undefined,
start_date: editBatch.start_date || undefined,
end_date: editBatch.end_date || undefined,
},
});
}}
>
{updateMut.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Students Management Dialog */}
<Dialog open={studentsOpen} onOpenChange={setStudentsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
Classroom Students {currentBatch?.name}
</DialogTitle>
<DialogDescription>
{currentBatch?.course_name && `Course: ${currentBatch.course_name}`}
{currentBatch ? ` · ${currentBatch.student_count} student(s)` : ""}
</DialogDescription>
</DialogHeader>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{currentBatch?.student_ids?.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No students in this classroom yet.
</p>
) : (
allStudents
.filter((s) => batchStudentIds.has(s.id))
.map((s) => (
<div key={s.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive"
onClick={() => {
if (studentsBatchId && window.confirm(`Remove ${s.name} from this classroom?`))
removeStudentMut.mutate({ batchId: studentsBatchId, studentIds: [s.id] });
}}
>
<UserMinus className="h-3.5 w-3.5 mr-1" /> Remove
</Button>
</div>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setStudentsOpen(false)}>Close</Button>
<Button
onClick={() => {
setSelectedAddIds([]);
setAddStudentsOpen(true);
}}
>
<UserPlus className="h-4 w-4 mr-1" /> Add Students
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Students to Batch Dialog */}
<Dialog open={addStudentsOpen} onOpenChange={setAddStudentsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Students to {currentBatch?.name}</DialogTitle>
<DialogDescription>
Select students to add to this classroom.
</DialogDescription>
</DialogHeader>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{availableStudents.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
All students are already in this classroom.
</p>
) : (
availableStudents.map((s) => (
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
<Checkbox
checked={selectedAddIds.includes(s.id)}
onCheckedChange={() =>
setSelectedAddIds((prev) =>
prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id],
)
}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
</label>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddStudentsOpen(false)}>Cancel</Button>
<Button
disabled={addStudentsMut.isPending || selectedAddIds.length === 0}
onClick={() => {
if (studentsBatchId)
addStudentsMut.mutate({ batchId: studentsBatchId, studentIds: selectedAddIds });
}}
>
{addStudentsMut.isPending
? "Adding..."
: `Add ${selectedAddIds.length} Student(s)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { GraduationCap, Loader2 } from "lucide-react";
import { useResendOtp, useVerifyEmail } from "@/hooks/queries/useSignup";
import { ApiError } from "@/lib/api-client";
const RESEND_LIMIT = 3;
const COOLDOWN_SEC = 60;
function errorMessage(err: unknown): string {
if (err instanceof ApiError) return err.message;
if (err instanceof Error) return err.message;
return "Verification failed";
}
export default function EmailVerification() {
const [searchParams] = useSearchParams();
const email = searchParams.get("email")?.trim() || "";
const navigate = useNavigate();
const verify = useVerifyEmail();
const resend = useResendOtp();
const [otp, setOtp] = useState("");
const [error, setError] = useState<string | null>(null);
const [resendAttempts, setResendAttempts] = useState(0);
const [cooldown, setCooldown] = useState(0);
const submittingRef = useRef(false);
const lastSubmittedOtp = useRef<string | null>(null);
useEffect(() => {
if (otp.length < 6) lastSubmittedOtp.current = null;
}, [otp]);
useEffect(() => {
if (cooldown <= 0) return;
const t = window.setInterval(() => {
setCooldown((c) => (c <= 1 ? 0 : c - 1));
}, 1000);
return () => window.clearInterval(t);
}, [cooldown]);
const runVerify = useCallback(
async (code: string) => {
if (!email || code.length !== 6) return;
setError(null);
submittingRef.current = true;
try {
await verify.mutateAsync({ email, otp: code });
navigate("/onboarding", { replace: true });
} catch (e) {
lastSubmittedOtp.current = null;
setError(errorMessage(e));
} finally {
submittingRef.current = false;
}
},
[email, navigate, verify],
);
useEffect(() => {
if (otp.length !== 6 || submittingRef.current || verify.isPending) return;
if (lastSubmittedOtp.current === otp) return;
lastSubmittedOtp.current = otp;
void runVerify(otp);
}, [otp, runVerify, verify.isPending]);
const handleResend = async () => {
if (!email || cooldown > 0 || resendAttempts >= RESEND_LIMIT || resend.isPending) return;
setError(null);
try {
await resend.mutateAsync(email);
setResendAttempts((n) => n + 1);
setCooldown(COOLDOWN_SEC);
} catch (e) {
setError(errorMessage(e));
}
};
if (!email) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-4">
<Card className="w-full max-w-md border-0 shadow-lg">
<CardHeader className="text-center">
<CardTitle>Missing email</CardTitle>
<CardDescription>Add an email query parameter or return to registration.</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Button asChild variant="outline">
<Link to="/register">Back to registration</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-muted/40 to-background p-4">
<div className="w-full max-w-md space-y-8">
<div className="flex items-center justify-center gap-2">
<div className="h-10 w-10 rounded-xl bg-primary flex items-center justify-center shadow-sm">
<GraduationCap className="h-6 w-6 text-primary-foreground" />
</div>
<h1 className="text-2xl font-bold tracking-tight">Verify your email</h1>
</div>
<Card className="border-0 shadow-xl bg-card/80 backdrop-blur-sm">
<CardHeader className="text-center space-y-2">
<CardTitle className="text-lg">Check your inbox</CardTitle>
<CardDescription className="text-base leading-relaxed">
We&apos;ve sent a 6-digit verification code to{" "}
<span className="font-medium text-foreground">{email}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col items-center gap-4">
<InputOTP maxLength={6} value={otp} onChange={setOtp} disabled={verify.isPending}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{verify.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</div>
)}
</div>
{error && (
<p className="text-center text-sm text-destructive bg-destructive/10 rounded-md py-2 px-3">{error}</p>
)}
<Button
className="w-full"
size="lg"
onClick={() => void runVerify(otp)}
disabled={otp.length !== 6 || verify.isPending}
>
{verify.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying
</>
) : (
"Verify"
)}
</Button>
<div className="text-center text-sm space-y-2">
<p className="text-muted-foreground">
Resend attempts:{" "}
<span className="font-medium text-foreground">
{resendAttempts}/{RESEND_LIMIT}
</span>
</p>
<button
type="button"
onClick={() => void handleResend()}
disabled={cooldown > 0 || resendAttempts >= RESEND_LIMIT || resend.isPending}
className="text-primary font-medium hover:underline disabled:opacity-50 disabled:no-underline"
>
{resend.isPending ? (
"Sending…"
) : cooldown > 0 ? (
`Resend code in ${cooldown}s`
) : (
"Resend code"
)}
</button>
</div>
<p className="text-center text-sm text-muted-foreground">
Wrong address?{" "}
<Link to="/register" className="text-primary font-medium hover:underline">
Change email
</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}

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