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:
1281
docs/ENCOACH_ODOO19_BACKEND_SRS.md
Normal file
1281
docs/ENCOACH_ODOO19_BACKEND_SRS.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
104
src/hooks/queries/useAcademic.ts
Normal file
104
src/hooks/queries/useAcademic.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
94
src/hooks/queries/useAdmissions.ts
Normal file
94
src/hooks/queries/useAdmissions.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
199
src/hooks/queries/useInstitutionalExams.ts
Normal file
199
src/hooks/queries/useInstitutionalExams.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
205
src/pages/AdmissionApplication.tsx
Normal file
205
src/pages/AdmissionApplication.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/pages/admin/AcademicYearManager.tsx
Normal file
208
src/pages/admin/AcademicYearManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/pages/admin/AdmissionDetail.tsx
Normal file
173
src/pages/admin/AdmissionDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/pages/admin/AdmissionList.tsx
Normal file
111
src/pages/admin/AdmissionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/pages/admin/AdmissionRegisterPage.tsx
Normal file
182
src/pages/admin/AdmissionRegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/pages/admin/DepartmentManager.tsx
Normal file
154
src/pages/admin/DepartmentManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
src/pages/admin/InstitutionalExamSessions.tsx
Normal file
274
src/pages/admin/InstitutionalExamSessions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
src/pages/admin/MarksheetManager.tsx
Normal file
312
src/pages/admin/MarksheetManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/pages/student/SubjectRegistrationPage.tsx
Normal file
145
src/pages/student/SubjectRegistrationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/services/academic.service.ts
Normal file
64
src/services/academic.service.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
56
src/services/admission.service.ts
Normal file
56
src/services/admission.service.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
165
src/services/institutional-exam.service.ts
Normal file
165
src/services/institutional-exam.service.ts
Normal 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
50
src/types/academic.ts
Normal 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
63
src/types/admission.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
176
src/types/institutional-exam.ts
Normal file
176
src/types/institutional-exam.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user