feat: integrate client requirements (courseware, communication, notifications, FAQ, enhanced approvals)

Add 4 new Odoo modules coverage:
- encoach_courseware: chapters, materials, chapter progress, AI workbench
- encoach_communication: discussion boards, announcements, messaging
- encoach_notification: notification engine, rules, preferences
- encoach_faq: FAQ categories and items, role-filtered

Frontend changes:
- 4 new type files (courseware, communication, notification, faq)
- 5 new service files (courseware, communication, notification, faq, plagiarism)
- 4 new hook files (useCourseware, useCommunication, useNotifications, useFaq)
- 15 new page components (teacher: chapters, chapter detail, AI workbench,
  discussions, announcements; student: chapter view, discussions, announcements,
  messages, journey; admin: FAQ manager, notification rules, approval config;
  public: official exam access, FAQ page)
- Updated App.tsx routing with all new pages
- Updated TeacherLayout, StudentLayout, AdminLmsLayout sidebars

SRS updates:
- ENCOACH_UNIFIED_SRS.md: added Parts IX-XI (Courseware, Communication,
  Notifications/FAQ), enhanced exam management (exercise vs exam, official
  access modes), enhanced approval workflows (timed stages, escalation,
  bypass), enhanced assignments (late submissions, extensions), plagiarism
  detection. Total modules: 35
- ENCOACH_ODOO19_BACKEND_SRS.md v2.0: added sections 26-31, updated module
  inventory to 35, updated endpoint count to 280+, added enhanced assignment
  models with late submission/extension support

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-27 00:20:30 +04:00
parent 19d7e01be1
commit 5980451ea8
38 changed files with 4849 additions and 63 deletions

View File

