feat: add OpenEduCat institutional LMS features + Odoo 19 Backend SRS

- Update ENCOACH_UNIFIED_SRS.md with 6 new feature sections:
  Academic Year/Term, Departments, Admission/Enrollment,
  Subject Registration, Institutional Exams, Assignment Submissions
- Add TypeScript types: academic.ts, admission.ts, institutional-exam.ts
- Add API services: academic, admission, institutional-exam
- Add TanStack Query hooks: useAcademic, useAdmissions, useInstitutionalExams
- Add 9 new pages: AcademicYearManager, DepartmentManager, AdmissionList,
  AdmissionDetail, AdmissionRegisterPage, AdmissionApplication,
  SubjectRegistrationPage, InstitutionalExamSessions, MarksheetManager
- Update App.tsx routes, AdminLmsLayout sidebar (Institutional group),
  StudentLayout sidebar (Subject Registration)
- Generate ENCOACH_ODOO19_BACKEND_SRS.md: complete Odoo 19 backend spec
  covering 200+ REST API endpoints, 31 modules, all OpenEduCat models

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-26 23:42:06 +04:00
parent 3fbbfd9348
commit 19d7e01be1
27 changed files with 4691 additions and 27 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -53,11 +53,19 @@
28. [Subscriptions and Payments](#28-subscriptions-and-payments)
29. [Training Content](#29-training-content)
**Part VIII -- Technical Specifications**
30. [Data Models](#30-data-models)
31. [REST API Specification](#31-rest-api-specification)
32. [Frontend Page Inventory](#32-frontend-page-inventory)
33. [Non-Functional Requirements](#33-non-functional-requirements)
**Part VIII -- Institutional LMS (OpenEduCat)**
30. [Academic Year and Term Management](#30-academic-year-and-term-management)
31. [Department Management](#31-department-management)
32. [Admission and Enrollment](#32-admission-and-enrollment)
33. [Subject Registration](#33-subject-registration)
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)
---
@@ -344,7 +352,7 @@ 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, Assignments, Grades, Attendance, Timetable, Profile
**Student sidebar:** Dashboard, Subjects, Courses, Subject Registration, Assignments, Grades, Attendance, Timetable, Profile
**Teacher sidebar:** Dashboard, Courses, Assignments, Attendance, Students, Timetable, Profile
@@ -354,6 +362,7 @@ The current permission model is IELTS-hardcoded (`generate_reading`, `createWrit
|-------|-------|
| **LMS** | Dashboard, Courses, Students, Teachers, Batches, Timetable, Reports, Settings |
| **Platform** | Platform Dashboard |
| **Institutional** | Academic Years, Departments, Admissions, Exam Sessions, Marksheets |
| **Academic** | Assignments, Exams List, Exam Structures, Rubrics, Generation, Approval Workflows |
| **Management** | Users, Entities, Classrooms, Taxonomy Manager, Resource Manager |
| **Reports** | Student Performance, Stats Corporate, Record |
@@ -962,11 +971,469 @@ Each simulated AI component in the new frontend maps to a real backend service:
---
# Part VIII -- Technical Specifications
# Part VIII -- Institutional LMS (OpenEduCat)
## 30. Data Models
All LMS features in this part are backed by OpenEduCat community modules (ported to Odoo 19). The `encoach_lms_api` module extends these models and exposes them via REST API to the React frontend.
### 30.1 Existing Models (from `ielts-be-v0`)
---
## 30. Academic Year and Term Management
### 30.1 Overview
Academic years define the institutional calendar. Each year contains terms (semesters, quarters) that govern course scheduling, batch periods, and exam sessions.
### 30.2 Data Models (OpenEduCat)
**`op.academic.year`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Academic year name (e.g., "2026-2027") |
| `start_date` | Date | Year start |
| `end_date` | Date | Year end |
| `term_structure` | Selection | `two_sem`, `two_sem_qua`, `three_sem`, `four_Quarter`, `final_year`, `others` |
| `academic_term_ids` | One2many | Terms within this year |
**`op.academic.term`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Term name (e.g., "Semester 1") |
| `term_start_date` | Date | Term start |
| `term_end_date` | Date | Term end |
| `academic_year_id` | Many2one | Parent academic year |
| `parent_term` | Many2one | Parent term (for quarters within semesters) |
### 30.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-AY-01 | List academic years with terms | `/admin/academic-years` | Admin |
| FR-AY-02 | Create academic year with term structure selection | `/admin/academic-years` | Admin |
| FR-AY-03 | Auto-generate terms based on selected structure | `/admin/academic-years` | Admin |
| FR-AY-04 | Edit/delete academic years and terms | `/admin/academic-years` | Admin |
| FR-AY-05 | Link batches and courses to academic terms | Related pages | Admin |
### 30.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/academic-years` | List academic years |
| `POST` | `/api/academic-years` | Create academic year |
| `GET` | `/api/academic-years/{id}` | Get year with terms |
| `PATCH` | `/api/academic-years/{id}` | Update year |
| `DELETE` | `/api/academic-years/{id}` | Delete year |
| `POST` | `/api/academic-years/{id}/generate-terms` | Auto-generate terms from structure |
| `GET` | `/api/academic-terms` | List terms (filter by year) |
| `POST` | `/api/academic-terms` | Create term |
| `PATCH` | `/api/academic-terms/{id}` | Update term |
| `DELETE` | `/api/academic-terms/{id}` | Delete term |
---
## 31. Department Management
### 31.1 Overview
Departments organize faculty, courses, and subjects into academic units (e.g., "Department of Mathematics", "Department of IT").
### 31.2 Data Model (OpenEduCat)
**`op.department`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Department name |
| `code` | Char | Department code |
| `parent_id` | Many2one | Parent department (for hierarchy) |
### 31.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-DEPT-01 | List departments | `/admin/departments` | Admin |
| FR-DEPT-02 | Create/edit department | `/admin/departments` | Admin |
| FR-DEPT-03 | Delete department | `/admin/departments` | Admin |
| FR-DEPT-04 | Assign department to courses and faculty | Related pages | Admin |
### 31.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/departments` | List departments |
| `POST` | `/api/departments` | Create department |
| `PATCH` | `/api/departments/{id}` | Update department |
| `DELETE` | `/api/departments/{id}` | Delete department |
---
## 32. Admission and Enrollment
### 32.1 Overview
The admission workflow manages student applications from submission through confirmation to enrollment. Admission registers define open enrollment windows.
### 32.2 Data Models (OpenEduCat)
**`op.admission.register`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Register name (e.g., "Fall 2026 Admissions") |
| `course_id` | Many2one | Target course |
| `start_date` | Date | Application window start |
| `end_date` | Date | Application window end |
| `min_count` | Integer | Minimum applications to proceed |
| `max_count` | Integer | Maximum applications accepted |
| `state` | Selection | `draft`, `confirm`, `cancel`, `done` |
| `product_id` | Many2one | Fee product for admission |
**`op.admission`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Applicant name |
| `application_number` | Char | Auto-generated application number |
| `register_id` | Many2one | Admission register |
| `course_id` | Many2one | Applied course |
| `batch_id` | Many2one | Target batch |
| `first_name` | Char | First name |
| `middle_name` | Char | Middle name |
| `last_name` | Char | Last name |
| `email` | Char | Email |
| `phone` | Char | Phone |
| `birth_date` | Date | Date of birth |
| `gender` | Selection | `m`, `f`, `o` |
| `nationality` | Many2one | Country |
| `state` | Selection | `draft`, `submit`, `confirm`, `admission`, `reject`, `cancel`, `done` |
| `fees` | Float | Admission fee amount |
| `image` | Binary | Applicant photo |
| `student_id` | Many2one | Created student record (after admission) |
### 32.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-ADM-01 | List admission registers with status | `/admin/admission-register` | Admin |
| FR-ADM-02 | Create/edit admission register (course, dates, capacity) | `/admin/admission-register` | Admin |
| FR-ADM-03 | Open/close admission register | `/admin/admission-register` | Admin |
| FR-ADM-04 | List admissions with filters (status, course, register) | `/admin/admissions` | Admin |
| FR-ADM-05 | View admission detail | `/admin/admissions/:id` | Admin |
| FR-ADM-06 | Transition admission status (submit, confirm, admit, reject) | `/admin/admissions/:id` | Admin |
| FR-ADM-07 | On admit: auto-create `op.student` and `op.student.course` records | Backend | System |
| FR-ADM-08 | Public admission application form | `/apply` | Public/Student |
| FR-ADM-09 | Applicant status tracking | `/apply/status` | Public |
### 32.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admission-registers` | List registers |
| `POST` | `/api/admission-registers` | Create register |
| `PATCH` | `/api/admission-registers/{id}` | Update register |
| `POST` | `/api/admission-registers/{id}/confirm` | Confirm register |
| `POST` | `/api/admission-registers/{id}/close` | Close register |
| `GET` | `/api/admissions` | List admissions |
| `GET` | `/api/admissions/{id}` | Get admission detail |
| `POST` | `/api/admissions` | Submit admission application |
| `POST` | `/api/admissions/{id}/submit` | Submit for review |
| `POST` | `/api/admissions/{id}/confirm` | Confirm admission |
| `POST` | `/api/admissions/{id}/admit` | Admit applicant (creates student) |
| `POST` | `/api/admissions/{id}/reject` | Reject admission |
---
## 33. Subject Registration
### 33.1 Overview
Within enrolled courses, students register for specific subjects. This is required for timetable, attendance, and exam eligibility.
### 33.2 Data Model (OpenEduCat)
**`op.subject.registration`**
| Field | Type | Description |
|-------|------|-------------|
| `student_id` | Many2one | Student |
| `course_id` | Many2one | Course |
| `batch_id` | Many2one | Batch |
| `subject_ids` | Many2many | Selected subjects |
| `min_unit_load` | Float | Minimum units from course config |
| `max_unit_load` | Float | Maximum units from course config |
| `state` | Selection | `draft`, `confirm`, `reject`, `done` |
### 33.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-SR-01 | Students view available subjects for enrolled courses | `/student/subject-registration` | Student |
| FR-SR-02 | Students select subjects within unit load limits | `/student/subject-registration` | Student |
| FR-SR-03 | Submit registration for approval | `/student/subject-registration` | Student |
| FR-SR-04 | Admin/teacher view and approve/reject registrations | `/admin/students` | Admin |
| FR-SR-05 | Confirmed registration updates `op.student.course.subject_ids` | Backend | System |
### 33.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/subject-registrations` | List registrations (filter by student, course, status) |
| `POST` | `/api/subject-registrations` | Create registration |
| `PATCH` | `/api/subject-registrations/{id}` | Update subjects |
| `POST` | `/api/subject-registrations/{id}/confirm` | Confirm registration |
| `POST` | `/api/subject-registrations/{id}/reject` | Reject registration |
| `GET` | `/api/subject-registrations/available` | Get available subjects for current student |
---
## 34. Institutional Exam Sessions
### 34.1 Overview
Institutional exam sessions group traditional exams (midterms, finals) for a course/batch. These are separate from EnCoach AI-powered exams. They use the OpenEduCat exam, marksheet, and grading system.
### 34.2 Data Models (OpenEduCat)
**`op.exam.session`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Session name (e.g., "Fall 2026 Midterms") |
| `course_id` | Many2one | Course |
| `batch_id` | Many2one | Batch |
| `exam_code` | Char | Session code |
| `start_date` | Date | Session start |
| `end_date` | Date | Session end |
| `exam_type` | Many2one | Exam type (midterm, final, quiz) |
| `evaluation_type` | Selection | `normal` or `grade` |
| `venue` | Many2one | Venue partner |
| `state` | Selection | `draft`, `schedule`, `held`, `cancel`, `done` |
| `exam_ids` | One2many | Exams in this session |
**`op.exam` (institutional)**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Exam name |
| `session_id` | Many2one | Parent session |
| `subject_id` | Many2one | Subject being examined |
| `exam_code` | Char | Exam code |
| `start_time` | Datetime | Exam start |
| `end_time` | Datetime | Exam end |
| `total_marks` | Integer | Maximum marks |
| `min_marks` | Integer | Passing marks |
| `state` | Selection | `draft`, `schedule`, `held`, `result_updated`, `cancel`, `done` |
| `responsible_id` | Many2many | Responsible faculty |
| `attendees_line` | One2many | Exam attendees with marks |
**`op.exam.type`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Type name (Midterm, Final, Quiz, Lab) |
**`op.exam.room`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Room name |
| `classroom_id` | Many2one | Linked classroom |
| `capacity` | Integer | Room capacity |
**`op.exam.attendees`**
| Field | Type | Description |
|-------|------|-------------|
| `exam_id` | Many2one | Exam |
| `student_id` | Many2one | Student |
| `room_id` | Many2one | Assigned room |
| `marks` | Integer | Obtained marks |
| `status` | Selection | `pass`, `fail` (computed from marks vs min_marks) |
**`op.marksheet.register`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Register name |
| `exam_session_id` | Many2one | Exam session |
| `marksheet_line` | One2many | Marksheet lines per student |
| `result_template_id` | Many2one | Result template |
| `state` | Selection | `draft`, `validated`, `cancelled` |
| `total_pass` | Integer | Computed pass count |
| `total_failed` | Integer | Computed fail count |
**`op.marksheet.line`**
| Field | Type | Description |
|-------|------|-------------|
| `student_id` | Many2one | Student |
| `marksheet_reg_id` | Many2one | Parent register |
| `result_line` | One2many | Per-exam results |
| `total_marks` | Integer | Computed total |
| `percentage` | Float | Computed percentage |
| `grade` | Char | Computed grade (if grade evaluation) |
| `status` | Selection | `pass`, `fail` (computed) |
**`op.result.line`**
| Field | Type | Description |
|-------|------|-------------|
| `marksheet_line_id` | Many2one | Parent marksheet line |
| `exam_id` | Many2one | Exam |
| `student_id` | Many2one | Student |
| `marks` | Integer | Marks obtained |
| `grade` | Char | Computed grade |
| `status` | Selection | `pass`, `fail` (computed from marks vs min_marks) |
**`op.result.template`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Template name |
| `exam_session_id` | Many2one | Exam session |
| `grade_ids` | Many2many | Grade configuration rules |
| `result_date` | Date | Result publication date |
| `state` | Selection | `draft`, `result_generated` |
**`op.grade.configuration`**
| Field | Type | Description |
|-------|------|-------------|
| `min_per` | Integer | Minimum percentage |
| `max_per` | Integer | Maximum percentage |
| `result` | Char | Grade display (e.g., "A", "B+", "Pass") |
### 34.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-IEX-01 | List exam sessions with status filters | `/admin/exam-sessions` | Admin |
| FR-IEX-02 | Create exam session (course, batch, dates, type) | `/admin/exam-sessions` | Admin |
| FR-IEX-03 | Add exams to session (subject, time, marks) | `/admin/exam-sessions` | Admin |
| FR-IEX-04 | Schedule and manage session state transitions | `/admin/exam-sessions` | Admin |
| FR-IEX-05 | Enter marks for exam attendees | `/admin/exam-sessions` | Admin/Teacher |
| FR-IEX-06 | Configure grade scales | `/admin/marksheets` | Admin |
| FR-IEX-07 | Create result templates and generate marksheets | `/admin/marksheets` | Admin |
| FR-IEX-08 | View marksheet results (pass/fail, grades, percentages) | `/admin/marksheets` | Admin |
| FR-IEX-09 | Students view institutional exam results | `/student/grades` | Student |
### 34.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/inst-exam-sessions` | List exam sessions |
| `POST` | `/api/inst-exam-sessions` | Create session |
| `GET` | `/api/inst-exam-sessions/{id}` | Get session with exams |
| `PATCH` | `/api/inst-exam-sessions/{id}` | Update session |
| `DELETE` | `/api/inst-exam-sessions/{id}` | Delete session |
| `POST` | `/api/inst-exam-sessions/{id}/schedule` | Schedule session |
| `POST` | `/api/inst-exam-sessions/{id}/done` | Mark session done |
| `POST` | `/api/inst-exams` | Add exam to session |
| `PATCH` | `/api/inst-exams/{id}` | Update exam |
| `POST` | `/api/inst-exams/{id}/attendees` | Add attendees |
| `PATCH` | `/api/inst-exams/{id}/marks` | Enter marks |
| `GET` | `/api/exam-types` | List exam types |
| `POST` | `/api/exam-types` | Create exam type |
| `GET` | `/api/grade-config` | List grade configurations |
| `POST` | `/api/grade-config` | Create grade config |
| `GET` | `/api/result-templates` | List result templates |
| `POST` | `/api/result-templates` | Create result template |
| `POST` | `/api/result-templates/{id}/generate` | Generate marksheets |
| `GET` | `/api/marksheets` | List marksheets |
| `GET` | `/api/marksheets/{id}` | Get marksheet with results |
| `POST` | `/api/marksheets/{id}/validate` | Validate marksheet |
---
## 35. Assignment File Submissions
### 35.1 Overview
Traditional course assignments where students submit files and teachers grade them. Coexists with EnCoach exam-wrapper assignments (`encoach.assignment`).
### 35.2 Data Models (OpenEduCat)
**`grading.assignment.type`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Type name (Homework, Project, Lab Report, Presentation) |
**`grading.assignment`**
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Assignment title |
| `course_id` | Many2one | Course |
| `subject_id` | Many2one | Subject |
| `issued_date` | Datetime | Issue date |
| `assignment_type` | Many2one | Assignment type |
| `faculty_id` | Many2one | Issuing faculty |
| `point` | Float | Maximum points |
**`op.assignment` (extends `grading.assignment`)**
| Field | Type | Description |
|-------|------|-------------|
| `batch_id` | Many2one | Batch |
| `marks` | Float | Maximum marks |
| `description` | Text | Assignment description |
| `state` | Selection | `draft`, `publish`, `finish`, `cancel` |
| `submission_date` | Datetime | Submission deadline |
| `allocation_ids` | Many2many | Allocated students |
| `assignment_sub_line` | One2many | Student submissions |
| `reviewer` | Many2one | Reviewing faculty |
**`op.assignment.sub.line`**
| Field | Type | Description |
|-------|------|-------------|
| `assignment_id` | Many2one | Parent assignment |
| `student_id` | Many2one | Submitting student |
| `description` | Text | Submission notes |
| `submission_date` | Datetime | Actual submission time |
| `attachment_ids` | Many2many | Uploaded files |
| `marks` | Float | Awarded marks |
| `state` | Selection | `draft`, `submit`, `accept`, `reject`, `change_req` |
| `faculty_id` | Many2one | Grading faculty |
| `note` | Text | Faculty feedback |
### 35.3 Functional Requirements
| ID | Requirement | Route | Role |
|----|-------------|-------|------|
| FR-SUB-01 | Teacher creates course assignment with description, deadline, files | `/teacher/assignments` | Teacher |
| FR-SUB-02 | Teacher publishes assignment (visible to allocated students) | `/teacher/assignments` | Teacher |
| FR-SUB-03 | Student views assigned course assignments with deadlines | `/student/assignments` | Student |
| FR-SUB-04 | Student uploads files as submission | `/student/assignments` | Student |
| FR-SUB-05 | Teacher views submissions per assignment | `/teacher/assignments/:id` | Teacher |
| FR-SUB-06 | Teacher grades submission (marks, feedback, accept/reject/change request) | `/teacher/assignments/:id` | Teacher |
| FR-SUB-07 | Admin views all course assignments across batches | `/admin/assignments` | Admin |
### 35.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/course-assignments` | List course assignments |
| `POST` | `/api/course-assignments` | Create assignment |
| `GET` | `/api/course-assignments/{id}` | Get assignment with submissions |
| `PATCH` | `/api/course-assignments/{id}` | Update assignment |
| `POST` | `/api/course-assignments/{id}/publish` | Publish assignment |
| `POST` | `/api/course-assignments/{id}/finish` | Close assignment |
| `GET` | `/api/course-assignments/{id}/submissions` | List submissions |
| `POST` | `/api/course-assignments/{id}/submit` | Student submits (multipart file upload) |
| `PATCH` | `/api/submissions/{id}` | Grade submission (marks, feedback, status) |
| `GET` | `/api/assignment-types` | List assignment types |
| `POST` | `/api/assignment-types` | Create assignment type |
---
# Part IX -- Technical Specifications
## 36. Data Models
### 36.1 Existing Models (from `ielts-be-v0`)
| Model | Module | Purpose |
|-------|--------|---------|
@@ -994,7 +1461,7 @@ Each simulated AI component in the new frontend maps to a real backend service:
| `encoach.elai.avatar` | `encoach_ai_media` | ELAI avatar profiles |
| `encoach.registration` | `encoach_registration` | Batch registration mixin |
### 30.2 New Models (Adaptive Learning Engine)
### 36.2 New Models (Adaptive Learning Engine)
| Model | Module | Purpose | Key Fields |
|-------|--------|---------|------------|
@@ -1009,14 +1476,14 @@ Each simulated AI component in the new frontend maps to a real backend service:
| `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` |
### 30.3 New Models (SIS Integration)
### 36.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 |
### 30.4 New Models (Branding)
### 36.4 New Models (Branding)
| Model | Module | Purpose |
|-------|--------|---------|
@@ -1024,9 +1491,9 @@ Each simulated AI component in the new frontend maps to a real backend service:
---
## 31. REST API Specification
## 37. REST API Specification
### 31.1 Existing Endpoints (81+)
### 37.1 Existing Endpoints (81+)
All existing endpoints from the Odoo 19 backend remain. Key groups:
@@ -1049,7 +1516,7 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| Storage | `/api/storage` | 2 |
| Approvals | `/api/approval-workflows` | 3 |
### 31.2 New Endpoints (Adaptive Learning Engine, 40+)
### 37.2 New Endpoints (Adaptive Learning Engine, 40+)
| Group | Method | Path | Description |
|-------|--------|------|-------------|
@@ -1101,7 +1568,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 |
### 31.3 New Endpoints (LMS API Bridge)
### 37.3 New Endpoints (LMS API Bridge)
| Method | Path | Description |
|--------|------|-------------|
@@ -1114,7 +1581,7 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `GET/POST` | `/api/attendance` | Attendance records |
| `GET` | `/api/grades` | Gradebook |
### 31.4 New Endpoints (SIS Integration)
### 37.4 New Endpoints (SIS Integration)
| Method | Path | Description |
|--------|------|-------------|
@@ -1123,11 +1590,61 @@ 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+)
| Group | Method | Path | Description |
|-------|--------|------|-------------|
| **Academic Years** | `GET/POST` | `/api/academic-years` | List/create years |
| | `GET/PATCH/DELETE` | `/api/academic-years/{id}` | Year CRUD |
| | `POST` | `/api/academic-years/{id}/generate-terms` | Auto-generate terms |
| | `GET/POST` | `/api/academic-terms` | List/create terms |
| | `PATCH/DELETE` | `/api/academic-terms/{id}` | Term CRUD |
| **Departments** | `GET/POST` | `/api/departments` | List/create |
| | `PATCH/DELETE` | `/api/departments/{id}` | CRUD |
| **Admissions** | `GET/POST` | `/api/admission-registers` | List/create registers |
| | `PATCH` | `/api/admission-registers/{id}` | Update |
| | `POST` | `/api/admission-registers/{id}/confirm` | Confirm register |
| | `POST` | `/api/admission-registers/{id}/close` | Close register |
| | `GET/POST` | `/api/admissions` | List/create admissions |
| | `GET` | `/api/admissions/{id}` | Admission detail |
| | `POST` | `/api/admissions/{id}/submit` | Submit for review |
| | `POST` | `/api/admissions/{id}/confirm` | Confirm |
| | `POST` | `/api/admissions/{id}/admit` | Admit (creates student) |
| | `POST` | `/api/admissions/{id}/reject` | Reject |
| **Subject Registration** | `GET/POST` | `/api/subject-registrations` | List/create |
| | `PATCH` | `/api/subject-registrations/{id}` | Update subjects |
| | `POST` | `/api/subject-registrations/{id}/confirm` | Confirm |
| | `POST` | `/api/subject-registrations/{id}/reject` | Reject |
| | `GET` | `/api/subject-registrations/available` | Available subjects |
| **Inst. Exam Sessions** | `GET/POST` | `/api/inst-exam-sessions` | List/create |
| | `GET/PATCH/DELETE` | `/api/inst-exam-sessions/{id}` | CRUD |
| | `POST` | `/api/inst-exam-sessions/{id}/schedule` | Schedule |
| | `POST` | `/api/inst-exam-sessions/{id}/done` | Mark done |
| | `POST` | `/api/inst-exams` | Add exam |
| | `PATCH` | `/api/inst-exams/{id}` | Update exam |
| | `POST` | `/api/inst-exams/{id}/attendees` | Add attendees |
| | `PATCH` | `/api/inst-exams/{id}/marks` | Enter marks |
| | `GET/POST` | `/api/exam-types` | List/create types |
| | `GET/POST` | `/api/grade-config` | Grade configuration |
| | `GET/POST` | `/api/result-templates` | Result templates |
| | `POST` | `/api/result-templates/{id}/generate` | Generate marksheets |
| | `GET` | `/api/marksheets` | List marksheets |
| | `GET` | `/api/marksheets/{id}` | Marksheet detail |
| | `POST` | `/api/marksheets/{id}/validate` | Validate |
| **Course Assignments** | `GET/POST` | `/api/course-assignments` | List/create |
| | `GET/PATCH` | `/api/course-assignments/{id}` | CRUD |
| | `POST` | `/api/course-assignments/{id}/publish` | Publish |
| | `POST` | `/api/course-assignments/{id}/finish` | Close |
| | `GET` | `/api/course-assignments/{id}/submissions` | List submissions |
| | `POST` | `/api/course-assignments/{id}/submit` | Student submits |
| | `PATCH` | `/api/submissions/{id}` | Grade submission |
| | `GET/POST` | `/api/assignment-types` | Assignment types |
---
## 32. Frontend Page Inventory
## 38. Frontend Page Inventory
### 32.1 Auth Pages (3)
### 38.1 Auth Pages (3)
| Route | Page | API Dependencies |
|-------|------|-----------------|
@@ -1135,7 +1652,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` |
### 32.2 Student Pages (10+)
### 38.2 Student Pages (12+)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
@@ -1152,8 +1669,9 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `/student/attendance` | Attendance | `/api/attendance` | -- |
| `/student/timetable` | Timetable | `/api/timetable` | -- |
| `/student/profile` | Profile | `/api/user` | -- |
| `/student/subject-registration` | **NEW:** Subject Registration | `/api/subject-registrations/available`, `/api/subject-registrations` | -- |
### 32.3 Teacher Pages (10)
### 38.3 Teacher Pages (10)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
@@ -1168,7 +1686,14 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `/teacher/timetable` | Timetable | `/api/timetable` | -- |
| `/teacher/profile` | Profile | `/api/user` | -- |
### 32.4 Admin Pages (30+)
### 38.4 Public Pages (2)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
| `/apply` | **NEW:** Admission Application | `/api/admissions`, `/api/admission-registers` | -- |
| `/apply/status` | **NEW:** Application Status | `/api/admissions/{id}` | -- |
### 38.5 Admin Pages (38+)
| Route | Page | Key APIs | AI Components |
|-------|------|----------|---------------|
@@ -1204,10 +1729,17 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| `/admin/settings-platform` | Platform Settings | Settings APIs | -- |
| `/admin/exam` | Exam Runner | `/api/exam`, `/api/sessions` | -- |
| `/admin/profile` | Admin Profile | `/api/user` | -- |
| `/admin/academic-years` | **NEW:** Academic Year Manager | `/api/academic-years`, `/api/academic-terms` | -- |
| `/admin/departments` | **NEW:** Department Manager | `/api/departments` | -- |
| `/admin/admissions` | **NEW:** Admission List | `/api/admissions` | -- |
| `/admin/admissions/:id` | **NEW:** Admission Detail | `/api/admissions/{id}` | -- |
| `/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` | -- |
---
## 33. Non-Functional Requirements
## 39. Non-Functional Requirements
| ID | Requirement |
|----|-------------|
@@ -1258,11 +1790,19 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| 18 | `encoach_adaptive` | **NEW** | Proficiency, learning plans, progress tracking, content cache |
| 19 | `encoach_adaptive_api` | **NEW** | REST controllers for adaptive learning |
| 20 | `encoach_adaptive_ai` | **NEW** | AI services for diagnostics, plans, coaching, content |
| 21 | `encoach_lms_api` | **NEW** | REST API bridge for OpenEduCat models |
| 21 | `encoach_lms_api` | **NEW** | REST API bridge for OpenEduCat models (extends `op.course`, `op.batch`, adds missing fields) |
| 22 | `encoach_sis` | **NEW** | UTAS SIS integration (sync, mapping) |
| 23 | `encoach_branding` | **NEW** | Whitelabeling configuration |
| 24 | `openeducat_core` | **OpenEduCat (port to v19)** | `op.course`, `op.batch`, `op.student`, `op.faculty`, `op.subject`, `op.department`, `op.category`, `op.academic.year`, `op.academic.term`, `op.student.course`, `op.subject.registration` |
| 25 | `openeducat_timetable` | **OpenEduCat (port to v19)** | `op.session`, `op.timing` |
| 26 | `openeducat_attendance` | **OpenEduCat (port to v19)** | `op.attendance.register`, `op.attendance.sheet`, `op.attendance.line`, `op.attendance.type` |
| 27 | `openeducat_classroom` | **OpenEduCat (port to v19)** | `op.classroom` (physical rooms) |
| 28 | `openeducat_exam` | **OpenEduCat (port to v19)** | `op.exam.session`, `op.exam`, `op.exam.type`, `op.exam.attendees`, `op.exam.room`, `op.marksheet.register`, `op.marksheet.line`, `op.result.line`, `op.result.template`, `op.grade.configuration` |
| 29 | `openeducat_assignment` | **OpenEduCat (port to v19)** | `op.assignment`, `op.assignment.sub.line`, `grading.assignment`, `grading.assignment.type` |
| 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 + 8 new = 23 modules**
**Total: 15 existing EnCoach + 8 new EnCoach + 8 OpenEduCat = 31 modules**
---
@@ -1273,11 +1813,14 @@ All existing endpoints from the Odoo 19 backend remain. Key groups:
| **P0** | Auth, core user management, taxonomy models, resource model | Platform foundation; blocks everything |
| **P1** | Diagnostic engine, proficiency profile, learning plan generation | Core adaptive learning loop |
| **P1** | Exam engine with multi-subject support | Exam generation and delivery |
| **P2** | LMS API bridge (OpenEduCat), course/batch/timetable/attendance | LMS functionality |
| **P2** | OpenEduCat porting (v17→v19), LMS API bridge, course/batch/timetable/attendance | LMS functionality |
| **P2** | Academic years/terms, departments, admission workflow | Institutional operations |
| **P2** | Content delivery (hybrid), practice questions, mastery quizzes | Learning content consumption |
| **P2** | AI components wiring (all 15 frontend components to real backend) | AI integration |
| **P3** | AI coaching (hints, explanations, study suggestions) | Learning enhancement |
| **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 |
| **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

@@ -70,6 +70,15 @@ import AdminSettings from "@/pages/admin/AdminSettings";
import AdminProfileLms from "@/pages/admin/AdminProfile";
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
import ResourceManager from "@/pages/admin/ResourceManager";
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
import DepartmentManager from "@/pages/admin/DepartmentManager";
import AdmissionList from "@/pages/admin/AdmissionList";
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
import MarksheetManager from "@/pages/admin/MarksheetManager";
import AdmissionApplication from "@/pages/AdmissionApplication";
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
import NotFound from "@/pages/NotFound";
import { queryClient } from "@/lib/query-client";
@@ -85,6 +94,8 @@ const App = () => (
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
{/* Public admission */}
<Route path="/apply" element={<AdmissionApplication />} />
{/* Student routes */}
<Route element={<ProtectedRoute allowedRoles={["student"]} />}>
@@ -102,6 +113,7 @@ const App = () => (
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
<Route path="/student/plan/:subjectId" element={<LearningPlanPage />} />
<Route path="/student/topic/:topicId" element={<TopicLearning />} />
<Route path="/student/subject-registration" element={<SubjectRegistrationPage />} />
</Route>
</Route>
@@ -163,6 +175,14 @@ const App = () => (
<Route path="/admin/exam" element={<ExamPage />} />
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
<Route path="/admin/resources" element={<ResourceManager />} />
{/* Institutional LMS pages */}
<Route path="/admin/academic-years" element={<AcademicYearManager />} />
<Route path="/admin/departments" element={<DepartmentManager />} />
<Route path="/admin/admissions" element={<AdmissionList />} />
<Route path="/admin/admissions/:id" element={<AdmissionDetail />} />
<Route path="/admin/admission-register" element={<AdmissionRegisterPage />} />
<Route path="/admin/exam-sessions" element={<InstitutionalExamSessions />} />
<Route path="/admin/marksheets" element={<MarksheetManager />} />
</Route>
</Route>

View File

@@ -26,6 +26,7 @@ import {
FileText, Layers, BookOpen, Wand2, GitBranch, Users, BarChart3,
Building2, History, CreditCard, Ticket, BookA, PenTool,
Calendar, ChevronDown, HelpCircle, LucideIcon, Target, FolderOpen,
CalendarDays, Landmark, UserPlus, ScrollText, Award,
} from "lucide-react";
import React from "react";
@@ -60,6 +61,15 @@ const adaptiveItems: NavItem[] = [
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
];
const institutionalItems: NavItem[] = [
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
{ title: "Departments", url: "/admin/departments", icon: Landmark },
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
];
const managementItems: NavItem[] = [
{ title: "Users", url: "/admin/users", icon: Users },
{ title: "Entities", url: "/admin/entities", icon: Building2 },
@@ -126,6 +136,9 @@ const routeLabels: Record<string, string> = {
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
profile: "Profile", exam: "Exam",
"academic-years": "Academic Years", departments: "Departments",
admissions: "Admissions", "admission-register": "Admission Register",
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
};
function AppBreadcrumbs() {
@@ -257,6 +270,7 @@ function AdminSidebar() {
<SidebarNavGroup label="Overview" items={overviewItems} />
<SidebarNavGroup label="LMS" items={lmsItems} />
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
<SidebarNavGroup label="Institutional" items={institutionalItems} />
<SidebarNavGroup label="Academic" items={academicItems} />
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}

View File

@@ -1,7 +1,7 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
} from "lucide-react";
const navGroups: NavGroup[] = [
@@ -10,6 +10,7 @@ const navGroups: NavGroup[] = [
items: [
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
],
},

View File

@@ -5,4 +5,7 @@ export * from "./useExams";
export * from "./useAssignments";
export * from "./useEntities";
export * from "./useAdaptive";
export * from "./useAcademic";
export * from "./useAdmissions";
export * from "./useInstitutionalExams";
export { usePermissions } from "../usePermissions";

View File

@@ -92,4 +92,65 @@ export const queryKeys = {
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
alerts: ["analytics", "alerts"] as const,
},
academicYears: {
all: ["academic-years"] as const,
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
detail: (id: number) => ["academic-years", id] as const,
},
academicTerms: {
all: ["academic-terms"] as const,
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
detail: (id: number) => ["academic-terms", id] as const,
},
departments: {
all: ["departments"] as const,
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
detail: (id: number) => ["departments", id] as const,
},
admissionRegisters: {
all: ["admission-registers"] as const,
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
detail: (id: number) => ["admission-registers", id] as const,
},
admissions: {
all: ["admissions"] as const,
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
detail: (id: number) => ["admissions", id] as const,
},
instExamSessions: {
all: ["inst-exam-sessions"] as const,
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
detail: (id: number) => ["inst-exam-sessions", id] as const,
},
examTypes: {
all: ["exam-types"] as const,
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
},
gradeConfigs: {
all: ["grade-configs"] as const,
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
},
resultTemplates: {
all: ["result-templates"] as const,
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
},
marksheets: {
all: ["marksheets"] as const,
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
detail: (id: number) => ["marksheets", id] as const,
},
courseAssignments: {
all: ["course-assignments"] as const,
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
detail: (id: number) => ["course-assignments", id] as const,
},
assignmentTypes: {
all: ["assignment-types"] as const,
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
},
subjectRegistrations: {
all: ["subject-registrations"] as const,
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
},
};

View File

@@ -0,0 +1,104 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { academicService } from "@/services/academic.service";
import type { PaginationParams } from "@/types";
import type {
AcademicYearCreateRequest,
AcademicTermCreateRequest,
DepartmentCreateRequest,
} from "@/types/academic";
export function useAcademicYears(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.academicYears.list(params),
queryFn: () => academicService.listAcademicYears(params),
});
}
export function useAcademicYear(id: number) {
return useQuery({
queryKey: queryKeys.academicYears.detail(id),
queryFn: () => academicService.getAcademicYear(id),
enabled: !!id,
});
}
export function useAcademicTerms(yearId?: number) {
return useQuery({
queryKey: queryKeys.academicTerms.list({ year_id: yearId }),
queryFn: () => academicService.listTerms({ year_id: yearId }),
});
}
export function useDepartments(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.departments.list(params),
queryFn: () => academicService.listDepartments(params),
});
}
export function useCreateAcademicYear() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AcademicYearCreateRequest) => academicService.createAcademicYear(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicYears.all }),
});
}
export function useUpdateAcademicYear() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<AcademicYearCreateRequest> }) =>
academicService.updateAcademicYear(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicYears.all }),
});
}
export function useDeleteAcademicYear() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => academicService.deleteAcademicYear(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicYears.all }),
});
}
export function useGenerateTerms() {
const qc = useQueryClient();
return useMutation({
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
});
}
export function useCreateAcademicTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AcademicTermCreateRequest) => academicService.createTerm(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
});
}
export function useCreateDepartment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: DepartmentCreateRequest) => academicService.createDepartment(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.departments.all }),
});
}
export function useUpdateDepartment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<DepartmentCreateRequest> }) =>
academicService.updateDepartment(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.departments.all }),
});
}
export function useDeleteDepartment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => academicService.deleteDepartment(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.departments.all }),
});
}

View File

@@ -0,0 +1,94 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { admissionService } from "@/services/admission.service";
import type { PaginationParams } from "@/types";
import type {
AdmissionRegisterCreateRequest,
AdmissionCreateRequest,
} from "@/types/admission";
export function useAdmissionRegisters(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.admissionRegisters.list(params),
queryFn: () => admissionService.listRegisters(params),
});
}
export function useAdmissions(params?: PaginationParams & { state?: string; register_id?: number; course_id?: number }) {
return useQuery({
queryKey: queryKeys.admissions.list(params),
queryFn: () => admissionService.listAdmissions(params),
});
}
export function useAdmission(id: number) {
return useQuery({
queryKey: queryKeys.admissions.detail(id),
queryFn: () => admissionService.getAdmission(id),
enabled: !!id,
});
}
export function useCreateAdmissionRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AdmissionRegisterCreateRequest) => admissionService.createRegister(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissionRegisters.all }),
});
}
export function useConfirmRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.confirmRegister(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissionRegisters.all }),
});
}
export function useCloseRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.closeRegister(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissionRegisters.all }),
});
}
export function useCreateAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: AdmissionCreateRequest) => admissionService.createAdmission(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useSubmitAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.submitAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useConfirmAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.confirmAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useAdmitAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.admitAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}
export function useRejectAdmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => admissionService.rejectAdmission(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.admissions.all }),
});
}

View File

@@ -0,0 +1,199 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { institutionalExamService } from "@/services/institutional-exam.service";
import type { PaginationParams } from "@/types";
import type {
InstExamSessionCreateRequest,
InstExamCreateRequest,
ResultTemplateCreateRequest,
CourseAssignmentCreateRequest,
SubjectRegistrationCreateRequest,
AssignmentSubmission,
} from "@/types/institutional-exam";
export function useInstitutionalExamSessions(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.instExamSessions.list(params),
queryFn: () => institutionalExamService.listSessions(params),
});
}
export function useInstitutionalExamSession(id: number) {
return useQuery({
queryKey: queryKeys.instExamSessions.detail(id),
queryFn: () => institutionalExamService.getSession(id),
enabled: !!id,
});
}
export function useExamTypes(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.examTypes.list(params),
queryFn: () => institutionalExamService.listExamTypes(params),
});
}
export function useGradeConfigs(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.gradeConfigs.list(params),
queryFn: () => institutionalExamService.listGradeConfigs(params),
});
}
export function useResultTemplates(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.resultTemplates.list(params),
queryFn: () => institutionalExamService.listResultTemplates(params),
});
}
export function useMarksheets(params?: PaginationParams) {
return useQuery({
queryKey: queryKeys.marksheets.list(params),
queryFn: () => institutionalExamService.listMarksheets(params),
});
}
export function useMarksheet(id: number) {
return useQuery({
queryKey: queryKeys.marksheets.detail(id),
queryFn: () => institutionalExamService.getMarksheet(id),
enabled: !!id,
});
}
export function useCourseAssignments(params?: PaginationParams & { course_id?: number; batch_id?: number; state?: string }) {
return useQuery({
queryKey: queryKeys.courseAssignments.list(params),
queryFn: () => institutionalExamService.listCourseAssignments(params),
});
}
export function useCourseAssignment(id: number) {
return useQuery({
queryKey: queryKeys.courseAssignments.detail(id),
queryFn: () => institutionalExamService.getCourseAssignment(id),
enabled: !!id,
});
}
export function useSubjectRegistrations(params?: PaginationParams & { student_id?: number; course_id?: number; state?: string }) {
return useQuery({
queryKey: queryKeys.subjectRegistrations.list(params),
queryFn: () => institutionalExamService.listSubjectRegistrations(params),
});
}
export function useAvailableSubjects(params?: { student_id?: number; course_id?: number }) {
return useQuery({
queryKey: queryKeys.subjectRegistrations.available(params),
queryFn: () => institutionalExamService.getAvailableSubjects(params),
});
}
export function useCreateExamSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: InstExamSessionCreateRequest) => institutionalExamService.createSession(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useScheduleExamSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.scheduleSession(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useCreateInstitutionalExam() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: InstExamCreateRequest) => institutionalExamService.createExam(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useEnterMarks() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, data }: { examId: number; data: { marks: { student_id: number; score: number }[] } }) =>
institutionalExamService.enterMarks(examId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.instExamSessions.all }),
});
}
export function useCreateResultTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ResultTemplateCreateRequest) => institutionalExamService.createResultTemplate(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.resultTemplates.all }),
});
}
export function useGenerateMarksheets() {
const qc = useQueryClient();
return useMutation({
mutationFn: (templateId: number) => institutionalExamService.generateMarksheets(templateId),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.marksheets.all }),
});
}
export function useValidateMarksheet() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.validateMarksheet(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.marksheets.all }),
});
}
export function useCreateCourseAssignment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CourseAssignmentCreateRequest) => institutionalExamService.createCourseAssignment(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function usePublishCourseAssignment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.publishCourseAssignment(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function useSubmitAssignment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ assignmentId, file }: { assignmentId: number; file: File }) =>
institutionalExamService.submitAssignment(assignmentId, file),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function useGradeSubmission() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ submissionId, data }: { submissionId: number; data: Partial<AssignmentSubmission> }) =>
institutionalExamService.gradeSubmission(submissionId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.courseAssignments.all }),
});
}
export function useCreateSubjectRegistration() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: SubjectRegistrationCreateRequest) => institutionalExamService.createSubjectRegistration(data),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.subjectRegistrations.all }),
});
}
export function useConfirmSubjectRegistration() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => institutionalExamService.confirmSubjectRegistration(id),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.subjectRegistrations.all }),
});
}

View File

@@ -0,0 +1,205 @@
import { useState } from "react";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useToast } from "@/hooks/use-toast";
import type { AdmissionCreateRequest } from "@/types/admission";
type Step = "register" | "personal" | "review" | "success";
export default function AdmissionApplication() {
const { toast } = useToast();
const [step, setStep] = useState<Step>("register");
const [selectedRegisterId, setSelectedRegisterId] = useState<number>(0);
const [form, setForm] = useState<Omit<AdmissionCreateRequest, "register_id">>({
first_name: "",
last_name: "",
email: "",
gender: "m",
});
const [applicationNumber, setApplicationNumber] = useState("");
const { data: registersData, isLoading: loadingRegisters } = useQuery({
queryKey: ["admission-registers", "list"],
queryFn: () => admissionService.listRegisters(),
});
const registers = registersData?.items ?? [];
const selectedRegister = registers.find((r: { id: number }) => r.id === selectedRegisterId);
const submitMutation = useMutation({
mutationFn: async () => {
const admission = await admissionService.createAdmission({ ...form, register_id: selectedRegisterId });
await admissionService.submitAdmission(admission.id);
return admission;
},
onSuccess: (data) => {
setApplicationNumber(data.application_number);
setStep("success");
},
onError: () => toast({ title: "Error", description: "Submission failed. Please try again.", variant: "destructive" }),
});
const canProceedFromRegister = selectedRegisterId > 0;
const canProceedFromPersonal = form.first_name && form.last_name && form.email;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-lg">
{step !== "success" && (
<div className="flex items-center justify-center gap-2 mb-8">
{["register", "personal", "review"].map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s === step ? "bg-primary text-primary-foreground" :
["register", "personal", "review"].indexOf(step) > i ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
}`}>
{i + 1}
</div>
{i < 2 && <div className="w-12 h-px bg-border" />}
</div>
))}
</div>
)}
{step === "register" && (
<Card>
<CardHeader>
<CardTitle>Select Course</CardTitle>
<CardDescription>Choose the admission register you want to apply to.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadingRegisters ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-2">
{registers
.filter((r: { state: string }) => r.state === "confirm")
.map((r: { id: number; name: string; course_name: string; start_date: string; end_date: string }) => (
<button
key={r.id}
onClick={() => setSelectedRegisterId(r.id)}
className={`w-full text-left p-4 rounded-lg border transition-colors ${selectedRegisterId === r.id ? "bg-primary/10 border-primary" : "hover:bg-accent"}`}
>
<p className="font-medium">{r.name}</p>
<p className="text-sm text-muted-foreground">{r.course_name} · {r.start_date} {r.end_date}</p>
</button>
))}
{registers.filter((r: { state: string }) => r.state === "confirm").length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No open admissions available.</p>
)}
</div>
)}
<Button className="w-full" onClick={() => setStep("personal")} disabled={!canProceedFromRegister}>
Next
</Button>
</CardContent>
</Card>
)}
{step === "personal" && (
<Card>
<CardHeader>
<CardTitle>Personal Information</CardTitle>
<CardDescription>Fill in your personal details.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>First Name *</Label>
<Input value={form.first_name} onChange={e => setForm({ ...form, first_name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Last Name *</Label>
<Input value={form.last_name} onChange={e => setForm({ ...form, last_name: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Middle Name</Label>
<Input value={form.middle_name ?? ""} onChange={e => setForm({ ...form, middle_name: e.target.value || undefined })} />
</div>
<div className="space-y-2">
<Label>Email *</Label>
<Input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone ?? ""} onChange={e => setForm({ ...form, phone: e.target.value || undefined })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Birth Date</Label>
<Input type="date" value={form.birth_date ?? ""} onChange={e => setForm({ ...form, birth_date: e.target.value || undefined })} />
</div>
<div className="space-y-2">
<Label>Gender *</Label>
<Select value={form.gender} onValueChange={v => setForm({ ...form, gender: v as "m" | "f" | "o" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="m">Male</SelectItem>
<SelectItem value="f">Female</SelectItem>
<SelectItem value="o">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setStep("register")}>Back</Button>
<Button className="flex-1" onClick={() => setStep("review")} disabled={!canProceedFromPersonal}>Next</Button>
</div>
</CardContent>
</Card>
)}
{step === "review" && (
<Card>
<CardHeader>
<CardTitle>Review & Submit</CardTitle>
<CardDescription>Please review your application before submitting.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium">Course</p>
<p className="text-sm text-muted-foreground">{selectedRegister?.name} {selectedRegister?.course_name}</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium">Applicant</p>
<p className="text-sm text-muted-foreground">{form.first_name} {form.middle_name ?? ""} {form.last_name}</p>
<p className="text-sm text-muted-foreground">{form.email} {form.phone ? `· ${form.phone}` : ""}</p>
<p className="text-sm text-muted-foreground capitalize">{form.gender === "m" ? "Male" : form.gender === "f" ? "Female" : "Other"} {form.birth_date ? `· ${form.birth_date}` : ""}</p>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setStep("personal")}>Back</Button>
<Button className="flex-1" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>
{submitMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Application
</Button>
</div>
</CardContent>
</Card>
)}
{step === "success" && (
<Card>
<CardContent className="pt-8 pb-8 text-center space-y-4">
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto" />
<div>
<h2 className="text-xl font-bold">Application Submitted!</h2>
<p className="text-muted-foreground mt-1">Your application has been received.</p>
</div>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm text-muted-foreground">Application Number</p>
<p className="text-2xl font-bold">{applicationNumber}</p>
</div>
<p className="text-sm text-muted-foreground">Please save this number for future reference.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, ChevronRight, RefreshCw, Loader2 } from "lucide-react";
import {
useAcademicYears,
useCreateAcademicYear,
useDeleteAcademicYear,
useGenerateTerms,
useAcademicTerms,
} from "@/hooks/queries/useAcademic";
import { useToast } from "@/hooks/use-toast";
import type { AcademicYear, AcademicYearCreateRequest } from "@/types/academic";
const TERM_STRUCTURES: { value: AcademicYear["term_structure"]; label: string }[] = [
{ value: "two_sem", label: "Two Semesters" },
{ value: "two_sem_qua", label: "Two Semesters + Quarter" },
{ value: "three_sem", label: "Three Semesters" },
{ value: "four_Quarter", label: "Four Quarters" },
{ value: "final_year", label: "Final Year" },
{ value: "others", label: "Others" },
];
export default function AcademicYearManager() {
const { toast } = useToast();
const { data: yearsData, isLoading } = useAcademicYears();
const years = yearsData?.items ?? [];
const createYear = useCreateAcademicYear();
const deleteYear = useDeleteAcademicYear();
const generateTerms = useGenerateTerms();
const [showCreate, setShowCreate] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [form, setForm] = useState<AcademicYearCreateRequest>({
name: "",
start_date: "",
end_date: "",
term_structure: "two_sem",
});
const { data: termsData } = useAcademicTerms(expandedId ?? undefined);
const terms = termsData?.items ?? [];
const handleCreate = () => {
createYear.mutate(form, {
onSuccess: () => {
toast({ title: "Academic year created" });
setShowCreate(false);
setForm({ name: "", start_date: "", end_date: "", term_structure: "two_sem" });
},
onError: () => toast({ title: "Error", description: "Failed to create academic year", variant: "destructive" }),
});
};
const handleDelete = (id: number) => {
deleteYear.mutate(id, {
onSuccess: () => toast({ title: "Academic year deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
};
const handleGenerateTerms = (yearId: number) => {
generateTerms.mutate(yearId, {
onSuccess: () => toast({ title: "Terms generated" }),
onError: () => toast({ title: "Error", description: "Failed to generate terms", 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">Academic Years</h1>
<p className="text-muted-foreground">Manage academic years and their terms.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Academic Year</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Academic Year</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. 2025-2026" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Term Structure</Label>
<Select value={form.term_structure} onValueChange={v => setForm({ ...form, term_structure: v as AcademicYear["term_structure"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TERM_STRUCTURES.map(ts => (
<SelectItem key={ts.value} value={ts.value}>{ts.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleCreate} disabled={createYear.isPending || !form.name || !form.start_date || !form.end_date}>
{createYear.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead />
<TableHead>Name</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Term Structure</TableHead>
<TableHead>Terms</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{years.map((year: AcademicYear) => (
<Collapsible key={year.id} asChild open={expandedId === year.id} onOpenChange={open => setExpandedId(open ? year.id : null)}>
<>
<TableRow>
<TableCell>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ChevronRight className={`h-4 w-4 transition-transform ${expandedId === year.id ? "rotate-90" : ""}`} />
</Button>
</CollapsibleTrigger>
</TableCell>
<TableCell className="font-medium">{year.name}</TableCell>
<TableCell>{year.start_date}</TableCell>
<TableCell>{year.end_date}</TableCell>
<TableCell>
<Badge variant="outline">{TERM_STRUCTURES.find(t => t.value === year.term_structure)?.label ?? year.term_structure}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{year.terms?.length ?? 0}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleGenerateTerms(year.id)} disabled={generateTerms.isPending}>
<RefreshCw className="mr-1 h-3 w-3" /> Generate Terms
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(year.id)} disabled={deleteYear.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<TableRow>
<TableCell colSpan={7} className="bg-muted/50 p-4">
{expandedId === year.id && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Terms</h4>
{terms.length > 0 ? (
<div className="grid gap-2">
{terms.map(term => (
<div key={term.id} className="flex items-center justify-between rounded border p-3 bg-background">
<span className="text-sm font-medium">{term.name}</span>
<span className="text-sm text-muted-foreground">{term.term_start_date} {term.term_end_date}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No terms yet. Click "Generate Terms" to auto-create.</p>
)}
</div>
)}
</TableCell>
</TableRow>
</CollapsibleContent>
</>
</Collapsible>
))}
{years.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No academic years found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import { useParams, useNavigate } 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 { ArrowLeft, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useToast } from "@/hooks/use-toast";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
submit: "secondary",
confirm: "default",
admission: "default",
reject: "destructive",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const qc = useQueryClient();
const admissionId = Number(id);
const { data: admission, isLoading } = useQuery({
queryKey: ["admissions", admissionId],
queryFn: () => admissionService.getAdmission(admissionId),
enabled: !!admissionId,
});
const makeTransition = (action: "submit" | "confirm" | "admit" | "reject") => {
const fns = {
submit: admissionService.submitAdmission,
confirm: admissionService.confirmAdmission,
admit: admissionService.admitAdmission,
reject: admissionService.rejectAdmission,
};
return fns[action];
};
const transitionMutation = useMutation({
mutationFn: ({ action }: { action: "submit" | "confirm" | "admit" | "reject" }) =>
makeTransition(action)(admissionId),
onSuccess: (_, { action }) => {
qc.invalidateQueries({ queryKey: ["admissions"] });
toast({ title: `Admission ${action}${action === "submit" ? "ted" : action === "reject" ? "ed" : "ed"}` });
},
onError: () => toast({ title: "Error", description: "Action failed", 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>;
if (!admission) return <div className="text-center py-12 text-muted-foreground">Admission not found.</div>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>
<p className="text-muted-foreground">Application #{admission.application_number}</p>
</div>
<Badge variant={stateBadgeVariant[admission.state] ?? "outline"} className="capitalize text-sm px-3 py-1">
{admission.state}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>Applicant Information</CardTitle></CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Full Name</dt>
<dd className="text-sm font-medium">{admission.first_name} {admission.middle_name ?? ""} {admission.last_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Email</dt>
<dd className="text-sm font-medium">{admission.email}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Phone</dt>
<dd className="text-sm font-medium">{admission.phone ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Birth Date</dt>
<dd className="text-sm font-medium">{admission.birth_date ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Gender</dt>
<dd className="text-sm font-medium capitalize">{admission.gender === "m" ? "Male" : admission.gender === "f" ? "Female" : "Other"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Nationality</dt>
<dd className="text-sm font-medium">{admission.nationality_name ?? "—"}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Application Details</CardTitle></CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Course</dt>
<dd className="text-sm font-medium">{admission.course_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Register</dt>
<dd className="text-sm font-medium">{admission.register_name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Batch</dt>
<dd className="text-sm font-medium">{admission.batch_name ?? "—"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Fees</dt>
<dd className="text-sm font-medium">{admission.fees}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-muted-foreground">Applied On</dt>
<dd className="text-sm font-medium">{new Date(admission.created_at).toLocaleDateString()}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
<Card>
<CardHeader><CardTitle>Actions</CardTitle></CardHeader>
<CardContent>
<div className="flex items-center gap-3">
{admission.state === "draft" && (
<Button onClick={() => transitionMutation.mutate({ action: "submit" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Application
</Button>
)}
{admission.state === "submit" && (
<>
<Button onClick={() => transitionMutation.mutate({ action: "confirm" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm
</Button>
<Button variant="destructive" onClick={() => transitionMutation.mutate({ action: "reject" })} disabled={transitionMutation.isPending}>
Reject
</Button>
</>
)}
{admission.state === "confirm" && (
<>
<Button onClick={() => transitionMutation.mutate({ action: "admit" })} disabled={transitionMutation.isPending}>
{transitionMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Admit
</Button>
<Button variant="destructive" onClick={() => transitionMutation.mutate({ action: "reject" })} disabled={transitionMutation.isPending}>
Reject
</Button>
</>
)}
{["admission", "reject", "done"].includes(admission.state) && (
<p className="text-sm text-muted-foreground">No further actions available.</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import type { Admission, AdmissionState } from "@/types/admission";
const STATE_TABS: { value: string; label: string }[] = [
{ value: "all", label: "All" },
{ value: "draft", label: "Draft" },
{ value: "submit", label: "Submitted" },
{ value: "confirm", label: "Confirmed" },
{ value: "admission", label: "Admitted" },
{ value: "reject", label: "Rejected" },
];
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
submit: "secondary",
confirm: "default",
admission: "default",
reject: "destructive",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionList() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [stateFilter, setStateFilter] = useState("all");
const { data, isLoading } = useQuery({
queryKey: ["admissions", "list", { search, state: stateFilter === "all" ? undefined : stateFilter }],
queryFn: () => admissionService.listAdmissions({
search: search || undefined,
state: stateFilter === "all" ? undefined : stateFilter,
} as Parameters<typeof admissionService.listAdmissions>[0]),
});
const admissions = data?.items ?? [];
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">Admissions</h1>
<p className="text-muted-foreground">View and manage student admission applications.</p>
</div>
<div className="flex items-center gap-2">
{STATE_TABS.map(tab => (
<Button
key={tab.value}
variant={stateFilter === tab.value ? "default" : "outline"}
size="sm"
onClick={() => setStateFilter(tab.value)}
>
{tab.label}
</Button>
))}
</div>
<Card>
<CardHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search by name..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Application #</TableHead>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Register</TableHead>
<TableHead>State</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{admissions.map((adm: Admission) => (
<TableRow key={adm.id} className="cursor-pointer hover:bg-accent/50" onClick={() => navigate(`/admin/admissions/${adm.id}`)}>
<TableCell className="font-medium">{adm.application_number}</TableCell>
<TableCell>{adm.first_name} {adm.last_name}</TableCell>
<TableCell>{adm.course_name}</TableCell>
<TableCell className="text-muted-foreground">{adm.register_name}</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[adm.state] ?? "outline"} className="capitalize">{adm.state}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{new Date(adm.created_at).toLocaleDateString()}</TableCell>
</TableRow>
))}
{admissions.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No admissions found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { admissionService } from "@/services/admission.service";
import { useCourses } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { AdmissionRegister, AdmissionRegisterCreateRequest } from "@/types/admission";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
confirm: "default",
cancel: "destructive",
done: "secondary",
};
export default function AdmissionRegisterPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data: registersData, isLoading } = useQuery({
queryKey: ["admission-registers", "list"],
queryFn: () => admissionService.listRegisters(),
});
const registers = registersData?.items ?? [];
const { data: coursesData } = useCourses();
const courses = coursesData?.items ?? [];
const [form, setForm] = useState<AdmissionRegisterCreateRequest>({
name: "",
course_id: 0,
start_date: "",
end_date: "",
min_count: undefined,
max_count: undefined,
});
const createMutation = useMutation({
mutationFn: (data: AdmissionRegisterCreateRequest) => admissionService.createRegister(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admission-registers"] });
toast({ title: "Register created" });
setShowCreate(false);
setForm({ name: "", course_id: 0, start_date: "", end_date: "" });
},
onError: () => toast({ title: "Error", description: "Failed to create register", variant: "destructive" }),
});
const confirmMutation = useMutation({
mutationFn: (id: number) => admissionService.confirmRegister(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admission-registers"] }); toast({ title: "Register confirmed" }); },
onError: () => toast({ title: "Error", description: "Failed to confirm", variant: "destructive" }),
});
const closeMutation = useMutation({
mutationFn: (id: number) => admissionService.closeRegister(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admission-registers"] }); toast({ title: "Register closed" }); },
onError: () => toast({ title: "Error", description: "Failed to close", 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">Admission Registers</h1>
<p className="text-muted-foreground">Manage admission registers and their status.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Register</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Admission Register</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Fall 2025 Intake" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id ? form.course_id.toString() : ""} onValueChange={v => setForm({ ...form, course_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map((c: { id: number; name: string }) => (
<SelectItem key={c.id} value={c.id.toString()}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Min Count</Label>
<Input type="number" value={form.min_count ?? ""} onChange={e => setForm({ ...form, min_count: e.target.value ? Number(e.target.value) : undefined })} />
</div>
<div className="space-y-2">
<Label>Max Count</Label>
<Input type="number" value={form.max_count ?? ""} onChange={e => setForm({ ...form, max_count: e.target.value ? Number(e.target.value) : undefined })} />
</div>
</div>
<Button className="w-full" onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.name || !form.course_id}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Register
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Applications</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{registers.map((reg: AdmissionRegister) => (
<TableRow key={reg.id}>
<TableCell className="font-medium">{reg.name}</TableCell>
<TableCell>{reg.course_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{reg.start_date} {reg.end_date}</TableCell>
<TableCell>{reg.min_count}{reg.max_count}</TableCell>
<TableCell><Badge variant="secondary">{reg.application_count}</Badge></TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[reg.state] ?? "outline"} className="capitalize">{reg.state}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{reg.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => confirmMutation.mutate(reg.id)} disabled={confirmMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Confirm
</Button>
)}
{reg.state === "confirm" && (
<Button variant="ghost" size="sm" onClick={() => closeMutation.mutate(reg.id)} disabled={closeMutation.isPending}>
<XCircle className="mr-1 h-3 w-3" /> Close
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
{registers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No admission registers found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import {
useDepartments,
useCreateDepartment,
useUpdateDepartment,
useDeleteDepartment,
} from "@/hooks/queries/useAcademic";
import { useToast } from "@/hooks/use-toast";
import type { Department, DepartmentCreateRequest } from "@/types/academic";
export default function DepartmentManager() {
const { toast } = useToast();
const { data: deptsData, isLoading } = useDepartments();
const depts = deptsData?.items ?? [];
const createDept = useCreateDepartment();
const updateDept = useUpdateDepartment();
const deleteDept = useDeleteDepartment();
const [showDialog, setShowDialog] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [form, setForm] = useState<DepartmentCreateRequest>({ name: "", code: "" });
const openCreate = () => {
setEditing(null);
setForm({ name: "", code: "" });
setShowDialog(true);
};
const openEdit = (dept: Department) => {
setEditing(dept);
setForm({ name: dept.name, code: dept.code, parent_id: dept.parent_id });
setShowDialog(true);
};
const handleSave = () => {
if (editing) {
updateDept.mutate({ id: editing.id, data: form }, {
onSuccess: () => { toast({ title: "Department updated" }); setShowDialog(false); },
onError: () => toast({ title: "Error", description: "Failed to update", variant: "destructive" }),
});
} else {
createDept.mutate(form, {
onSuccess: () => { toast({ title: "Department created" }); setShowDialog(false); setForm({ name: "", code: "" }); },
onError: () => toast({ title: "Error", description: "Failed to create", variant: "destructive" }),
});
}
};
const handleDelete = (id: number) => {
deleteDept.mutate(id, {
onSuccess: () => toast({ title: "Department deleted" }),
onError: () => toast({ title: "Error", description: "Failed to delete", variant: "destructive" }),
});
};
const isSaving = createDept.isPending || updateDept.isPending;
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">Departments</h1>
<p className="text-muted-foreground">Manage departments and their hierarchy.</p>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button onClick={openCreate}><Plus className="mr-2 h-4 w-4" /> Add Department</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{editing ? "Edit Department" : "Create Department"}</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Computer Science" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input placeholder="e.g. CS" value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Parent Department</Label>
<Select value={form.parent_id?.toString() ?? "none"} onValueChange={v => setForm({ ...form, parent_id: v === "none" ? undefined : Number(v) })}>
<SelectTrigger><SelectValue placeholder="None" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{depts.filter(d => d.id !== editing?.id).map(d => (
<SelectItem key={d.id} value={d.id.toString()}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={handleSave} disabled={isSaving || !form.name || !form.code}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editing ? "Update" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Parent</TableHead>
<TableHead>Courses</TableHead>
<TableHead>Faculty</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{depts.map((dept: Department) => (
<TableRow key={dept.id}>
<TableCell className="font-medium">{dept.name}</TableCell>
<TableCell>{dept.code}</TableCell>
<TableCell className="text-muted-foreground">{dept.parent_name ?? "—"}</TableCell>
<TableCell>{dept.course_count}</TableCell>
<TableCell>{dept.faculty_count}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(dept)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(dept.id)} disabled={deleteDept.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{depts.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No departments found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, ChevronRight, CalendarCheck, CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useCourses, useBatches } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { InstitutionalExamSession, InstitutionalExam } from "@/types/institutional-exam";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
schedule: "secondary",
held: "default",
cancel: "destructive",
done: "default",
};
export default function InstitutionalExamSessions() {
const { toast } = useToast();
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const { data: sessionsData, isLoading } = useQuery({
queryKey: ["inst-exam-sessions", "list"],
queryFn: () => institutionalExamService.listSessions(),
});
const sessions = sessionsData?.items ?? [];
const { data: expandedSession } = useQuery({
queryKey: ["inst-exam-sessions", expandedId],
queryFn: () => institutionalExamService.getSession(expandedId!),
enabled: !!expandedId,
});
const { data: examTypesData } = useQuery({
queryKey: ["exam-types", "list"],
queryFn: () => institutionalExamService.listExamTypes(),
});
const examTypes = examTypesData?.items ?? [];
const { data: coursesData } = useCourses();
const courses = coursesData?.items ?? [];
const { data: batchesData } = useBatches();
const batches = batchesData?.items ?? [];
const [form, setForm] = useState({
name: "",
course_id: 0,
batch_id: 0,
start_date: "",
end_date: "",
exam_type_id: 0,
evaluation_type: "normal" as "normal" | "grade",
});
const createMutation = useMutation({
mutationFn: () => institutionalExamService.createSession(form as Parameters<typeof institutionalExamService.createSession>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] });
toast({ title: "Exam session created" });
setShowCreate(false);
setForm({ name: "", course_id: 0, batch_id: 0, start_date: "", end_date: "", exam_type_id: 0, evaluation_type: "normal" });
},
onError: () => toast({ title: "Error", description: "Failed to create session", variant: "destructive" }),
});
const scheduleMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.scheduleSession(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] }); toast({ title: "Session scheduled" }); },
onError: () => toast({ title: "Error", description: "Failed to schedule", variant: "destructive" }),
});
const doneMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.markSessionDone(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["inst-exam-sessions"] }); toast({ title: "Session marked as done" }); },
onError: () => toast({ title: "Error", description: "Failed to update", 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">Exam Sessions</h1>
<p className="text-muted-foreground">Manage institutional exam sessions and their exams.</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create Session</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Exam Session</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mid-Term Exams 2025" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id ? form.course_id.toString() : ""} onValueChange={v => setForm({ ...form, course_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{courses.map((c: { id: number; name: string }) => (
<SelectItem key={c.id} value={c.id.toString()}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Batch</Label>
<Select value={form.batch_id ? form.batch_id.toString() : ""} onValueChange={v => setForm({ ...form, batch_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{batches.map((b: { id: number; name: string }) => (
<SelectItem key={b.id} value={b.id.toString()}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Exam Type</Label>
<Select value={form.exam_type_id ? form.exam_type_id.toString() : ""} onValueChange={v => setForm({ ...form, exam_type_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
{examTypes.map((t: { id: number; name: string }) => (
<SelectItem key={t.id} value={t.id.toString()}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Evaluation Type</Label>
<Select value={form.evaluation_type} onValueChange={v => setForm({ ...form, evaluation_type: v as "normal" | "grade" })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="grade">Grade</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button className="w-full" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !form.name || !form.course_id || !form.batch_id}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Session
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead />
<TableHead>Name</TableHead>
<TableHead>Course / Batch</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Type</TableHead>
<TableHead>Exams</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session: InstitutionalExamSession) => (
<Collapsible key={session.id} asChild open={expandedId === session.id} onOpenChange={open => setExpandedId(open ? session.id : null)}>
<>
<TableRow>
<TableCell>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ChevronRight className={`h-4 w-4 transition-transform ${expandedId === session.id ? "rotate-90" : ""}`} />
</Button>
</CollapsibleTrigger>
</TableCell>
<TableCell className="font-medium">{session.name}</TableCell>
<TableCell>
<div>
<span className="text-sm">{session.course_name}</span>
<span className="text-xs text-muted-foreground ml-1">/ {session.batch_name}</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{session.start_date} {session.end_date}</TableCell>
<TableCell><Badge variant="outline">{session.exam_type_name}</Badge></TableCell>
<TableCell><Badge variant="secondary">{session.exam_count}</Badge></TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[session.state] ?? "outline"} className="capitalize">{session.state}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{session.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => scheduleMutation.mutate(session.id)} disabled={scheduleMutation.isPending}>
<CalendarCheck className="mr-1 h-3 w-3" /> Schedule
</Button>
)}
{session.state === "schedule" && (
<Button variant="ghost" size="sm" onClick={() => doneMutation.mutate(session.id)} disabled={doneMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Done
</Button>
)}
</div>
</TableCell>
</TableRow>
<CollapsibleContent asChild>
<TableRow>
<TableCell colSpan={8} className="bg-muted/50 p-4">
{expandedSession && expandedId === session.id ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">Exams in Session</h4>
{(expandedSession as InstitutionalExamSession & { exams?: InstitutionalExam[] }).exams?.length ? (
<div className="grid gap-2">
{((expandedSession as InstitutionalExamSession & { exams?: InstitutionalExam[] }).exams ?? []).map((exam: InstitutionalExam) => (
<div key={exam.id} className="flex items-center justify-between rounded border p-3 bg-background">
<div>
<span className="text-sm font-medium">{exam.name}</span>
<span className="text-xs text-muted-foreground ml-2">{exam.subject_name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{exam.total_marks} marks</span>
<Badge variant="outline" className="text-xs capitalize">{exam.state}</Badge>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No exams in this session yet.</p>
)}
</div>
) : (
<div className="flex justify-center py-4"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
)}
</TableCell>
</TableRow>
</CollapsibleContent>
</>
</Collapsible>
))}
{sessions.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">No exam sessions found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,312 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, FileSpreadsheet, CheckCircle2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useToast } from "@/hooks/use-toast";
import type { ResultTemplate, Marksheet, MarksheetLine, GradeConfig } from "@/types/institutional-exam";
const marksheetStateBadge: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
validated: "default",
cancelled: "destructive",
};
export default function MarksheetManager() {
const { toast } = useToast();
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<"templates" | "marksheets">("templates");
const [showCreateTemplate, setShowCreateTemplate] = useState(false);
const [selectedMarksheetId, setSelectedMarksheetId] = useState<number | null>(null);
const { data: templatesData, isLoading: loadingTemplates } = useQuery({
queryKey: ["result-templates", "list"],
queryFn: () => institutionalExamService.listResultTemplates(),
});
const templates = templatesData?.items ?? [];
const { data: marksheetsData, isLoading: loadingMarksheets } = useQuery({
queryKey: ["marksheets", "list"],
queryFn: () => institutionalExamService.listMarksheets(),
enabled: activeTab === "marksheets",
});
const marksheets = marksheetsData?.items ?? [];
const { data: selectedMarksheet } = useQuery({
queryKey: ["marksheets", selectedMarksheetId],
queryFn: () => institutionalExamService.getMarksheet(selectedMarksheetId!),
enabled: !!selectedMarksheetId,
});
const { data: sessionsData } = useQuery({
queryKey: ["inst-exam-sessions", "list"],
queryFn: () => institutionalExamService.listSessions(),
});
const sessions = sessionsData?.items ?? [];
const { data: gradeConfigsData } = useQuery({
queryKey: ["grade-configs", "list"],
queryFn: () => institutionalExamService.listGradeConfigs(),
});
const gradeConfigs = gradeConfigsData?.items ?? [];
const [templateForm, setTemplateForm] = useState({
name: "",
exam_session_id: 0,
grade_ids: [] as number[],
});
const createTemplateMutation = useMutation({
mutationFn: () => institutionalExamService.createResultTemplate(templateForm as Parameters<typeof institutionalExamService.createResultTemplate>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Template created" });
setShowCreateTemplate(false);
setTemplateForm({ name: "", exam_session_id: 0, grade_ids: [] });
},
onError: () => toast({ title: "Error", description: "Failed to create template", variant: "destructive" }),
});
const generateMutation = useMutation({
mutationFn: (templateId: number) => institutionalExamService.generateMarksheets(templateId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
qc.invalidateQueries({ queryKey: ["result-templates"] });
toast({ title: "Marksheets generated" });
},
onError: () => toast({ title: "Error", description: "Generation failed", variant: "destructive" }),
});
const validateMutation = useMutation({
mutationFn: (id: number) => institutionalExamService.validateMarksheet(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["marksheets"] });
toast({ title: "Marksheet validated" });
},
onError: () => toast({ title: "Error", description: "Validation failed", variant: "destructive" }),
});
const isLoading = activeTab === "templates" ? loadingTemplates : loadingMarksheets;
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">Marksheet Manager</h1>
<p className="text-muted-foreground">Manage result templates and marksheets.</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant={activeTab === "templates" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("templates")}>
Result Templates
</Button>
<Button variant={activeTab === "marksheets" ? "default" : "outline"} size="sm" onClick={() => setActiveTab("marksheets")}>
Marksheets
</Button>
</div>
{activeTab === "templates" && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Result Templates</CardTitle>
<Dialog open={showCreateTemplate} onOpenChange={setShowCreateTemplate}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="mr-2 h-4 w-4" /> Create Template</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Result Template</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mid-Term Results" value={templateForm.name} onChange={e => setTemplateForm({ ...templateForm, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Exam Session</Label>
<Select value={templateForm.exam_session_id ? templateForm.exam_session_id.toString() : ""} onValueChange={v => setTemplateForm({ ...templateForm, exam_session_id: Number(v) })}>
<SelectTrigger><SelectValue placeholder="Select session" /></SelectTrigger>
<SelectContent>
{sessions.map((s: { id: number; name: string }) => (
<SelectItem key={s.id} value={s.id.toString()}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Grade Configuration</Label>
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-lg p-2">
{gradeConfigs.map((gc: GradeConfig) => (
<label key={gc.id} className="flex items-center gap-2 p-1 hover:bg-accent/50 rounded cursor-pointer">
<input
type="checkbox"
checked={templateForm.grade_ids.includes(gc.id)}
onChange={e => {
setTemplateForm(prev => ({
...prev,
grade_ids: e.target.checked
? [...prev.grade_ids, gc.id]
: prev.grade_ids.filter(id => id !== gc.id),
}));
}}
className="rounded"
/>
<span className="text-sm">{gc.result} ({gc.min_per}%{gc.max_per}%)</span>
</label>
))}
{gradeConfigs.length === 0 && <p className="text-xs text-muted-foreground p-1">No grade configs available.</p>}
</div>
</div>
<Button className="w-full" onClick={() => createTemplateMutation.mutate()} disabled={createTemplateMutation.isPending || !templateForm.name || !templateForm.exam_session_id}>
{createTemplateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Template
</Button>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Evaluation</TableHead>
<TableHead>Result Date</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((tpl: ResultTemplate) => (
<TableRow key={tpl.id}>
<TableCell className="font-medium">{tpl.name}</TableCell>
<TableCell>{tpl.exam_session_name}</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{tpl.evaluation_type}</Badge></TableCell>
<TableCell className="text-muted-foreground">{tpl.result_date}</TableCell>
<TableCell>
<Badge variant={tpl.state === "result_generated" ? "default" : "outline"} className="capitalize">
{tpl.state.replace("_", " ")}
</Badge>
</TableCell>
<TableCell className="text-right">
{tpl.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => generateMutation.mutate(tpl.id)} disabled={generateMutation.isPending}>
<FileSpreadsheet className="mr-1 h-3 w-3" /> Generate Marksheets
</Button>
)}
</TableCell>
</TableRow>
))}
{templates.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">No result templates found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{activeTab === "marksheets" && (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Exam Session</TableHead>
<TableHead>Generated</TableHead>
<TableHead>Pass</TableHead>
<TableHead>Fail</TableHead>
<TableHead>State</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{marksheets.map((ms: Marksheet) => (
<TableRow
key={ms.id}
className="cursor-pointer hover:bg-accent/50"
onClick={() => setSelectedMarksheetId(selectedMarksheetId === ms.id ? null : ms.id)}
>
<TableCell className="font-medium">{ms.name}</TableCell>
<TableCell>{ms.exam_session_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{ms.generated_date} by {ms.generated_by_name}</TableCell>
<TableCell><Badge variant="default">{ms.total_pass}</Badge></TableCell>
<TableCell><Badge variant="destructive">{ms.total_failed}</Badge></TableCell>
<TableCell>
<Badge variant={marksheetStateBadge[ms.state] ?? "outline"} className="capitalize">{ms.state}</Badge>
</TableCell>
<TableCell className="text-right" onClick={e => e.stopPropagation()}>
{ms.state === "draft" && (
<Button variant="ghost" size="sm" onClick={() => validateMutation.mutate(ms.id)} disabled={validateMutation.isPending}>
<CheckCircle2 className="mr-1 h-3 w-3" /> Validate
</Button>
)}
</TableCell>
</TableRow>
))}
{marksheets.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">No marksheets found.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{selectedMarksheet && (
<Card>
<CardHeader>
<CardTitle>Results {selectedMarksheet.name}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Total Marks</TableHead>
<TableHead>Percentage</TableHead>
<TableHead>Grade</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(selectedMarksheet.lines ?? []).map((line: MarksheetLine) => (
<TableRow key={line.id}>
<TableCell className="font-medium">{line.student_name}</TableCell>
<TableCell>{line.total_marks}</TableCell>
<TableCell>{line.percentage.toFixed(1)}%</TableCell>
<TableCell>{line.grade ?? "—"}</TableCell>
<TableCell>
<Badge variant={line.status === "pass" ? "default" : "destructive"} className="capitalize">{line.status}</Badge>
</TableCell>
</TableRow>
))}
{(selectedMarksheet.lines ?? []).length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No results in this marksheet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useToast } from "@/hooks/use-toast";
import type { SubjectRegistration } from "@/types/institutional-exam";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
confirm: "default",
reject: "destructive",
done: "secondary",
};
export default function SubjectRegistrationPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [selectedSubjects, setSelectedSubjects] = useState<number[]>([]);
const { data: registrationsData, isLoading } = useQuery({
queryKey: ["subject-registrations", "list"],
queryFn: () => institutionalExamService.listSubjectRegistrations(),
});
const registrations = registrationsData?.items ?? [];
const { data: available = [], isLoading: loadingAvailable } = useQuery({
queryKey: ["subject-registrations", "available"],
queryFn: () => institutionalExamService.getAvailableSubjects(),
});
const createMutation = useMutation({
mutationFn: () => institutionalExamService.createSubjectRegistration({
subject_ids: selectedSubjects,
} as Parameters<typeof institutionalExamService.createSubjectRegistration>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["subject-registrations"] });
toast({ title: "Registration submitted" });
setSelectedSubjects([]);
},
onError: () => toast({ title: "Error", description: "Registration failed", variant: "destructive" }),
});
const toggleSubject = (id: number) => {
setSelectedSubjects(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
};
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">Subject Registration</h1>
<p className="text-muted-foreground">Register for subjects within your enrolled courses.</p>
</div>
<Card>
<CardHeader>
<CardTitle>Available Subjects</CardTitle>
<CardDescription>Select the subjects you want to register for this term.</CardDescription>
</CardHeader>
<CardContent>
{loadingAvailable ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : available.length > 0 ? (
<div className="space-y-4">
<div className="space-y-2">
{available.map((subj: SubjectRegistration) => (
<label
key={subj.id}
className="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent/50 cursor-pointer"
>
<Checkbox
checked={selectedSubjects.includes(subj.id)}
onCheckedChange={() => toggleSubject(subj.id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{subj.subject_names?.join(", ") || subj.course_name}</p>
<p className="text-xs text-muted-foreground">
{subj.course_name} · {subj.batch_name}
{subj.min_unit_load > 0 && ` · Load: ${subj.min_unit_load}${subj.max_unit_load}`}
</p>
</div>
</label>
))}
</div>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending || selectedSubjects.length === 0}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Registration ({selectedSubjects.length} selected)
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No subjects available for registration.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>My Registrations</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Subjects</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{registrations.map((reg: SubjectRegistration) => (
<TableRow key={reg.id}>
<TableCell className="font-medium">{reg.course_name}</TableCell>
<TableCell className="text-muted-foreground">{reg.batch_name}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{reg.subject_names.slice(0, 3).map((name, i) => (
<Badge key={i} variant="secondary" className="text-xs">{name}</Badge>
))}
{reg.subject_names.length > 3 && <Badge variant="secondary" className="text-xs">+{reg.subject_names.length - 3}</Badge>}
</div>
</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[reg.state] ?? "outline"} className="capitalize">{reg.state}</Badge>
</TableCell>
</TableRow>
))}
{registrations.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">No registrations yet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { api } from "@/lib/api-client";
import type {
AcademicYear, AcademicTerm, Department,
AcademicYearCreateRequest, AcademicTermCreateRequest, DepartmentCreateRequest,
} from "@/types/academic";
import type { PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export const academicService = {
async listAcademicYears(params?: PaginationParams): Promise<PaginatedResponse<AcademicYear>> {
return api.get<PaginatedResponse<AcademicYear>>("/academic-years", params as Record<string, string | number | boolean | undefined>);
},
async getAcademicYear(id: number): Promise<AcademicYear> {
return api.get<AcademicYear>(`/academic-years/${id}`);
},
async createAcademicYear(data: AcademicYearCreateRequest): Promise<AcademicYear> {
return api.post<AcademicYear>("/academic-years", data);
},
async updateAcademicYear(id: number, data: Partial<AcademicYearCreateRequest>): Promise<AcademicYear> {
return api.patch<AcademicYear>(`/academic-years/${id}`, data);
},
async deleteAcademicYear(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/academic-years/${id}`);
},
async generateTerms(yearId: number): Promise<AcademicTerm[]> {
return api.post<AcademicTerm[]>(`/academic-years/${yearId}/generate-terms`);
},
async listTerms(params?: PaginationParams & { year_id?: number }): Promise<PaginatedResponse<AcademicTerm>> {
return api.get<PaginatedResponse<AcademicTerm>>("/academic-terms", params as Record<string, string | number | boolean | undefined>);
},
async createTerm(data: AcademicTermCreateRequest): Promise<AcademicTerm> {
return api.post<AcademicTerm>("/academic-terms", data);
},
async updateTerm(id: number, data: Partial<AcademicTermCreateRequest>): Promise<AcademicTerm> {
return api.patch<AcademicTerm>(`/academic-terms/${id}`, data);
},
async deleteTerm(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/academic-terms/${id}`);
},
async listDepartments(params?: PaginationParams): Promise<PaginatedResponse<Department>> {
return api.get<PaginatedResponse<Department>>("/departments", params as Record<string, string | number | boolean | undefined>);
},
async createDepartment(data: DepartmentCreateRequest): Promise<Department> {
return api.post<Department>("/departments", data);
},
async updateDepartment(id: number, data: Partial<DepartmentCreateRequest>): Promise<Department> {
return api.patch<Department>(`/departments/${id}`, data);
},
async deleteDepartment(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/departments/${id}`);
},
};

View File

@@ -0,0 +1,56 @@
import { api } from "@/lib/api-client";
import type {
AdmissionRegister, Admission,
AdmissionRegisterCreateRequest, AdmissionCreateRequest,
} from "@/types/admission";
import type { PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export const admissionService = {
async listRegisters(params?: PaginationParams): Promise<PaginatedResponse<AdmissionRegister>> {
return api.get<PaginatedResponse<AdmissionRegister>>("/admission-registers", params as Record<string, string | number | boolean | undefined>);
},
async createRegister(data: AdmissionRegisterCreateRequest): Promise<AdmissionRegister> {
return api.post<AdmissionRegister>("/admission-registers", data);
},
async updateRegister(id: number, data: Partial<AdmissionRegisterCreateRequest>): Promise<AdmissionRegister> {
return api.patch<AdmissionRegister>(`/admission-registers/${id}`, data);
},
async confirmRegister(id: number): Promise<AdmissionRegister> {
return api.post<AdmissionRegister>(`/admission-registers/${id}/confirm`);
},
async closeRegister(id: number): Promise<AdmissionRegister> {
return api.post<AdmissionRegister>(`/admission-registers/${id}/close`);
},
async listAdmissions(params?: PaginationParams & { state?: string; register_id?: number; course_id?: number }): Promise<PaginatedResponse<Admission>> {
return api.get<PaginatedResponse<Admission>>("/admissions", params as Record<string, string | number | boolean | undefined>);
},
async getAdmission(id: number): Promise<Admission> {
return api.get<Admission>(`/admissions/${id}`);
},
async createAdmission(data: AdmissionCreateRequest): Promise<Admission> {
return api.post<Admission>("/admissions", data);
},
async submitAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/submit`);
},
async confirmAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/confirm`);
},
async admitAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/admit`);
},
async rejectAdmission(id: number): Promise<Admission> {
return api.post<Admission>(`/admissions/${id}/reject`);
},
};

View File

@@ -19,3 +19,6 @@ export { resourcesService } from "./resources.service";
export { coachingService } from "./coaching.service";
export { analyticsService } from "./analytics.service";
export { lmsService } from "./lms.service";
export { academicService } from "./academic.service";
export { admissionService } from "./admission.service";
export { institutionalExamService } from "./institutional-exam.service";

View File

@@ -0,0 +1,165 @@
import { api } from "@/lib/api-client";
import type {
InstExamSession, InstExam, ExamType, GradeConfig,
ResultTemplate, Marksheet, CourseAssignment, AssignmentSubmission,
AssignmentType, SubjectRegistration,
InstExamSessionCreateRequest, InstExamCreateRequest,
ExamTypeCreateRequest, GradeConfigCreateRequest,
ResultTemplateCreateRequest, CourseAssignmentCreateRequest,
AssignmentTypeCreateRequest, SubjectRegistrationCreateRequest,
} from "@/types/institutional-exam";
import type { PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export const institutionalExamService = {
async listSessions(params?: PaginationParams): Promise<PaginatedResponse<InstExamSession>> {
return api.get<PaginatedResponse<InstExamSession>>("/inst-exam-sessions", params as Record<string, string | number | boolean | undefined>);
},
async getSession(id: number): Promise<InstExamSession> {
return api.get<InstExamSession>(`/inst-exam-sessions/${id}`);
},
async createSession(data: InstExamSessionCreateRequest): Promise<InstExamSession> {
return api.post<InstExamSession>("/inst-exam-sessions", data);
},
async updateSession(id: number, data: Partial<InstExamSessionCreateRequest>): Promise<InstExamSession> {
return api.patch<InstExamSession>(`/inst-exam-sessions/${id}`, data);
},
async deleteSession(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/inst-exam-sessions/${id}`);
},
async scheduleSession(id: number): Promise<InstExamSession> {
return api.post<InstExamSession>(`/inst-exam-sessions/${id}/schedule`);
},
async markSessionDone(id: number): Promise<InstExamSession> {
return api.post<InstExamSession>(`/inst-exam-sessions/${id}/done`);
},
async createExam(data: InstExamCreateRequest): Promise<InstExam> {
return api.post<InstExam>("/inst-exams", data);
},
async updateExam(id: number, data: Partial<InstExamCreateRequest>): Promise<InstExam> {
return api.patch<InstExam>(`/inst-exams/${id}`, data);
},
async addAttendees(examId: number, data: { student_ids: number[] }): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/inst-exams/${examId}/attendees`, data);
},
async enterMarks(examId: number, data: { marks: { student_id: number; score: number }[] }): Promise<ApiSuccessResponse> {
return api.patch<ApiSuccessResponse>(`/inst-exams/${examId}/marks`, data);
},
async listExamTypes(params?: PaginationParams): Promise<PaginatedResponse<ExamType>> {
return api.get<PaginatedResponse<ExamType>>("/exam-types", params as Record<string, string | number | boolean | undefined>);
},
async createExamType(data: ExamTypeCreateRequest): Promise<ExamType> {
return api.post<ExamType>("/exam-types", data);
},
async listGradeConfigs(params?: PaginationParams): Promise<PaginatedResponse<GradeConfig>> {
return api.get<PaginatedResponse<GradeConfig>>("/grade-config", params as Record<string, string | number | boolean | undefined>);
},
async createGradeConfig(data: GradeConfigCreateRequest): Promise<GradeConfig> {
return api.post<GradeConfig>("/grade-config", data);
},
async listResultTemplates(params?: PaginationParams): Promise<PaginatedResponse<ResultTemplate>> {
return api.get<PaginatedResponse<ResultTemplate>>("/result-templates", params as Record<string, string | number | boolean | undefined>);
},
async createResultTemplate(data: ResultTemplateCreateRequest): Promise<ResultTemplate> {
return api.post<ResultTemplate>("/result-templates", data);
},
async generateMarksheets(templateId: number): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/result-templates/${templateId}/generate`);
},
async listMarksheets(params?: PaginationParams): Promise<PaginatedResponse<Marksheet>> {
return api.get<PaginatedResponse<Marksheet>>("/marksheets", params as Record<string, string | number | boolean | undefined>);
},
async getMarksheet(id: number): Promise<Marksheet> {
return api.get<Marksheet>(`/marksheets/${id}`);
},
async validateMarksheet(id: number): Promise<Marksheet> {
return api.post<Marksheet>(`/marksheets/${id}/validate`);
},
async listCourseAssignments(params?: PaginationParams & { course_id?: number; batch_id?: number; state?: string }): Promise<PaginatedResponse<CourseAssignment>> {
return api.get<PaginatedResponse<CourseAssignment>>("/course-assignments", params as Record<string, string | number | boolean | undefined>);
},
async createCourseAssignment(data: CourseAssignmentCreateRequest): Promise<CourseAssignment> {
return api.post<CourseAssignment>("/course-assignments", data);
},
async getCourseAssignment(id: number): Promise<CourseAssignment> {
return api.get<CourseAssignment>(`/course-assignments/${id}`);
},
async updateCourseAssignment(id: number, data: Partial<CourseAssignmentCreateRequest>): Promise<CourseAssignment> {
return api.patch<CourseAssignment>(`/course-assignments/${id}`, data);
},
async publishCourseAssignment(id: number): Promise<CourseAssignment> {
return api.post<CourseAssignment>(`/course-assignments/${id}/publish`);
},
async finishCourseAssignment(id: number): Promise<CourseAssignment> {
return api.post<CourseAssignment>(`/course-assignments/${id}/finish`);
},
async listSubmissions(assignmentId: number, params?: PaginationParams): Promise<PaginatedResponse<AssignmentSubmission>> {
return api.get<PaginatedResponse<AssignmentSubmission>>(`/course-assignments/${assignmentId}/submissions`, params as Record<string, string | number | boolean | undefined>);
},
async submitAssignment(assignmentId: number, file: File): Promise<AssignmentSubmission> {
return api.upload<AssignmentSubmission>(`/course-assignments/${assignmentId}/submit`, file);
},
async gradeSubmission(submissionId: number, data: Partial<AssignmentSubmission>): Promise<AssignmentSubmission> {
return api.patch<AssignmentSubmission>(`/submissions/${submissionId}`, data);
},
async listAssignmentTypes(params?: PaginationParams): Promise<PaginatedResponse<AssignmentType>> {
return api.get<PaginatedResponse<AssignmentType>>("/assignment-types", params as Record<string, string | number | boolean | undefined>);
},
async createAssignmentType(data: AssignmentTypeCreateRequest): Promise<AssignmentType> {
return api.post<AssignmentType>("/assignment-types", data);
},
async listSubjectRegistrations(params?: PaginationParams & { student_id?: number; course_id?: number; state?: string }): Promise<PaginatedResponse<SubjectRegistration>> {
return api.get<PaginatedResponse<SubjectRegistration>>("/subject-registrations", params as Record<string, string | number | boolean | undefined>);
},
async createSubjectRegistration(data: SubjectRegistrationCreateRequest): Promise<SubjectRegistration> {
return api.post<SubjectRegistration>("/subject-registrations", data);
},
async updateSubjectRegistration(id: number, data: Partial<SubjectRegistrationCreateRequest>): Promise<SubjectRegistration> {
return api.patch<SubjectRegistration>(`/subject-registrations/${id}`, data);
},
async confirmSubjectRegistration(id: number): Promise<SubjectRegistration> {
return api.post<SubjectRegistration>(`/subject-registrations/${id}/confirm`);
},
async rejectSubjectRegistration(id: number): Promise<SubjectRegistration> {
return api.post<SubjectRegistration>(`/subject-registrations/${id}/reject`);
},
async getAvailableSubjects(params?: { student_id?: number; course_id?: number }): Promise<SubjectRegistration[]> {
return api.get<SubjectRegistration[]>("/subject-registrations/available", params as Record<string, string | number | boolean | undefined>);
},
};

50
src/types/academic.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface AcademicYear {
id: number;
name: string;
start_date: string;
end_date: string;
term_structure: "two_sem" | "two_sem_qua" | "three_sem" | "four_Quarter" | "final_year" | "others";
terms: AcademicTerm[];
}
export interface AcademicTerm {
id: number;
name: string;
term_start_date: string;
term_end_date: string;
academic_year_id: number;
academic_year_name: string;
parent_term_id?: number;
parent_term_name?: string;
}
export interface Department {
id: number;
name: string;
code: string;
parent_id?: number;
parent_name?: string;
course_count: number;
faculty_count: number;
}
export interface AcademicYearCreateRequest {
name: string;
start_date: string;
end_date: string;
term_structure: AcademicYear["term_structure"];
}
export interface AcademicTermCreateRequest {
name: string;
term_start_date: string;
term_end_date: string;
academic_year_id: number;
parent_term_id?: number;
}
export interface DepartmentCreateRequest {
name: string;
code: string;
parent_id?: number;
}

63
src/types/admission.ts Normal file
View File

@@ -0,0 +1,63 @@
export type AdmissionRegisterState = "draft" | "confirm" | "cancel" | "done";
export interface AdmissionRegister {
id: number;
name: string;
course_id: number;
course_name: string;
start_date: string;
end_date: string;
min_count: number;
max_count: number;
application_count: number;
state: AdmissionRegisterState;
}
export type AdmissionState = "draft" | "submit" | "confirm" | "admission" | "reject" | "cancel" | "done";
export interface Admission {
id: number;
name: string;
application_number: string;
register_id: number;
register_name: string;
course_id: number;
course_name: string;
batch_id?: number;
batch_name?: string;
first_name: string;
middle_name?: string;
last_name: string;
email: string;
phone?: string;
birth_date?: string;
gender: "m" | "f" | "o";
nationality_id?: number;
nationality_name?: string;
state: AdmissionState;
fees: number;
image?: string;
student_id?: number;
created_at: string;
}
export interface AdmissionCreateRequest {
register_id: number;
first_name: string;
middle_name?: string;
last_name: string;
email: string;
phone?: string;
birth_date?: string;
gender: "m" | "f" | "o";
nationality_id?: number;
}
export interface AdmissionRegisterCreateRequest {
name: string;
course_id: number;
start_date: string;
end_date: string;
min_count?: number;
max_count?: number;
}

View File

@@ -12,3 +12,6 @@ export * from "./taxonomy";
export * from "./adaptive";
export * from "./lms";
export * from "./ai";
export * from "./academic";
export * from "./admission";
export * from "./institutional-exam";

View File

@@ -0,0 +1,176 @@
export type ExamSessionState = "draft" | "schedule" | "held" | "cancel" | "done";
export type InstitutionalExamState = "draft" | "schedule" | "held" | "result_updated" | "cancel" | "done";
export type MarksheetState = "draft" | "validated" | "cancelled";
export type SubmissionState = "draft" | "submit" | "accept" | "reject" | "change_req";
export interface ExamType {
id: number;
name: string;
}
export interface ExamRoom {
id: number;
name: string;
classroom_id?: number;
classroom_name?: string;
capacity: number;
}
export interface InstitutionalExamSession {
id: number;
name: string;
course_id: number;
course_name: string;
batch_id: number;
batch_name: string;
exam_code: string;
start_date: string;
end_date: string;
exam_type_id: number;
exam_type_name: string;
evaluation_type: "normal" | "grade";
venue?: string;
state: ExamSessionState;
exam_count: number;
}
export interface InstitutionalExam {
id: number;
name: string;
session_id: number;
subject_id: number;
subject_name: string;
exam_code: string;
start_time: string;
end_time: string;
total_marks: number;
min_marks: number;
state: InstitutionalExamState;
responsible_names: string[];
attendee_count: number;
}
export interface ExamAttendee {
id: number;
exam_id: number;
student_id: number;
student_name: string;
room_id?: number;
room_name?: string;
marks: number;
status: "pass" | "fail";
}
export interface GradeConfig {
id: number;
min_per: number;
max_per: number;
result: string;
}
export interface ResultTemplate {
id: number;
name: string;
exam_session_id: number;
exam_session_name: string;
evaluation_type: "normal" | "grade";
result_date: string;
grade_ids: number[];
state: "draft" | "result_generated";
}
export interface Marksheet {
id: number;
name: string;
exam_session_id: number;
exam_session_name: string;
result_template_id: number;
generated_date: string;
generated_by_name: string;
state: MarksheetState;
total_pass: number;
total_failed: number;
lines: MarksheetLine[];
}
export interface MarksheetLine {
id: number;
student_id: number;
student_name: string;
total_marks: number;
percentage: number;
grade?: string;
status: "pass" | "fail";
results: ResultLine[];
}
export interface ResultLine {
id: number;
exam_id: number;
exam_name: string;
subject_name: string;
marks: number;
grade?: string;
status: "pass" | "fail";
}
export interface CourseAssignmentType {
id: number;
name: string;
}
export interface CourseAssignment {
id: number;
name: string;
course_id: number;
course_name: string;
subject_id?: number;
subject_name?: string;
batch_id: number;
batch_name: string;
faculty_id: number;
faculty_name: string;
description: string;
marks: number;
point: number;
assignment_type_id: number;
assignment_type_name: string;
issued_date: string;
submission_date: string;
state: "draft" | "publish" | "finish" | "cancel";
allocation_count: number;
submission_count: number;
reviewer_id?: number;
reviewer_name?: string;
}
export interface AssignmentSubmission {
id: number;
assignment_id: number;
student_id: number;
student_name: string;
description?: string;
submission_date?: string;
attachment_ids: number[];
attachment_names: string[];
marks: number;
state: SubmissionState;
faculty_id?: number;
faculty_name?: string;
note?: string;
}
export interface SubjectRegistration {
id: number;
student_id: number;
student_name: string;
course_id: number;
course_name: string;
batch_id: number;
batch_name: string;
subject_ids: number[];
subject_names: string[];
min_unit_load: number;
max_unit_load: number;
state: "draft" | "confirm" | "reject" | "done";
}