@@ -1,9 +1,9 @@
# EnCoach Platform — Odoo 19 Backend SRS
**Version:** 1.0
**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.
**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.
---
@@ -33,9 +33,15 @@
22. [Subscription and Payments](#22-subscription-and-payments)
23. [Support Tickets](#23-support-tickets)
24. [File Storage](#24-file-storage)
25. [Approval Workflows](#25-approval-workflows)
26. [Non-Functional Requirements](#26-non-functional-requirements)
27. [Implementation Priority](#27-implementation-priority)
25. [Approval Workflows (Enhanced)](#25-approval-workflows-enhanced)
26. [Courseware and Content Delivery](#26-courseware-and-content-delivery)
27. [Communication System](#27-communication-system)
28. [Notification Engine](#28-notification-engine)
29. [FAQ System](#29-faq-system)
30. [Plagiarism Detection](#30-plagiarism-detection)
31. [Official Exam Access](#31-official-exam-access)
32. [Non-Functional Requirements](#32-non-functional-requirements)
33. [Implementation Priority](#33-implementation-priority)
---
@@ -47,7 +53,7 @@ This document specifies the complete Odoo 19 backend for the EnCoach Adaptive Le
### 1.2 Frontend Contract
The React frontend has 24 API service modules that define the exact endpoints the backend must implement. This SRS documents every endpoint, its expected request/response shape, and the underlying Odoo models.
The React frontend has 29 API service modules that define the exact endpoints the backend must implement. This SRS documents every endpoint, its expected request/response shape, and the underlying Odoo models.
### 1.3 Key Principles
@@ -163,8 +169,12 @@ All OpenEduCat models need `to_api_dict()` methods on the `encoach_lms_api` inhe
| 29 | `encoach_adaptive_ai` | Custom | AI services for diagnostics, plan generation, coaching, content generation |
| 30 | `encoach_sis` | Custom | UTAS SIS integration (sync, mapping) |
| 31 | `encoach_branding` | Custom | Whitelabeling configuration |
| 32 | `encoach_courseware` | Custom | Course chapters, chapter materials, chapter progress tracking, AI workbench for content generation |
| 33 | `encoach_communication` | Custom | Discussion boards, threaded posts, announcements, direct messaging |
| 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) + 23 custom = 31 modules**
**Total: 8 OpenEduCat (ported) + 27 custom = 35 modules**
---
@@ -611,17 +621,48 @@ Traditional institutional exams (midterms, finals) managed through OpenEduCat. S
Exam-wrapper assignments where an EnCoach exam is assigned to students/classrooms.
### 13.2 API Endpoints
### 13.2 Enhanced Fields (Late Submission + Plagiarism)
Add to `encoach.assignment`:
| Field | Type | Description |
|-------|------|-------------|
| `late_deadline` | Datetime | Final late submission cutoff |
| `penalty_type` | Selection | `none`, `percentage`, `fixed_deduction` |
| `penalty_value` | Float | Penalty amount |
| `max_submissions` | Integer | Maximum allowed submissions (0 = unlimited) |
| `allow_extensions` | Boolean | Student extension requests |
| `plagiarism_enabled` | Boolean | Enable GPTZero plagiarism check |
| `plagiarism_max_checks` | Integer | Max plagiarism checks per submission |
| `reminder_count` | Integer | Number of reminders |
| `reminder_frequency` | Selection | `once`, `daily`, `custom` |
### 13.3 Extension Request Model (`encoach.extension.request`)
| Field | Type | Description |
|-------|------|-------------|
| `student_id` | Many2one (`res.users`) | Requesting student |
| `assignment_id` | Many2one | Target assignment |
| `reason` | Text | Justification |
| `requested_date` | Datetime | New deadline |
| `status` | Selection | `pending`, `approved`, `rejected` |
| `approved_by` | Many2one | Approving teacher |
| `response_note` | Text | Teacher response |
### 13.4 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/assignments` | JWT | Paginated assignments |
| `GET` | `/api/assignments/{id}` | JWT | Assignment detail |
| `POST` | `/api/assignments` | JWT | Create assignment |
| `POST` | `/api/assignments` | JWT | Create assignment (includes late submission fields) |
| `PATCH` | `/api/assignments/{id}` | JWT | Update assignment |
| `DELETE` | `/api/assignments/{id}` | JWT | Delete assignment |
| `POST` | `/api/assignments/{id}/archive` | JWT | Archive assignment |
| `POST` | `/api/assignments/{id}/start` | JWT | Start assignment (student) |
| `POST` | `/api/assignments/{id}/request-extension` | JWT (student) | Request deadline extension |
| `GET` | `/api/assignments/{id}/extension-requests` | JWT (teacher) | List extension requests |
| `POST` | `/api/extension-requests/{id}/respond` | JWT (teacher) | Approve/reject extension |
---
@@ -1107,32 +1148,431 @@ Files are stored as `ir.attachment` records. For production, configure Odoo to u
---
## 25. Approval Workflows
## 25. Approval Workflows (Enhanced)
### 25.1 Model (`encoach.approval.workflow`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Workflow name |
| `type` | Selection | `exam_publish`, `assignment_publish`, `content_publish` |
| `steps` | One2many | Approval steps |
| `type` | Selection | `exam_publish`, `assignment_publish`, `content_publish`, `material_publish` |
| `stage_ids` | One2many | Approval stages (ordered) |
| `entity_id` | Many2one | Entity |
| `allow_bypass` | Boolean | Whether bypass is allowed |
| `active` | Boolean | Active flag |
### 25.2 API Endpoints
### 25.2 Model (`encoach.approval.stage`)
| Field | Type | Description |
|-------|------|-------------|
| `workflow_id` | Many2one | Parent workflow |
| `sequence` | Integer | Stage order |
| `approver_id` | Many2one | Assigned approver (`res.users`) |
| `max_days` | Integer | Maximum days before auto-escalation |
| `auto_escalate` | Boolean | Whether to auto-escalate on timeout |
| `notification_email` | Char | Email for stage notifications |
| `status` | Selection | `pending`, `approved`, `rejected`, `escalated`, `bypassed` |
| `comment` | Text | Reviewer comment |
| `acted_at` | Datetime | When action was taken |
### 25.3 Model (`encoach.approval.request`)
| Field | Type | Description |
|-------|------|-------------|
| `workflow_id` | Many2one | Workflow template |
| `res_model` | Char | Target model (e.g., `encoach.exam`) |
| `res_id` | Integer | Target record ID |
| `current_stage_id` | Many2one | Current active stage |
| `requester_id` | Many2one | User who submitted for approval |
| `state` | Selection | `in_progress`, `approved`, `rejected`, `bypassed` |
| `bypass_reason` | Text | Justification if bypassed |
| `created_at` | Datetime | Request creation time |
### 25.4 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/approval-workflows` | JWT | Paginated workflows |
| `POST` | `/api/approval-workflows` | JWT | Create workflow |
| `PATCH` | `/api/approval-workflows/{id}` | JWT | Update workflow |
| `POST` | `/api/approval-workflows` | JWT | Create workflow with stages |
| `GET` | `/api/approval-workflows/{id}` | JWT | Get workflow with stages |
| `PATCH` | `/api/approval-workflows/{id}` | JWT | Update workflow and stages |
| `DELETE` | `/api/approval-workflows/{id}` | JWT | Delete workflow |
| `POST` | `/api/approval-requests` | JWT | Submit item for approval |
| `GET` | `/api/approval-requests` | JWT | List approval requests (filter: state, type) |
| `POST` | `/api/approval-requests/{id}/approve` | JWT | Approve current stage |
| `POST` | `/api/approval-requests/{id}/reject` | JWT | Reject at current stage |
| `POST` | `/api/approval-requests/{id}/bypass` | JWT | Bypass approval (requires justification) |
| `POST` | `/api/approval-requests/{id}/return` | JWT | Return to specific stage |
### 25.5 Cron: Auto-Escalation
A server cron runs daily to check approval stages that have exceeded `max_days`. For stages with `auto_escalate = True`, the system automatically advances to the next stage and sends a notification email to the new approver with escalation reason.
---
## 26. Non-Functional Requirements
## 26. Courseware and Content Delivery
### 26.1 Performance
**Module:** `encoach_courseware`
**Dependencies:** `encoach_core`, `openeducat_core`, `encoach_ai`
### 26.1 Model (`encoach.course.chapter`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char (required) | Chapter title |
| `course_id` | Many2one (`op.course`) | Parent course |
| `sequence` | Integer | Display order |
| `description` | Text | Chapter description |
| `start_date` | Datetime | Scheduled start date |
| `end_date` | Datetime | Optional end date |
| `unlock_mode` | Selection | `auto_date`, `manual`, `prerequisite` |
| `is_unlocked` | Boolean | Current unlock status |
| `topic_id` | Many2one (`encoach.topic`) | Bridge to adaptive engine |
| `material_ids` | One2many | Chapter materials |
| `assignment_ids` | Many2many (`encoach.assignment`) | Linked assignments |
| `exam_ids` | Many2many (`encoach.exam`) | Linked exams/quizzes |
| `active` | Boolean | Soft-delete flag |
### 26.2 Model (`encoach.chapter.material`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char (required) | Material title |
| `chapter_id` | Many2one | Parent chapter |
| `type` | Selection | `pdf`, `document`, `video`, `audio`, `image`, `link`, `article` |
| `file` | Binary (attachment=True) | Uploaded file |
| `url` | Char | External URL |
| `description` | Text | Description |
| `sequence` | Integer | Display order |
| `allow_download` | Boolean | Student download permission |
| `is_book` | Boolean | Course book flag |
| `book_chapters` | Text | JSON book chapter structure |
| `active` | Boolean | Soft-delete flag |
### 26.3 Model (`encoach.chapter.progress`)
| Field | Type | Description |
|-------|------|-------------|
| `student_id` | Many2one (`res.users`) | Student |
| `chapter_id` | Many2one | Chapter |
| `status` | Selection | `not_started`, `in_progress`, `completed` |
| `started_at` | Datetime | First access time |
| `completed_at` | Datetime | Completion time |
| `materials_completed` | Integer | Count of materials viewed |
| `materials_total` | Integer | Computed total materials |
### 26.4 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/courses/{courseId}/chapters` | JWT | List chapters (ordered by sequence) |
| `POST` | `/api/courses/{courseId}/chapters` | JWT (teacher) | Create chapter |
| `GET` | `/api/chapters/{id}` | JWT | Chapter detail with material count |
| `PATCH` | `/api/chapters/{id}` | JWT (teacher) | Update chapter |
| `DELETE` | `/api/chapters/{id}` | JWT (teacher) | Soft-delete chapter |
| `POST` | `/api/chapters/{id}/unlock` | JWT (teacher) | Manually unlock |
| `POST` | `/api/chapters/{id}/lock` | JWT (teacher) | Manually lock |
| `PATCH` | `/api/courses/{courseId}/chapters/reorder` | JWT (teacher) | Reorder (body: `{ids: []}`) |
| `GET` | `/api/chapters/{id}/progress` | JWT | Student progress |
| `POST` | `/api/chapters/{id}/progress/complete` | JWT (student) | Mark chapter completed |
| `GET` | `/api/chapters/{chapterId}/materials` | JWT | List materials |
| `POST` | `/api/chapters/{chapterId}/materials` | JWT (teacher) | Upload material (multipart) |
| `PATCH` | `/api/materials/{id}` | JWT (teacher) | Update material metadata |
| `DELETE` | `/api/materials/{id}` | JWT (teacher) | Delete material |
| `GET` | `/api/materials/{id}/download` | JWT | Download file (streaming response) |
| `POST` | `/api/materials/{id}/viewed` | JWT (student) | Record material view |
| `PATCH` | `/api/chapters/{chapterId}/materials/reorder` | JWT (teacher) | Reorder materials |
### 26.5 AI Workbench Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/workbench/generate-outline` | JWT (teacher) | AI generates course outline from requirements (topic, objectives, complexity) |
| `POST` | `/api/workbench/generate-chapter` | JWT (teacher) | AI generates content for a specific chapter |
| `POST` | `/api/workbench/generate-rubric` | JWT (teacher) | AI generates grading rubric |
| `POST` | `/api/workbench/regenerate` | JWT (teacher) | Regenerate specific section |
| `POST` | `/api/workbench/suggest-materials` | JWT (teacher) | AI suggests supplementary materials based on student performance profiles |
| `POST` | `/api/workbench/publish` | JWT (teacher) | Publish generated content to course chapters (may trigger approval workflow) |
AI Workbench uses GPT-4o (temp 0.7 for generation) and calls `encoach_ai` service layer.
### 26.6 Cron: Auto-Unlock Chapters
Server cron runs every hour. For chapters with `unlock_mode = 'auto_date'` and `start_date <= now()` and `is_unlocked = False`, set `is_unlocked = True` and trigger notification.
---
## 27. Communication System
**Module:** `encoach_communication`
**Dependencies:** `encoach_core`, `openeducat_core`
### 27.1 Model (`encoach.discussion.board`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char (required) | Board title |
| `course_id` | Many2one (`op.course`) | Associated course |
| `batch_id` | Many2one (`op.batch`) | Associated batch (optional) |
| `chapter_id` | Many2one (`encoach.course.chapter`) | Associated chapter (optional) |
| `assignment_id` | Many2one | Associated assignment (optional) |
| `is_enabled` | Boolean | Teacher toggle |
| `allow_student_posts` | Boolean | Student thread creation permission |
| `post_count` | Integer | Computed count of posts |
### 27.2 Model (`encoach.discussion.post`)
| Field | Type | Description |
|-------|------|-------------|
| `board_id` | Many2one | Parent board |
| `parent_id` | Many2one (self) | Parent post for threading |
| `author_id` | Many2one (`res.users`) | Author |
| `title` | Char | Thread title (root posts only) |
| `content` | Text | Post body (markdown) |
| `attachment_ids` | Many2many (`ir.attachment`) | File attachments |
| `is_pinned` | Boolean | Teacher pin |
| `is_resolved` | Boolean | Resolved flag (Q&A mode) |
| `reply_count` | Integer | Computed child count |
| `created_at` | Datetime | Post timestamp |
### 27.3 Model (`encoach.announcement`)
| Field | Type | Description |
|-------|------|-------------|
| `title` | Char (required) | Announcement title |
| `content` | Text | Body (markdown) |
| `author_id` | Many2one (`res.users`) | Author |
| `course_id` | Many2one (`op.course`) | Target course (null = system-wide) |
| `batch_id` | Many2one (`op.batch`) | Target batch (null = all batches) |
| `priority` | Selection | `normal`, `important`, `urgent` |
| `is_published` | Boolean | Published status |
| `published_at` | Datetime | Publish timestamp |
| `expires_at` | Datetime | Expiry time |
| `send_email` | Boolean | Also send via email |
| `attachment_ids` | Many2many (`ir.attachment`) | Attachments |
### 27.4 Model (`encoach.message`)
| Field | Type | Description |
|-------|------|-------------|
| `sender_id` | Many2one (`res.users`) | Sender |
| `recipient_id` | Many2one (`res.users`) | Recipient |
| `subject` | Char | Subject line |
| `content` | Text | Message body |
| `is_read` | Boolean | Read flag |
| `read_at` | Datetime | Read timestamp |
| `attachment_ids` | Many2many (`ir.attachment`) | Attachments |
| `send_email_copy` | Boolean | Send email copy |
| `created_at` | Datetime | Sent time |
### 27.5 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/discussion-boards` | JWT | List boards (filter: `course_id`, `batch_id`) |
| `POST` | `/api/discussion-boards` | JWT (teacher) | Create board |
| `PATCH` | `/api/discussion-boards/{id}` | JWT (teacher) | Update settings |
| `GET` | `/api/discussion-boards/{id}/posts` | JWT | Paginated posts (threaded) |
| `POST` | `/api/discussion-boards/{id}/posts` | JWT | Create post/reply |
| `PATCH` | `/api/posts/{id}` | JWT | Update own post |
| `DELETE` | `/api/posts/{id}` | JWT (teacher/admin) | Delete post |
| `POST` | `/api/posts/{id}/pin` | JWT (teacher) | Pin/unpin toggle |
| `POST` | `/api/posts/{id}/resolve` | JWT (teacher) | Mark as resolved |
| `GET` | `/api/announcements` | JWT | List announcements (filter: `course_id`, `priority`) |
| `POST` | `/api/announcements` | JWT (teacher/admin) | Create announcement |
| `PATCH` | `/api/announcements/{id}` | JWT | Update |
| `DELETE` | `/api/announcements/{id}` | JWT | Delete |
| `POST` | `/api/announcements/{id}/publish` | JWT | Publish |
| `GET` | `/api/messages` | JWT | Inbox (filter: `is_read`) |
| `GET` | `/api/messages/sent` | JWT | Sent messages |
| `POST` | `/api/messages` | JWT | Send message |
| `GET` | `/api/messages/{id}` | JWT | Message detail (auto-marks read) |
| `DELETE` | `/api/messages/{id}` | JWT | Delete message |
| `GET` | `/api/messages/unread-count` | JWT | Unread count `{count: N}` |
### 27.6 Email Integration
When `send_email = True` on announcements or `send_email_copy = True` on messages, the module uses Odoo's `mail.thread` mixin to send emails through the configured outgoing mail server.
---
## 28. Notification Engine
**Module:** `encoach_notification`
**Dependencies:** `encoach_core`
### 28.1 Model (`encoach.notification`)
| Field | Type | Description |
|-------|------|-------------|
| `user_id` | Many2one (`res.users`) | Recipient |
| `title` | Char | Notification title |
| `message` | Text | Body |
| `type` | Selection | `deadline`, `chapter_unlock`, `result_release`, `announcement`, `assignment`, `exam`, `message`, `system` |
| `action_url` | Char | Frontend URL to navigate to |
| `is_read` | Boolean | Read status |
| `read_at` | Datetime | Read timestamp |
| `channel` | Selection | `in_app`, `email`, `both` |
| `created_at` | Datetime | Created time |
### 28.2 Model (`encoach.notification.rule`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Rule name |
| `event_type` | Selection | `assignment_due`, `exam_due`, `chapter_unlock`, `result_release`, `submission_graded`, `extension_response` |
| `days_before` | Integer | Days before event to trigger |
| `frequency` | Selection | `once`, `daily`, `custom` |
| `custom_intervals` | Text (JSON) | Custom intervals array (e.g., `[7, 3, 1]`) |
| `channel` | Selection | `in_app`, `email`, `both` |
| `entity_id` | Many2one | Entity scope |
| `active` | Boolean | Active flag |
### 28.3 Model (`encoach.notification.preferences`)
| Field | Type | Description |
|-------|------|-------------|
| `user_id` | Many2one (`res.users`) | One per user |
| `email_enabled` | Boolean | Master email toggle |
| `assignment_alerts` | Boolean | Assignment notifications |
| `exam_alerts` | Boolean | Exam notifications |
| `chapter_alerts` | Boolean | Chapter unlock notifications |
| `announcement_alerts` | Boolean | Announcement notifications |
| `message_alerts` | Boolean | Message notifications |
### 28.4 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/notifications` | JWT | Paginated list (filter: `type`, `is_read`) |
| `POST` | `/api/notifications/{id}/read` | JWT | Mark as read |
| `POST` | `/api/notifications/read-all` | JWT | Mark all as read |
| `GET` | `/api/notifications/unread-count` | JWT | `{count: N}` |
| `GET` | `/api/notification-rules` | JWT (admin) | List rules |
| `POST` | `/api/notification-rules` | JWT (admin) | Create rule |
| `PATCH` | `/api/notification-rules/{id}` | JWT (admin) | Update rule |
| `DELETE` | `/api/notification-rules/{id}` | JWT (admin) | Delete rule |
| `GET` | `/api/notification-preferences` | JWT | Get user preferences |
| `PATCH` | `/api/notification-preferences` | JWT | Update preferences |
### 28.5 Crons
1. **Deadline Reminders:** Runs daily. Checks all active rules, matches upcoming events (assignments, exams) within `days_before`, creates notifications and sends emails per user preferences.
2. **Chapter Unlock Notifications:** Triggered by the courseware cron when chapters are auto-unlocked.
---
## 29. FAQ System
**Module:** `encoach_faq`
**Dependencies:** `encoach_core`
### 29.1 Model (`encoach.faq.category`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Category name |
| `sequence` | Integer | Display order |
| `icon` | Char | Icon identifier |
| `audience` | Selection | `student`, `entity`, `both` |
| `active` | Boolean | Active flag |
### 29.2 Model (`encoach.faq.item`)
| Field | Type | Description |
|-------|------|-------------|
| `question` | Char | Question text |
| `answer` | Text | Answer (markdown, embedded images/video) |
| `category_id` | Many2one | Parent category |
| `audience` | Selection | `student`, `entity`, `both` |
| `image` | Binary | Optional image |
| `video_url` | Char | Optional video URL |
| `sequence` | Integer | Display order |
| `active` | Boolean | Active flag |
### 29.3 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/faq/categories` | Public/JWT | List categories (filter: `audience`) |
| `POST` | `/api/faq/categories` | JWT (admin) | Create category |
| `PATCH` | `/api/faq/categories/{id}` | JWT (admin) | Update category |
| `DELETE` | `/api/faq/categories/{id}` | JWT (admin) | Delete category |
| `GET` | `/api/faq/items` | Public/JWT | List items (filter: `category_id`, `audience`, `search`) |
| `POST` | `/api/faq/items` | JWT (admin) | Create item |
| `PATCH` | `/api/faq/items/{id}` | JWT (admin) | Update item |
| `DELETE` | `/api/faq/items/{id}` | JWT (admin) | Delete item |
---
## 30. Plagiarism Detection
**Module enhancement:** `encoach_ai_grading` (add plagiarism sub-service)
### 30.1 Plagiarism Report Storage
Add to `encoach.submission` or create `encoach.plagiarism.report`:
| Field | Type | Description |
|-------|------|-------------|
| `submission_id` | Many2one | Target submission |
| `overall_score` | Float | AI probability score (0-1) |
| `per_sentence` | Text (JSON) | Per-sentence analysis from GPTZero |
| `report_pdf` | Binary | Generated PDF report |
| `checked_at` | Datetime | When check was run |
### 30.2 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/plagiarism/check` | JWT (teacher) | Submit text for GPTZero analysis |
| `GET` | `/api/plagiarism/report/{submissionId}` | JWT | Get plagiarism report |
| `GET` | `/api/plagiarism/report/{submissionId}/download` | JWT | Download PDF report |
### 30.3 Integration
- Uses GPTZero `v2/predict/text` endpoint (already configured in `encoach_ai_grading`)
- Can be enabled/disabled per assignment via `plagiarism_enabled` boolean on `encoach.assignment`
- Configurable max submissions for plagiarism check via `plagiarism_max_checks` integer
---
## 31. Official Exam Access
**Module enhancement:** `encoach_exam` (add official access modes)
### 31.1 Model Extensions
Add to `encoach.exam`:
| Field | Type | Description |
|-------|------|-------------|
| `is_exercise` | Boolean | Exercise (practice, no journey tracking) vs Exam |
| `is_official` | Boolean | Official exam flag |
| `access_mode` | Selection | `normal`, `link`, `landing_page`, `separate_login` |
| `access_token` | Char | Unique access token (auto-generated) |
| `access_code` | Char | Exam access code |
| `activation_start` | Datetime | Exam start time |
| `activation_end` | Datetime | Exam end time |
| `generate_unique_per_student` | Boolean | AI generates unique variant per student |
| `reminder_count` | Integer | Number of reminders to send |
| `reminder_frequency` | Selection | `once`, `daily`, `custom` |
### 31.2 API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/exam/{id}/generate-link` | JWT (teacher) | Generate unique access token/URL |
| `GET` | `/api/exam/access/{token}` | Public | Access exam via token (validates token, returns exam info) |
| `POST` | `/api/exam/official-login` | Public | Login with exam code + credentials |
| `POST` | `/api/exam/{id}/suspend` | JWT (teacher) | Suspend exam |
| `POST` | `/api/exam/{id}/revoke` | JWT (teacher) | Revoke exam access |
---
## 32. Non-Functional Requirements
### 32.1 Performance
| ID | Requirement |
|----|-------------|
@@ -1140,7 +1580,7 @@ Files are stored as `ir.attachment` records. For production, configure Odoo to u
| NFR-PERF-02 | AI endpoints may take up to 30s (use async job for long operations) |
| NFR-PERF-03 | Pagination: default `size=20`, max `size=100` |
### 26.2 Security
### 32.2 Security
| ID | Requirement |
|----|-------------|
@@ -1151,7 +1591,7 @@ Files are stored as `ir.attachment` records. For production, configure Odoo to u
| NFR-SEC-05 | File upload size limit: 50MB |
| NFR-SEC-06 | API keys stored in `ir.config_parameter`, never in code |
### 26.3 Scalability
### 32.3 Scalability
| ID | Requirement |
|----|-------------|
@@ -1159,7 +1599,7 @@ Files are stored as `ir.attachment` records. For production, configure Odoo to u
| NFR-SCALE-02 | Support 50+ subjects, 500+ topics per subject, 1000+ resources per subject |
| NFR-SCALE-03 | Database indices on all foreign keys and frequently filtered fields |
### 26.4 Reliability
### 32.4 Reliability
| ID | Requirement |
|----|-------------|
@@ -1167,7 +1607,7 @@ Files are stored as `ir.attachment` records. For production, configure Odoo to u
| NFR-REL-02 | Webhook processing is idempotent |
| NFR-REL-03 | Soft-delete for resources (preserve completion records) |
### 26.5 API Conventions
### 32.5 API Conventions
| Convention | Detail |
|-----------|--------|
@@ -1181,7 +1621,7 @@ Files are stored as `ir.attachment` records. For production, configure Odoo to u
---
## 27. Implementation Priority
## 33. Implementation Priority
| Priority | Modules | Rationale |
|----------|---------|-----------|
@@ -1190,25 +1630,33 @@ Files are stored as `ir.attachment` records. For production, configure Odoo to u
| **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** | Approval workflows, storage | Operational features |
| **P4** | Storage | Operational features |
---
## Appendix A: Complete Endpoint Summary
Total API endpoints: **200+**
Total API endpoints: **280+**
| Service Area | Endpoint Count |
|-------------|---------------|
@@ -1236,7 +1684,15 @@ Total API endpoints: **200+**
| Subscriptions & Payments | 8 + 3 webhooks |
| Tickets | 4 |
| Storage | 2 |
| Approval Workflows | 4 |
| 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** |
---
@@ -1273,9 +1729,31 @@ res.users ──extends──> encoach.user
├── op.exam.session ──> op.exam ──> op.exam.attendees
│ └── op.marksheet.register ──> op.marksheet.line
── op.assignment ──> op.assignment.sub.line (submissions)
── op.assignment ──> op.assignment.sub.line (submissions)
├── encoach.course.chapter ──> op.course
│ ├── encoach.chapter.material
│ ├── encoach.chapter.progress ──> res.users
│ └── encoach.topic (bridge to adaptive)
├── encoach.discussion.board ──> op.course / op.batch / encoach.course.chapter
│ └── encoach.discussion.post (self-referencing for threads)
├── encoach.announcement ──> op.course / op.batch
├── encoach.message ──> res.users (sender/recipient)
├── encoach.notification ──> res.users
│ └── encoach.notification.rule (event triggers)
├── encoach.faq.category ──> encoach.faq.item
├── encoach.approval.workflow ──> encoach.approval.stage
│ └── encoach.approval.request
└── encoach.plagiarism.report ──> submission
```
---
*This SRS is the definitive backend specification for the EnCoach platform on Odoo 19. It covers all 200+ REST API endpoints expected by the React frontend, all Odoo models (OpenEduCat + custom), and all AI service integrations.*
*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.*

View File

@@ -61,11 +61,25 @@
34. [Institutional Exam Sessions](#34-institutional-exam-sessions)
35. [Assignment File Submissions](#35-assignment-file-submissions)
**Part IX -- Technical Specifications**
36. [Data Models](#36-data-models)
37. [REST API Specification](#37-rest-api-specification)
38. [Frontend Page Inventory](#38-frontend-page-inventory)
39. [Non-Functional Requirements](#39-non-functional-requirements)
**Part IX -- Courseware and Content Delivery**
36. [Course Chapters](#36-course-chapters)
37. [Chapter Materials](#37-chapter-materials)
38. [AI Workbench for Course Content](#38-ai-workbench-for-course-content)
**Part X -- Communication**
39. [Discussion Boards](#39-discussion-boards)
40. [Announcements](#40-announcements)
41. [Messaging](#41-messaging)
**Part XI -- Notifications and FAQ**
42. [Notification Engine](#42-notification-engine)
43. [FAQ System](#43-faq-system)
**Part XII -- Technical Specifications**
44. [Data Models](#44-data-models)
45. [REST API Specification](#45-rest-api-specification)
46. [Frontend Page Inventory](#46-frontend-page-inventory)
47. [Non-Functional Requirements](#47-non-functional-requirements)
---
@@ -352,9 +366,9 @@ The current permission model is IELTS-hardcoded (`generate_reading`, `createWrit
| FR-SIDE-03 | Logout ends session and clears state |
| FR-SIDE-04 | Ticket badge shows assigned count |
**Student sidebar:** Dashboard, Subjects, Courses, Subject Registration, Assignments, Grades, Attendance, Timetable, Profile
**Student sidebar:** Dashboard, Subjects, Courses, Subject Registration, Assignments, Grades, Attendance, Timetable, Discussions, Messages, Announcements, Journey, Profile
**Teacher sidebar:** Dashboard, Courses, Assignments, Attendance, Students, Timetable, Profile
**Teacher sidebar:** Dashboard, Courses, Assignments, Attendance, Students, Timetable, Discussions, Announcements, Profile
**Admin sidebar (grouped):**
@@ -723,15 +737,31 @@ Courses are managed through OpenEduCat models exposed via REST API. The frontend
| FR-EX-05 | Columns: title, creator, module, rubrics, difficulty, exercises, timer, access, created at | Production |
| FR-EX-06 | Row actions: set public/private, edit, load, delete | Production |
| FR-EX-07 | **NEW:** Subject filter (English, Math, IT) alongside module filter | New |
| FR-EX-08 | **NEW:** Exercise vs Exam differentiation -- `is_exercise` flag; exercises are self-paced practice (no journey tracking), exams are official graded assessments | Client |
| FR-EX-09 | **NEW:** `is_official` flag on exams -- official exams track to student journey and support special access modes | Client |
| FR-EX-10 | **NEW:** Official exam access modes: (1) unique shareable link, (2) landing page access, (3) separate login screen | Client |
| FR-EX-11 | **NEW:** Exam activation control -- teacher sets start date, end date, activation status | Client |
| FR-EX-12 | **NEW:** Different exam for each student option (AI generates unique variants per student) | Client |
| FR-EX-13 | **NEW:** Configurable reminder frequency -- number of reminders, notification via email and system notification | Client |
| FR-EX-14 | **NEW:** Exam suspend/revoke actions | Client |
| FR-EX-15 | **NEW:** Student inquiry and extension requests on exams | Client |
### 16.2 Rubrics (Production Parity)
### 16.2 Official Exam Access (NEW)
| Access Mode | Description | Route |
|-------------|-------------|-------|
| **Link Access** | Teacher generates a unique URL; students access exam via the link without full platform login | `/exam/access/{token}` |
| **Landing Page** | Exam appears on the public landing page; students authenticate with exam code + credentials | `/exam/official/{code}` |
| **Separate Login** | Dedicated login screen for the exam only; no access to other platform features | `/exam/login/{examId}` |
### 16.4 Rubrics (Production Parity)
| ID | Requirement |
|----|-------------|
| FR-RUB-01-05 | Search, pagination, create rubric, filter dropdown, metadata and edit |
| FR-RG-01-05 | Rubric groups: search, pagination, create, filter, edit/delete |
### 16.3 Exam Structures (Production Parity)
### 16.5 Exam Structures (Production Parity)
| ID | Requirement |
|----|-------------|
@@ -803,13 +833,53 @@ Courses are managed through OpenEduCat models exposed via REST API. The frontend
| IT Scenario | AI evaluation | GPT-4o evaluates against rubric |
| All Writing | AI detection | GPTZero per-sentence analysis |
### 19.2 Approval Workflows (Production Parity)
### 19.2 Plagiarism Detection (NEW)
| ID | Requirement |
|----|-------------|
| FR-AWF-01 | List/filter workflows |
| FR-AWF-02 | Create templates / add workflow |
| ID | Requirement | Source |
|----|-------------|--------|
| FR-PLAG-01 | Enable/disable plagiarism check per assignment or exam | Client |
| FR-PLAG-02 | Automatic GPTZero analysis on submission | Client |
| FR-PLAG-03 | Downloadable plagiarism report (PDF) per submission | Client |
| FR-PLAG-04 | Configurable number of allowed submissions with plagiarism check | Client |
| FR-PLAG-05 | Teacher views plagiarism score and per-sentence analysis in grading interface | Client |
| FR-PLAG-06 | Aggregate plagiarism overview for assignment (flagged submissions count) | Client |
**API Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/plagiarism/check` | Run plagiarism check on text/submission |
| `GET` | `/api/plagiarism/report/{submissionId}` | Get plagiarism report |
| `GET` | `/api/plagiarism/report/{submissionId}/download` | Download report as PDF |
### 19.3 Approval Workflows (Enhanced)
| ID | Requirement | Source |
|----|-------------|--------|
| FR-AWF-01 | List/filter workflows | Production |
| FR-AWF-02 | Create templates / add workflow | Production |
| FR-AWF-03 | **NEW:** AI Grading Assistant on workflow page | New frontend |
| FR-AWF-04 | **NEW:** Configurable approval stages with number of days per stage (shown as calendar) | Client |
| FR-AWF-05 | **NEW:** Auto-escalation -- if time passes, automatically move to next authority with highlighted reason | Client |
| FR-AWF-06 | **NEW:** Bypass option -- terminate approval flow with mandatory justification sent to final authority | Client |
| FR-AWF-07 | **NEW:** Comments saved through the approval process | Client |
| FR-AWF-08 | **NEW:** Status view of approval progress (first reviewer, rejected, final reviewer, etc.) | Client |
| FR-AWF-09 | **NEW:** Final authority can choose who to pass back the exam to | Client |
| FR-AWF-10 | **NEW:** Configurable email notifications per approval stage | Client |
| FR-AWF-11 | **NEW:** Approval applies to both exams and course materials | Client |
**Approval Stage Model:**
| Field | Type | Description |
|-------|------|-------------|
| `workflow_id` | Many2one | Parent workflow |
| `sequence` | Integer | Stage order |
| `approver_id` | Many2one | Approver user |
| `max_days` | Integer | Maximum days before auto-escalation |
| `notification_email` | Char | Email for notifications |
| `status` | Selection | `pending`, `approved`, `rejected`, `escalated`, `bypassed` |
| `comment` | Text | Reviewer comment |
| `acted_at` | Datetime | When action was taken |
---
@@ -900,6 +970,34 @@ Each simulated AI component in the new frontend maps to a real backend service:
| FR-ASG-04 | Breadcrumb navigation |
| FR-ASG-05 | Assignment execution via `/exam?assignment={id}` |
| FR-ASG-06 | **NEW:** Subject-scoped assignments (assign Math exams, IT exams, not just IELTS) |
| FR-ASG-07 | **NEW:** Late submission handling -- configurable late submission date/time with penalty structure | Client |
| FR-ASG-08 | **NEW:** Extension requests -- students can request deadline extensions through the platform | Client |
| FR-ASG-09 | **NEW:** Submission tracking -- status flow (start date, extension granted, submitted, in review, graded) | Client |
| FR-ASG-10 | **NEW:** Configurable number of allowed submissions per assignment | Client |
| FR-ASG-11 | **NEW:** Revision history -- track all submission versions per student | Client |
| FR-ASG-12 | **NEW:** Reminder notifications -- configurable frequency, sent via email and system notification | Client |
### 25.2 Late Submission Policy
| Field | Type | Description |
|-------|------|-------------|
| `late_deadline` | Datetime | Final late submission cutoff |
| `penalty_type` | Selection | `none`, `percentage`, `fixed_deduction` |
| `penalty_value` | Float | Penalty amount (e.g., 10 = 10% or 10 points) |
| `max_submissions` | Integer | Maximum allowed submissions (0 = unlimited) |
| `allow_extensions` | Boolean | Whether students can request extensions |
### 25.3 Extension Request
| Field | Type | Description |
|-------|------|-------------|
| `student_id` | Many2one | Requesting student |
| `assignment_id` | Many2one | Target assignment |
| `reason` | Text | Justification |
| `requested_date` | Datetime | Requested new deadline |
| `status` | Selection | `pending`, `approved`, `rejected` |
| `approved_by` | Many2one | Approving teacher |
| `response_note` | Text | Teacher response |
---
@@ -1429,11 +1527,463 @@ Traditional course assignments where students submit files and teachers grade th
---
# Part IX -- Technical Specifications
# Part IX -- Courseware and Content Delivery
## 36. Data Models
This part covers the Moodle-style structured course delivery system: chapter-based content organization, material management, and AI-powered course content generation.
### 36.1 Existing Models (from `ielts-be-v0`)
---
## 36. Course Chapters
### 36.1 Overview
Courses are divided into chapters (modules). Teachers create chapters with scheduled start dates, content materials, and lock/unlock controls. Students progress through chapters sequentially or as unlocked by the teacher.
### 36.2 Data Model (`encoach.course.chapter`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Chapter title |
| `course_id` | Many2one | Parent course (`op.course`) |
| `sequence` | Integer | Display order |
| `description` | Text | Chapter description |
| `start_date` | Datetime | Scheduled start date |
| `end_date` | Datetime | Optional end date |
| `unlock_mode` | Selection | `auto_date` (unlock when start_date reached), `manual` (teacher clicks), `prerequisite` (previous chapter completed) |
| `is_unlocked` | Boolean | Current unlock status |
| `topic_id` | Many2one | Optional link to `encoach.topic` (bridges to adaptive engine) |
| `material_ids` | One2many | Chapter materials |
| `assignment_ids` | Many2many | Linked assignments |
| `exam_ids` | Many2many | Linked exams/quizzes |
| `active` | Boolean | Soft-delete flag |
### 36.3 Chapter Progress Model (`encoach.chapter.progress`)
| Field | Type | Description |
|-------|------|-------------|
| `student_id` | Many2one | Student |
| `chapter_id` | Many2one | Chapter |
| `status` | Selection | `not_started`, `in_progress`, `completed` |
| `started_at` | Datetime | When student first accessed |
| `completed_at` | Datetime | When marked complete |
| `materials_completed` | Integer | Count of materials viewed/downloaded |
| `materials_total` | Integer | Total materials in chapter |
### 36.4 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-CH-01 | Teacher creates chapters for a course with name, description, sequence | `/teacher/courses/:id/chapters` | Teacher |
| FR-CH-02 | Teacher sets chapter start date and unlock mode (auto/manual/prerequisite) | `/teacher/courses/:id/chapters` | Teacher |
| FR-CH-03 | Teacher reorders chapters via drag-and-drop | `/teacher/courses/:id/chapters` | Teacher |
| FR-CH-04 | Teacher manually unlocks/locks a chapter | `/teacher/courses/:id/chapters` | Teacher |
| FR-CH-05 | System auto-unlocks chapters when start_date is reached (cron job) | Backend | System |
| FR-CH-06 | Student sees unlocked chapters on course detail page | `/student/courses/:id` | Student |
| FR-CH-07 | Student views chapter content and materials | `/student/courses/:id/chapters/:chapterId` | Student |
| FR-CH-08 | System tracks per-student chapter progress | Backend | System |
| FR-CH-09 | Chapter completion notification sent to student | Backend | System |
| FR-CH-10 | Teacher views chapter progress per student | `/teacher/courses/:id/chapters` | Teacher |
### 36.5 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/courses/{courseId}/chapters` | List chapters for course |
| `POST` | `/api/courses/{courseId}/chapters` | Create chapter |
| `GET` | `/api/chapters/{id}` | Get chapter detail with materials |
| `PATCH` | `/api/chapters/{id}` | Update chapter |
| `DELETE` | `/api/chapters/{id}` | Delete chapter |
| `POST` | `/api/chapters/{id}/unlock` | Manually unlock chapter |
| `POST` | `/api/chapters/{id}/lock` | Manually lock chapter |
| `PATCH` | `/api/courses/{courseId}/chapters/reorder` | Reorder chapters |
| `GET` | `/api/chapters/{id}/progress` | Get student progress for chapter |
| `POST` | `/api/chapters/{id}/progress/complete` | Mark chapter as completed |
---
## 37. Chapter Materials
### 37.1 Overview
Each chapter contains materials: uploaded files (PDFs, documents), videos, audio, images, hyperlinks, and articles. Teachers control download permissions per material.
### 37.2 Data Model (`encoach.chapter.material`)
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Material title |
| `chapter_id` | Many2one | Parent chapter |
| `type` | Selection | `pdf`, `document`, `video`, `audio`, `image`, `link`, `article` |
| `file` | Binary | Uploaded file (for pdf, document, video, audio, image) |
| `url` | Char | External URL (for link, article, or external video) |
| `description` | Text | Material description |
| `sequence` | Integer | Display order |
| `allow_download` | Boolean | Whether students can download the file |
| `is_book` | Boolean | Whether this material is a course book (multi-book support) |
| `book_chapters` | Text (JSON) | Book chapter structure (if following original book chapters) |
| `active` | Boolean | Soft-delete flag |
### 37.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-MAT-01 | Teacher uploads materials (PDF, Word, video, audio, images) per chapter | `/teacher/courses/:id/chapters/:chapterId` | Teacher |
| FR-MAT-02 | Teacher adds external links, articles, hyperlinks | `/teacher/courses/:id/chapters/:chapterId` | Teacher |
| FR-MAT-03 | Teacher uploads books with chapter structure (follow original or custom) | `/teacher/courses/:id/chapters/:chapterId` | Teacher |
| FR-MAT-04 | Teacher sets download enable/disable per material | `/teacher/courses/:id/chapters/:chapterId` | Teacher |
| FR-MAT-05 | Teacher uploads teaching aids (video, audio, pictures, hyperlinks) | `/teacher/courses/:id/chapters/:chapterId` | Teacher |
| FR-MAT-06 | Student views materials for unlocked chapters | `/student/courses/:id/chapters/:chapterId` | Student |
| FR-MAT-07 | Student downloads materials if download is enabled | `/student/courses/:id/chapters/:chapterId` | Student |
| FR-MAT-08 | System tracks which materials a student has viewed/downloaded | Backend | System |
### 37.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/chapters/{chapterId}/materials` | List materials |
| `POST` | `/api/chapters/{chapterId}/materials` | Upload material (multipart) |
| `PATCH` | `/api/materials/{id}` | Update material metadata |
| `DELETE` | `/api/materials/{id}` | Delete material |
| `GET` | `/api/materials/{id}/download` | Download material file |
| `POST` | `/api/materials/{id}/viewed` | Mark material as viewed by student |
| `PATCH` | `/api/chapters/{chapterId}/materials/reorder` | Reorder materials |
---
## 38. AI Workbench for Course Content
### 38.1 Overview
Teachers use the AI Workbench to generate course content automatically. The AI generates chapters, materials, exercises, and grading rubrics based on high-level requirements input by the teacher.
### 38.2 AI Workbench Flow
1. Teacher inputs: topic name, objectives, complexity level, target audience
2. AI generates: chapter structure with learning outcomes, reading content, exercises, multimedia suggestions
3. Teacher reviews generated content
4. Teacher can: modify, regenerate specific sections, add additional context
5. Teacher approves and publishes (follows approval workflow if configured)
6. AI can suggest additional materials based on selected student performance profiles
### 38.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-WB-01 | Teacher opens AI Workbench from course page | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-02 | Teacher inputs high-level requirements (topic, objectives, complexity) | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-03 | AI generates course outline with chapters and learning outcomes | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-04 | AI generates reading content, exercises, and multimedia for each chapter | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-05 | AI generates grading rubrics based on assignment/exam complexity | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-06 | Teacher reviews and modifies AI-generated content | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-07 | Teacher can prompt AI to regenerate specific sections | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-08 | Teacher selects student profiles for AI to suggest additional materials based on performance | `/teacher/courses/:id/workbench` | Teacher |
| FR-WB-09 | Generated content follows approval workflow before publishing | Backend | System |
### 38.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/workbench/generate-outline` | Generate course outline from requirements |
| `POST` | `/api/workbench/generate-chapter` | Generate content for a specific chapter |
| `POST` | `/api/workbench/generate-rubric` | Generate grading rubric |
| `POST` | `/api/workbench/regenerate` | Regenerate a specific section |
| `POST` | `/api/workbench/suggest-materials` | AI suggests materials based on student profiles |
| `POST` | `/api/workbench/publish` | Publish generated content to course chapters |
---
# Part X -- Communication
## 39. Discussion Boards
### 39.1 Overview
Per-class discussion boards enable student-to-student and student-to-teacher communication. Discussion boards can be attached to courses, chapters, or assignments.
### 39.2 Data Models
**`encoach.discussion.board`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Board title |
| `course_id` | Many2one | Associated course |
| `batch_id` | Many2one | Associated batch (optional) |
| `chapter_id` | Many2one | Associated chapter (optional) |
| `assignment_id` | Many2one | Associated assignment (optional) |
| `is_enabled` | Boolean | Whether teacher has enabled discussions |
| `allow_student_posts` | Boolean | Whether students can create new threads |
| `post_count` | Integer | Computed post count |
**`encoach.discussion.post`**
| Field | Type | Description |
|-------|------|-------------|
| `board_id` | Many2one | Parent board |
| `parent_id` | Many2one | Parent post (for replies, nested threads) |
| `author_id` | Many2one | Author user |
| `title` | Char | Post title (for root posts) |
| `content` | Text | Post content (supports markdown) |
| `attachment_ids` | Many2many | File attachments |
| `is_pinned` | Boolean | Pinned by teacher |
| `is_resolved` | Boolean | Marked as resolved (for Q&A) |
| `created_at` | Datetime | Post time |
### 39.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-DISC-01 | Teacher enables/disables discussion board per course or chapter | `/teacher/courses/:id/discussions` | Teacher |
| FR-DISC-02 | Students create discussion threads | `/student/discussions` | Student |
| FR-DISC-03 | Students and teachers reply to threads (nested replies) | `/student/discussions`, `/teacher/courses/:id/discussions` | Both |
| FR-DISC-04 | Teacher pins important posts | `/teacher/courses/:id/discussions` | Teacher |
| FR-DISC-05 | Teacher marks posts as resolved | `/teacher/courses/:id/discussions` | Teacher |
| FR-DISC-06 | Students view other participants within the same class | `/student/discussions` | Student |
| FR-DISC-07 | File attachments on posts | Both pages | Both |
### 39.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/discussion-boards` | List boards (filter: course_id, batch_id) |
| `POST` | `/api/discussion-boards` | Create board |
| `PATCH` | `/api/discussion-boards/{id}` | Update board settings |
| `GET` | `/api/discussion-boards/{id}/posts` | List posts for board (paginated) |
| `POST` | `/api/discussion-boards/{id}/posts` | Create post/reply |
| `PATCH` | `/api/posts/{id}` | Update post |
| `DELETE` | `/api/posts/{id}` | Delete post |
| `POST` | `/api/posts/{id}/pin` | Pin/unpin post |
| `POST` | `/api/posts/{id}/resolve` | Mark as resolved |
---
## 40. Announcements
### 40.1 Overview
Teachers broadcast announcements to classes. System-wide announcements can be created by administrators.
### 40.2 Data Model (`encoach.announcement`)
| Field | Type | Description |
|-------|------|-------------|
| `title` | Char | Announcement title |
| `content` | Text | Announcement body (supports markdown) |
| `author_id` | Many2one | Author |
| `course_id` | Many2one | Target course (null = system-wide) |
| `batch_id` | Many2one | Target batch (null = all batches in course) |
| `priority` | Selection | `normal`, `important`, `urgent` |
| `is_published` | Boolean | Published status |
| `published_at` | Datetime | Publish time |
| `expires_at` | Datetime | Expiry time (optional) |
| `send_email` | Boolean | Also send via email |
| `attachment_ids` | Many2many | File attachments |
### 40.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-ANN-01 | Teacher creates announcement for class/batch | `/teacher/announcements` | Teacher |
| FR-ANN-02 | Teacher sends announcement via platform and/or email | `/teacher/announcements` | Teacher |
| FR-ANN-03 | Admin creates system-wide announcements | `/admin/announcements` | Admin |
| FR-ANN-04 | Students view announcements on dashboard and dedicated page | `/student/announcements` | Student |
| FR-ANN-05 | Priority-based display (urgent announcements highlighted) | All dashboards | All |
### 40.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/announcements` | List announcements (filter: course_id, priority) |
| `POST` | `/api/announcements` | Create announcement |
| `PATCH` | `/api/announcements/{id}` | Update announcement |
| `DELETE` | `/api/announcements/{id}` | Delete announcement |
| `POST` | `/api/announcements/{id}/publish` | Publish announcement |
---
## 41. Messaging
### 41.1 Overview
Direct messaging between platform users. Teachers can message students individually. Students can message other students within the same class (if allowed). Platform can send emails through linked accounts.
### 41.2 Data Model (`encoach.message`)
| Field | Type | Description |
|-------|------|-------------|
| `sender_id` | Many2one | Sender user |
| `recipient_id` | Many2one | Recipient user |
| `subject` | Char | Message subject |
| `content` | Text | Message body |
| `is_read` | Boolean | Read status |
| `read_at` | Datetime | When read |
| `attachment_ids` | Many2many | File attachments |
| `send_email_copy` | Boolean | Also send copy via email |
| `created_at` | Datetime | Sent time |
### 41.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-MSG-01 | Users send direct messages to other users | `/student/messages`, `/teacher/messages` | All |
| FR-MSG-02 | Inbox with read/unread filtering | `/student/messages` | All |
| FR-MSG-03 | Option to send email copy through the platform | Both pages | All |
| FR-MSG-04 | File attachments on messages | Both pages | All |
| FR-MSG-05 | Students see other participants within same class | `/student/messages` | Student |
### 41.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/messages` | List messages (inbox, filter: unread) |
| `GET` | `/api/messages/sent` | List sent messages |
| `POST` | `/api/messages` | Send message |
| `GET` | `/api/messages/{id}` | Get message detail (marks as read) |
| `DELETE` | `/api/messages/{id}` | Delete message |
| `GET` | `/api/messages/unread-count` | Get unread message count |
---
# Part XI -- Notifications and FAQ
## 42. Notification Engine
### 42.1 Overview
Centralized notification system supporting both in-app and email notifications. Notifications are triggered by system events (deadlines, chapter unlocks, result releases, announcements) and can be configured by teachers and admins.
### 42.2 Data Models
**`encoach.notification`**
| Field | Type | Description |
|-------|------|-------------|
| `user_id` | Many2one | Recipient user |
| `title` | Char | Notification title |
| `message` | Text | Notification body |
| `type` | Selection | `deadline`, `chapter_unlock`, `result_release`, `announcement`, `assignment`, `exam`, `message`, `system` |
| `action_url` | Char | URL to redirect to when clicked |
| `is_read` | Boolean | Read status |
| `read_at` | Datetime | When read |
| `channel` | Selection | `in_app`, `email`, `both` |
| `created_at` | Datetime | Created time |
**`encoach.notification.rule`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Rule name |
| `event_type` | Selection | `assignment_due`, `exam_due`, `chapter_unlock`, `result_release`, `submission_graded`, `extension_response` |
| `days_before` | Integer | Days before event to send notification |
| `frequency` | Selection | `once`, `daily`, `custom` |
| `custom_intervals` | Text (JSON) | Custom reminder intervals (e.g., [7, 3, 1] days before) |
| `channel` | Selection | `in_app`, `email`, `both` |
| `entity_id` | Many2one | Entity scope |
| `active` | Boolean | Active flag |
**`encoach.notification.preferences`**
| Field | Type | Description |
|-------|------|-------------|
| `user_id` | Many2one | User |
| `email_enabled` | Boolean | Receive email notifications |
| `assignment_alerts` | Boolean | Assignment deadline alerts |
| `exam_alerts` | Boolean | Exam alerts |
| `chapter_alerts` | Boolean | Chapter unlock alerts |
| `announcement_alerts` | Boolean | Announcement alerts |
| `message_alerts` | Boolean | New message alerts |
### 42.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-NOT-01 | In-app notification bell with unread count (existing NotificationDropdown enhanced) | All layouts | All |
| FR-NOT-02 | Email notifications for configured events | Backend | System |
| FR-NOT-03 | Admin configures notification rules (event type, timing, frequency) | `/admin/notification-rules` | Admin |
| FR-NOT-04 | Teacher sets reminder frequency for assignments and exams | Assignment/exam creation forms | Teacher |
| FR-NOT-05 | Students receive deadline alerts, chapter unlock alerts, result release alerts | Dashboard | Student |
| FR-NOT-06 | Users configure personal notification preferences | Profile pages | All |
| FR-NOT-07 | Notification action button redirects to relevant page | All layouts | All |
### 42.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/notifications` | List notifications (paginated, filter: type, is_read) |
| `POST` | `/api/notifications/{id}/read` | Mark as read |
| `POST` | `/api/notifications/read-all` | Mark all as read |
| `GET` | `/api/notifications/unread-count` | Get unread count |
| `GET` | `/api/notification-rules` | List rules |
| `POST` | `/api/notification-rules` | Create rule |
| `PATCH` | `/api/notification-rules/{id}` | Update rule |
| `DELETE` | `/api/notification-rules/{id}` | Delete rule |
| `GET` | `/api/notification-preferences` | Get user preferences |
| `PATCH` | `/api/notification-preferences` | Update user preferences |
---
## 43. FAQ System
### 43.1 Overview
Interactive FAQ system editable by super admin. FAQ items are organized by categories and filtered by target audience (student, entity, or both). Supports text, images, and video content.
### 43.2 Data Models
**`encoach.faq.category`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Category name |
| `sequence` | Integer | Display order |
| `icon` | Char | Icon identifier |
| `audience` | Selection | `student`, `entity`, `both` |
| `active` | Boolean | Active flag |
**`encoach.faq.item`**
| Field | Type | Description |
|-------|------|-------------|
| `question` | Char | Question text |
| `answer` | Text | Answer (supports markdown, embedded images/video) |
| `category_id` | Many2one | Parent category |
| `audience` | Selection | `student`, `entity`, `both` |
| `image` | Binary | Optional image |
| `video_url` | Char | Optional video URL |
| `sequence` | Integer | Display order |
| `active` | Boolean | Active flag |
### 43.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-FAQ-01 | Super admin manages FAQ categories and items | `/admin/faq` | Admin |
| FR-FAQ-02 | Admin adds text, images, and video to FAQ answers | `/admin/faq` | Admin |
| FR-FAQ-03 | Admin sets audience filter per category/item (student, entity, both) | `/admin/faq` | Admin |
| FR-FAQ-04 | Students and entity users view FAQs filtered by their role | `/faq` | All |
| FR-FAQ-05 | Search bar to find FAQ items by keyword | `/faq` | All |
| FR-FAQ-06 | Interactive accordion-style display (click question to expand answer) | `/faq` | All |
### 43.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/faq/categories` | List categories (filter: audience) |
| `POST` | `/api/faq/categories` | Create category |
| `PATCH` | `/api/faq/categories/{id}` | Update category |
| `DELETE` | `/api/faq/categories/{id}` | Delete category |
| `GET` | `/api/faq/items` | List items (filter: category_id, audience, search) |
| `POST` | `/api/faq/items` | Create item |
| `PATCH` | `/api/faq/items/{id}` | Update item |
| `DELETE` | `/api/faq/items/{id}` | Delete item |
---
# Part XII -- Technical Specifications
## 44. Data Models
### 44.1 Existing Models (from `ielts-be-v0`)
| Model | Module | Purpose |
|-------|--------|---------|
@@ -1461,7 +2011,7 @@ Traditional course assignments where students submit files and teachers grade th
| `encoach.elai.avatar` | `encoach_ai_media` | ELAI avatar profiles |
| `encoach.registration` | `encoach_registration` | Batch registration mixin |
### 36.2 New Models (Adaptive Learning Engine)
### 44.2 New Models (Adaptive Learning Engine)
| Model | Module | Purpose | Key Fields |
|-------|--------|---------|------------|
@@ -1476,14 +2026,14 @@ Traditional course assignments where students submit files and teachers grade th
| `encoach.resource.completion` | `encoach_adaptive` | Resource viewing tracking | `student_id`, `resource_id`, `viewed`, `time_spent_minutes`, `rating` |
| `encoach.ai.content.cache` | `encoach_adaptive` | Cached AI-generated content | `topic_id`, `content_type`, `difficulty_level`, `content`, `model_used`, `review_status` |
### 36.3 New Models (SIS Integration)
### 44.3 New Models (SIS Integration)
| Model | Module | Purpose |
|-------|--------|---------|
| `encoach.sis.sync` | `encoach_sis` | SIS sync job tracking |
| `encoach.sis.mapping` | `encoach_sis` | Field mapping between SIS and EnCoach |
### 36.4 New Models (Branding)
### 44.4 New Models (Branding)
| Model | Module | Purpose |
|-------|--------|---------|
@@ -1491,9 +2041,9 @@ Traditional course assignments where students submit files and teachers grade th
---
## 37. REST API Specification
## 45. REST API Specification
### 37.1 Existing Endpoints (81+)
### 45.1 Existing Endpoints (81+)
All existing endpoints from the Odoo 19 backend remain. Key groups:
@@ -1516,7 +2066,7 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| Storage | `/api/storage` | 2 |
| Approvals | `/api/approval-workflows` | 3 |
### 37.2 New Endpoints (Adaptive Learning Engine, 40+)
### 45.2 New Endpoints (Adaptive Learning Engine, 40+)
| Group | Method | Path | Description |
|-------|--------|------|-------------|
@@ -1568,7 +2118,7 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| | `GET` | `/api/analytics/subject` | Subject analytics |
| | `GET` | `/api/analytics/content-gaps` | Content gap report |
### 37.3 New Endpoints (LMS API Bridge)
### 45.3 New Endpoints (LMS API Bridge)
| Method | Path | Description |
|--------|------|-------------|
@@ -1581,7 +2131,7 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `GET/POST` | `/api/attendance` | Attendance records |
| `GET` | `/api/grades` | Gradebook |
### 37.4 New Endpoints (SIS Integration)
### 45.4 New Endpoints (SIS Integration)
| Method | Path | Description |
|--------|------|-------------|
@@ -1590,7 +2140,7 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `GET` | `/api/sis/mappings` | Field mappings |
| `PATCH` | `/api/sis/mappings` | Update mappings |
### 37.5 New Endpoints (OpenEduCat Institutional LMS, 60+)
### 45.5 New Endpoints (OpenEduCat Institutional LMS, 60+)
| Group | Method | Path | Description |
|-------|--------|------|-------------|
@@ -1642,9 +2192,70 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
---
## 38. Frontend Page Inventory
### 45.6 New Endpoints (Courseware, Communication, Notifications, FAQ, 60+)
### 38.1 Auth Pages (3)
| Group | Method | Path | Description |
|-------|--------|------|-------------|
| **Chapters** | `GET` | `/api/courses/{courseId}/chapters` | List chapters |
| | `POST` | `/api/courses/{courseId}/chapters` | Create chapter |
| | `GET` | `/api/chapters/{id}` | Chapter detail |
| | `PATCH` | `/api/chapters/{id}` | Update chapter |
| | `DELETE` | `/api/chapters/{id}` | Delete chapter |
| | `POST` | `/api/chapters/{id}/unlock` | Unlock chapter |
| | `POST` | `/api/chapters/{id}/lock` | Lock chapter |
| | `PATCH` | `/api/courses/{courseId}/chapters/reorder` | Reorder |
| | `GET` | `/api/chapters/{id}/progress` | Progress |
| | `POST` | `/api/chapters/{id}/progress/complete` | Complete |
| **Materials** | `GET` | `/api/chapters/{chapterId}/materials` | List |
| | `POST` | `/api/chapters/{chapterId}/materials` | Upload |
| | `PATCH` | `/api/materials/{id}` | Update |
| | `DELETE` | `/api/materials/{id}` | Delete |
| | `GET` | `/api/materials/{id}/download` | Download |
| | `POST` | `/api/materials/{id}/viewed` | Mark viewed |
| **Workbench** | `POST` | `/api/workbench/generate-outline` | Generate outline |
| | `POST` | `/api/workbench/generate-chapter` | Generate chapter |
| | `POST` | `/api/workbench/generate-rubric` | Generate rubric |
| | `POST` | `/api/workbench/regenerate` | Regenerate section |
| | `POST` | `/api/workbench/suggest-materials` | Suggest materials |
| | `POST` | `/api/workbench/publish` | Publish content |
| **Discussions** | `GET/POST` | `/api/discussion-boards` | List/create boards |
| | `PATCH` | `/api/discussion-boards/{id}` | Update board |
| | `GET/POST` | `/api/discussion-boards/{id}/posts` | List/create posts |
| | `PATCH/DELETE` | `/api/posts/{id}` | Update/delete post |
| | `POST` | `/api/posts/{id}/pin` | Pin post |
| | `POST` | `/api/posts/{id}/resolve` | Resolve post |
| **Announcements** | `GET/POST` | `/api/announcements` | List/create |
| | `PATCH/DELETE` | `/api/announcements/{id}` | Update/delete |
| | `POST` | `/api/announcements/{id}/publish` | Publish |
| **Messages** | `GET` | `/api/messages` | Inbox |
| | `GET` | `/api/messages/sent` | Sent |
| | `POST` | `/api/messages` | Send |
| | `GET` | `/api/messages/{id}` | Detail |
| | `DELETE` | `/api/messages/{id}` | Delete |
| | `GET` | `/api/messages/unread-count` | Unread count |
| **Notifications** | `GET` | `/api/notifications` | List |
| | `POST` | `/api/notifications/{id}/read` | Mark read |
| | `POST` | `/api/notifications/read-all` | Read all |
| | `GET` | `/api/notifications/unread-count` | Count |
| | `GET/POST` | `/api/notification-rules` | Rules |
| | `PATCH/DELETE` | `/api/notification-rules/{id}` | Rule CRUD |
| | `GET/PATCH` | `/api/notification-preferences` | User prefs |
| **FAQ** | `GET/POST` | `/api/faq/categories` | Categories |
| | `PATCH/DELETE` | `/api/faq/categories/{id}` | Category CRUD |
| | `GET/POST` | `/api/faq/items` | Items |
| | `PATCH/DELETE` | `/api/faq/items/{id}` | Item CRUD |
| **Plagiarism** | `POST` | `/api/plagiarism/check` | Check text |
| | `GET` | `/api/plagiarism/report/{submissionId}` | Get report |
| | `GET` | `/api/plagiarism/report/{submissionId}/download` | Download PDF |
| **Exam Access** | `POST` | `/api/exam/{id}/generate-link` | Generate access link |
| | `GET` | `/api/exam/access/{token}` | Access via link |
| | `POST` | `/api/exam/official-login` | Official exam login |
---
## 46. Frontend Page Inventory
### 46.1 Auth Pages (3)
| Route | Page | API Dependencies |
|-------|------|-----------------|
@@ -1652,7 +2263,7 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `/register` | Register | `POST /api/register` |
| `/forgot-password` | Forgot Password | `POST /api/reset/sendVerification` |
### 38.2 Student Pages (12+)
### 46.2 Student Pages (18+)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
@@ -1670,8 +2281,13 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `/student/timetable` | Timetable | `/api/timetable` | -- |
| `/student/profile` | Profile | `/api/user` | -- |
| `/student/subject-registration` | **NEW:** Subject Registration | `/api/subject-registrations/available`, `/api/subject-registrations` | -- |
| `/student/courses/:id/chapters/:chapterId` | **NEW:** Chapter View | `/api/chapters/{id}`, `/api/chapters/{chapterId}/materials` | -- |
| `/student/discussions` | **NEW:** Discussion Board | `/api/discussion-boards`, `/api/discussion-boards/{id}/posts` | -- |
| `/student/announcements` | **NEW:** Announcements | `/api/announcements` | -- |
| `/student/messages` | **NEW:** Messages | `/api/messages`, `/api/messages/unread-count` | -- |
| `/student/journey` | **NEW:** Student Journey | `/api/analytics/student`, `/api/proficiency` | -- |
### 38.3 Teacher Pages (10)
### 46.3 Teacher Pages (15+)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
@@ -1685,15 +2301,24 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `/teacher/students` | Students | `/api/users/list?type=student` | AiRiskBadge |
| `/teacher/timetable` | Timetable | `/api/timetable` | -- |
| `/teacher/profile` | Profile | `/api/user` | -- |
| `/teacher/courses/:id/chapters` | **NEW:** Course Chapters | `/api/courses/{courseId}/chapters` | -- |
| `/teacher/courses/:id/chapters/:chapterId` | **NEW:** Chapter Detail | `/api/chapters/{id}`, `/api/chapters/{chapterId}/materials` | -- |
| `/teacher/courses/:id/workbench` | **NEW:** AI Workbench | `/api/workbench/*` | AiCreationAssistant |
| `/teacher/courses/:id/discussions` | **NEW:** Discussion Board | `/api/discussion-boards`, `/api/discussion-boards/{id}/posts` | -- |
| `/teacher/announcements` | **NEW:** Announcements | `/api/announcements` | -- |
### 38.4 Public Pages (2)
### 46.4 Public Pages (4+)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
| `/apply` | **NEW:** Admission Application | `/api/admissions`, `/api/admission-registers` | -- |
| `/apply/status` | **NEW:** Application Status | `/api/admissions/{id}` | -- |
| `/exam/access/{token}` | **NEW:** Official Exam Access (Link) | `/api/exam/access/{token}` | -- |
| `/exam/official/{code}` | **NEW:** Official Exam Access (Landing) | `/api/exam/official-login` | -- |
| `/exam/login/{examId}` | **NEW:** Official Exam Login | `/api/exam/official-login` | -- |
| `/faq` | **NEW:** FAQ | `/api/faq/categories`, `/api/faq/items` | -- |
### 38.5 Admin Pages (38+)
### 46.5 Admin Pages (42+)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
@@ -1736,10 +2361,13 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `/admin/admission-register` | **NEW:** Admission Register | `/api/admission-registers` | -- |
| `/admin/exam-sessions` | **NEW:** Institutional Exam Sessions | `/api/inst-exam-sessions`, `/api/inst-exams` | -- |
| `/admin/marksheets` | **NEW:** Marksheet Manager | `/api/marksheets`, `/api/result-templates`, `/api/grade-config` | -- |
| `/admin/faq` | **NEW:** FAQ Manager | `/api/faq/categories`, `/api/faq/items` | -- |
| `/admin/notification-rules` | **NEW:** Notification Rules | `/api/notification-rules` | -- |
| `/admin/approval-config` | **NEW:** Approval Workflow Config | `/api/approval-workflows` | -- |
---
## 39. Non-Functional Requirements
## 47. Non-Functional Requirements
| ID | Requirement |
|----|-------------|
@@ -1802,7 +2430,12 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| 30 | `openeducat_admission` | **OpenEduCat (port to v19)** | `op.admission`, `op.admission.register` |
| 31 | `openeducat_facility` | **OpenEduCat (port to v19)** | `op.facility`, `op.facility.line` (dependency of classroom) |
**Total: 15 existing EnCoach + 8 new EnCoach + 8 OpenEduCat = 31 modules**
| 32 | `encoach_courseware` | **NEW** | Course chapters, chapter materials, chapter progress, AI workbench |
| 33 | `encoach_communication` | **NEW** | Discussion boards, posts, announcements, messaging |
| 34 | `encoach_notification` | **NEW** | Notification engine, notification rules, reminders, preferences |
| 35 | `encoach_faq` | **NEW** | FAQ categories and items, role-filtered |
**Total: 15 existing EnCoach + 12 new EnCoach + 8 OpenEduCat = 35 modules**
---
@@ -1821,6 +2454,14 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| **P3** | Spaced repetition, analytics dashboards | Progress optimization |
| **P3** | Institutional exam sessions, marksheets, subject registration | Institutional assessment |
| **P3** | Course assignment submissions (file upload, grading) | Homework workflow |
| **P2** | Courseware module (chapters, materials, progress tracking) | Structured course delivery |
| **P2** | Communication module (discussions, announcements, messaging) | Collaboration |
| **P3** | Notification engine (rules, email integration, reminders) | User engagement |
| **P3** | Enhanced approval workflows (timed stages, escalation, bypass) | Governance |
| **P3** | Plagiarism detection (GPTZero integration) | Academic integrity |
| **P3** | Official exam access modes (link, landing page, separate login) | Exam security |
| **P4** | FAQ system (admin-managed, role-filtered) | User support |
| **P4** | AI Workbench for course content generation | Content automation |
| **P3** | SIS integration, whitelabeling | University integration |
| **P4** | Content gap detection, FAISS tips per subject, batch optimizer | Operational efficiency |
| **P4** | Payment system, subscription management | Commercial features |

View File

@@ -79,6 +79,22 @@ import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
import MarksheetManager from "@/pages/admin/MarksheetManager";
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 OfficialExamAccess from "@/pages/OfficialExamAccess";
import FaqPage from "@/pages/FaqPage";
import NotFound from "@/pages/NotFound";
import { queryClient } from "@/lib/query-client";
@@ -94,8 +110,12 @@ const App = () => (
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
{/* Public admission */}
{/* Public pages */}
<Route path="/apply" element={<AdmissionApplication />} />
<Route path="/exam/access/:token" element={<OfficialExamAccess />} />
<Route path="/exam/official/:code" element={<OfficialExamAccess />} />
<Route path="/exam/login/:examId" element={<OfficialExamAccess />} />
<Route path="/faq" element={<FaqPage />} />
{/* Student routes */}
<Route element={<ProtectedRoute allowedRoles={["student"]} />}>
@@ -114,6 +134,11 @@ const App = () => (
<Route path="/student/plan/:subjectId" element={<LearningPlanPage />} />
<Route path="/student/topic/:topicId" element={<TopicLearning />} />
<Route path="/student/subject-registration" element={<SubjectRegistrationPage />} />
<Route path="/student/courses/:id/chapters/:chapterId" element={<StudentChapterView />} />
<Route path="/student/discussions" element={<StudentDiscussionBoard />} />
<Route path="/student/announcements" element={<StudentAnnouncements />} />
<Route path="/student/messages" element={<StudentMessages />} />
<Route path="/student/journey" element={<StudentJourney />} />
</Route>
</Route>
@@ -129,6 +154,11 @@ 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/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} />
</Route>
</Route>
@@ -183,6 +213,9 @@ const App = () => (
<Route path="/admin/admission-register" element={<AdmissionRegisterPage />} />
<Route path="/admin/exam-sessions" element={<InstitutionalExamSessions />} />
<Route path="/admin/marksheets" element={<MarksheetManager />} />
<Route path="/admin/faq" element={<FaqManager />} />
<Route path="/admin/notification-rules" element={<NotificationRules />} />
<Route path="/admin/approval-config" element={<ApprovalWorkflowConfig />} />
</Route>
</Route>

View File

@@ -27,6 +27,7 @@ import {
Building2, History, CreditCard, Ticket, BookA, PenTool,
Calendar, ChevronDown, HelpCircle, LucideIcon, Target, FolderOpen,
CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow,
} from "lucide-react";
import React from "react";
@@ -87,6 +88,12 @@ const trainingItems: NavItem[] = [
{ title: "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 },
];
const supportItems: NavItem[] = [
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
@@ -139,6 +146,8 @@ const routeLabels: Record<string, string> = {
"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",
};
function AppBreadcrumbs() {
@@ -274,6 +283,7 @@ function AdminSidebar() {
<SidebarNavGroup label="Academic" items={academicItems} />
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
<SidebarNavGroup label="Configuration" items={configItems} />
<SidebarGroup>
<Collapsible defaultOpen className="group/collapsible">

View File

@@ -2,6 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp,
} from "lucide-react";
const navGroups: NavGroup[] = [
@@ -26,6 +27,15 @@ const navGroups: NavGroup[] = [
{ 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 },
],
},
{
label: "Communication",
items: [
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
{ title: "Messages", url: "/student/messages", icon: Mail },
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
],
},
{

View File

@@ -1,7 +1,8 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, FileText,
CalendarCheck, Users, Calendar, User,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2,
} from "lucide-react";
const navGroups: NavGroup[] = [
@@ -21,6 +22,13 @@ const navGroups: NavGroup[] = [
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
],
},
{
label: "Communication",
items: [
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
],
},
{
label: "Account",
items: [

View File

@@ -1,4 +1,8 @@
export * from "./keys";
export * from "./useCourseware";
export * from "./useCommunication";
export * from "./useNotifications";
export * from "./useFaq";
export * from "./useLms";
export * from "./useUsers";
export * from "./useExams";

View File

@@ -153,4 +153,20 @@ export const queryKeys = {
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] 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,
unreadMessages: ["messages", "unread-count"] as const,
notifications: (params?: Record<string, unknown>) => ["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,
};

View File

@@ -0,0 +1,140 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { communicationService } from "@/services/communication.service";
import type {
DiscussionBoard,
PostCreateRequest,
AnnouncementCreateRequest,
MessageCreateRequest,
} from "@/types/communication";
import type { PaginationParams } from "@/types";
export function useDiscussionBoards(params?: { course_id?: number; batch_id?: number }) {
return useQuery({
queryKey: queryKeys.discussionBoards(params),
queryFn: () => communicationService.listDiscussionBoards(params),
});
}
export function usePosts(boardId: number, params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.posts(boardId, params),
queryFn: () => communicationService.listPosts(boardId, params),
enabled: !!boardId,
});
}
export function useAnnouncements(params?: { course_id?: number; priority?: string }) {
return useQuery({
queryKey: queryKeys.announcements(params),
queryFn: () => communicationService.listAnnouncements(params),
});
}
export function useMessages(params?: { is_read?: boolean } & PaginationParams) {
return useQuery({
queryKey: queryKeys.messages(params),
queryFn: () => communicationService.listMessages(params),
});
}
export function useSentMessages(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.sentMessages(params),
queryFn: () => communicationService.listSentMessages(params),
});
}
export function useUnreadMessageCount() {
return useQuery({
queryKey: queryKeys.unreadMessages,
queryFn: () => communicationService.getUnreadMessageCount(),
});
}
export function useCreateBoard() {
const qc = useQueryClient();
return useMutation({
mutationFn: (
data: Partial<DiscussionBoard> & Pick<DiscussionBoard, "name" | "course_id">,
) => communicationService.createDiscussionBoard(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["discussion-boards"] });
},
});
}
export function useCreatePost() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ boardId, data }: { boardId: number; data: PostCreateRequest }) =>
communicationService.createPost(boardId, data),
onSuccess: (_, { boardId }) => {
qc.invalidateQueries({ queryKey: ["posts", boardId] });
qc.invalidateQueries({ queryKey: ["discussion-boards"] });
},
});
}
export function usePinPost() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body?: { pinned?: boolean } }) =>
communicationService.pinPost(id, body),
onSuccess: (post) => {
qc.invalidateQueries({ queryKey: ["posts", post.board_id] });
},
});
}
export function useResolvePost() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => communicationService.resolvePost(id),
onSuccess: (post) => {
qc.invalidateQueries({ queryKey: ["posts", post.board_id] });
},
});
}
export function useCreateAnnouncement() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AnnouncementCreateRequest) => communicationService.createAnnouncement(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["announcements"] });
},
});
}
export function usePublishAnnouncement() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => communicationService.publishAnnouncement(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["announcements"] });
},
});
}
export function useSendMessage() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: MessageCreateRequest) => communicationService.sendMessage(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["messages"] });
qc.invalidateQueries({ queryKey: queryKeys.unreadMessages });
},
});
}
export function useDeleteMessage() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => communicationService.deleteMessage(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["messages"] });
qc.invalidateQueries({ queryKey: queryKeys.unreadMessages });
},
});
}

View File

@@ -0,0 +1,204 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { coursewareService } from "@/services/courseware.service";
import type {
ChapterCreateRequest,
WorkbenchGenerateRequest,
} from "@/types/courseware";
export function useChapters(courseId: number) {
return useQuery({
queryKey: queryKeys.chapters(courseId),
queryFn: () => coursewareService.listChapters(courseId),
enabled: !!courseId,
});
}
export function useChapter(id: number) {
return useQuery({
queryKey: queryKeys.chapter(id),
queryFn: () => coursewareService.getChapter(id),
enabled: !!id,
});
}
export function useChapterMaterials(chapterId: number) {
return useQuery({
queryKey: queryKeys.chapterMaterials(chapterId),
queryFn: () => coursewareService.listMaterials(chapterId),
enabled: !!chapterId,
});
}
export function useChapterProgress(chapterId: number) {
return useQuery({
queryKey: queryKeys.chapterProgress(chapterId),
queryFn: () => coursewareService.getChapterProgress(chapterId),
enabled: !!chapterId,
});
}
export function useCreateChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ courseId, data }: { courseId: number; data: ChapterCreateRequest }) =>
coursewareService.createChapter(courseId, data),
onSuccess: (_, { courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapters(courseId) });
},
});
}
export function useUpdateChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ChapterCreateRequest> }) =>
coursewareService.updateChapter(id, data),
onSuccess: (chapter) => {
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapter.id) });
qc.invalidateQueries({ queryKey: queryKeys.chapters(chapter.course_id) });
},
});
}
export function useDeleteChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, courseId }: { id: number; courseId: number }) =>
coursewareService.deleteChapter(id),
onSuccess: (_, { courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapters(courseId) });
},
});
}
export function useUnlockChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, courseId }: { id: number; courseId: number }) =>
coursewareService.unlockChapter(id),
onSuccess: (_, { id, courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapter(id) });
qc.invalidateQueries({ queryKey: queryKeys.chapters(courseId) });
},
});
}
export function useLockChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, courseId }: { id: number; courseId: number }) =>
coursewareService.lockChapter(id),
onSuccess: (_, { id, courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapter(id) });
qc.invalidateQueries({ queryKey: queryKeys.chapters(courseId) });
},
});
}
export function useReorderChapters() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ courseId, body }: { courseId: number; body: { ids: number[] } }) =>
coursewareService.reorderChapters(courseId, body),
onSuccess: (_, { courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapters(courseId) });
},
});
}
export function useUploadMaterial() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ chapterId, formData }: { chapterId: number; formData: FormData }) =>
coursewareService.uploadMaterial(chapterId, formData),
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({
mutationFn: ({ id, chapterId }: { id: number; chapterId: number }) =>
coursewareService.deleteMaterial(id),
onSuccess: (_, { chapterId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
},
});
}
export function useMarkMaterialViewed() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, chapterId }: { id: number; chapterId: number }) =>
coursewareService.markMaterialViewed(id),
onSuccess: (_, { chapterId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
},
});
}
export function useCompleteChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
onSuccess: (_, chapterId) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
},
});
}
export function useGenerateOutline() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: WorkbenchGenerateRequest) => coursewareService.generateOutline(data),
onSuccess: (_, data) => {
qc.invalidateQueries({ queryKey: queryKeys.chapters(data.course_id) });
qc.invalidateQueries({ queryKey: queryKeys.lms.course(data.course_id) });
},
});
}
export function useGenerateChapterContent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: WorkbenchGenerateRequest) => coursewareService.generateChapterContent(data),
onSuccess: (_, data) => {
qc.invalidateQueries({ queryKey: queryKeys.chapters(data.course_id) });
qc.invalidateQueries({ queryKey: queryKeys.lms.course(data.course_id) });
},
});
}
export function useGenerateRubric() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: WorkbenchGenerateRequest) => coursewareService.generateRubric(data),
onSuccess: (_, data) => {
qc.invalidateQueries({ queryKey: queryKeys.chapters(data.course_id) });
qc.invalidateQueries({ queryKey: queryKeys.lms.course(data.course_id) });
},
});
}
export function usePublishWorkbench() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) => coursewareService.publishWorkbench(data),
onSuccess: (_, data) => {
const courseId = data.course_id;
if (typeof courseId === "number") {
qc.invalidateQueries({ queryKey: queryKeys.chapters(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.lms.course(courseId) });
}
},
});
}

View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { faqService } from "@/services/faq.service";
import type { FaqCategoryCreateRequest, FaqItemCreateRequest } from "@/types/faq";
export function useFaqCategories(audience?: string) {
return useQuery({
queryKey: queryKeys.faqCategories(audience),
queryFn: () => faqService.listCategories(audience ? { audience } : undefined),
});
}
export function useFaqItems(params?: { category_id?: number; audience?: string; search?: string }) {
return useQuery({
queryKey: queryKeys.faqItems(params),
queryFn: () => faqService.listItems(params),
});
}
export function useCreateFaqCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: FaqCategoryCreateRequest) => faqService.createCategory(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["faq", "categories"] });
},
});
}
export function useUpdateFaqCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<FaqCategoryCreateRequest> }) =>
faqService.updateCategory(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["faq", "categories"] });
qc.invalidateQueries({ queryKey: ["faq", "items"] });
},
});
}
export function useDeleteFaqCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => faqService.deleteCategory(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["faq", "categories"] });
qc.invalidateQueries({ queryKey: ["faq", "items"] });
},
});
}
export function useCreateFaqItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: FaqItemCreateRequest) => faqService.createItem(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["faq", "items"] });
qc.invalidateQueries({ queryKey: ["faq", "categories"] });
},
});
}
export function useUpdateFaqItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<FaqItemCreateRequest> }) =>
faqService.updateItem(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["faq", "items"] });
qc.invalidateQueries({ queryKey: ["faq", "categories"] });
},
});
}
export function useDeleteFaqItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => faqService.deleteItem(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["faq", "items"] });
qc.invalidateQueries({ queryKey: ["faq", "categories"] });
},
});
}

View File

@@ -0,0 +1,103 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { notificationService } from "@/services/notification.service";
import type { NotificationPreferences, NotificationRuleCreateRequest } from "@/types/notification";
import type { PaginationParams } from "@/types";
export function useNotifications(params?: PaginationParams & { type?: string; is_read?: boolean }) {
return useQuery({
queryKey: queryKeys.notifications(params),
queryFn: () => notificationService.listNotifications(params),
});
}
export function useUnreadNotificationCount() {
return useQuery({
queryKey: queryKeys.unreadNotifications,
queryFn: () => notificationService.getUnreadNotificationCount(),
});
}
export function useNotificationRules() {
return useQuery({
queryKey: queryKeys.notificationRules,
queryFn: () => notificationService.listNotificationRules(),
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: queryKeys.notificationPreferences,
queryFn: () => notificationService.getNotificationPreferences(),
});
}
export function useMarkNotificationRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => notificationService.markNotificationRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["notifications"] });
qc.invalidateQueries({ queryKey: queryKeys.unreadNotifications });
},
});
}
export function useMarkAllNotificationsRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => notificationService.markAllNotificationsRead(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["notifications"] });
qc.invalidateQueries({ queryKey: queryKeys.unreadNotifications });
},
});
}
export function useCreateNotificationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: NotificationRuleCreateRequest) =>
notificationService.createNotificationRule(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.notificationRules });
},
});
}
export function useUpdateNotificationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: Partial<NotificationRuleCreateRequest>;
}) => notificationService.updateNotificationRule(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.notificationRules });
},
});
}
export function useDeleteNotificationRule() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => notificationService.deleteNotificationRule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.notificationRules });
},
});
}
export function useUpdateNotificationPreferences() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<NotificationPreferences>) =>
notificationService.updateNotificationPreferences(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.notificationPreferences });
},
});
}

96
src/pages/FaqPage.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Search, HelpCircle, Video } from "lucide-react";
import { useFaqCategories, useFaqItems } from "@/hooks/queries";
export default function FaqPage() {
const [search, setSearch] = useState("");
const { data: categories = [], isLoading: lc } = useFaqCategories();
const { data: allItems = [], isLoading: li } = useFaqItems(search ? { search } : undefined);
const filteredCategories = categories.filter(cat =>
allItems.some(item => item.category_id === cat.id),
);
const getItemsForCategory = (catId: number) =>
allItems.filter(item => item.category_id === catId);
if (lc || li) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6 max-w-3xl mx-auto">
<div className="text-center">
<h1 className="text-3xl font-bold">Frequently Asked Questions</h1>
<p className="text-muted-foreground mt-2">Find answers to common questions about EnCoach.</p>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-10"
placeholder="Search questions..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{filteredCategories.length > 0 ? (
<div className="space-y-6">
{filteredCategories.map(cat => {
const catItems = getItemsForCategory(cat.id);
if (catItems.length === 0) return null;
return (
<Card key={cat.id}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{cat.name}</CardTitle>
<Badge variant="secondary" className="text-xs">{cat.item_count} questions</Badge>
</div>
</CardHeader>
<CardContent>
<Accordion type="multiple">
{catItems
.sort((a, b) => a.sequence - b.sequence)
.map(item => (
<AccordionItem key={item.id} value={String(item.id)}>
<AccordionTrigger className="text-left text-sm font-medium">
{item.question}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{item.answer}</p>
{item.video_url && (
<a
href={item.video_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Video className="h-4 w-4" /> Watch video
</a>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
);
})}
</div>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<HelpCircle className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>{search ? "No questions match your search." : "No FAQ items available yet."}</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useSearchParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2, Clock, AlertCircle, ClipboardList } from "lucide-react";
import { examsService } from "@/services/exams.service";
import { useQuery } from "@tanstack/react-query";
export default function OfficialExamAccess() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const { data: examInfo, isLoading, error } = useQuery({
queryKey: ["exam", "access", token],
queryFn: async () => {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/exam/access/${token}`);
if (!res.ok) throw new Error(res.status === 404 ? "Invalid or expired token" : "Failed to load exam");
return res.json() as Promise<{ id: number; name: string; duration_minutes: number; start_time: string; module: string }>;
},
enabled: !!token,
retry: false,
});
const handleStart = () => {
if (examInfo) {
navigate(`/exam/${examInfo.module}/${examInfo.id}?token=${token}`);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
{!token ? (
<CardContent className="py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-3" />
<h2 className="text-lg font-semibold">No Access Token</h2>
<p className="text-sm text-muted-foreground mt-1">Please use the link provided by your institution to access the exam.</p>
</CardContent>
) : isLoading ? (
<CardContent className="py-12 text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground mt-3">Loading exam information...</p>
</CardContent>
) : error ? (
<CardContent className="py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-3" />
<h2 className="text-lg font-semibold">Access Denied</h2>
<p className="text-sm text-muted-foreground mt-1">{(error as Error).message}</p>
</CardContent>
) : examInfo ? (
<>
<CardHeader className="text-center">
<div className="h-14 w-14 rounded-xl bg-primary/10 flex items-center justify-center mx-auto mb-2">
<ClipboardList className="h-7 w-7 text-primary" />
</div>
<CardTitle>{examInfo.name}</CardTitle>
<CardDescription>Official Exam Access</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Duration</span>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span className="font-medium">{examInfo.duration_minutes} minutes</span>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Start Time</span>
<span className="font-medium">{new Date(examInfo.start_time).toLocaleString()}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Module</span>
<Badge variant="outline">{examInfo.module}</Badge>
</div>
</div>
<Button className="w-full" size="lg" onClick={handleStart}>
Start Exam
</Button>
</CardContent>
</>
) : null}
</Card>
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Loader2, ChevronRight, Settings2, ArrowRight } from "lucide-react";
import { approvalsService, type ApprovalWorkflow } from "@/services/approvals.service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
interface WorkflowStage {
order: number;
approver_id: number;
approver_name: string;
max_days?: number;
notification_email?: string;
auto_escalate?: boolean;
}
export default function ApprovalWorkflowConfig() {
const { toast } = useToast();
const qc = useQueryClient();
const { data: workflowsData, isLoading } = useQuery({
queryKey: ["approvals", "list"],
queryFn: () => approvalsService.list(),
});
const workflows = workflowsData?.results ?? [];
const createWorkflow = useMutation({
mutationFn: (data: Partial<ApprovalWorkflow>) => approvalsService.create(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to create workflow", variant: "destructive" }),
});
const updateWorkflow = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ApprovalWorkflow> }) => approvalsService.update(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to update workflow", variant: "destructive" }),
});
const deleteWorkflow = useMutation({
mutationFn: (id: number) => approvalsService.delete(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to delete workflow", variant: "destructive" }),
});
const [showCreate, setShowCreate] = useState(false);
const [wfName, setWfName] = useState("");
const [stages, setStages] = useState<WorkflowStage[]>([{ order: 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
const [bypassEnabled, setBypassEnabled] = useState(false);
const resetForm = () => {
setWfName("");
setStages([{ order: 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
setBypassEnabled(false);
};
const addStage = () => {
setStages(prev => [...prev, { order: prev.length + 1, approver_id: 0, approver_name: "", max_days: 3, auto_escalate: false }]);
};
const removeStage = (idx: number) => {
setStages(prev => prev.filter((_, i) => i !== idx).map((s, i) => ({ ...s, order: i + 1 })));
};
const updateStage = (idx: number, field: keyof WorkflowStage, value: string | number | boolean) => {
setStages(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s));
};
const handleCreate = () => {
createWorkflow.mutate(
{ name: wfName, steps: stages.map(s => ({ order: s.order, approver_id: s.approver_id, approver_name: s.approver_name })), status: "draft" } as Partial<ApprovalWorkflow>,
{ onSuccess: () => { toast({ title: "Workflow Created" }); setShowCreate(false); resetForm(); } },
);
};
const handleDelete = (id: number) => {
deleteWorkflow.mutate(id, {
onSuccess: () => toast({ title: "Workflow Deleted" }),
});
};
const handleToggleStatus = (wf: ApprovalWorkflow) => {
const newStatus = wf.status === "active" ? "draft" : "active";
updateWorkflow.mutate(
{ id: wf.id, data: { status: newStatus } },
{ onSuccess: () => toast({ title: `Workflow ${newStatus === "active" ? "Activated" : "Deactivated"}` }) },
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Approval Workflows</h1>
<p className="text-muted-foreground">Configure multi-stage approval workflows with escalation rules.</p>
</div>
<Dialog open={showCreate} onOpenChange={open => { setShowCreate(open); if (!open) resetForm(); }}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Workflow</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle>Create Approval Workflow</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Workflow Name</Label>
<Input placeholder="e.g. Leave Approval" value={wfName} onChange={e => setWfName(e.target.value)} />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Stages</Label>
<Button variant="outline" size="sm" onClick={addStage}><Plus className="mr-2 h-3 w-3" /> Add Stage</Button>
</div>
{stages.map((stage, idx) => (
<Card key={idx}>
<CardContent className="py-3 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline">Stage {stage.order}</Badge>
{stages.length > 1 && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStage(idx)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Approver Name</Label>
<Input value={stage.approver_name} onChange={e => updateStage(idx, "approver_name", e.target.value)} placeholder="Name" />
</div>
<div className="space-y-1">
<Label className="text-xs">Approver ID</Label>
<Input type="number" value={stage.approver_id || ""} onChange={e => updateStage(idx, "approver_id", Number(e.target.value))} placeholder="ID" />
</div>
<div className="space-y-1">
<Label className="text-xs">Max Days</Label>
<Input type="number" min={1} value={stage.max_days ?? 3} onChange={e => updateStage(idx, "max_days", Number(e.target.value))} />
</div>
<div className="space-y-1">
<Label className="text-xs">Email</Label>
<Input type="email" value={stage.notification_email ?? ""} onChange={e => updateStage(idx, "notification_email", e.target.value)} placeholder="email@..." />
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={!!stage.auto_escalate} onCheckedChange={v => updateStage(idx, "auto_escalate", v)} />
<Label className="text-xs">Auto-escalate after max days</Label>
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex items-center gap-2 p-3 rounded-lg border">
<Switch checked={bypassEnabled} onCheckedChange={setBypassEnabled} />
<Label>Allow bypass (skip all stages)</Label>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createWorkflow.isPending || !wfName || stages.length === 0}>
{createWorkflow.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Workflow
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-4">
{workflows.map(wf => (
<Card key={wf.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Settings2 className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{wf.name}</CardTitle>
<CardDescription>{wf.entity_name}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={wf.status === "active" ? "default" : "secondary"}>{wf.status}</Badge>
<Switch checked={wf.status === "active"} onCheckedChange={() => handleToggleStatus(wf)} />
<Button variant="ghost" size="icon" onClick={() => handleDelete(wf.id)} disabled={deleteWorkflow.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 flex-wrap">
{wf.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex items-center gap-1 px-3 py-1.5 rounded-full border bg-muted/50 text-sm">
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
<span>{step.approver_name}</span>
</div>
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">Created: {new Date(wf.created_at).toLocaleDateString()}</p>
</CardContent>
</Card>
))}
{workflows.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Settings2 className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No approval workflows configured.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,283 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Pencil, Loader2, HelpCircle, FolderOpen, GripVertical } from "lucide-react";
import { useFaqCategories, useFaqItems, useCreateFaqCategory, useUpdateFaqCategory, useDeleteFaqCategory, useCreateFaqItem, useUpdateFaqItem, useDeleteFaqItem } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { FaqAudience, FaqCategory, FaqItem } from "@/types/faq";
export default function FaqManager() {
const { toast } = useToast();
const { data: categories = [], isLoading: lc } = useFaqCategories();
const [selectedCatId, setSelectedCatId] = useState<number | null>(null);
const { data: items = [], isLoading: li } = useFaqItems(selectedCatId ? { category_id: selectedCatId } : undefined);
const createCategory = useCreateFaqCategory();
const updateCategory = useUpdateFaqCategory();
const deleteCategory = useDeleteFaqCategory();
const createItem = useCreateFaqItem();
const updateItem = useUpdateFaqItem();
const deleteItem = useDeleteFaqItem();
const [showAddCat, setShowAddCat] = useState(false);
const [catName, setCatName] = useState("");
const [catAudience, setCatAudience] = useState<FaqAudience>("both");
const [showAddItem, setShowAddItem] = useState(false);
const [editingItem, setEditingItem] = useState<FaqItem | null>(null);
const [itemQuestion, setItemQuestion] = useState("");
const [itemAnswer, setItemAnswer] = useState("");
const [itemAudience, setItemAudience] = useState<FaqAudience>("both");
const [itemVideoUrl, setItemVideoUrl] = useState("");
const [editingCat, setEditingCat] = useState<FaqCategory | null>(null);
const [editCatName, setEditCatName] = useState("");
const resetItemForm = () => { setItemQuestion(""); setItemAnswer(""); setItemAudience("both"); setItemVideoUrl(""); setEditingItem(null); };
const handleCreateCategory = () => {
createCategory.mutate(
{ name: catName, audience: catAudience },
{
onSuccess: () => { toast({ title: "Category Created" }); setShowAddCat(false); setCatName(""); },
onError: () => toast({ title: "Error", description: "Failed to create category", variant: "destructive" }),
},
);
};
const handleUpdateCategory = () => {
if (!editingCat) return;
updateCategory.mutate(
{ id: editingCat.id, data: { name: editCatName } },
{
onSuccess: () => { toast({ title: "Category Updated" }); setEditingCat(null); },
onError: () => toast({ title: "Error", description: "Failed to update category", variant: "destructive" }),
},
);
};
const handleDeleteCategory = (id: number) => {
deleteCategory.mutate(id, {
onSuccess: () => { toast({ title: "Category Deleted" }); if (selectedCatId === id) setSelectedCatId(null); },
onError: () => toast({ title: "Error", description: "Failed to delete category", variant: "destructive" }),
});
};
const handleSaveItem = () => {
if (editingItem) {
updateItem.mutate(
{ id: editingItem.id, data: { question: itemQuestion, answer: itemAnswer, audience: itemAudience, video_url: itemVideoUrl || undefined } },
{
onSuccess: () => { toast({ title: "Item Updated" }); setShowAddItem(false); resetItemForm(); },
onError: () => toast({ title: "Error", description: "Failed to update item", variant: "destructive" }),
},
);
} else if (selectedCatId) {
createItem.mutate(
{ question: itemQuestion, answer: itemAnswer, category_id: selectedCatId, audience: itemAudience, video_url: itemVideoUrl || undefined },
{
onSuccess: () => { toast({ title: "Item Created" }); setShowAddItem(false); resetItemForm(); },
onError: () => toast({ title: "Error", description: "Failed to create item", variant: "destructive" }),
},
);
}
};
const handleDeleteItem = (id: number) => {
deleteItem.mutate(id, {
onSuccess: () => toast({ title: "Item Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete item", variant: "destructive" }),
});
};
const openEditItem = (item: FaqItem) => {
setEditingItem(item);
setItemQuestion(item.question);
setItemAnswer(item.answer);
setItemAudience(item.audience);
setItemVideoUrl(item.video_url ?? "");
setShowAddItem(true);
};
if (lc) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">FAQ Manager</h1>
<p className="text-muted-foreground">Manage FAQ categories and items.</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Categories</h3>
<Dialog open={showAddCat} onOpenChange={setShowAddCat}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm"><Plus className="h-4 w-4" /></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Category</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Category name" value={catName} onChange={e => setCatName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Audience</Label>
<Select value={catAudience} onValueChange={v => setCatAudience(v as FaqAudience)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="entity">Entity</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleCreateCategory} disabled={createCategory.isPending || !catName}>
{createCategory.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{categories.map(cat => (
<div key={cat.id} className="group relative">
<button
onClick={() => setSelectedCatId(cat.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${selectedCatId === cat.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}
>
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{cat.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{cat.item_count} items</span>
<Badge variant="outline" className="text-xs">{cat.audience}</Badge>
</div>
</div>
</div>
</button>
<div className="absolute top-2 right-2 hidden group-hover:flex gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setEditingCat(cat); setEditCatName(cat.name); }}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteCategory(cat.id)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
</div>
))}
{categories.length === 0 && <p className="text-sm text-muted-foreground px-1">No categories yet.</p>}
</div>
<div className="md:col-span-2">
{!selectedCatId ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">Select a category to manage its FAQ items.</CardContent></Card>
) : li ? (
<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="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">FAQ Items</h3>
<Button size="sm" onClick={() => { resetItemForm(); setShowAddItem(true); }}>
<Plus className="mr-2 h-4 w-4" /> Add Item
</Button>
</div>
{items
.sort((a, b) => a.sequence - b.sequence)
.map(item => (
<Card key={item.id}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
<GripVertical className="h-5 w-5 text-muted-foreground mt-0.5 cursor-grab shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{item.question}</p>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{item.answer}</p>
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className="text-xs">{item.audience}</Badge>
{item.video_url && <Badge variant="secondary" className="text-xs">Video</Badge>}
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={() => openEditItem(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteItem(item.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{items.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No items in this category.</CardContent></Card>
)}
</div>
)}
</div>
</div>
<Dialog open={!!editingCat} onOpenChange={open => { if (!open) setEditingCat(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Category</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editCatName} onChange={e => setEditCatName(e.target.value)} />
</div>
<Button className="w-full" onClick={handleUpdateCategory} disabled={updateCategory.isPending || !editCatName}>
{updateCategory.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={showAddItem} onOpenChange={open => { setShowAddItem(open); if (!open) resetItemForm(); }}>
<DialogContent>
<DialogHeader><DialogTitle>{editingItem ? "Edit FAQ Item" : "New FAQ Item"}</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Question</Label>
<Input placeholder="FAQ question" value={itemQuestion} onChange={e => setItemQuestion(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Answer (Markdown)</Label>
<Textarea placeholder="Write the answer..." rows={4} value={itemAnswer} onChange={e => setItemAnswer(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Audience</Label>
<Select value={itemAudience} onValueChange={v => setItemAudience(v as FaqAudience)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="both">Both</SelectItem>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="entity">Entity</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Video URL (optional)</Label>
<Input placeholder="https://..." value={itemVideoUrl} onChange={e => setItemVideoUrl(e.target.value)} />
</div>
<Button className="w-full" onClick={handleSaveItem} disabled={(createItem.isPending || updateItem.isPending) || !itemQuestion || !itemAnswer}>
{(createItem.isPending || updateItem.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingItem ? "Save Changes" : "Create Item"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Pencil, Trash2, Loader2, Bell } from "lucide-react";
import { useNotificationRules, useCreateNotificationRule, useUpdateNotificationRule, useDeleteNotificationRule } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { NotificationChannel, ReminderFrequency, NotificationRule, NotificationRuleCreateRequest } from "@/types/notification";
export default function NotificationRules() {
const { toast } = useToast();
const { data: rules = [], isLoading } = useNotificationRules();
const createRule = useCreateNotificationRule();
const updateRule = useUpdateNotificationRule();
const deleteRule = useDeleteNotificationRule();
const [showForm, setShowForm] = useState(false);
const [editingRule, setEditingRule] = useState<NotificationRule | null>(null);
const [name, setName] = useState("");
const [eventType, setEventType] = useState("");
const [daysBefore, setDaysBefore] = useState(1);
const [frequency, setFrequency] = useState<ReminderFrequency>("once");
const [channel, setChannel] = useState<NotificationChannel>("in_app");
const resetForm = () => { setName(""); setEventType(""); setDaysBefore(1); setFrequency("once"); setChannel("in_app"); setEditingRule(null); };
const openEdit = (rule: NotificationRule) => {
setEditingRule(rule);
setName(rule.name);
setEventType(rule.event_type);
setDaysBefore(rule.days_before);
setFrequency(rule.frequency);
setChannel(rule.channel);
setShowForm(true);
};
const handleSave = () => {
const data = { name, event_type: eventType, days_before: daysBefore, frequency, channel };
if (editingRule) {
updateRule.mutate(
{ id: editingRule.id, data },
{
onSuccess: () => { toast({ title: "Rule Updated" }); setShowForm(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to update rule", variant: "destructive" }),
},
);
} else {
createRule.mutate(data, {
onSuccess: () => { toast({ title: "Rule Created" }); setShowForm(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to create rule", variant: "destructive" }),
});
}
};
const handleDelete = (id: number) => {
deleteRule.mutate(id, {
onSuccess: () => toast({ title: "Rule Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete rule", variant: "destructive" }),
});
};
const handleToggleActive = (rule: NotificationRule) => {
updateRule.mutate(
{ id: rule.id, data: { active: !rule.active } as Partial<NotificationRuleCreateRequest> & { active?: boolean } },
{
onSuccess: () => toast({ title: rule.active ? "Rule Deactivated" : "Rule Activated" }),
onError: () => toast({ title: "Error", description: "Failed to toggle rule", variant: "destructive" }),
},
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Notification Rules</h1>
<p className="text-muted-foreground">Configure automated notification rules and reminders.</p>
</div>
<Dialog open={showForm} onOpenChange={open => { setShowForm(open); if (!open) resetForm(); }}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Rule</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{editingRule ? "Edit Rule" : "Create Notification Rule"}</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Rule name" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Event Type</Label>
<Select value={eventType} onValueChange={setEventType}>
<SelectTrigger><SelectValue placeholder="Select event" /></SelectTrigger>
<SelectContent>
<SelectItem value="deadline">Deadline</SelectItem>
<SelectItem value="chapter_unlock">Chapter Unlock</SelectItem>
<SelectItem value="result_release">Result Release</SelectItem>
<SelectItem value="assignment">Assignment</SelectItem>
<SelectItem value="exam">Exam</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Days Before</Label>
<Input type="number" min={0} value={daysBefore} onChange={e => setDaysBefore(Number(e.target.value))} />
</div>
<div className="space-y-2">
<Label>Frequency</Label>
<Select value={frequency} onValueChange={v => setFrequency(v as ReminderFrequency)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="once">Once</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Channel</Label>
<Select value={channel} onValueChange={v => setChannel(v as NotificationChannel)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="in_app">In-App</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleSave} disabled={(createRule.isPending || updateRule.isPending) || !name || !eventType}>
{(createRule.isPending || updateRule.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingRule ? "Save Changes" : "Create Rule"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Event</TableHead>
<TableHead>Days Before</TableHead>
<TableHead>Frequency</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map(rule => (
<TableRow key={rule.id}>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell><Badge variant="outline">{rule.event_type}</Badge></TableCell>
<TableCell>{rule.days_before}</TableCell>
<TableCell>{rule.frequency}</TableCell>
<TableCell><Badge variant="secondary">{rule.channel}</Badge></TableCell>
<TableCell>
<Switch checked={rule.active} onCheckedChange={() => handleToggleActive(rule)} />
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(rule)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(rule.id)} disabled={deleteRule.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{rules.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
<Bell className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No notification rules configured.</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Megaphone, AlertTriangle, AlertCircle, Info } from "lucide-react";
import { useAnnouncements } from "@/hooks/queries";
import type { Announcement } from "@/types/communication";
const priorityConfig: Record<Announcement["priority"], { variant: "destructive" | "default" | "secondary"; icon: React.ReactNode }> = {
urgent: { variant: "destructive", icon: <AlertTriangle className="h-4 w-4" /> },
important: { variant: "default", icon: <AlertCircle className="h-4 w-4" /> },
normal: { variant: "secondary", icon: <Info className="h-4 w-4" /> },
};
export default function StudentAnnouncements() {
const { data: announcements = [], isLoading } = useAnnouncements();
const sorted = [...announcements]
.filter(a => a.is_published)
.sort((a, b) => new Date(b.published_at ?? b.expires_at ?? "").getTime() - new Date(a.published_at ?? a.expires_at ?? "").getTime());
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Announcements</h1>
<p className="text-muted-foreground">Stay up to date with the latest announcements from your courses.</p>
</div>
<div className="space-y-3">
{sorted.map(a => {
const cfg = priorityConfig[a.priority];
return (
<Card key={a.id} className={a.priority === "urgent" ? "border-destructive/30" : ""}>
<CardContent className="flex items-start gap-4 py-4">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ${a.priority === "urgent" ? "bg-destructive/10 text-destructive" : a.priority === "important" ? "bg-yellow-500/10 text-yellow-600" : "bg-primary/10 text-primary"}`}>
{cfg.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{a.title}</span>
<Badge variant={cfg.variant}>{a.priority}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">{a.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{a.author_name}</span>
{a.course_name && <><span>·</span><span>{a.course_name}</span></>}
{a.published_at && <><span>·</span><span>{new Date(a.published_at).toLocaleDateString()}</span></>}
</div>
</div>
</CardContent>
</Card>
);
})}
{sorted.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Megaphone className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No announcements right now.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2 } from "lucide-react";
import { useChapter, useChapterMaterials, useChapterProgress, useCompleteChapter, useMarkMaterialViewed } from "@/hooks/queries";
import { coursewareService } from "@/services/courseware.service";
import { useToast } from "@/hooks/use-toast";
import type { MaterialType } from "@/types/courseware";
const typeIcons: Record<MaterialType, React.ReactNode> = {
pdf: <FileText className="h-5 w-5" />,
document: <FileText className="h-5 w-5" />,
video: <Video className="h-5 w-5" />,
audio: <Music className="h-5 w-5" />,
image: <Image className="h-5 w-5" />,
link: <Link2 className="h-5 w-5" />,
article: <FileText className="h-5 w-5" />,
};
export default function StudentChapterView() {
const { courseId, chapterId } = useParams<{ courseId: string; chapterId: string }>();
const chId = Number(chapterId);
const { toast } = useToast();
const { data: chapter, isLoading: lc } = useChapter(chId);
const { data: materials = [], isLoading: lm } = useChapterMaterials(chId);
const { data: progress, isLoading: lp } = useChapterProgress(chId);
const completeChapter = useCompleteChapter();
const markViewed = useMarkMaterialViewed();
const completionPct = progress ? Math.round((progress.materials_completed / Math.max(progress.materials_total, 1)) * 100) : 0;
const handleDownload = async (materialId: number, name: string) => {
try {
const blob = await coursewareService.downloadMaterial(materialId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
} catch {
toast({ title: "Error", description: "Download failed", variant: "destructive" });
}
};
const handleMarkComplete = () => {
completeChapter.mutate(chId, {
onSuccess: () => toast({ title: "Chapter Completed!" }),
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
});
};
const handleOpenMaterial = (materialId: number) => {
markViewed.mutate({ id: materialId, chapterId: chId });
};
if (lc || lm || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
{chapter && (
<div>
<h1 className="text-2xl font-bold">{chapter.name}</h1>
{chapter.description && <p className="text-muted-foreground mt-1">{chapter.description}</p>}
</div>
)}
{progress && (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Progress</span>
<span className="text-sm text-muted-foreground">{progress.materials_completed} / {progress.materials_total} materials</span>
</div>
<Progress value={completionPct} className="h-2" />
<div className="flex justify-between items-center mt-3">
<span className="text-sm text-muted-foreground">{completionPct}% complete</span>
{progress.status !== "completed" && (
<Button size="sm" onClick={handleMarkComplete} disabled={completeChapter.isPending}>
{completeChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckCircle2 className="mr-2 h-4 w-4" /> Mark Complete
</Button>
)}
{progress.status === "completed" && (
<Badge variant="default"><CheckCircle2 className="mr-1 h-3 w-3" /> Completed</Badge>
)}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{materials
.sort((a, b) => a.sequence - b.sequence)
.map(m => (
<Card key={m.id} className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => handleOpenMaterial(m.id)}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 text-primary">
{typeIcons[m.type]}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{m.name}</p>
<Badge variant="outline" className="mt-1 text-xs">{m.type}</Badge>
</div>
{m.allow_download && (
<Button variant="ghost" size="icon" className="shrink-0" onClick={(e) => { e.stopPropagation(); handleDownload(m.id, m.name); }}>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
))}
{materials.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No materials available in this chapter.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MessageSquare, Pin, CheckCircle2, Loader2, ArrowLeft, Send, Users } from "lucide-react";
import { useDiscussionBoards, usePosts, useCreatePost } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { DiscussionPost } from "@/types/communication";
function PostItem({ post, boardId }: { post: DiscussionPost; boardId: number }) {
const [showReply, setShowReply] = useState(false);
const [replyContent, setReplyContent] = useState("");
const createPost = useCreatePost();
const { toast } = useToast();
const handleReply = () => {
createPost.mutate(
{ boardId, data: { board_id: boardId, parent_id: post.id, content: replyContent } },
{
onSuccess: () => { setShowReply(false); setReplyContent(""); },
onError: () => toast({ title: "Error", description: "Failed to reply", variant: "destructive" }),
},
);
};
return (
<div className="space-y-2">
<div className={`p-4 rounded-lg border ${post.is_pinned ? "border-primary/30 bg-primary/5" : ""}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{post.title && <p className="font-medium">{post.title}</p>}
<p className="text-sm mt-1">{post.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{post.author_name}</span>
<span>·</span>
<Badge variant="outline" className="text-xs">{post.author_role}</Badge>
<span>·</span>
<span>{new Date(post.created_at).toLocaleDateString()}</span>
{post.is_pinned && <Badge variant="secondary" className="text-xs"><Pin className="h-3 w-3 mr-1" />Pinned</Badge>}
{post.is_resolved && <Badge variant="default" className="text-xs"><CheckCircle2 className="h-3 w-3 mr-1" />Resolved</Badge>}
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowReply(!showReply)}>Reply</Button>
</div>
{showReply && (
<div className="flex gap-2 mt-3 pt-3 border-t">
<Input placeholder="Write a reply..." value={replyContent} onChange={e => setReplyContent(e.target.value)} className="flex-1" />
<Button size="sm" onClick={handleReply} disabled={createPost.isPending || !replyContent}>
{createPost.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
)}
</div>
{post.replies && post.replies.length > 0 && (
<div className="ml-6 space-y-2">
{post.replies.map(reply => (
<PostItem key={reply.id} post={reply} boardId={boardId} />
))}
</div>
)}
</div>
);
}
export default function StudentDiscussionBoard() {
const { toast } = useToast();
const { data: boards = [], isLoading } = useDiscussionBoards();
const [selectedBoardId, setSelectedBoardId] = useState<number | null>(null);
const { data: postsData, isLoading: loadingPosts } = usePosts(selectedBoardId ?? 0);
const createPost = useCreatePost();
const [showNewPost, setShowNewPost] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
const posts = postsData?.results ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;
createPost.mutate(
{ boardId: selectedBoardId, data: { board_id: selectedBoardId, title: newTitle, content: newContent } },
{
onSuccess: () => { toast({ title: "Post Created" }); setShowNewPost(false); setNewTitle(""); setNewContent(""); },
onError: () => toast({ title: "Error", description: "Failed to create post", variant: "destructive" }),
},
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Discussion Boards</h1>
<p className="text-muted-foreground">Participate in course discussions with your classmates.</p>
</div>
{!selectedBoardId ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{boards.map(board => (
<Card key={board.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedBoardId(board.id)}>
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{board.name}</CardTitle>
<p className="text-xs text-muted-foreground">{board.course_name}</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{board.post_count} posts</span>
<Users className="h-4 w-4" />
</div>
</CardContent>
</Card>
))}
{boards.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No discussion boards available for your courses.</p>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
</Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
<DialogContent>
<DialogHeader><DialogTitle>Create Post</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Title</Label>
<Input placeholder="Post title" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your post..." rows={4} value={newContent} onChange={e => setNewContent(e.target.value)} />
</div>
<Button className="w-full" onClick={handleCreatePost} disabled={createPost.isPending || !newContent}>
{createPost.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Post
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{loadingPosts ? (
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="space-y-3">
{posts.map(post => (
<PostItem key={post.id} post={post} boardId={selectedBoardId} />
))}
{posts.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No posts yet. Start the conversation!</CardContent></Card>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { BookOpen, TrendingUp, Activity, CheckCircle2, FileText, ClipboardList } from "lucide-react";
import { useProficiencySummary } from "@/hooks/queries";
import { analyticsService } from "@/services/analytics.service";
import { adaptiveService } from "@/services/adaptive.service";
import { useQuery } from "@tanstack/react-query";
export default function StudentJourney() {
const { data: summaries = [], isLoading: ls } = useProficiencySummary();
const { data: analyticsData, isLoading: la } = useQuery({
queryKey: ["analytics", "student", "journey"],
queryFn: () => analyticsService.getStudentAnalytics(),
});
const { data: planData, isLoading: lp } = useQuery({
queryKey: ["adaptive", "plan", "all"],
queryFn: () => adaptiveService.getLearningPlan({}),
});
const analytics = analyticsData as { recent_activity?: { action: string; description: string; date: string }[] } | undefined;
const recentActivity = analytics?.recent_activity?.slice(0, 10) ?? [];
if (ls || la || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const activityIcons: Record<string, React.ReactNode> = {
exam: <ClipboardList className="h-4 w-4" />,
assignment: <FileText className="h-4 w-4" />,
chapter: <BookOpen className="h-4 w-4" />,
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">My Learning Journey</h1>
<p className="text-muted-foreground">Track your progress and proficiency across all subjects.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summaries.map(s => {
const mastery = Math.round(s.overall_mastery ?? 0);
return (
<Card key={s.subject_id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<BookOpen className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{s.subject_name ?? `Subject ${s.subject_id}`}</CardTitle>
<CardDescription>{s.topics_mastered ?? 0} / {s.topics_total ?? 0} topics mastered</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Overall Mastery</span>
<span className="font-medium">{mastery}%</span>
</div>
<Progress value={mastery} className="h-2" />
</div>
<Badge variant={mastery >= 80 ? "default" : mastery >= 50 ? "secondary" : "outline"}>
{mastery >= 80 ? "Proficient" : mastery >= 50 ? "Developing" : "Beginner"}
</Badge>
</CardContent>
</Card>
);
})}
{summaries.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<TrendingUp className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>Take a diagnostic assessment to start tracking your journey.</p>
</div>
)}
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" />
<CardTitle>Recent Activity</CardTitle>
</div>
<CardDescription>Your last 10 actions across all courses.</CardDescription>
</CardHeader>
<CardContent>
{recentActivity.length > 0 ? (
<div className="space-y-3">
{recentActivity.map((act, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg border">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
{activityIcons[act.action] ?? <CheckCircle2 className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{act.description}</p>
<p className="text-xs text-muted-foreground">{new Date(act.date).toLocaleDateString()}</p>
</div>
<Badge variant="outline" className="text-xs shrink-0">{act.action}</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">No recent activity to show.</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Mail, Send, Loader2, Inbox, PenSquare } from "lucide-react";
import { useMessages, useSentMessages, useUnreadMessageCount, useSendMessage } from "@/hooks/queries";
import { communicationService } from "@/services/communication.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import type { Message } from "@/types/communication";
export default function StudentMessages() {
const { toast } = useToast();
const qc = useQueryClient();
const { data: inboxData, isLoading: li } = useMessages();
const { data: sentData, isLoading: ls } = useSentMessages();
const { data: unreadData } = useUnreadMessageCount();
const sendMessage = useSendMessage();
const inbox = inboxData?.results ?? [];
const sent = sentData?.results ?? [];
const unreadCount = unreadData?.count ?? 0;
const [showCompose, setShowCompose] = useState(false);
const [recipientId, setRecipientId] = useState("");
const [subject, setSubject] = useState("");
const [content, setContent] = useState("");
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [showDetail, setShowDetail] = useState(false);
const markRead = useMutation({
mutationFn: (id: number) => communicationService.getMessage(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["messages"] });
qc.invalidateQueries({ queryKey: ["messages", "unread-count"] });
},
});
const handleOpenMessage = (msg: Message) => {
setSelectedMessage(msg);
setShowDetail(true);
if (!msg.is_read) markRead.mutate(msg.id);
};
const handleSend = () => {
sendMessage.mutate(
{ recipient_id: Number(recipientId), subject, content },
{
onSuccess: () => { toast({ title: "Message Sent" }); setShowCompose(false); setRecipientId(""); setSubject(""); setContent(""); },
onError: () => toast({ title: "Error", description: "Failed to send message", variant: "destructive" }),
},
);
};
if (li || ls) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Messages</h1>
<p className="text-muted-foreground">Communicate with your classmates and teachers.</p>
</div>
<Button onClick={() => setShowCompose(true)}>
<PenSquare className="mr-2 h-4 w-4" /> New Message
</Button>
</div>
<Tabs defaultValue="inbox">
<TabsList>
<TabsTrigger value="inbox" className="gap-2">
<Inbox className="h-4 w-4" /> Inbox
{unreadCount > 0 && <Badge variant="destructive" className="ml-1 text-xs px-1.5">{unreadCount}</Badge>}
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
<Send className="h-4 w-4" /> Sent
</TabsTrigger>
</TabsList>
<TabsContent value="inbox" className="space-y-2 mt-4">
{inbox.map(msg => (
<Card key={msg.id} className={`cursor-pointer hover:shadow-sm transition-shadow ${!msg.is_read ? "border-primary/30 bg-primary/5" : ""}`} onClick={() => handleOpenMessage(msg)}>
<CardContent className="flex items-center gap-4 py-3">
<Mail className={`h-5 w-5 shrink-0 ${!msg.is_read ? "text-primary" : "text-muted-foreground"}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm truncate ${!msg.is_read ? "font-semibold" : ""}`}>{msg.subject}</span>
{!msg.is_read && <Badge variant="default" className="text-xs">New</Badge>}
</div>
<p className="text-xs text-muted-foreground mt-0.5">From: {msg.sender_name}</p>
</div>
<span className="text-xs text-muted-foreground shrink-0">{new Date(msg.created_at).toLocaleDateString()}</span>
</CardContent>
</Card>
))}
{inbox.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Your inbox is empty.</CardContent></Card>
)}
</TabsContent>
<TabsContent value="sent" className="space-y-2 mt-4">
{sent.map(msg => (
<Card key={msg.id} className="cursor-pointer hover:shadow-sm transition-shadow" onClick={() => handleOpenMessage(msg)}>
<CardContent className="flex items-center gap-4 py-3">
<Send className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<span className="text-sm truncate">{msg.subject}</span>
<p className="text-xs text-muted-foreground mt-0.5">To: {msg.recipient_name}</p>
</div>
<span className="text-xs text-muted-foreground shrink-0">{new Date(msg.created_at).toLocaleDateString()}</span>
</CardContent>
</Card>
))}
{sent.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No sent messages.</CardContent></Card>
)}
</TabsContent>
</Tabs>
<Sheet open={showDetail} onOpenChange={setShowDetail}>
<SheetContent className="sm:max-w-lg">
{selectedMessage && (
<>
<SheetHeader>
<SheetTitle>{selectedMessage.subject}</SheetTitle>
</SheetHeader>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>From: {selectedMessage.sender_name}</span>
<span>·</span>
<span>To: {selectedMessage.recipient_name}</span>
</div>
<span className="text-xs text-muted-foreground">{new Date(selectedMessage.created_at).toLocaleString()}</span>
<div className="p-4 rounded-lg border bg-muted/30 text-sm whitespace-pre-wrap">{selectedMessage.content}</div>
</div>
</>
)}
</SheetContent>
</Sheet>
<Dialog open={showCompose} onOpenChange={setShowCompose}>
<DialogContent>
<DialogHeader><DialogTitle>New Message</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Recipient ID</Label>
<Input type="number" placeholder="Enter recipient user ID" value={recipientId} onChange={e => setRecipientId(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Subject</Label>
<Input placeholder="Message subject" value={subject} onChange={e => setSubject(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your message..." rows={4} value={content} onChange={e => setContent(e.target.value)} />
</div>
<Button className="w-full" onClick={handleSend} disabled={sendMessage.isPending || !recipientId || !subject || !content}>
{sendMessage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Send className="mr-2 h-4 w-4" /> Send
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, Sparkles, ChevronRight, CheckCircle2, BookOpen } from "lucide-react";
import { useGenerateOutline, useGenerateChapterContent, usePublishWorkbench } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
type Complexity = "beginner" | "intermediate" | "advanced";
export default function AiWorkbench() {
const { courseId } = useParams<{ courseId: string }>();
const cid = Number(courseId);
const { toast } = useToast();
const [step, setStep] = useState(1);
const [topic, setTopic] = useState("");
const [objectives, setObjectives] = useState("");
const [complexity, setComplexity] = useState<Complexity>("intermediate");
const [outline, setOutline] = useState<WorkbenchGeneratedOutline | null>(null);
const [generatedContent, setGeneratedContent] = useState<WorkbenchGeneratedChapter | null>(null);
const generateOutline = useGenerateOutline();
const generateContent = useGenerateChapterContent();
const publishWorkbench = usePublishWorkbench();
const handleGenerateOutline = () => {
generateOutline.mutate(
{ course_id: cid, topic, objectives, complexity },
{
onSuccess: (data) => { setOutline(data); setStep(2); },
onError: () => toast({ title: "Error", description: "Failed to generate outline", variant: "destructive" }),
},
);
};
const handleGenerateContent = () => {
generateContent.mutate(
{ course_id: cid, topic, objectives, complexity },
{
onSuccess: (data) => { setGeneratedContent(data); setStep(3); },
onError: () => toast({ title: "Error", description: "Failed to generate content", variant: "destructive" }),
},
);
};
const handlePublish = () => {
publishWorkbench.mutate(
{ course_id: cid, topic, objectives, complexity, outline, content: generatedContent },
{
onSuccess: () => { toast({ title: "Published Successfully" }); setStep(4); },
onError: () => toast({ title: "Error", description: "Failed to publish", variant: "destructive" }),
},
);
};
const steps = [
{ num: 1, label: "Requirements" },
{ num: 2, label: "Review Outline" },
{ num: 3, label: "Generated Content" },
{ num: 4, label: "Published" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">AI Workbench</h1>
<p className="text-muted-foreground">Generate course content using AI assistance.</p>
</div>
<div className="flex items-center gap-2">
{steps.map((s, i) => (
<div key={s.num} className="flex items-center">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm ${step >= s.num ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground"}`}>
{step > s.num ? <CheckCircle2 className="h-4 w-4" /> : <span className="h-5 w-5 rounded-full border flex items-center justify-center text-xs">{s.num}</span>}
{s.label}
</div>
{i < steps.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground mx-1" />}
</div>
))}
</div>
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Input Requirements</CardTitle>
<CardDescription>Describe what content you want the AI to generate.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Topic</Label>
<Input placeholder="e.g. Introduction to Calculus" value={topic} onChange={e => setTopic(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Learning Objectives</Label>
<Textarea placeholder="List the key objectives, one per line..." rows={4} value={objectives} onChange={e => setObjectives(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Complexity Level</Label>
<Select value={complexity} onValueChange={v => setComplexity(v as Complexity)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="beginner">Beginner</SelectItem>
<SelectItem value="intermediate">Intermediate</SelectItem>
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerateOutline} disabled={generateOutline.isPending || !topic || !objectives}>
{generateOutline.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Sparkles className="mr-2 h-4 w-4" /> Generate Outline
</Button>
</CardContent>
</Card>
)}
{step === 2 && outline && (
<Card>
<CardHeader>
<CardTitle>Review Generated Outline</CardTitle>
<CardDescription>Review the AI-generated chapter outline before generating detailed content.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{outline.chapters.map((ch, i) => (
<div key={i} className="p-4 rounded-lg border space-y-2">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary" />
<span className="font-medium">{ch.name}</span>
<Badge variant="outline">{ch.estimated_duration_hours}h</Badge>
</div>
<p className="text-sm text-muted-foreground">{ch.description}</p>
<div className="flex flex-wrap gap-1">
{ch.learning_outcomes.map((lo, j) => (
<Badge key={j} variant="secondary" className="text-xs">{lo}</Badge>
))}
</div>
</div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
<Button onClick={handleGenerateContent} disabled={generateContent.isPending}>
{generateContent.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Content
</Button>
</div>
</CardContent>
</Card>
)}
{step === 3 && generatedContent && (
<Card>
<CardHeader>
<CardTitle>Generated Content</CardTitle>
<CardDescription>Review the detailed content before publishing.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: generatedContent.content }} />
{generatedContent.exercises.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
{generatedContent.exercises.map((ex, i) => (
<div key={i} className="p-3 rounded border text-sm">
<p className="font-medium">{ex.question}</p>
<p className="text-muted-foreground mt-1">{ex.answer}</p>
<Badge variant="outline" className="mt-1 text-xs">{ex.type}</Badge>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(2)}>Back</Button>
<Button onClick={handlePublish} disabled={publishWorkbench.isPending}>
{publishWorkbench.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Publish
</Button>
</div>
</CardContent>
</Card>
)}
{step === 4 && (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-3" />
<h2 className="text-xl font-semibold">Content Published!</h2>
<p className="text-muted-foreground mt-1">The AI-generated content has been added to your course.</p>
<Button variant="outline" className="mt-4" onClick={() => { setStep(1); setOutline(null); setGeneratedContent(null); setTopic(""); setObjectives(""); }}>
Generate More
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useRef } from "react";
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Trash2, Loader2, FileText, Video, Image, Link2, Upload, Music } from "lucide-react";
import { useChapter, useChapterMaterials, useUploadMaterial, useDeleteMaterial } from "@/hooks/queries";
import { coursewareService } from "@/services/courseware.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import type { MaterialType } from "@/types/courseware";
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" />,
};
export default function ChapterDetail() {
const { courseId, chapterId } = useParams<{ courseId: string; chapterId: string }>();
const chId = Number(chapterId);
const { toast } = useToast();
const qc = useQueryClient();
const { data: chapter, isLoading: loadingChapter } = useChapter(chId);
const { data: materials = [], isLoading: loadingMaterials } = useChapterMaterials(chId);
const uploadMaterial = useUploadMaterial();
const deleteMaterial = useDeleteMaterial();
const fileRef = useRef<HTMLInputElement>(null);
const [showUpload, setShowUpload] = useState(false);
const [matName, setMatName] = useState("");
const [matType, setMatType] = useState<MaterialType>("pdf");
const [matUrl, setMatUrl] = useState("");
const [matFile, setMatFile] = useState<File | null>(null);
const [allowDownload, setAllowDownload] = useState(true);
const toggleDownload = useMutation({
mutationFn: ({ id, allow }: { id: number; allow: boolean }) =>
coursewareService.updateMaterial(id, { allow_download: allow }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["chapter-materials", chId] }),
onError: () => toast({ title: "Error", description: "Failed to update material", variant: "destructive" }),
});
const resetForm = () => { setMatName(""); setMatType("pdf"); setMatUrl(""); setMatFile(null); setAllowDownload(true); };
const handleUpload = () => {
const formData = new FormData();
formData.append("name", matName);
formData.append("type", matType);
formData.append("allow_download", String(allowDownload));
if (matFile) formData.append("file", matFile);
if (matUrl) formData.append("url", matUrl);
uploadMaterial.mutate(
{ chapterId: chId, formData },
{
onSuccess: () => { toast({ title: "Material Uploaded" }); setShowUpload(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to upload material", variant: "destructive" }),
},
);
};
const handleDelete = (id: number) => {
deleteMaterial.mutate(
{ id, chapterId: chId },
{
onSuccess: () => toast({ title: "Material Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete material", variant: "destructive" }),
},
);
};
if (loadingChapter || loadingMaterials) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
{chapter && (
<Card>
<CardHeader>
<CardTitle>{chapter.name}</CardTitle>
<CardDescription>{chapter.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4 text-sm">
{chapter.start_date && <span className="text-muted-foreground">Starts: {new Date(chapter.start_date).toLocaleDateString()}</span>}
{chapter.end_date && <span className="text-muted-foreground">Ends: {new Date(chapter.end_date).toLocaleDateString()}</span>}
<Badge variant={chapter.is_unlocked ? "default" : "secondary"}>
{chapter.is_unlocked ? "Unlocked" : "Locked"}
</Badge>
<Badge variant="outline">{chapter.unlock_mode}</Badge>
</div>
</CardContent>
</Card>
)}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Materials</h2>
<Dialog open={showUpload} onOpenChange={setShowUpload}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Upload Material</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Upload Material</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Material name" value={matName} onChange={e => setMatName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={matType} onValueChange={v => setMatType(v as MaterialType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="article">Article</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>File</Label>
<Input ref={fileRef} type="file" onChange={e => setMatFile(e.target.files?.[0] ?? null)} />
</div>
<div className="space-y-2">
<Label>Or URL</Label>
<Input placeholder="https://..." value={matUrl} onChange={e => setMatUrl(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<Checkbox id="allow-dl" checked={allowDownload} onCheckedChange={v => setAllowDownload(!!v)} />
<Label htmlFor="allow-dl">Allow Download</Label>
</div>
<Button className="w-full" onClick={handleUpload} disabled={uploadMaterial.isPending || !matName}>
{uploadMaterial.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Upload className="mr-2 h-4 w-4" /> Upload
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Download</TableHead>
<TableHead className="w-16">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials
.sort((a, b) => a.sequence - b.sequence)
.map(m => (
<TableRow key={m.id}>
<TableCell className="text-muted-foreground">{m.sequence}</TableCell>
<TableCell className="flex items-center gap-2">
{typeIcons[m.type]}
<span>{m.name}</span>
</TableCell>
<TableCell><Badge variant="outline">{m.type}</Badge></TableCell>
<TableCell>
<Checkbox
checked={m.allow_download}
onCheckedChange={v => toggleDownload.mutate({ id: m.id, allow: !!v })}
/>
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleDelete(m.id)} disabled={deleteMaterial.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{materials.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No materials uploaded yet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, GripVertical, Lock, Unlock, Trash2, Loader2, BookOpen } from "lucide-react";
import { useChapters, useCreateChapter, useDeleteChapter, useUnlockChapter, useLockChapter } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { ChapterUnlockMode } from "@/types/courseware";
export default function CourseChapters() {
const { courseId } = useParams<{ courseId: string }>();
const cid = Number(courseId);
const { toast } = useToast();
const { data: chapters = [], isLoading } = useChapters(cid);
const createChapter = useCreateChapter();
const deleteChapter = useDeleteChapter();
const unlockChapter = useUnlockChapter();
const lockChapter = useLockChapter();
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [unlockMode, setUnlockMode] = useState<ChapterUnlockMode>("manual");
const resetForm = () => { setName(""); setDescription(""); setStartDate(""); setUnlockMode("manual"); };
const handleCreate = () => {
createChapter.mutate(
{ courseId: cid, data: { name, course_id: cid, description, start_date: startDate || undefined, unlock_mode: unlockMode } },
{
onSuccess: () => { toast({ title: "Chapter Created" }); setShowAdd(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to create chapter", variant: "destructive" }),
},
);
};
const handleDelete = (id: number) => {
deleteChapter.mutate(
{ id, courseId: cid },
{
onSuccess: () => toast({ title: "Chapter Deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete chapter", variant: "destructive" }),
},
);
};
const handleToggleLock = (id: number, isUnlocked: boolean) => {
const mutation = isUnlocked ? lockChapter : unlockChapter;
mutation.mutate(
{ id, courseId: cid },
{
onSuccess: () => toast({ title: isUnlocked ? "Chapter Locked" : "Chapter Unlocked" }),
onError: () => toast({ title: "Error", description: "Failed to update lock status", variant: "destructive" }),
},
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Course Chapters</h1>
<p className="text-muted-foreground">Manage chapters and their sequence for this course.</p>
</div>
<Dialog open={showAdd} onOpenChange={setShowAdd}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Add Chapter</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Add New Chapter</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="Chapter name" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea placeholder="Brief description" value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Unlock Mode</Label>
<Select value={unlockMode} onValueChange={v => setUnlockMode(v as ChapterUnlockMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="auto_date">Auto (Date)</SelectItem>
<SelectItem value="prerequisite">Prerequisite</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createChapter.isPending || !name}>
{createChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Chapter
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-3">
{chapters
.sort((a, b) => a.sequence - b.sequence)
.map(ch => (
<Card key={ch.id} className="hover:shadow-sm transition-shadow">
<CardContent className="flex items-center gap-4 py-4">
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 cursor-grab" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-primary shrink-0" />
<span className="font-medium truncate">{ch.name}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{ch.start_date && <span>{new Date(ch.start_date).toLocaleDateString()}</span>}
<span>{ch.material_count} material{ch.material_count !== 1 ? "s" : ""}</span>
</div>
</div>
<Badge variant={ch.is_unlocked ? "default" : "secondary"}>
{ch.is_unlocked ? "Unlocked" : "Locked"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleLock(ch.id, ch.is_unlocked)}
disabled={unlockChapter.isPending || lockChapter.isPending}
>
{ch.is_unlocked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(ch.id)}
disabled={deleteChapter.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</CardContent>
</Card>
))}
{chapters.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No chapters yet. Add your first chapter to get started.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Megaphone, Loader2, Send } from "lucide-react";
import { useAnnouncements, useCreateAnnouncement, usePublishAnnouncement, useCourses } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { Announcement } from "@/types/communication";
const priorityColors: Record<Announcement["priority"], string> = {
urgent: "destructive",
important: "default",
normal: "secondary",
};
export default function TeacherAnnouncements() {
const { toast } = useToast();
const { data: announcements = [], isLoading } = useAnnouncements();
const { data: coursesData } = useCourses();
const courses = coursesData?.results ?? [];
const createAnnouncement = useCreateAnnouncement();
const publishAnnouncement = usePublishAnnouncement();
const [showCreate, setShowCreate] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [courseId, setCourseId] = useState<string>("");
const [priority, setPriority] = useState<Announcement["priority"]>("normal");
const [sendEmail, setSendEmail] = useState(false);
const resetForm = () => { setTitle(""); setContent(""); setCourseId(""); setPriority("normal"); setSendEmail(false); };
const handleCreate = () => {
createAnnouncement.mutate(
{ title, content, course_id: courseId ? Number(courseId) : undefined, priority, send_email: sendEmail },
{
onSuccess: () => { toast({ title: "Announcement Created" }); setShowCreate(false); resetForm(); },
onError: () => toast({ title: "Error", description: "Failed to create announcement", variant: "destructive" }),
},
);
};
const handlePublish = (id: number) => {
publishAnnouncement.mutate(id, {
onSuccess: () => toast({ title: "Announcement Published" }),
onError: () => toast({ title: "Error", description: "Failed to publish announcement", variant: "destructive" }),
});
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Announcements</h1>
<p className="text-muted-foreground">Create and manage announcements for your courses.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Announcement</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>New Announcement</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Title</Label>
<Input placeholder="Announcement title" value={title} onChange={e => setTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your announcement..." rows={4} value={content} onChange={e => setContent(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={courseId} onValueChange={setCourseId}>
<SelectTrigger><SelectValue placeholder="All courses" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Courses</SelectItem>
{courses.map(c => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={priority} onValueChange={v => setPriority(v as Announcement["priority"])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="important">Important</SelectItem>
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="send-email" checked={sendEmail} onCheckedChange={v => setSendEmail(!!v)} />
<Label htmlFor="send-email">Send email notification</Label>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createAnnouncement.isPending || !title || !content}>
{createAnnouncement.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="space-y-3">
{announcements.map(a => (
<Card key={a.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Megaphone className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{a.title}</span>
<Badge variant={priorityColors[a.priority] as "default" | "secondary" | "destructive"}>{a.priority}</Badge>
{a.is_published && <Badge variant="outline">Published</Badge>}
</div>
<p className="text-sm text-muted-foreground line-clamp-1 mt-0.5">{a.content}</p>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
{a.course_name && <span>{a.course_name}</span>}
{a.published_at && <span>· {new Date(a.published_at).toLocaleDateString()}</span>}
</div>
</div>
{!a.is_published && (
<Button size="sm" onClick={() => handlePublish(a.id)} disabled={publishAnnouncement.isPending}>
{publishAnnouncement.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Publish
</Button>
)}
</CardContent>
</Card>
))}
{announcements.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Megaphone className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No announcements yet.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MessageSquare, Pin, CheckCircle2, Loader2, ArrowLeft, Send } from "lucide-react";
import { useDiscussionBoards, usePosts, useCreatePost, usePinPost, useResolvePost } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { DiscussionPost } from "@/types/communication";
function PostThread({ post, boardId, onPin, onResolve }: { post: DiscussionPost; boardId: number; onPin: (id: number, pinned: boolean) => void; onResolve: (id: number) => void }) {
const [showReply, setShowReply] = useState(false);
const [replyContent, setReplyContent] = useState("");
const createPost = useCreatePost();
const { toast } = useToast();
const handleReply = () => {
createPost.mutate(
{ boardId, data: { board_id: boardId, parent_id: post.id, content: replyContent } },
{
onSuccess: () => { setShowReply(false); setReplyContent(""); },
onError: () => toast({ title: "Error", description: "Failed to post reply", variant: "destructive" }),
},
);
};
return (
<div className="space-y-2">
<div className={`p-4 rounded-lg border ${post.is_pinned ? "border-primary/30 bg-primary/5" : ""}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{post.title && <p className="font-medium">{post.title}</p>}
<p className="text-sm mt-1">{post.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{post.author_name}</span>
<span>·</span>
<Badge variant="outline" className="text-xs">{post.author_role}</Badge>
<span>·</span>
<span>{new Date(post.created_at).toLocaleDateString()}</span>
{post.is_pinned && <Badge variant="secondary" className="text-xs"><Pin className="h-3 w-3 mr-1" />Pinned</Badge>}
{post.is_resolved && <Badge variant="default" className="text-xs"><CheckCircle2 className="h-3 w-3 mr-1" />Resolved</Badge>}
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button variant="ghost" size="sm" onClick={() => onPin(post.id, !post.is_pinned)}>
<Pin className="h-3 w-3" />
</Button>
{!post.is_resolved && (
<Button variant="ghost" size="sm" onClick={() => onResolve(post.id)}>
<CheckCircle2 className="h-3 w-3" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => setShowReply(!showReply)}>Reply</Button>
</div>
</div>
{showReply && (
<div className="flex gap-2 mt-3 pt-3 border-t">
<Input placeholder="Write a reply..." value={replyContent} onChange={e => setReplyContent(e.target.value)} className="flex-1" />
<Button size="sm" onClick={handleReply} disabled={createPost.isPending || !replyContent}>
{createPost.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
)}
</div>
{post.replies && post.replies.length > 0 && (
<div className="ml-6 space-y-2">
{post.replies.map(reply => (
<PostThread key={reply.id} post={reply} boardId={boardId} onPin={onPin} onResolve={onResolve} />
))}
</div>
)}
</div>
);
}
export default function TeacherDiscussionBoard() {
const { toast } = useToast();
const { data: boards = [], isLoading } = useDiscussionBoards();
const [selectedBoardId, setSelectedBoardId] = useState<number | null>(null);
const { data: postsData, isLoading: loadingPosts } = usePosts(selectedBoardId ?? 0);
const createPost = useCreatePost();
const pinPost = usePinPost();
const resolvePost = useResolvePost();
const [showNewPost, setShowNewPost] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
const posts = postsData?.results ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;
createPost.mutate(
{ boardId: selectedBoardId, data: { board_id: selectedBoardId, title: newTitle, content: newContent } },
{
onSuccess: () => { toast({ title: "Post Created" }); setShowNewPost(false); setNewTitle(""); setNewContent(""); },
onError: () => toast({ title: "Error", description: "Failed to create post", variant: "destructive" }),
},
);
};
const handlePin = (id: number, pinned: boolean) => {
pinPost.mutate(
{ id, body: { pinned } },
{ onError: () => toast({ title: "Error", description: "Failed to pin post", variant: "destructive" }) },
);
};
const handleResolve = (id: number) => {
resolvePost.mutate(id, {
onError: () => toast({ title: "Error", description: "Failed to resolve post", variant: "destructive" }),
});
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Discussion Boards</h1>
<p className="text-muted-foreground">Manage and participate in course discussions.</p>
</div>
{!selectedBoardId ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{boards.map(board => (
<Card key={board.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedBoardId(board.id)}>
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{board.name}</CardTitle>
<p className="text-xs text-muted-foreground">{board.course_name}</p>
</div>
</div>
</CardHeader>
<CardContent>
<span className="text-sm text-muted-foreground">{board.post_count} posts</span>
</CardContent>
</Card>
))}
{boards.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No discussion boards available.</p>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
</Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
<DialogContent>
<DialogHeader><DialogTitle>Create Post</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Title</Label>
<Input placeholder="Post title" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your post..." rows={4} value={newContent} onChange={e => setNewContent(e.target.value)} />
</div>
<Button className="w-full" onClick={handleCreatePost} disabled={createPost.isPending || !newContent}>
{createPost.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Post
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{loadingPosts ? (
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="space-y-3">
{posts.map(post => (
<PostThread key={post.id} post={post} boardId={selectedBoardId} onPin={handlePin} onResolve={handleResolve} />
))}
{posts.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No posts yet. Start the conversation!</CardContent></Card>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { api } from "@/lib/api-client";
import type {
DiscussionBoard,
DiscussionPost,
Announcement,
Message,
PostCreateRequest,
AnnouncementCreateRequest,
MessageCreateRequest,
} from "@/types/communication";
import type { ApiSuccessResponse, PaginatedResponse, PaginationParams } from "@/types";
export const communicationService = {
async listDiscussionBoards(params?: { course_id?: number; batch_id?: number }): Promise<DiscussionBoard[]> {
return api.get<DiscussionBoard[]>("/discussion-boards", params as Record<string, string | number | boolean | undefined>);
},
async createDiscussionBoard(data: Partial<DiscussionBoard> & Pick<DiscussionBoard, "name" | "course_id">): Promise<DiscussionBoard> {
return api.post<DiscussionBoard>("/discussion-boards", data);
},
async updateDiscussionBoard(id: number, data: Partial<DiscussionBoard>): Promise<DiscussionBoard> {
return api.patch<DiscussionBoard>(`/discussion-boards/${id}`, data);
},
async listPosts(boardId: number, params?: PaginationParams): Promise<PaginatedResponse<DiscussionPost>> {
return api.get<PaginatedResponse<DiscussionPost>>(
`/discussion-boards/${boardId}/posts`,
params as Record<string, string | number | boolean | undefined>,
);
},
async createPost(boardId: number, data: PostCreateRequest): Promise<DiscussionPost> {
return api.post<DiscussionPost>(`/discussion-boards/${boardId}/posts`, data);
},
async updatePost(id: number, data: Partial<PostCreateRequest>): Promise<DiscussionPost> {
return api.patch<DiscussionPost>(`/posts/${id}`, data);
},
async deletePost(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/posts/${id}`);
},
async pinPost(id: number, body?: { pinned?: boolean }): Promise<DiscussionPost> {
return api.post<DiscussionPost>(`/posts/${id}/pin`, body);
},
async resolvePost(id: number): Promise<DiscussionPost> {
return api.post<DiscussionPost>(`/posts/${id}/resolve`);
},
async listAnnouncements(params?: { course_id?: number; priority?: string }): Promise<Announcement[]> {
return api.get<Announcement[]>("/announcements", params as Record<string, string | number | boolean | undefined>);
},
async createAnnouncement(data: AnnouncementCreateRequest): Promise<Announcement> {
return api.post<Announcement>("/announcements", data);
},
async updateAnnouncement(id: number, data: Partial<AnnouncementCreateRequest>): Promise<Announcement> {
return api.patch<Announcement>(`/announcements/${id}`, data);
},
async deleteAnnouncement(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/announcements/${id}`);
},
async publishAnnouncement(id: number): Promise<Announcement> {
return api.post<Announcement>(`/announcements/${id}/publish`);
},
async listMessages(params?: { is_read?: boolean } & PaginationParams): Promise<PaginatedResponse<Message>> {
return api.get<PaginatedResponse<Message>>("/messages", params as Record<string, string | number | boolean | undefined>);
},
async listSentMessages(params?: PaginationParams): Promise<PaginatedResponse<Message>> {
return api.get<PaginatedResponse<Message>>("/messages/sent", params as Record<string, string | number | boolean | undefined>);
},
async sendMessage(data: MessageCreateRequest): Promise<Message> {
return api.post<Message>("/messages", data);
},
async getMessage(id: number): Promise<Message> {
return api.get<Message>(`/messages/${id}`);
},
async deleteMessage(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/messages/${id}`);
},
async getUnreadMessageCount(): Promise<{ count: number }> {
return api.get<{ count: number }>("/messages/unread-count");
},
};

View File

@@ -0,0 +1,112 @@
import { api } from "@/lib/api-client";
import type {
CourseChapter,
ChapterMaterial,
ChapterProgress,
ChapterCreateRequest,
WorkbenchGenerateRequest,
WorkbenchGeneratedOutline,
WorkbenchGeneratedChapter,
} from "@/types/courseware";
import type { ApiSuccessResponse } from "@/types";
const blobDownloadHeaders = (): Record<string, string> => ({
Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}`,
});
export const coursewareService = {
async listChapters(courseId: number): Promise<CourseChapter[]> {
return api.get<CourseChapter[]>(`/courses/${courseId}/chapters`);
},
async createChapter(courseId: number, data: ChapterCreateRequest): Promise<CourseChapter> {
return api.post<CourseChapter>(`/courses/${courseId}/chapters`, data);
},
async getChapter(id: number): Promise<CourseChapter> {
return api.get<CourseChapter>(`/chapters/${id}`);
},
async updateChapter(id: number, data: Partial<ChapterCreateRequest>): Promise<CourseChapter> {
return api.patch<CourseChapter>(`/chapters/${id}`, data);
},
async deleteChapter(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/chapters/${id}`);
},
async unlockChapter(id: number): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/chapters/${id}/unlock`);
},
async lockChapter(id: number): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/chapters/${id}/lock`);
},
async reorderChapters(courseId: number, body: { ids: number[] }): Promise<ApiSuccessResponse> {
return api.patch<ApiSuccessResponse>(`/courses/${courseId}/chapters/reorder`, body);
},
async getChapterProgress(id: number): Promise<ChapterProgress> {
return api.get<ChapterProgress>(`/chapters/${id}/progress`);
},
async markChapterComplete(id: number): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/chapters/${id}/progress/complete`);
},
async listMaterials(chapterId: number): Promise<ChapterMaterial[]> {
return api.get<ChapterMaterial[]>(`/chapters/${chapterId}/materials`);
},
async uploadMaterial(chapterId: number, formData: FormData): Promise<ChapterMaterial> {
return api.upload<ChapterMaterial>(`/chapters/${chapterId}/materials`, formData);
},
async updateMaterial(id: number, data: Partial<ChapterMaterial>): Promise<ChapterMaterial> {
return api.patch<ChapterMaterial>(`/materials/${id}`, data);
},
async deleteMaterial(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/materials/${id}`);
},
async downloadMaterial(id: number): Promise<Blob> {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/materials/${id}/download`, {
headers: blobDownloadHeaders(),
});
return res.blob();
},
async markMaterialViewed(id: number): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/materials/${id}/viewed`);
},
async reorderMaterials(chapterId: number, body: { ids: number[] }): Promise<ApiSuccessResponse> {
return api.patch<ApiSuccessResponse>(`/chapters/${chapterId}/materials/reorder`, body);
},
async generateOutline(data: WorkbenchGenerateRequest): Promise<WorkbenchGeneratedOutline> {
return api.post<WorkbenchGeneratedOutline>("/workbench/generate-outline", data);
},
async generateChapterContent(data: WorkbenchGenerateRequest): Promise<WorkbenchGeneratedChapter> {
return api.post<WorkbenchGeneratedChapter>("/workbench/generate-chapter", data);
},
async generateRubric(data: WorkbenchGenerateRequest): Promise<{ rubric: string }> {
return api.post("/workbench/generate-rubric", data);
},
async regenerateSection(data: Record<string, unknown>): Promise<WorkbenchGeneratedChapter> {
return api.post<WorkbenchGeneratedChapter>("/workbench/regenerate", data);
},
async suggestMaterials(data: Record<string, unknown>): Promise<unknown> {
return api.post("/workbench/suggest-materials", data);
},
async publishWorkbench(data: Record<string, unknown>): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>("/workbench/publish", data);
},
};

View File

@@ -0,0 +1,42 @@
import { api } from "@/lib/api-client";
import type {
FaqCategory,
FaqItem,
FaqCategoryCreateRequest,
FaqItemCreateRequest,
} from "@/types/faq";
import type { ApiSuccessResponse } from "@/types";
export const faqService = {
async listCategories(params?: { audience?: string }): Promise<FaqCategory[]> {
return api.get<FaqCategory[]>("/faq/categories", params as Record<string, string | number | boolean | undefined>);
},
async createCategory(data: FaqCategoryCreateRequest): Promise<FaqCategory> {
return api.post<FaqCategory>("/faq/categories", data);
},
async updateCategory(id: number, data: Partial<FaqCategoryCreateRequest>): Promise<FaqCategory> {
return api.patch<FaqCategory>(`/faq/categories/${id}`, data);
},
async deleteCategory(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/faq/categories/${id}`);
},
async listItems(params?: { category_id?: number; audience?: string; search?: string }): Promise<FaqItem[]> {
return api.get<FaqItem[]>("/faq/items", params as Record<string, string | number | boolean | undefined>);
},
async createItem(data: FaqItemCreateRequest): Promise<FaqItem> {
return api.post<FaqItem>("/faq/items", data);
},
async updateItem(id: number, data: Partial<FaqItemCreateRequest>): Promise<FaqItem> {
return api.patch<FaqItem>(`/faq/items/${id}`, data);
},
async deleteItem(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/faq/items/${id}`);
},
};

View File

@@ -22,3 +22,8 @@ export { lmsService } from "./lms.service";
export { academicService } from "./academic.service";
export { admissionService } from "./admission.service";
export { institutionalExamService } from "./institutional-exam.service";
export { coursewareService } from "./courseware.service";
export { communicationService } from "./communication.service";
export { notificationService } from "./notification.service";
export { faqService } from "./faq.service";
export { plagiarismService } from "./plagiarism.service";

View File

@@ -0,0 +1,55 @@
import { api } from "@/lib/api-client";
import type {
Notification,
NotificationRule,
NotificationPreferences,
NotificationRuleCreateRequest,
} from "@/types/notification";
import type { ApiSuccessResponse, PaginatedResponse, PaginationParams } from "@/types";
export const notificationService = {
async listNotifications(
params?: PaginationParams & { type?: string; is_read?: boolean },
): Promise<PaginatedResponse<Notification>> {
return api.get<PaginatedResponse<Notification>>(
"/notifications",
params as Record<string, string | number | boolean | undefined>,
);
},
async markNotificationRead(id: number): Promise<Notification> {
return api.post<Notification>(`/notifications/${id}/read`);
},
async markAllNotificationsRead(): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>("/notifications/read-all");
},
async getUnreadNotificationCount(): Promise<{ count: number }> {
return api.get<{ count: number }>("/notifications/unread-count");
},
async listNotificationRules(): Promise<NotificationRule[]> {
return api.get<NotificationRule[]>("/notification-rules");
},
async createNotificationRule(data: NotificationRuleCreateRequest): Promise<NotificationRule> {
return api.post<NotificationRule>("/notification-rules", data);
},
async updateNotificationRule(id: number, data: Partial<NotificationRuleCreateRequest>): Promise<NotificationRule> {
return api.patch<NotificationRule>(`/notification-rules/${id}`, data);
},
async deleteNotificationRule(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/notification-rules/${id}`);
},
async getNotificationPreferences(): Promise<NotificationPreferences> {
return api.get<NotificationPreferences>("/notification-preferences");
},
async updateNotificationPreferences(data: Partial<NotificationPreferences>): Promise<NotificationPreferences> {
return api.patch<NotificationPreferences>("/notification-preferences", data);
},
};

View File

@@ -0,0 +1,28 @@
import { api } from "@/lib/api-client";
export interface PlagiarismCheckRequest {
text: string;
submission_id?: number;
}
export interface PlagiarismCheckResult {
similarity_score: number;
matches: { source: string; excerpt: string; score: number }[];
}
export const plagiarismService = {
async check(body: PlagiarismCheckRequest): Promise<PlagiarismCheckResult> {
return api.post<PlagiarismCheckResult>("/plagiarism/check", body);
},
async getReport(submissionId: number): Promise<PlagiarismCheckResult> {
return api.get<PlagiarismCheckResult>(`/plagiarism/report/${submissionId}`);
},
async downloadReport(submissionId: number): Promise<Blob> {
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/plagiarism/report/${submissionId}/download`, {
headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` },
});
return res.blob();
},
};

View File

@@ -0,0 +1,91 @@
export interface DiscussionBoard {
id: number;
name: string;
course_id: number;
course_name: string;
batch_id?: number;
batch_name?: string;
chapter_id?: number;
chapter_name?: string;
assignment_id?: number;
is_enabled: boolean;
allow_student_posts: boolean;
post_count: number;
}
export interface DiscussionPost {
id: number;
board_id: number;
parent_id?: number;
author_id: number;
author_name: string;
author_role: string;
title?: string;
content: string;
attachment_ids: number[];
attachment_names: string[];
is_pinned: boolean;
is_resolved: boolean;
reply_count: number;
created_at: string;
replies?: DiscussionPost[];
}
export interface Announcement {
id: number;
title: string;
content: string;
author_id: number;
author_name: string;
course_id?: number;
course_name?: string;
batch_id?: number;
batch_name?: string;
priority: "normal" | "important" | "urgent";
is_published: boolean;
published_at?: string;
expires_at?: string;
send_email: boolean;
attachment_ids: number[];
attachment_names: string[];
}
export interface Message {
id: number;
sender_id: number;
sender_name: string;
recipient_id: number;
recipient_name: string;
subject: string;
content: string;
is_read: boolean;
read_at?: string;
attachment_ids: number[];
attachment_names: string[];
send_email_copy: boolean;
created_at: string;
}
export interface PostCreateRequest {
board_id: number;
parent_id?: number;
title?: string;
content: string;
}
export interface AnnouncementCreateRequest {
title: string;
content: string;
course_id?: number;
batch_id?: number;
priority?: "normal" | "important" | "urgent";
send_email?: boolean;
expires_at?: string;
}
export interface MessageCreateRequest {
recipient_id: number;
subject: string;
content: string;
send_email_copy?: boolean;
}

87
src/types/courseware.ts Normal file
View File

@@ -0,0 +1,87 @@
export type ChapterUnlockMode = "auto_date" | "manual" | "prerequisite";
export type ChapterProgressStatus = "not_started" | "in_progress" | "completed";
export type MaterialType = "pdf" | "document" | "video" | "audio" | "image" | "link" | "article";
export interface CourseChapter {
id: number;
name: string;
course_id: number;
course_name: string;
sequence: number;
description: string;
start_date?: string;
end_date?: string;
unlock_mode: ChapterUnlockMode;
is_unlocked: boolean;
topic_id?: number;
topic_name?: string;
material_count: number;
assignment_ids: number[];
exam_ids: number[];
}
export interface ChapterMaterial {
id: number;
name: string;
chapter_id: number;
type: MaterialType;
file_url?: string;
url?: string;
description?: string;
sequence: number;
allow_download: boolean;
is_book: boolean;
book_chapters?: string;
}
export interface ChapterProgress {
id: number;
student_id: number;
student_name: string;
chapter_id: number;
status: ChapterProgressStatus;
started_at?: string;
completed_at?: string;
materials_completed: number;
materials_total: number;
}
export interface ChapterCreateRequest {
name: string;
course_id: number;
sequence?: number;
description?: string;
start_date?: string;
end_date?: string;
unlock_mode: ChapterUnlockMode;
topic_id?: number;
}
export interface MaterialUploadRequest {
name: string;
chapter_id: number;
type: MaterialType;
url?: string;
description?: string;
allow_download?: boolean;
is_book?: boolean;
}
export interface WorkbenchGenerateRequest {
course_id: number;
topic: string;
objectives: string;
complexity: "beginner" | "intermediate" | "advanced";
target_audience?: string;
}
export interface WorkbenchGeneratedOutline {
chapters: { name: string; description: string; learning_outcomes: string[]; estimated_duration_hours: number }[];
suggested_rubric?: string;
}
export interface WorkbenchGeneratedChapter {
content: string;
exercises: { question: string; answer: string; type: string }[];
multimedia_suggestions: { type: string; description: string }[];
}

38
src/types/faq.ts Normal file
View File

@@ -0,0 +1,38 @@
export type FaqAudience = "student" | "entity" | "both";
export interface FaqCategory {
id: number;
name: string;
sequence: number;
icon?: string;
audience: FaqAudience;
item_count: number;
}
export interface FaqItem {
id: number;
question: string;
answer: string;
category_id: number;
category_name: string;
audience: FaqAudience;
image?: string;
video_url?: string;
sequence: number;
}
export interface FaqCategoryCreateRequest {
name: string;
sequence?: number;
icon?: string;
audience: FaqAudience;
}
export interface FaqItemCreateRequest {
question: string;
answer: string;
category_id: number;
audience?: FaqAudience;
video_url?: string;
sequence?: number;
}

View File

@@ -15,3 +15,7 @@ export * from "./ai";
export * from "./academic";
export * from "./admission";
export * from "./institutional-exam";
export * from "./courseware";
export * from "./communication";
export * from "./notification";
export * from "./faq";

48
src/types/notification.ts Normal file
View File

@@ -0,0 +1,48 @@
export type NotificationType = "deadline" | "chapter_unlock" | "result_release" | "announcement" | "assignment" | "exam" | "message" | "system";
export type NotificationChannel = "in_app" | "email" | "both";
export type ReminderFrequency = "once" | "daily" | "custom";
export interface Notification {
id: number;
user_id: number;
title: string;
message: string;
type: NotificationType;
action_url?: string;
is_read: boolean;
read_at?: string;
channel: NotificationChannel;
created_at: string;
}
export interface NotificationRule {
id: number;
name: string;
event_type: string;
days_before: number;
frequency: ReminderFrequency;
custom_intervals?: number[];
channel: NotificationChannel;
entity_id?: number;
entity_name?: string;
active: boolean;
}
export interface NotificationPreferences {
email_enabled: boolean;
assignment_alerts: boolean;
exam_alerts: boolean;
chapter_alerts: boolean;
announcement_alerts: boolean;
message_alerts: boolean;
}
export interface NotificationRuleCreateRequest {
name: string;
event_type: string;
days_before: number;
frequency: ReminderFrequency;
custom_intervals?: number[];
channel: NotificationChannel;
entity_id?: number;
}