fix
This commit is contained in:
@@ -0,0 +1,216 @@
|
|||||||
|
# Backend (Postman) vs Frontend — fields & endpoints to decide on
|
||||||
|
|
||||||
|
Source of truth: the Postman collection covering Terms, Courses, Sessions, Exams, Homeworks, Media (2026-05). This doc lists every place where backend and FE disagree on shape, plus FE-side concepts the backend doc has no slot for. Each item needs a product/design call before we wire it up.
|
||||||
|
|
||||||
|
Conventions in the rest of this doc:
|
||||||
|
- **B → FE** means the backend exposes a field/endpoint that the FE doesn't surface yet.
|
||||||
|
- **FE → B** means the FE shows/sends a field the backend doc doesn't accept.
|
||||||
|
- **shape diff** means both sides handle the concept but in different shapes (enum values, nesting, naming).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Backend | FE today | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /terms?per_page&active_only` | `getTermsList` | aligned |
|
||||||
|
| `GET /terms/:id` | `showTerm` | aligned |
|
||||||
|
| `POST /terms` | `addNewTerm` | aligned |
|
||||||
|
| `PATCH /terms/:id` | `updateTerm` | aligned |
|
||||||
|
| `DELETE /terms/:id` | `deleteTerm` | aligned |
|
||||||
|
| — | `cloneTerm` (`POST /admin/terms/:id/clone`) | **FE-only; backend has nothing.** Decide: drop the clone button, or ask backend to add it. |
|
||||||
|
| — | `changeStatusTerm` (`POST /admin/terms/:id/status`) | **Use `PATCH /terms/:id` with `is_active`** — dedicated status endpoint dropped. |
|
||||||
|
| — | `listUserTerm`, `addUserTerm`, `removeUserTerm`, `changeLeaveStatus` | **FE-only.** Term-students subtab + leave toggle. Backend exposes nothing equivalent. Keep mock-only until backend adds. |
|
||||||
|
| — | `listCourseTerm`, `addCourseTerm`, `removeCourseTerm` | **FE-only.** Can be replaced by `GET /courses?term_id=` for the list; the attach/detach side has no backend equivalent (course `term_id` is set at create-time). |
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Backend → FE | FE has it as | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `starts_at`, `ends_at` (ISO datetime) | `startDate`, `endDate` | Naming diff; we send `starts_at`/`ends_at` in the mock already — confirm the FE form should rename or keep an adapter. |
|
||||||
|
| `cover_url` (read) + `cover_media_id` (write) | `coverUrl` / `coverMediaId` | aligned in shape, just camelCase. |
|
||||||
|
| `created_at` | `createdAt` | aligned. |
|
||||||
|
| `is_active` | `isActive` | aligned. |
|
||||||
|
| `description` | `description` | aligned. |
|
||||||
|
|
||||||
|
**FE → B** (need decision):
|
||||||
|
- `studentsCount`, `coursesCount` — computed UI counts. Backend response doesn't include them. Either compute client-side or ask backend for `?include=counts`.
|
||||||
|
- `image` (UI alias of cover) — FE keeps this for a fallback. Backend response only has `cover_url`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Courses
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Backend | FE today | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /courses?term_id&per_page` | `getCoursesList` | aligned (drop `/admin/` prefix in `endpoints.js`). |
|
||||||
|
| `GET /courses/:id` | `showCourse` | aligned. Backend response includes nested `term` and `teacher` — FE already reads `course.term` / `course.teacher`, good. |
|
||||||
|
| `POST /courses` | `addNewCourse` | aligned. Backend currently requires `term_id` (422 example), but per user instruction the standalone (template-tab) flow must allow `term_id: null`. **Decide:** ask backend to make `term_id` nullable, or refuse to submit until a term is picked. |
|
||||||
|
| `PATCH /courses/:id` | `updateCourse` | aligned. Used now for status toggle too (drops dedicated `/status` endpoint). |
|
||||||
|
| `DELETE /courses/:id` | `deleteCourse` | aligned. |
|
||||||
|
| — | `changeStatusCourse` (`/admin/courses/:id/status`) | **Dropped.** Use `PATCH /courses/:id { is_active }`. |
|
||||||
|
| — | `listCourseStudents`, `addCourseStudent`, `removeCourseStudent` | **FE-only.** CourseDetailsModal "students" tab + `AddCourseStudentModal`. Backend doesn't expose course-students; either add a sub-resource or remove the UI. Kept mock-only for now. |
|
||||||
|
| — | `listCourseSessions`, `attachCourseSession`, `detachCourseSession` | **FE-only.** CourseDetailsModal "sessions" tab can be served by `GET /sessions?course_id=`. The attach/detach side (M:N) has no backend equivalent — backend uses session.course_id (1:N). Kept mock-only for now; recommend swapping the list tab to the regular sessions query. |
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Backend ↔ FE | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `term_id` ⇄ `termId` | aligned. Nullable in CourseFormPage, required in AddOfferedCourseModal — schema reflects this. |
|
||||||
|
| `teacher_id` ⇄ `teacherId` | aligned. |
|
||||||
|
| `capacity` ⇄ `capacity` | aligned. |
|
||||||
|
| `is_active` ⇄ `isActive` | aligned. |
|
||||||
|
| `cover_media_id` (write) / `cover_url` (read) ⇄ `coverMediaId` / `coverUrl` | aligned. |
|
||||||
|
| `description` ⇄ `description` | aligned. |
|
||||||
|
| Nested `term`, `teacher` on show response | FE already reads. |
|
||||||
|
|
||||||
|
**FE → B** (need decision):
|
||||||
|
- `sessionsCount` — number-of-sessions field on the create form. Backend has nothing. Either compute server-side from related sessions, or drop the field.
|
||||||
|
- `prerequisites` (array of { courseId, course }) — backend has no prerequisite relation. Drop or ask backend for it.
|
||||||
|
- `contentType` (video/voice/text) + `contentMediaId` — single course-level content file. Backend treats files only as session media. Decide whether course-level content should move to "intro session" or stay a course concept.
|
||||||
|
- `image` (UI alias of cover_url) — kept as a fallback alongside `coverUrl`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Backend | FE today | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /sessions?course_id&per_page` | `getSessionsList` | aligned. |
|
||||||
|
| `GET /sessions/:id` | `showSession` | aligned. |
|
||||||
|
| `POST /sessions` | `addNewSession` | aligned. |
|
||||||
|
| `PATCH /sessions/:id` | `updateSession` | aligned. Used for status toggle now. |
|
||||||
|
| `DELETE /sessions/:id` | `deleteSession` | aligned. |
|
||||||
|
| — | `changeStatusSession` (`/admin/sessions/:id/toggle-status`) | **Dropped.** Use PATCH with `is_active`. |
|
||||||
|
| — | `getSessionsAttendance` (`/admin/sessions/:sessionId/attendances`) | **FE-only.** `SessionAttendanceModal` depends on this. Kept mock-only. |
|
||||||
|
|
||||||
|
### Fields — biggest gap of all five resources
|
||||||
|
|
||||||
|
| Backend | FE today | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `type` enum: `online \| offline \| content` | `sessionType` enum: `in_person`, `online`, `video`, `audio`, `text`, `slide`, `pdf` | **Shape diff.** FE has 7 values; backend has 3. There's an existing `SESSION_TYPE_TO_SPEC` mapper in `services/mock/fixtures/admin-sessions.js`. Decide whether the FE keeps the richer 7-value enum (and we map down to backend's 3) or collapses. |
|
||||||
|
| `starts_at`, `location`, `link` | All three live **inside** `form.sessionConfig.*` plus also derived to top-level `startsAt` / `location` / `link` in the mock | **Structural diff.** Backend wants flat fields; FE form nests them under `sessionConfig` keyed by `sessionType`. The mock derives top-level from `sessionConfig.*` for show payloads. Decide whether the FE form should flatten the schema to match backend or keep the conditional-by-type config UI. |
|
||||||
|
| `media_ids[]` (write) / `media[]` (read with `collection_name`, `file_name`, `mime_type`, `file_size`, `url`, `download_url`) | `materials[]` with `{ fileId, isRequired, type, title, order }` | **Shape diff.** Backend's media rows are typed by upload-purpose (video/voice/pdf/slide/attachment); FE has its own `type` enum. Decide which shape the FE keeps. |
|
||||||
|
| — | `durationMinutes`, `order`, `sessionConfig.minWatchedPercent`, `sessionConfig.minReadPercent`, `sessionConfig.mustCompleteBeforeNext`, `sessionConfig.platform` | **FE → B**, all UI-only fields. Backend has nothing equivalent. Drop, move into a `metadata` JSON, or ask backend to add. |
|
||||||
|
| — | `usedInTerms` | UI-only count, no backend. |
|
||||||
|
| — | `image` (vs `media`) | UI-only thumbnail; backend doesn't separate. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exams
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Backend | FE today | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /exams/:id` (with questions+options) | `showExam` | aligned. |
|
||||||
|
| `POST /exams` | `addNewExam` | aligned. |
|
||||||
|
| `PATCH /exams/:id` | `updateExam` | aligned. |
|
||||||
|
| `DELETE /exams/:id` | `deleteExam` | aligned. |
|
||||||
|
| `POST /exams/:examId/questions` | — | **Backend → FE.** New endpoint. Today the FE submits the whole question list inside the exam create/update payload. Decide whether to keep "all-in-one" submission (and ask backend to accept it) or switch to add-questions-after-create. |
|
||||||
|
| `POST /questions/:questionId/options` | — | **Backend → FE.** Same as above — backend lets you add options one at a time. FE today bundles all options with the question. |
|
||||||
|
| `POST /exams/:examId/submit` | — (student-side feature) | **Backend → FE.** Student-side; not in current admin UI. |
|
||||||
|
| — | `getExamsList` (`/admin/exams`) | **FE-only.** ExamsListPage depends on it. Kept mock-only; ask backend to add a list endpoint. |
|
||||||
|
| — | `getExamParticipants`, `showExamParticipant` | **FE-only.** ExamParticipantsModal + ExamParticipantDetailsModal depend on these. Kept mock-only. |
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Backend ↔ FE | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `session_id` ⇄ `sessionId` | aligned. |
|
||||||
|
| `title` ⇄ `title` | aligned. |
|
||||||
|
| `description` ⇄ `description` | aligned. |
|
||||||
|
| `pass_score` ⇄ `passingScore` | aligned (naming diff). |
|
||||||
|
| `is_active` | **Backend → FE.** Exam form has no active toggle. Decide whether to add it. |
|
||||||
|
| Backend question shape: `{ question_text, position, options: [{ option_text, is_correct }] }` | FE: `{ title, score, correctAnswerId, answers: [{ id, title }] }` | **Shape diff.** Backend hides `is_correct` from public reads (only on add). FE concept of `score` (per-question weighting) has no backend slot. Decide: keep FE scoring (ask backend to store) or drop. |
|
||||||
|
|
||||||
|
**FE → B** (need decision):
|
||||||
|
- `durationMinutes` — no backend slot.
|
||||||
|
- `randomize` — no backend slot.
|
||||||
|
- `endDate` / `startDate` — exam validity window, no backend slot.
|
||||||
|
- `usedInTerms` — derived UI count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Homeworks (FE calls them "assignments")
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Backend | FE today | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /homeworks` | `addNewAssignment` | aligned (URL renamed to `/homeworks`; FE key name kept). |
|
||||||
|
| `PATCH /homeworks/:id` | `updateAssignment` | aligned. |
|
||||||
|
| `DELETE /homeworks/:id` | `deleteAssignment` | aligned. |
|
||||||
|
| `POST /homeworks/:homeworkId/submit` (student) | — | **Backend → FE.** Student submit, not in admin UI yet. |
|
||||||
|
| `PATCH /homework-submissions/:submissionId/review` | `reviewAssignmentSubmission` | aligned (URL renamed). |
|
||||||
|
| — | `getAssignmentsList` | **FE-only.** AssignmentsListPage depends on it. Kept mock-only. |
|
||||||
|
| — | `showAssignment`, `getAssignmentSubmissions`, `showAssignmentSubmission` | **FE-only.** Detail + submissions list — kept mock-only. |
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Backend ↔ FE | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `session_id` ⇄ `sessionId` | aligned. |
|
||||||
|
| `title`, `description` | aligned. |
|
||||||
|
| `deadline` (single datetime) | FE: `startDate` + `endDate` + computed `durationDays` | **Shape diff.** Backend has one deadline; FE has a window. Decide: drop start/end and use single deadline, or ask backend to add a window. |
|
||||||
|
| `is_active` | **Backend → FE.** FE form has no active toggle. |
|
||||||
|
| Submission `status`: `accepted \| denied` | FE: `pending \| approved \| rejected \| needs_revision` | **Shape diff.** Backend has two states; FE has four. The FE `pending/needs_revision` have no backend slot. |
|
||||||
|
| Submission `media_id` (single) | FE `attachments[]` (multiple) | **Shape diff.** Backend allows one file per submission; FE expects many. |
|
||||||
|
| Submission `teacher_feedback` ⇄ `reviewerNote` | naming diff. |
|
||||||
|
| Submission `reviewed_at` (read) | — | Backend provides; FE doesn't surface. |
|
||||||
|
|
||||||
|
**FE → B** (need decision):
|
||||||
|
- `priority` enum (`mandatory \| optional`) — no backend slot.
|
||||||
|
- `submissionsCount` — derived count, no backend slot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Media
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Backend | FE today | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /media` (multipart with `purpose` + `file`) | `uploadMedia` | aligned URL & method. |
|
||||||
|
| `GET /media/:id/download` | — | **Backend → FE.** Add as `downloadMedia`. |
|
||||||
|
| `DELETE /media/:id` | — | **Backend → FE.** Add as `deleteMedia`. |
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
- Backend response: `{ id, collection_name, file_name, mime_type, file_size, url, download_url }`. FE today reads `payload.id`, `payload.url`, sometimes `payload.uploadId` (a now-stale field). **Cleanup needed:** drop `uploadId`, use `id` everywhere.
|
||||||
|
- Backend `purpose` enum: `avatar | cover | video | voice | pdf | slide | attachment | homework_file`. FE upload calls currently hardcode `purpose: 'cover'` or `purpose: 'content'`. **`content` is not in the backend enum.** Decide which of the backend purposes each FE uploader should send (e.g., session video → `video`, course PDF → `pdf`, homework upload → `homework_file`).
|
||||||
|
- Pending media TTL: backend deletes unreferenced pending uploads after 24h. FE doesn't track this; if a user uploads a cover, abandons the form, and comes back next day, the `cover_media_id` reference will 404 on submit. Document the failure mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-cutting
|
||||||
|
|
||||||
|
### Snake_case vs camelCase
|
||||||
|
|
||||||
|
Backend wire format is snake_case throughout. FE today reads camelCase (e.g., the mock returns `coverUrl`, `isActive`). When the real backend lands, the FE will either need:
|
||||||
|
- a HTTP-layer transformer (camelize on response, snake_case on request), or
|
||||||
|
- camelCase field aliases on the backend serializer.
|
||||||
|
|
||||||
|
Decide before swapping the mock for the real API. Affects every screen.
|
||||||
|
|
||||||
|
### `/admin/` URL prefix
|
||||||
|
|
||||||
|
Backend doc has none — endpoints live at `/terms`, `/courses`, etc. The FE previously had `/admin/courses`, `/admin/sessions`, `/admin/exams`, `/admin/assignments`. **Aligned to backend (prefix dropped).** Auth context (admin role) is implicit in the token, not the URL.
|
||||||
|
|
||||||
|
### Sub-features that depend on missing backend endpoints
|
||||||
|
|
||||||
|
UI screens that work today against the mock but have no backend equivalent in the current doc (kept mock-only with TODOs in `endpoints.js`):
|
||||||
|
|
||||||
|
- Term: clone term, term status toggle, students-in-term subtab, leave toggle, courses-in-term subtab (could swap to `GET /courses?term_id=`)
|
||||||
|
- Course: status toggle (swapped to PATCH), students-in-course subtab, attach/detach sessions (M:N), `AddCourseStudentModal`, `AddSessionToCourseModal`
|
||||||
|
- Session: status toggle (swapped to PATCH), attendance roster
|
||||||
|
- Exam: list page, participants list, participant detail
|
||||||
|
- Assignment/Homework: list page, detail show, submissions list, submission detail
|
||||||
|
|
||||||
|
For each of these we need to either (a) ask backend to expose the endpoint, or (b) drop the UI surface.
|
||||||
@@ -21,7 +21,7 @@ import { computed } from 'vue'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info'>} */
|
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info' | 'cyan'>} */
|
||||||
variant: { type: String, default: 'neutral' },
|
variant: { type: String, default: 'neutral' },
|
||||||
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
|
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
|
||||||
size: { type: String, default: 'md' },
|
size: { type: String, default: 'md' },
|
||||||
@@ -133,5 +133,10 @@ const onClick = (event) => {
|
|||||||
background: rgba(104, 104, 104, 10%);
|
background: rgba(104, 104, 104, 10%);
|
||||||
color: #686868;
|
color: #686868;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--cyan {
|
||||||
|
background: rgba(104, 104, 104, 10%);
|
||||||
|
color: #686868;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -5,14 +5,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, toRefs, ref, reactive, defineProps, defineEmits, watch } from 'vue'
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
// Introducing tinymce editor
|
import tinymce from 'tinymce/tinymce'
|
||||||
import Editor from '@tinymce/tinymce-vue'
|
|
||||||
import tinymce from 'tinymce/tinymce' // tinymce defaults to hidden. If it is not introduced, the editor will not be displayed.
|
|
||||||
// Import configuration file
|
|
||||||
import '@/plugins/tinymce/importTinymce'
|
import '@/plugins/tinymce/importTinymce'
|
||||||
|
import Editor from '@tinymce/tinymce-vue'
|
||||||
import { initTiny } from '@/plugins/tinymce/tinymce'
|
import { initTiny } from '@/plugins/tinymce/tinymce'
|
||||||
|
import { onMounted, toRefs, ref, reactive, defineProps, defineEmits, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -106,7 +104,7 @@ const imgUploadFn = async (blobInfo, success, failure) => {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', blobInfo.blob(), blobInfo.filename())
|
formData.append('file', blobInfo.blob(), blobInfo.filename())
|
||||||
formData.append('batch_id', props.batchId)
|
formData.append('batch_id', props.batchId)
|
||||||
const response = await axios.post("https://api.madomotor.ir".props.imgUploadUrl, formData)
|
const response = await axios.post('https://api.madomotor.ir'.props.imgUploadUrl, formData)
|
||||||
|
|
||||||
if (response && response.status == 200) {
|
if (response && response.status == 200) {
|
||||||
return success(response.data.data.url)
|
return success(response.data.data.url)
|
||||||
@@ -42,7 +42,6 @@ export const fields = {
|
|||||||
role: 'نقش',
|
role: 'نقش',
|
||||||
roleId: 'نقش',
|
roleId: 'نقش',
|
||||||
isActive: 'فعال',
|
isActive: 'فعال',
|
||||||
isActiveByDefault: 'فعال بهصورت پیشفرض',
|
|
||||||
randomize: 'تصادفی',
|
randomize: 'تصادفی',
|
||||||
|
|
||||||
startDate: 'تاریخ شروع',
|
startDate: 'تاریخ شروع',
|
||||||
@@ -53,7 +52,6 @@ export const fields = {
|
|||||||
capacity: 'ظرفیت',
|
capacity: 'ظرفیت',
|
||||||
minCapacity: 'حداقل ظرفیت',
|
minCapacity: 'حداقل ظرفیت',
|
||||||
maxCapacity: 'حداکثر ظرفیت',
|
maxCapacity: 'حداکثر ظرفیت',
|
||||||
defaultCapacity: 'ظرفیت پیشفرض',
|
|
||||||
|
|
||||||
order: 'ترتیب',
|
order: 'ترتیب',
|
||||||
priority: 'اولویت',
|
priority: 'اولویت',
|
||||||
@@ -64,13 +62,10 @@ export const fields = {
|
|||||||
minAssignments: 'حداقل تکالیف',
|
minAssignments: 'حداقل تکالیف',
|
||||||
|
|
||||||
termId: 'ترم',
|
termId: 'ترم',
|
||||||
templateId: 'قالب',
|
|
||||||
courseId: 'دوره',
|
courseId: 'دوره',
|
||||||
courseTemplateId: 'قالب دوره',
|
|
||||||
sessionId: 'جلسه',
|
sessionId: 'جلسه',
|
||||||
sessionType: 'نوع جلسه',
|
sessionType: 'نوع جلسه',
|
||||||
teacherId: 'مدرس',
|
teacherId: 'مدرس',
|
||||||
defaultTeacherId: 'مدرس پیشفرض',
|
|
||||||
studentIds: 'دانشآموزان',
|
studentIds: 'دانشآموزان',
|
||||||
|
|
||||||
educationStatus: 'وضعیت تحصیلی',
|
educationStatus: 'وضعیت تحصیلی',
|
||||||
|
|||||||
+1
-1
@@ -168,7 +168,7 @@ export const ASSIGNMENT_PRIORITY = Object.freeze({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const TICKET_STATUS = Object.freeze({
|
export const TICKET_STATUS = Object.freeze({
|
||||||
pending: 'در انتظار پاسخ',
|
open: 'باز',
|
||||||
answered: 'پاسخ داده شده',
|
answered: 'پاسخ داده شده',
|
||||||
closed: 'بسته شده',
|
closed: 'بسته شده',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="assignment-item__sub">
|
<div class="assignment-item__sub">
|
||||||
<span class="assignment-item__sub-label">دوره:</span>
|
<span class="assignment-item__sub-label">دوره:</span>
|
||||||
<span class="assignment-item__sub-value">
|
<span class="assignment-item__sub-value">
|
||||||
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }}
|
{{ assignment.course?.title || assignment.courseTitle || '—' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="assignment-item__dot">|</span>
|
<span class="assignment-item__dot">|</span>
|
||||||
<span class="assignment-item__sub-label">جلسه:</span>
|
<span class="assignment-item__sub-label">جلسه:</span>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</TextField>
|
</TextField>
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.courseTemplateId"
|
v-model="form.courseId"
|
||||||
name="courseTemplateId"
|
name="courseId"
|
||||||
label="دوره الگو"
|
label="دوره"
|
||||||
:options="templateOptions"
|
:options="templateOptions"
|
||||||
option-label="title"
|
option-label="title"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
@@ -65,15 +65,15 @@ import TextField from '@/components/form/TextField.vue'
|
|||||||
import CircleButton from '@/components/CircleButton.vue'
|
import CircleButton from '@/components/CircleButton.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||||
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
courseId: '',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
fromDate: '',
|
fromDate: '',
|
||||||
toDate: '',
|
toDate: '',
|
||||||
@@ -85,7 +85,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
|||||||
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
courseId: '',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
fromDate: '',
|
fromDate: '',
|
||||||
toDate: '',
|
toDate: '',
|
||||||
@@ -105,16 +105,13 @@ const todayIso = new Date().toISOString()
|
|||||||
const templateSearch = ref('')
|
const templateSearch = ref('')
|
||||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||||
templateFilters,
|
|
||||||
templatePagination
|
|
||||||
)
|
|
||||||
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
||||||
|
|
||||||
const sessionSearch = ref('')
|
const sessionSearch = ref('')
|
||||||
const sessionListFilters = computed(() => ({
|
const sessionListFilters = computed(() => ({
|
||||||
title: sessionSearch.value,
|
title: sessionSearch.value,
|
||||||
courseTemplateId: form.value.courseTemplateId || undefined,
|
courseId: form.value.courseId || undefined,
|
||||||
}))
|
}))
|
||||||
const sessionPagination = ref({ page: 1, perPage: 30 })
|
const sessionPagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination)
|
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination)
|
||||||
|
|||||||
@@ -20,15 +20,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</TextField>
|
</TextField>
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.courseTemplateId"
|
v-model="form.courseId"
|
||||||
name="courseTemplateId"
|
name="courseId"
|
||||||
label="دوره مرتبط"
|
label="دوره مرتبط"
|
||||||
:options="templateOptions"
|
:options="templateOptions"
|
||||||
option-label="title"
|
option-label="title"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:on-search="searchTemplates"
|
:on-search="searchTemplates"
|
||||||
:error="errors.courseTemplateId"
|
:error="errors.courseId"
|
||||||
@update:model-value="onCourseChange"
|
@update:model-value="onCourseChange"
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
option-value="id"
|
option-value="id"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:on-search="searchSessions"
|
:on-search="searchSessions"
|
||||||
:disabled="!form.courseTemplateId"
|
:disabled="!form.courseId"
|
||||||
:error="errors.sessionId"
|
:error="errors.sessionId"
|
||||||
/>
|
/>
|
||||||
<DatePickerField
|
<DatePickerField
|
||||||
@@ -131,8 +131,8 @@ import FileUploader from '@/components/form/FileUploader.vue'
|
|||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||||
import { assignmentSchema } from '@/features/admin/assignments/schema'
|
import { assignmentSchema } from '@/features/admin/assignments/schema'
|
||||||
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
|
||||||
import {
|
import {
|
||||||
adminAssignmentsKeys,
|
adminAssignmentsKeys,
|
||||||
useAddAdminAssignmentMutation,
|
useAddAdminAssignmentMutation,
|
||||||
@@ -156,7 +156,7 @@ const priorityOptions = Object.entries(ASSIGNMENT_PRIORITY).map(([value, label])
|
|||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
courseId: '',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
@@ -173,10 +173,7 @@ const { validate, validateAt, errors, resetErrors } = useYup(schema)
|
|||||||
const templateSearch = ref('')
|
const templateSearch = ref('')
|
||||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||||
templateFilters,
|
|
||||||
templatePagination
|
|
||||||
)
|
|
||||||
const selectedTemplate = ref(null)
|
const selectedTemplate = ref(null)
|
||||||
const templateOptions = computed(() => {
|
const templateOptions = computed(() => {
|
||||||
const base = templatesResponse.value?.data ?? []
|
const base = templatesResponse.value?.data ?? []
|
||||||
@@ -189,11 +186,11 @@ const templateOptions = computed(() => {
|
|||||||
const sessionSearch = ref('')
|
const sessionSearch = ref('')
|
||||||
const sessionFilters = computed(() => ({
|
const sessionFilters = computed(() => ({
|
||||||
title: sessionSearch.value,
|
title: sessionSearch.value,
|
||||||
courseTemplateId: form.value.courseTemplateId,
|
courseId: form.value.courseId,
|
||||||
}))
|
}))
|
||||||
const sessionPagination = ref({ page: 1, perPage: 30 })
|
const sessionPagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination, {
|
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination, {
|
||||||
enabled: () => !!form.value.courseTemplateId,
|
enabled: () => !!form.value.courseId,
|
||||||
})
|
})
|
||||||
const selectedSession = ref(null)
|
const selectedSession = ref(null)
|
||||||
const sessionOptions = computed(() => {
|
const sessionOptions = computed(() => {
|
||||||
@@ -212,7 +209,7 @@ const searchSessions = useDebounce((q) => {
|
|||||||
}, 400)
|
}, 400)
|
||||||
|
|
||||||
const onCourseChange = (value) => {
|
const onCourseChange = (value) => {
|
||||||
form.value.courseTemplateId = value
|
form.value.courseId = value
|
||||||
form.value.sessionId = ''
|
form.value.sessionId = ''
|
||||||
selectedSession.value = null
|
selectedSession.value = null
|
||||||
}
|
}
|
||||||
@@ -223,13 +220,13 @@ const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, {
|
|||||||
|
|
||||||
watch(existingAssignment, (assignment) => {
|
watch(existingAssignment, (assignment) => {
|
||||||
if (!assignment) return
|
if (!assignment) return
|
||||||
const tpl = assignment.courseTemplate
|
const tpl = assignment.course
|
||||||
const sessionEntity = assignment.session
|
const sessionEntity = assignment.session
|
||||||
if (tpl) selectedTemplate.value = tpl
|
if (tpl) selectedTemplate.value = tpl
|
||||||
if (sessionEntity) selectedSession.value = sessionEntity
|
if (sessionEntity) selectedSession.value = sessionEntity
|
||||||
form.value = {
|
form.value = {
|
||||||
title: assignment.title || '',
|
title: assignment.title || '',
|
||||||
courseTemplateId: tpl?.id || assignment.courseTemplateId || '',
|
courseId: tpl?.id || assignment.courseId || '',
|
||||||
sessionId: sessionEntity?.id || assignment.sessionId || '',
|
sessionId: sessionEntity?.id || assignment.sessionId || '',
|
||||||
startDate: assignment.startDate || '',
|
startDate: assignment.startDate || '',
|
||||||
endDate: assignment.endDate || '',
|
endDate: assignment.endDate || '',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<p class="assignment-details__sub">
|
<p class="assignment-details__sub">
|
||||||
<span>
|
<span>
|
||||||
دوره:
|
دوره:
|
||||||
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }}
|
{{ assignment.course?.title || assignment.courseTitle || '—' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="assignment-details__sep">|</span>
|
<span class="assignment-details__sep">|</span>
|
||||||
<span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span>
|
<span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span>
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
<div class="submission-details__assignment-meta">
|
<div class="submission-details__assignment-meta">
|
||||||
<span>ترم: {{ submission.termTitle || '—' }}</span>
|
<span>ترم: {{ submission.termTitle || '—' }}</span>
|
||||||
<span class="submission-details__sep">|</span>
|
<span class="submission-details__sep">|</span>
|
||||||
<span>دوره: {{ submission.courseTemplateTitle || '—' }}</span>
|
<span>دوره: {{ submission.courseTitle || '—' }}</span>
|
||||||
<span class="submission-details__sep">|</span>
|
<span class="submission-details__sep">|</span>
|
||||||
<span>جلسه: {{ submission.sessionTitle || '—' }}</span>
|
<span>جلسه: {{ submission.sessionTitle || '—' }}</span>
|
||||||
<span class="submission-details__sep">|</span>
|
<span class="submission-details__sep">|</span>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const { openModal, isModal } = useModal()
|
|||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
courseId: '',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
fromDate: '',
|
fromDate: '',
|
||||||
toDate: '',
|
toDate: '',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { number, object, string } from 'yup'
|
|||||||
|
|
||||||
export const assignmentSchema = object().shape({
|
export const assignmentSchema = object().shape({
|
||||||
title: string().required().min(3),
|
title: string().required().min(3),
|
||||||
courseTemplateId: string().required(),
|
courseId: string().required(),
|
||||||
sessionId: string().required(),
|
sessionId: string().required(),
|
||||||
startDate: string().required(),
|
startDate: string().required(),
|
||||||
endDate: string().required(),
|
endDate: string().required(),
|
||||||
|
|||||||
@@ -21,18 +21,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="course-item__meta">
|
<div class="course-item__meta">
|
||||||
<div v-if="course.term?.title" class="course-item__pill">
|
<Badge v-if="course.term?.title" label="مختص به:" :value="course.term.title" />
|
||||||
<span class="course-item__pill-label">مختص به:</span>
|
<Badge label="ظرفیت:" :value="capacity || '—'" />
|
||||||
<span class="course-item__pill-value">{{ course.term.title }}</span>
|
<Badge
|
||||||
</div>
|
v-if="course.prerequisitesCount"
|
||||||
<div class="course-item__pill">
|
label="پیشنیاز:"
|
||||||
<span class="course-item__pill-label">ظرفیت:</span>
|
:value="course.prerequisitesCount"
|
||||||
<span class="course-item__pill-value">{{ capacity || '—' }}</span>
|
/>
|
||||||
</div>
|
|
||||||
<div v-if="course.prerequisitesCount" class="course-item__pill">
|
|
||||||
<span class="course-item__pill-label">پیشنیاز:</span>
|
|
||||||
<span class="course-item__pill-value">{{ course.prerequisitesCount }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isActive" class="course-item__status">
|
<div v-if="!isActive" class="course-item__status">
|
||||||
@@ -82,6 +77,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import Badge from '@/components/Badge.vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import CircleButton from '@/components/CircleButton.vue'
|
import CircleButton from '@/components/CircleButton.vue'
|
||||||
@@ -94,14 +90,14 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['edit', 'delete', 'change-status', 'show-details'])
|
const emit = defineEmits(['edit', 'delete', 'change-status', 'show-details'])
|
||||||
|
|
||||||
const teacherName = computed(() => {
|
const teacherName = computed(() => {
|
||||||
const t = props.course.teacher || props.course.defaultTeacher
|
const t = props.course.teacher
|
||||||
if (!t) return '—'
|
if (!t) return '—'
|
||||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
return t.name
|
||||||
})
|
})
|
||||||
|
|
||||||
const capacity = computed(() => props.course.capacity ?? props.course.defaultCapacity ?? '')
|
const capacity = computed(() => props.course.capacity ?? '')
|
||||||
|
|
||||||
const isActive = computed(() => props.course.isActive ?? props.course.isActiveByDefault ?? false)
|
const isActive = computed(() => props.course.isActive ?? false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -193,28 +189,7 @@ const isActive = computed(() => props.course.isActive ?? props.course.isActiveBy
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
flex: 1 1 33%;
|
flex: 1 1 33%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
}
|
|
||||||
|
|
||||||
&__pill {
|
|
||||||
background: rgba(107, 107, 107, 5%);
|
|
||||||
padding: 0.25rem 1rem;
|
|
||||||
border-radius: 0.875rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__pill-label {
|
|
||||||
font-family: var(--font-family-fa);
|
|
||||||
font-weight: 300;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #848484;
|
|
||||||
margin-inline-end: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__pill-value {
|
|
||||||
font-family: var(--font-family-en);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__status {
|
&__status {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasicModal width="95%" max-width="42rem" min-width="auto" :show-close-button="true">
|
<BasicModal
|
||||||
|
title="افزودن دانشجو"
|
||||||
|
title-en="Add Student"
|
||||||
|
width="95%"
|
||||||
|
max-width="42rem"
|
||||||
|
min-width="auto"
|
||||||
|
:show-close-button="true"
|
||||||
|
>
|
||||||
<template #default="{ close }">
|
<template #default="{ close }">
|
||||||
<div class="add-course-student">
|
<div class="add-course-student">
|
||||||
<LineTitleBlock title="افزودن دانشجو" title-en="Add Student" />
|
|
||||||
|
|
||||||
<div class="add-course-student__search">
|
<div class="add-course-student__search">
|
||||||
<SvgIcon name="user" :size="18" color="var(--color-thd-gray)" />
|
<SvgIcon name="user" :size="18" color="var(--color-thd-gray)" />
|
||||||
<input
|
<input
|
||||||
@@ -21,7 +26,7 @@
|
|||||||
<div v-for="user in users" :key="user.id" class="add-course-student__row">
|
<div v-for="user in users" :key="user.id" class="add-course-student__row">
|
||||||
<div class="add-course-student__main">
|
<div class="add-course-student__main">
|
||||||
<div v-if="user.avatarUrl" class="add-course-student__avatar">
|
<div v-if="user.avatarUrl" class="add-course-student__avatar">
|
||||||
<img :src="user.avatarUrl" :alt="userLabel(user)" />
|
<img :src="user.avatarUrl" :alt="user?.name" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -30,7 +35,7 @@
|
|||||||
<SvgIcon name="user" :size="20" color="#bcbcbc" />
|
<SvgIcon name="user" :size="20" color="#bcbcbc" />
|
||||||
</div>
|
</div>
|
||||||
<div class="add-course-student__text">
|
<div class="add-course-student__text">
|
||||||
<p class="add-course-student__name">{{ userLabel(user) }}</p>
|
<p class="add-course-student__name">{{ user?.name }}</p>
|
||||||
<p class="add-course-student__meta">
|
<p class="add-course-student__meta">
|
||||||
<span>{{ user.address?.province?.name || '—' }}</span>
|
<span>{{ user.address?.province?.name || '—' }}</span>
|
||||||
<span class="add-course-student__sep">،</span>
|
<span class="add-course-student__sep">،</span>
|
||||||
@@ -98,11 +103,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|||||||
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import {
|
import {
|
||||||
adminCourseTemplatesKeys,
|
adminCoursesKeys,
|
||||||
useAddAdminTemplateStudentMutation,
|
useAddAdminCourseStudentMutation,
|
||||||
useAdminTemplateStudentsQuery,
|
useAdminCourseStudentsQuery,
|
||||||
useRemoveAdminTemplateStudentMutation,
|
useRemoveAdminCourseStudentMutation,
|
||||||
} from '@/services/query/admin-course-templates'
|
} from '@/services/query/admin-courses'
|
||||||
|
|
||||||
defineOptions({ name: 'AddCourseStudentModal' })
|
defineOptions({ name: 'AddCourseStudentModal' })
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ const queryClient = useQueryClient()
|
|||||||
const { getModal } = useModal()
|
const { getModal } = useModal()
|
||||||
|
|
||||||
const modalData = computed(() => getModal('AddCourseStudentModal')?.data ?? {})
|
const modalData = computed(() => getModal('AddCourseStudentModal')?.data ?? {})
|
||||||
const templateId = computed(() => modalData.value.templateId ?? null)
|
const courseId = computed(() => modalData.value.courseId ?? null)
|
||||||
|
|
||||||
const searchInput = ref('')
|
const searchInput = ref('')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -122,22 +127,16 @@ const users = computed(() => usersResponse.value?.data ?? [])
|
|||||||
|
|
||||||
const attachedFilters = computed(() => ({}))
|
const attachedFilters = computed(() => ({}))
|
||||||
const attachedPagination = ref({ page: 1, perPage: 200 })
|
const attachedPagination = ref({ page: 1, perPage: 200 })
|
||||||
const { data: attachedResponse } = useAdminTemplateStudentsQuery(
|
const { data: attachedResponse } = useAdminCourseStudentsQuery(
|
||||||
templateId,
|
courseId,
|
||||||
attachedFilters,
|
attachedFilters,
|
||||||
attachedPagination,
|
attachedPagination,
|
||||||
{ enabled: () => !!templateId.value }
|
{ enabled: () => !!courseId.value }
|
||||||
)
|
)
|
||||||
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((u) => u.id)))
|
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((u) => u.id)))
|
||||||
|
|
||||||
const isAttached = (id) => attachedIds.value.has(id)
|
const isAttached = (id) => attachedIds.value.has(id)
|
||||||
|
|
||||||
const userLabel = (user) =>
|
|
||||||
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
|
||||||
user.fullName ||
|
|
||||||
user.phoneNumber ||
|
|
||||||
'—'
|
|
||||||
|
|
||||||
const onSearchInput = useDebounce(() => {
|
const onSearchInput = useDebounce(() => {
|
||||||
searchQuery.value = searchInput.value || ''
|
searchQuery.value = searchInput.value || ''
|
||||||
userPagination.value = { ...userPagination.value, page: 1 }
|
userPagination.value = { ...userPagination.value, page: 1 }
|
||||||
@@ -145,17 +144,17 @@ const onSearchInput = useDebounce(() => {
|
|||||||
|
|
||||||
const pendingId = ref(null)
|
const pendingId = ref(null)
|
||||||
|
|
||||||
const addMutation = useAddAdminTemplateStudentMutation()
|
const addMutation = useAddAdminCourseStudentMutation()
|
||||||
const removeMutation = useRemoveAdminTemplateStudentMutation()
|
const removeMutation = useRemoveAdminCourseStudentMutation()
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
|
|
||||||
const onAttach = async (user) => {
|
const onAttach = async (user) => {
|
||||||
if (!templateId.value) return
|
if (!courseId.value) return
|
||||||
pendingId.value = user.id
|
pendingId.value = user.id
|
||||||
try {
|
try {
|
||||||
await addMutation.mutateAsync({
|
await addMutation.mutateAsync({
|
||||||
templateId: templateId.value,
|
courseId: courseId.value,
|
||||||
payload: { userIds: [user.id] },
|
payload: { userIds: [user.id] },
|
||||||
})
|
})
|
||||||
invalidate()
|
invalidate()
|
||||||
@@ -165,17 +164,17 @@ const onAttach = async (user) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onDetach = async (user) => {
|
const onDetach = async (user) => {
|
||||||
if (!templateId.value) return
|
if (!courseId.value) return
|
||||||
pendingId.value = user.id
|
pendingId.value = user.id
|
||||||
try {
|
try {
|
||||||
await removeMutation.mutateAsync({ templateId: templateId.value, userId: user.id })
|
await removeMutation.mutateAsync({ courseId: courseId.value, userId: user.id })
|
||||||
invalidate()
|
invalidate()
|
||||||
} finally {
|
} finally {
|
||||||
pendingId.value = null
|
pendingId.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(templateId, () => {
|
watch(courseId, () => {
|
||||||
searchInput.value = ''
|
searchInput.value = ''
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
pendingId.value = null
|
pendingId.value = null
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ import { objectToFormData } from '@/utils/object-to-formdata'
|
|||||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||||
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
|
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
|
||||||
import { offeredCourseSchema } from '@/features/admin/courses/schema'
|
import { offeredCourseSchema } from '@/features/admin/courses/schema'
|
||||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
|
||||||
import {
|
import {
|
||||||
adminCoursesKeys,
|
adminCoursesKeys,
|
||||||
useAddAdminCourseMutation,
|
useAddAdminCourseMutation,
|
||||||
useAdminCourseQuery,
|
useAdminCourseQuery,
|
||||||
|
useAdminCoursesListQuery,
|
||||||
useUpdateAdminCourseMutation,
|
useUpdateAdminCourseMutation,
|
||||||
} from '@/services/query/admin-courses'
|
} from '@/services/query/admin-courses'
|
||||||
|
|
||||||
@@ -139,19 +139,24 @@ const modalData = computed(() => getModal('AddOfferedCourseModal')?.data ?? {})
|
|||||||
const mode = computed(() => modalData.value.mode || 'add')
|
const mode = computed(() => modalData.value.mode || 'add')
|
||||||
const courseId = computed(() => modalData.value.courseId ?? null)
|
const courseId = computed(() => modalData.value.courseId ?? null)
|
||||||
const isEditMode = computed(() => mode.value === 'edit')
|
const isEditMode = computed(() => mode.value === 'edit')
|
||||||
|
const presetTermId = computed(() => modalData.value.termId ?? '')
|
||||||
|
|
||||||
const modeTitle = computed(() =>
|
const modeTitle = computed(() =>
|
||||||
isEditMode.value ? 'ویرایش دوره ارائه شده' : 'افزودن دوره ارائه شده'
|
isEditMode.value ? 'ویرایش دوره ارائه شده' : 'افزودن دوره ارائه شده'
|
||||||
)
|
)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
termId: '',
|
termId: presetTermId.value,
|
||||||
templateId: '',
|
templateId: '',
|
||||||
title: '',
|
title: '',
|
||||||
capacity: '',
|
capacity: '',
|
||||||
imageId: null,
|
imageId: null,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(presetTermId, (val) => {
|
||||||
|
if (val && !form.value.termId) form.value.termId = val
|
||||||
|
})
|
||||||
const image = ref(null)
|
const image = ref(null)
|
||||||
|
|
||||||
const schema = offeredCourseSchema
|
const schema = offeredCourseSchema
|
||||||
@@ -172,12 +177,9 @@ const termOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const templateSearch = ref('')
|
const templateSearch = ref('')
|
||||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
const templateFilters = computed(() => ({ title: templateSearch.value, termId: 'null' }))
|
||||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||||
templateFilters,
|
|
||||||
templatePagination
|
|
||||||
)
|
|
||||||
const selectedTemplate = ref(null)
|
const selectedTemplate = ref(null)
|
||||||
const templateOptions = computed(() => {
|
const templateOptions = computed(() => {
|
||||||
const base = templatesResponse.value?.data ?? []
|
const base = templatesResponse.value?.data ?? []
|
||||||
@@ -201,10 +203,9 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
|
|||||||
watch(existingCourse, (course) => {
|
watch(existingCourse, (course) => {
|
||||||
if (!course) return
|
if (!course) return
|
||||||
if (course.term) selectedTerm.value = course.term
|
if (course.term) selectedTerm.value = course.term
|
||||||
if (course.template) selectedTemplate.value = course.template
|
|
||||||
form.value = {
|
form.value = {
|
||||||
termId: course.term?.id || course.termId || '',
|
termId: course.term?.id || course.termId || '',
|
||||||
templateId: course.template?.id || course.templateId || '',
|
templateId: '',
|
||||||
title: course.title || '',
|
title: course.title || '',
|
||||||
capacity: course.capacity ?? '',
|
capacity: course.capacity ?? '',
|
||||||
imageId: course.imageId || null,
|
imageId: course.imageId || null,
|
||||||
@@ -237,10 +238,11 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
|
|||||||
const onSubmit = async (close) => {
|
const onSubmit = async (close) => {
|
||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid, payload } = await validate(form.value)
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
|
const { templateId: _ignored, ...submitPayload } = payload
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
await updateMutation.mutateAsync({ id: courseId.value, payload })
|
await updateMutation.mutateAsync({ id: courseId.value, payload: submitPayload })
|
||||||
} else {
|
} else {
|
||||||
await addMutation.mutateAsync(payload)
|
await addMutation.mutateAsync(submitPayload)
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
resetErrors()
|
resetErrors()
|
||||||
|
|||||||
@@ -95,11 +95,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|||||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import {
|
import {
|
||||||
adminCourseTemplatesKeys,
|
adminCoursesKeys,
|
||||||
useAdminTemplateSessionsQuery,
|
useAdminCourseSessionsQuery,
|
||||||
useAttachAdminTemplateSessionMutation,
|
useAttachAdminCourseSessionMutation,
|
||||||
useDetachAdminTemplateSessionMutation,
|
useDetachAdminCourseSessionMutation,
|
||||||
} from '@/services/query/admin-course-templates'
|
} from '@/services/query/admin-courses'
|
||||||
|
|
||||||
defineOptions({ name: 'AddSessionToCourseModal' })
|
defineOptions({ name: 'AddSessionToCourseModal' })
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ const queryClient = useQueryClient()
|
|||||||
const { getModal } = useModal()
|
const { getModal } = useModal()
|
||||||
|
|
||||||
const modalData = computed(() => getModal('AddSessionToCourseModal')?.data ?? {})
|
const modalData = computed(() => getModal('AddSessionToCourseModal')?.data ?? {})
|
||||||
const templateId = computed(() => modalData.value.templateId ?? null)
|
const courseId = computed(() => modalData.value.courseId ?? null)
|
||||||
|
|
||||||
const searchInput = ref('')
|
const searchInput = ref('')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -122,18 +122,18 @@ const sessions = computed(() => sessionsResponse.value?.data ?? [])
|
|||||||
|
|
||||||
const attachedFilters = computed(() => ({}))
|
const attachedFilters = computed(() => ({}))
|
||||||
const attachedPagination = ref({ page: 1, perPage: 200 })
|
const attachedPagination = ref({ page: 1, perPage: 200 })
|
||||||
const { data: attachedResponse } = useAdminTemplateSessionsQuery(
|
const { data: attachedResponse } = useAdminCourseSessionsQuery(
|
||||||
templateId,
|
courseId,
|
||||||
attachedFilters,
|
attachedFilters,
|
||||||
attachedPagination,
|
attachedPagination,
|
||||||
{ enabled: () => !!templateId.value }
|
{ enabled: () => !!courseId.value }
|
||||||
)
|
)
|
||||||
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((s) => s.id)))
|
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((s) => s.id)))
|
||||||
|
|
||||||
const isAttached = (id) => attachedIds.value.has(id)
|
const isAttached = (id) => attachedIds.value.has(id)
|
||||||
|
|
||||||
const teacherName = (session) => {
|
const teacherName = (session) => {
|
||||||
const t = session.teacher || session.defaultTeacher
|
const t = session.teacher
|
||||||
if (!t) return '—'
|
if (!t) return '—'
|
||||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||||
}
|
}
|
||||||
@@ -145,17 +145,17 @@ const onSearchInput = useDebounce(() => {
|
|||||||
|
|
||||||
const pendingId = ref(null)
|
const pendingId = ref(null)
|
||||||
|
|
||||||
const attachMutation = useAttachAdminTemplateSessionMutation()
|
const attachMutation = useAttachAdminCourseSessionMutation()
|
||||||
const detachMutation = useDetachAdminTemplateSessionMutation()
|
const detachMutation = useDetachAdminCourseSessionMutation()
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
|
|
||||||
const onAttach = async (session) => {
|
const onAttach = async (session) => {
|
||||||
if (!templateId.value) return
|
if (!courseId.value) return
|
||||||
pendingId.value = session.id
|
pendingId.value = session.id
|
||||||
try {
|
try {
|
||||||
await attachMutation.mutateAsync({
|
await attachMutation.mutateAsync({
|
||||||
templateId: templateId.value,
|
courseId: courseId.value,
|
||||||
payload: { sessionIds: [session.id] },
|
payload: { sessionIds: [session.id] },
|
||||||
})
|
})
|
||||||
invalidate()
|
invalidate()
|
||||||
@@ -165,17 +165,17 @@ const onAttach = async (session) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onDetach = async (session) => {
|
const onDetach = async (session) => {
|
||||||
if (!templateId.value) return
|
if (!courseId.value) return
|
||||||
pendingId.value = session.id
|
pendingId.value = session.id
|
||||||
try {
|
try {
|
||||||
await detachMutation.mutateAsync({ templateId: templateId.value, sessionId: session.id })
|
await detachMutation.mutateAsync({ courseId: courseId.value, sessionId: session.id })
|
||||||
invalidate()
|
invalidate()
|
||||||
} finally {
|
} finally {
|
||||||
pendingId.value = null
|
pendingId.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(templateId, () => {
|
watch(courseId, () => {
|
||||||
searchInput.value = ''
|
searchInput.value = ''
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
pendingId.value = null
|
pendingId.value = null
|
||||||
|
|||||||
@@ -166,12 +166,12 @@ import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
|||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import CourseSessionItem from '@/features/admin/courses/components/CourseSessionItem.vue'
|
import CourseSessionItem from '@/features/admin/courses/components/CourseSessionItem.vue'
|
||||||
import {
|
import {
|
||||||
adminCourseTemplatesKeys,
|
adminCoursesKeys,
|
||||||
useAdminCourseTemplateQuery,
|
useAdminCourseQuery,
|
||||||
useAdminTemplateSessionsQuery,
|
useAdminCourseSessionsQuery,
|
||||||
useAdminTemplateStudentsQuery,
|
useAdminCourseStudentsQuery,
|
||||||
useRemoveAdminTemplateStudentMutation,
|
useRemoveAdminCourseStudentMutation,
|
||||||
} from '@/services/query/admin-course-templates'
|
} from '@/services/query/admin-courses'
|
||||||
|
|
||||||
defineOptions({ name: 'CourseDetailsModal' })
|
defineOptions({ name: 'CourseDetailsModal' })
|
||||||
|
|
||||||
@@ -179,14 +179,14 @@ const queryClient = useQueryClient()
|
|||||||
const { openModal, getModal } = useModal()
|
const { openModal, getModal } = useModal()
|
||||||
|
|
||||||
const modalData = computed(() => getModal('CourseDetailsModal')?.data ?? {})
|
const modalData = computed(() => getModal('CourseDetailsModal')?.data ?? {})
|
||||||
const templateId = computed(() => modalData.value.id ?? null)
|
const courseId = computed(() => modalData.value.id ?? null)
|
||||||
|
|
||||||
const { data: course, isLoading } = useAdminCourseTemplateQuery(templateId, {
|
const { data: course, isLoading } = useAdminCourseQuery(courseId, {
|
||||||
enabled: () => !!templateId.value,
|
enabled: () => !!courseId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const teacherName = computed(() => {
|
const teacherName = computed(() => {
|
||||||
const t = course.value?.defaultTeacher || course.value?.teacher
|
const t = course.value?.teacher
|
||||||
if (!t) return '—'
|
if (!t) return '—'
|
||||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||||
})
|
})
|
||||||
@@ -203,12 +203,12 @@ const { pagination: sessionsPagination, setPage: setSessionsPage } = usePaginati
|
|||||||
perPage: 10,
|
perPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: sessionsData, isLoading: sessionsPending } = useAdminTemplateSessionsQuery(
|
const { data: sessionsData, isLoading: sessionsPending } = useAdminCourseSessionsQuery(
|
||||||
templateId,
|
courseId,
|
||||||
sessionsFilters,
|
sessionsFilters,
|
||||||
sessionsPagination,
|
sessionsPagination,
|
||||||
{
|
{
|
||||||
enabled: () => !!templateId.value && activeTab.value === 'sessions',
|
enabled: () => !!courseId.value && activeTab.value === 'sessions',
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -226,12 +226,12 @@ const { pagination: studentsPagination, setPage: setStudentsPage } = usePaginati
|
|||||||
perPage: 10,
|
perPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: studentsData, isLoading: studentsPending } = useAdminTemplateStudentsQuery(
|
const { data: studentsData, isLoading: studentsPending } = useAdminCourseStudentsQuery(
|
||||||
templateId,
|
courseId,
|
||||||
studentsFilters,
|
studentsFilters,
|
||||||
studentsPagination,
|
studentsPagination,
|
||||||
{
|
{
|
||||||
enabled: () => !!templateId.value && activeTab.value === 'students',
|
enabled: () => !!courseId.value && activeTab.value === 'students',
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -249,16 +249,16 @@ const studentName = (student) =>
|
|||||||
student.phoneNumber ||
|
student.phoneNumber ||
|
||||||
'—'
|
'—'
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
|
|
||||||
const removeStudentMutation = useRemoveAdminTemplateStudentMutation()
|
const removeStudentMutation = useRemoveAdminCourseStudentMutation()
|
||||||
|
|
||||||
const onOpenAddSession = () => {
|
const onOpenAddSession = () => {
|
||||||
openModal('AddSessionToCourseModal', { templateId: templateId.value })
|
openModal('AddSessionToCourseModal', { courseId: courseId.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOpenAddStudent = () => {
|
const onOpenAddStudent = () => {
|
||||||
openModal('AddCourseStudentModal', { templateId: templateId.value })
|
openModal('AddCourseStudentModal', { courseId: courseId.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onAskRemoveStudent = (student) => {
|
const onAskRemoveStudent = (student) => {
|
||||||
@@ -267,7 +267,7 @@ const onAskRemoveStudent = (student) => {
|
|||||||
message: `آیا از حذف <strong>${studentName(student)}</strong> از این دوره اطمینان دارید؟`,
|
message: `آیا از حذف <strong>${studentName(student)}</strong> از این دوره اطمینان دارید؟`,
|
||||||
onConfirm: () =>
|
onConfirm: () =>
|
||||||
removeStudentMutation.mutate(
|
removeStudentMutation.mutate(
|
||||||
{ templateId: templateId.value, userId: student.id },
|
{ courseId: courseId.value, userId: student.id },
|
||||||
{ onSuccess: invalidate }
|
{ onSuccess: invalidate }
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
+30
-31
@@ -2,11 +2,9 @@
|
|||||||
<div class="course-form">
|
<div class="course-form">
|
||||||
<BoxedIconTitleBlock
|
<BoxedIconTitleBlock
|
||||||
class="course-form__heading"
|
class="course-form__heading"
|
||||||
:title="isEditMode ? 'ویرایش دوره الگو' : 'افزودن دوره الگوی جدید'"
|
:title="isEditMode ? 'ویرایش دوره' : 'افزودن دوره جدید'"
|
||||||
:desc="
|
:desc="
|
||||||
isEditMode
|
isEditMode ? 'اطلاعات دوره را بهروز کنید' : 'در این قسمت میتوانید دوره جدید اضافه کنید'
|
||||||
? 'اطلاعات دوره الگو را بهروز کنید'
|
|
||||||
: 'در این قسمت میتوانید دوره الگوی جدید اضافه کنید'
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -44,26 +42,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="course-form__cell course-form__cell--third">
|
<div class="course-form__cell course-form__cell--third">
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.defaultTeacherId"
|
v-model="form.teacherId"
|
||||||
name="defaultTeacherId"
|
name="teacherId"
|
||||||
label="استاد"
|
label="استاد"
|
||||||
:options="teacherOptions"
|
:options="teacherOptions"
|
||||||
option-label="name"
|
option-label="name"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:on-search="searchTeachers"
|
:on-search="searchTeachers"
|
||||||
:error="errors.defaultTeacherId"
|
:error="errors.teacherId"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="course-form__cell course-form__cell--third">
|
<div class="course-form__cell course-form__cell--third">
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.defaultCapacity"
|
v-model="form.capacity"
|
||||||
name="defaultCapacity"
|
name="capacity"
|
||||||
label="ظرفیت (نفر)"
|
label="ظرفیت (نفر)"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
:convert-digits="true"
|
:convert-digits="true"
|
||||||
:error="errors.defaultCapacity"
|
:error="errors.capacity"
|
||||||
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
|
@blur="validateAt('capacity', form.capacity)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="course-form__cell course-form__cell--third">
|
<div class="course-form__cell course-form__cell--third">
|
||||||
@@ -171,18 +169,18 @@ import FileUploader from '@/components/form/FileUploader.vue'
|
|||||||
import ImageCropper from '@/components/form/ImageCropper.vue'
|
import ImageCropper from '@/components/form/ImageCropper.vue'
|
||||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||||
|
import { courseSchema } from '@/features/admin/courses/schema'
|
||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
||||||
import { courseTemplateSchema } from '@/features/admin/courses/schema'
|
|
||||||
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT } from '@/enums'
|
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT } from '@/enums'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import {
|
import {
|
||||||
adminCourseTemplatesKeys,
|
adminCoursesKeys,
|
||||||
useAddAdminCourseTemplateMutation,
|
useAddAdminCourseMutation,
|
||||||
useAdminCourseTemplateQuery,
|
useAdminCourseQuery,
|
||||||
useAdminCourseTemplatesListQuery,
|
useAdminCoursesListQuery,
|
||||||
useUpdateAdminCourseTemplateMutation,
|
useUpdateAdminCourseMutation,
|
||||||
} from '@/services/query/admin-course-templates'
|
} from '@/services/query/admin-courses'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -194,8 +192,8 @@ const isEditMode = computed(() => !!courseId.value)
|
|||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
defaultTeacherId: '',
|
teacherId: '',
|
||||||
defaultCapacity: '',
|
capacity: '',
|
||||||
sessionsCount: '',
|
sessionsCount: '',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
contentType: '',
|
contentType: '',
|
||||||
@@ -215,7 +213,7 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
|
|||||||
|
|
||||||
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
||||||
|
|
||||||
const { validate, validateAt, errors } = useYup(courseTemplateSchema)
|
const { validate, validateAt, errors } = useYup(courseSchema)
|
||||||
|
|
||||||
const teacherSearch = ref('')
|
const teacherSearch = ref('')
|
||||||
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
|
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
|
||||||
@@ -237,7 +235,7 @@ const teacherOptions = computed(() => {
|
|||||||
const prereqSearch = ref('')
|
const prereqSearch = ref('')
|
||||||
const prereqFilters = computed(() => ({ title: prereqSearch.value }))
|
const prereqFilters = computed(() => ({ title: prereqSearch.value }))
|
||||||
const prereqPagination = ref({ page: 1, perPage: 30 })
|
const prereqPagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: prereqsResponse } = useAdminCourseTemplatesListQuery(prereqFilters, prereqPagination)
|
const { data: prereqsResponse } = useAdminCoursesListQuery(prereqFilters, prereqPagination)
|
||||||
const selectedPrereqs = ref([])
|
const selectedPrereqs = ref([])
|
||||||
const prerequisiteOptions = computed(() => {
|
const prerequisiteOptions = computed(() => {
|
||||||
const base = prereqsResponse.value?.data ?? []
|
const base = prereqsResponse.value?.data ?? []
|
||||||
@@ -252,28 +250,28 @@ const searchPrerequisites = useDebounce((q) => {
|
|||||||
prereqSearch.value = q || ''
|
prereqSearch.value = q || ''
|
||||||
}, 400)
|
}, 400)
|
||||||
|
|
||||||
const { data: existingCourse } = useAdminCourseTemplateQuery(courseId, {
|
const { data: existingCourse } = useAdminCourseQuery(courseId, {
|
||||||
enabled: () => !!courseId.value,
|
enabled: () => !!courseId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(existingCourse, (course) => {
|
watch(existingCourse, (course) => {
|
||||||
if (!course) return
|
if (!course) return
|
||||||
const teacher = course.defaultTeacher || course.teacher
|
const teacher = course.teacher
|
||||||
if (teacher) selectedTeacher.value = teacher
|
if (teacher) selectedTeacher.value = teacher
|
||||||
const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : []
|
const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : []
|
||||||
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
|
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
|
||||||
|
|
||||||
form.value = {
|
form.value = {
|
||||||
title: course.title || '',
|
title: course.title || '',
|
||||||
defaultTeacherId: teacher?.id || course.defaultTeacherId || '',
|
teacherId: teacher?.id || course.teacherId || '',
|
||||||
defaultCapacity: course.defaultCapacity ?? course.capacity ?? '',
|
capacity: course.capacity ?? '',
|
||||||
sessionsCount: course.sessionsCount ?? '',
|
sessionsCount: course.sessionsCount ?? '',
|
||||||
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
|
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
|
||||||
contentType: course.contentType || '',
|
contentType: course.contentType || '',
|
||||||
contentMediaId: course.contentMediaId || null,
|
contentMediaId: course.contentMediaId || null,
|
||||||
description: course.description || '',
|
description: course.description || '',
|
||||||
coverMediaId: course.coverMediaId || null,
|
coverMediaId: course.coverMediaId || null,
|
||||||
termId: termId.value,
|
termId: course.termId ?? termId.value,
|
||||||
}
|
}
|
||||||
if (course.coverUrl) image.value = { url: course.coverUrl }
|
if (course.coverUrl) image.value = { url: course.coverUrl }
|
||||||
if (course.contentMedia) {
|
if (course.contentMedia) {
|
||||||
@@ -315,7 +313,7 @@ const onContentSelect = async (files) => {
|
|||||||
try {
|
try {
|
||||||
const formData = objectToFormData({
|
const formData = objectToFormData({
|
||||||
file,
|
file,
|
||||||
purpose: 'content',
|
purpose: 'voice',
|
||||||
context: 'course',
|
context: 'course',
|
||||||
type: form.value.contentType,
|
type: form.value.contentType,
|
||||||
})
|
})
|
||||||
@@ -336,8 +334,8 @@ const onContentRemove = () => {
|
|||||||
|
|
||||||
const onContentError = (msg) => toast.error(msg)
|
const onContentError = (msg) => toast.error(msg)
|
||||||
|
|
||||||
const addMutation = useAddAdminCourseTemplateMutation()
|
const addMutation = useAddAdminCourseMutation()
|
||||||
const updateMutation = useUpdateAdminCourseTemplateMutation()
|
const updateMutation = useUpdateAdminCourseMutation()
|
||||||
|
|
||||||
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
||||||
|
|
||||||
@@ -349,7 +347,7 @@ const onSubmit = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await addMutation.mutateAsync(payload)
|
await addMutation.mutateAsync(payload)
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
router.push({ name: 'admin-courses' })
|
router.push({ name: 'admin-courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +450,7 @@ const onCancel = () => router.push({ name: 'admin-courses' })
|
|||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
v-for="course in templates"
|
v-for="course in templates"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
:course="course"
|
:course="course"
|
||||||
@edit="onEditTemplate"
|
@edit="onEditCourse"
|
||||||
@delete="onAskDeleteTemplate"
|
@delete="onAskDelete"
|
||||||
@change-status="onChangeTemplateStatus"
|
@change-status="onChangeStatus"
|
||||||
@show-details="onShowDetails"
|
@show-details="onShowDetails"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,21 +36,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #offered>
|
<template #offered>
|
||||||
|
<NoItems
|
||||||
|
v-if="!hasOfferedTerm"
|
||||||
|
title="ترم را انتخاب کنید"
|
||||||
|
desc="برای نمایش دورههای ارائه شده، ابتدا ترم را از فیلترها انتخاب کنید."
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
|
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
|
||||||
<div v-else-if="offered.length > 0">
|
<div v-else-if="offered.length > 0">
|
||||||
<CourseItem
|
<CourseItem
|
||||||
v-for="course in offered"
|
v-for="course in offered"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
:course="course"
|
:course="course"
|
||||||
@edit="onEditOffered"
|
@edit="onEditCourse"
|
||||||
@delete="onAskDeleteOffered"
|
@delete="onAskDelete"
|
||||||
@change-status="onChangeOfferedStatus"
|
@change-status="onChangeStatus"
|
||||||
@show-details="onShowDetails"
|
@show-details="onShowDetails"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
||||||
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
|
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</TabsBlock>
|
</TabsBlock>
|
||||||
|
|
||||||
<AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" />
|
<AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" />
|
||||||
@@ -61,11 +68,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import useModal from '@/composables/useModal'
|
import useModal from '@/composables/useModal'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
||||||
import { usePagination } from '@/composables/usePagination'
|
import { usePagination } from '@/composables/usePagination'
|
||||||
@@ -81,25 +88,25 @@ import AddSessionToCourseModal from '@/features/admin/courses/components/modals/
|
|||||||
import {
|
import {
|
||||||
adminCoursesKeys,
|
adminCoursesKeys,
|
||||||
useAdminCoursesListQuery,
|
useAdminCoursesListQuery,
|
||||||
useChangeAdminCourseStatusMutation,
|
|
||||||
useDeleteAdminCourseMutation,
|
useDeleteAdminCourseMutation,
|
||||||
|
useUpdateAdminCourseMutation,
|
||||||
} from '@/services/query/admin-courses'
|
} from '@/services/query/admin-courses'
|
||||||
import {
|
|
||||||
adminCourseTemplatesKeys,
|
|
||||||
useAdminCourseTemplatesListQuery,
|
|
||||||
useChangeAdminCourseTemplateStatusMutation,
|
|
||||||
useDeleteAdminCourseTemplateMutation,
|
|
||||||
} from '@/services/query/admin-course-templates'
|
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { openModal, isModal } = useModal()
|
const { openModal, isModal } = useModal()
|
||||||
|
|
||||||
|
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
|
||||||
|
|
||||||
const onAdd = () => {
|
const onAdd = () => {
|
||||||
if (activeTab.value === 'templates') {
|
if (activeTab.value === 'templates') {
|
||||||
router.push({ name: 'admin-add-course-template' }).catch(() => {})
|
router.push({ name: 'admin-add-course' }).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
openModal('AddOfferedCourseModal', { mode: 'add' })
|
openModal('AddOfferedCourseModal', {
|
||||||
|
mode: 'add',
|
||||||
|
termId: routeTermId.value ?? undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,10 +128,16 @@ const tabs = [
|
|||||||
buttonAction: onAdd,
|
buttonAction: onAdd,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const activeTab = ref('templates')
|
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
|
||||||
|
|
||||||
const templateFilters = ref({ title: '', status: '', fromDate: '', toDate: '' })
|
const templateFilters = ref({ title: '', status: '', fromDate: '', toDate: '' })
|
||||||
const offeredFilters = ref({ title: '', termId: '', status: '', fromDate: '', toDate: '' })
|
const offeredFilters = ref({
|
||||||
|
title: '',
|
||||||
|
termId: routeTermId.value ?? '',
|
||||||
|
status: '',
|
||||||
|
fromDate: '',
|
||||||
|
toDate: '',
|
||||||
|
})
|
||||||
|
|
||||||
const currentFilters = computed({
|
const currentFilters = computed({
|
||||||
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
|
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
|
||||||
@@ -146,7 +159,7 @@ const {
|
|||||||
reset: resetOfferedPagination,
|
reset: resetOfferedPagination,
|
||||||
} = usePagination({ page: 1, perPage: 10 })
|
} = usePagination({ page: 1, perPage: 10 })
|
||||||
|
|
||||||
const { data: templatesData, isLoading: templatesPending } = useAdminCourseTemplatesListQuery(
|
const { data: templatesData, isLoading: templatesPending } = useAdminCoursesListQuery(
|
||||||
templateFilters,
|
templateFilters,
|
||||||
templatesPagination,
|
templatesPagination,
|
||||||
{
|
{
|
||||||
@@ -155,16 +168,18 @@ const { data: templatesData, isLoading: templatesPending } = useAdminCourseTempl
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasOfferedTerm = computed(() => !!offeredFilters.value.termId)
|
||||||
|
|
||||||
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
|
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
|
||||||
offeredFilters,
|
offeredFilters,
|
||||||
offeredPagination,
|
offeredPagination,
|
||||||
{
|
{
|
||||||
enabled: () => activeTab.value === 'offered',
|
enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const templates = computed(() => templatesData.value?.data?.items ?? [])
|
const templates = computed(() => templatesData.value?.data ?? [])
|
||||||
const templatesPaginationMeta = computed(() => ({
|
const templatesPaginationMeta = computed(() => ({
|
||||||
page: templatesPagination.value.page,
|
page: templatesPagination.value.page,
|
||||||
perPage: templatesPagination.value.perPage,
|
perPage: templatesPagination.value.perPage,
|
||||||
@@ -188,12 +203,8 @@ const onFilterApply = () => {
|
|||||||
}
|
}
|
||||||
const onFilterReset = onFilterApply
|
const onFilterReset = onFilterApply
|
||||||
|
|
||||||
const onEditTemplate = (course) => {
|
const onEditCourse = (course) => {
|
||||||
router.push({ name: 'admin-edit-course-template', params: { id: course.id } }).catch(() => {})
|
router.push({ name: 'admin-edit-course', params: { id: course.id } }).catch(() => {})
|
||||||
}
|
|
||||||
|
|
||||||
const onEditOffered = (course) => {
|
|
||||||
openModal('AddOfferedCourseModal', { mode: 'edit', courseId: course.id })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onShowDetails = (course) => {
|
const onShowDetails = (course) => {
|
||||||
@@ -203,45 +214,33 @@ const onShowDetails = (course) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalidateTemplates = () =>
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
|
||||||
const invalidateOffered = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
|
||||||
|
|
||||||
const deleteTemplateMutation = useDeleteAdminCourseTemplateMutation()
|
const deleteMutation = useDeleteAdminCourseMutation()
|
||||||
const changeTemplateStatusMutation = useChangeAdminCourseTemplateStatusMutation()
|
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
|
||||||
|
const updateMutation = useUpdateAdminCourseMutation()
|
||||||
|
|
||||||
const deleteOfferedMutation = useDeleteAdminCourseMutation()
|
const onAskDelete = (course) => {
|
||||||
const changeOfferedStatusMutation = useChangeAdminCourseStatusMutation()
|
|
||||||
|
|
||||||
const onAskDeleteTemplate = (course) => {
|
|
||||||
openModal('ConfirmModal', {
|
openModal('ConfirmModal', {
|
||||||
title: `حذف ${course.title}`,
|
title: `حذف ${course.title}`,
|
||||||
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که میخواهید <strong>${course.title}</strong> را حذف کنید؟`,
|
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که میخواهید <strong>${course.title}</strong> را حذف کنید؟`,
|
||||||
onConfirm: () => deleteTemplateMutation.mutate(course.id, { onSuccess: invalidateTemplates }),
|
onConfirm: () => deleteMutation.mutate(course.id, { onSuccess: invalidate }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onAskDeleteOffered = (course) => {
|
const onChangeStatus = ({ id, isActive }) => {
|
||||||
openModal('ConfirmModal', {
|
updateMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
|
||||||
title: `حذف ${course.title}`,
|
|
||||||
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که میخواهید <strong>${course.title}</strong> را حذف کنید؟`,
|
|
||||||
onConfirm: () => deleteOfferedMutation.mutate(course.id, { onSuccess: invalidateOffered }),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChangeTemplateStatus = ({ id, isActive }) => {
|
const syncRouteTermId = (termId) => {
|
||||||
changeTemplateStatusMutation.mutate(
|
if (!termId) return
|
||||||
{ id, payload: { isActiveByDefault: isActive } },
|
activeTab.value = 'offered'
|
||||||
{ onSuccess: invalidateTemplates }
|
offeredFilters.value = { ...offeredFilters.value, termId }
|
||||||
)
|
resetOfferedPagination()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChangeOfferedStatus = ({ id, isActive }) => {
|
onMounted(() => syncRouteTermId(routeTermId.value))
|
||||||
changeOfferedStatusMutation.mutate(
|
watch(routeTermId, (val) => syncRouteTermId(val))
|
||||||
{ id, payload: { isActive } },
|
|
||||||
{ onSuccess: invalidateOffered }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { array, boolean, mixed, number, object, string } from 'yup'
|
import { array, boolean, mixed, number, object, string } from 'yup'
|
||||||
|
|
||||||
export const courseTemplateSchema = object().shape({
|
export const courseSchema = object().shape({
|
||||||
title: string().required().min(3).max(255),
|
title: string().required().min(3).max(255),
|
||||||
defaultTeacherId: mixed().required(),
|
teacherId: mixed().required(),
|
||||||
defaultCapacity: number().required().min(1),
|
capacity: number().required().min(1),
|
||||||
sessionsCount: number().required().min(1),
|
sessionsCount: number().required().min(1),
|
||||||
prerequisites: array().nullable().default([]),
|
prerequisites: array().nullable().default([]),
|
||||||
contentType: string().oneOf(['video', 'voice', 'text']).required(),
|
contentType: string().oneOf(['video', 'voice', 'text']).required(),
|
||||||
contentMediaId: number().nullable().notRequired(),
|
contentMediaId: number().nullable().notRequired(),
|
||||||
description: string().nullable().notRequired(),
|
description: string().nullable().notRequired(),
|
||||||
termId: string().nullable(),
|
termId: mixed().nullable(),
|
||||||
|
isActive: boolean().nullable().notRequired(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const offeredCourseSchema = object().shape({
|
export const offeredCourseSchema = object().shape({
|
||||||
termId: string().required(),
|
termId: string().required(),
|
||||||
templateId: string().required(),
|
|
||||||
title: string().required(),
|
title: string().required(),
|
||||||
capacity: string().required(),
|
capacity: string().required(),
|
||||||
imageId: string().nullable().notRequired(),
|
imageId: string().nullable().notRequired(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="exam-item__sub">
|
<div class="exam-item__sub">
|
||||||
<span class="exam-item__sub-label">دوره:</span>
|
<span class="exam-item__sub-label">دوره:</span>
|
||||||
<span class="exam-item__sub-value">
|
<span class="exam-item__sub-value">
|
||||||
{{ exam.courseTemplate?.title || exam.courseTemplateTitle || '—' }}
|
{{ exam.course?.title || exam.courseTitle || '—' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="exam-item__dot">|</span>
|
<span class="exam-item__dot">|</span>
|
||||||
<span class="exam-item__sub-label">جلسه:</span>
|
<span class="exam-item__sub-label">جلسه:</span>
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
v-for="(question, questionIndex) in questions"
|
v-for="(question, questionIndex) in questions"
|
||||||
:key="question.id"
|
:key="question.id"
|
||||||
class="exam-question-builder__card"
|
class="exam-question-builder__card"
|
||||||
|
:class="{ 'exam-question-builder__card--readonly': isLocked(question) }"
|
||||||
>
|
>
|
||||||
<div class="exam-question-builder__head">
|
<div class="exam-question-builder__head">
|
||||||
<p class="exam-question-builder__title">سوال شماره {{ questionIndex + 1 }}</p>
|
<p class="exam-question-builder__title">
|
||||||
|
سوال شماره {{ questionIndex + 1 }}
|
||||||
|
<span v-if="isLocked(question)" class="exam-question-builder__lock">(ذخیره شده)</span>
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="disabled"
|
:disabled="disabled || isLocked(question)"
|
||||||
class="exam-question-builder__remove"
|
class="exam-question-builder__remove"
|
||||||
aria-label="حذف سوال"
|
aria-label="حذف سوال"
|
||||||
@click="removeQuestion(question.id)"
|
@click="removeQuestion(question.id)"
|
||||||
@@ -22,24 +26,24 @@
|
|||||||
<div class="exam-question-builder__col exam-question-builder__col--main">
|
<div class="exam-question-builder__col exam-question-builder__col--main">
|
||||||
<label class="exam-question-builder__label">متن سوال</label>
|
<label class="exam-question-builder__label">متن سوال</label>
|
||||||
<textarea
|
<textarea
|
||||||
:value="question.title"
|
:value="question.questionText"
|
||||||
:disabled="disabled"
|
:disabled="disabled || isLocked(question)"
|
||||||
rows="1"
|
rows="1"
|
||||||
placeholder="لطفا سوال خود را وارد کنید"
|
placeholder="لطفا سوال خود را وارد کنید"
|
||||||
class="exam-question-builder__textarea"
|
class="exam-question-builder__textarea"
|
||||||
@input="updateQuestion(question.id, { title: $event.target.value })"
|
@input="updateQuestion(question.id, { questionText: $event.target.value })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="exam-question-builder__col exam-question-builder__col--score">
|
<div class="exam-question-builder__col exam-question-builder__col--score">
|
||||||
<label class="exam-question-builder__label">بارم نمره</label>
|
<label class="exam-question-builder__label">ترتیب</label>
|
||||||
<input
|
<input
|
||||||
:value="question.score"
|
:value="question.position"
|
||||||
:disabled="disabled"
|
:disabled="disabled || isLocked(question)"
|
||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
placeholder="15"
|
placeholder="1"
|
||||||
class="exam-question-builder__input"
|
class="exam-question-builder__input"
|
||||||
@input="updateQuestion(question.id, { score: $event.target.value })"
|
@input="updateQuestion(question.id, { position: $event.target.value })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,31 +53,31 @@
|
|||||||
<p class="exam-question-builder__label">گزینهها</p>
|
<p class="exam-question-builder__label">گزینهها</p>
|
||||||
<div class="exam-question-builder__answers-list">
|
<div class="exam-question-builder__answers-list">
|
||||||
<div
|
<div
|
||||||
v-for="answer in question.answers"
|
v-for="option in question.options"
|
||||||
:key="answer.id"
|
:key="option.id"
|
||||||
class="exam-question-builder__answer"
|
class="exam-question-builder__answer"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:value="answer.title"
|
:value="option.optionText"
|
||||||
:disabled="disabled"
|
:disabled="disabled || isLocked(question)"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="متن گزینه"
|
placeholder="متن گزینه"
|
||||||
class="exam-question-builder__answer-input"
|
class="exam-question-builder__answer-input"
|
||||||
@input="updateOption(question.id, answer.id, $event.target.value)"
|
@input="updateOption(question.id, option.id, $event.target.value)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="disabled"
|
:disabled="disabled || isLocked(question)"
|
||||||
class="exam-question-builder__answer-remove"
|
class="exam-question-builder__answer-remove"
|
||||||
aria-label="حذف گزینه"
|
aria-label="حذف گزینه"
|
||||||
@click="removeOption(question.id, answer.id)"
|
@click="removeOption(question.id, option.id)"
|
||||||
>
|
>
|
||||||
<SvgIcon name="close" :size="14" color="#b1b1b1" />
|
<SvgIcon name="close" :size="14" color="#b1b1b1" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="disabled"
|
:disabled="disabled || isLocked(question)"
|
||||||
class="exam-question-builder__answer-add"
|
class="exam-question-builder__answer-add"
|
||||||
aria-label="افزودن گزینه"
|
aria-label="افزودن گزینه"
|
||||||
@click="addOption(question.id)"
|
@click="addOption(question.id)"
|
||||||
@@ -85,16 +89,14 @@
|
|||||||
|
|
||||||
<div class="exam-question-builder__correct">
|
<div class="exam-question-builder__correct">
|
||||||
<SelectField
|
<SelectField
|
||||||
:model-value="question.correctAnswerId"
|
:model-value="correctOptionId(question)"
|
||||||
:name="`correctAnswer-${question.id}`"
|
:name="`correctAnswer-${question.id}`"
|
||||||
:options="correctAnswerOptions(question)"
|
:options="correctOptions(question)"
|
||||||
option-label="label"
|
option-label="label"
|
||||||
option-value="value"
|
option-value="value"
|
||||||
label="گزینه صحیح"
|
label="گزینه صحیح"
|
||||||
:disabled="disabled || question.answers.length === 0"
|
:disabled="disabled || isLocked(question) || question.options.length === 0"
|
||||||
@update:model-value="
|
@update:model-value="(value) => setCorrectOption(question.id, value)"
|
||||||
(value) => updateQuestion(question.id, { correctAnswerId: value || null })
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,22 +130,26 @@ const defaultLabels = ['گزینه اول', 'گزینه دوم', 'گزینه س
|
|||||||
|
|
||||||
const questions = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []))
|
const questions = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []))
|
||||||
|
|
||||||
|
// Backend has no PATCH/DELETE for questions/options — once a question came
|
||||||
|
// from the server (numeric id, no `__local`), the form locks editing it.
|
||||||
|
const isLocked = (question) => question?.__local !== true
|
||||||
|
|
||||||
const cloneQuestions = () =>
|
const cloneQuestions = () =>
|
||||||
questions.value.map((q) => ({
|
questions.value.map((q) => ({
|
||||||
...q,
|
...q,
|
||||||
answers: Array.isArray(q.answers) ? q.answers.map((a) => ({ ...a })) : [],
|
options: Array.isArray(q.options) ? q.options.map((o) => ({ ...o })) : [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
|
||||||
const createOption = () => ({ id: createId('answer'), title: '' })
|
const createOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
|
||||||
|
|
||||||
const createQuestion = () => ({
|
const createQuestion = () => ({
|
||||||
id: createId('question'),
|
id: createId('question'),
|
||||||
title: '',
|
questionText: '',
|
||||||
score: '',
|
position: questions.value.length + 1,
|
||||||
correctAnswerId: null,
|
options: [createOption(), createOption()],
|
||||||
answers: [createOption(), createOption()],
|
__local: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitQuestions = (next) => emit('update:modelValue', next)
|
const emitQuestions = (next) => emit('update:modelValue', next)
|
||||||
@@ -163,39 +169,48 @@ const updateQuestion = (questionId, patch) => {
|
|||||||
const addOption = (questionId) => {
|
const addOption = (questionId) => {
|
||||||
const next = cloneQuestions().map((q) => {
|
const next = cloneQuestions().map((q) => {
|
||||||
if (q.id !== questionId) return q
|
if (q.id !== questionId) return q
|
||||||
return { ...q, answers: [...q.answers, createOption()] }
|
return { ...q, options: [...q.options, createOption()] }
|
||||||
})
|
})
|
||||||
emitQuestions(next)
|
emitQuestions(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeOption = (questionId, answerId) => {
|
const removeOption = (questionId, optionId) => {
|
||||||
|
const next = cloneQuestions().map((q) => {
|
||||||
|
if (q.id !== questionId) return q
|
||||||
|
const options = q.options.filter((o) => o.id !== optionId)
|
||||||
|
return { ...q, options }
|
||||||
|
})
|
||||||
|
emitQuestions(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOption = (questionId, optionId, optionText) => {
|
||||||
const next = cloneQuestions().map((q) => {
|
const next = cloneQuestions().map((q) => {
|
||||||
if (q.id !== questionId) return q
|
if (q.id !== questionId) return q
|
||||||
const answers = q.answers.filter((a) => a.id !== answerId)
|
|
||||||
return {
|
return {
|
||||||
...q,
|
...q,
|
||||||
answers,
|
options: q.options.map((o) => (o.id === optionId ? { ...o, optionText } : o)),
|
||||||
correctAnswerId: String(q.correctAnswerId) === String(answerId) ? null : q.correctAnswerId,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
emitQuestions(next)
|
emitQuestions(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateOption = (questionId, answerId, title) => {
|
const setCorrectOption = (questionId, optionId) => {
|
||||||
const next = cloneQuestions().map((q) => {
|
const next = cloneQuestions().map((q) => {
|
||||||
if (q.id !== questionId) return q
|
if (q.id !== questionId) return q
|
||||||
return {
|
return {
|
||||||
...q,
|
...q,
|
||||||
answers: q.answers.map((a) => (a.id === answerId ? { ...a, title } : a)),
|
options: q.options.map((o) => ({ ...o, isCorrect: String(o.id) === String(optionId) })),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
emitQuestions(next)
|
emitQuestions(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const correctOptionId = (question) => question.options.find((o) => o.isCorrect)?.id ?? null
|
||||||
|
|
||||||
const optionLabel = (index) => defaultLabels[index] || `گزینه ${index + 1}`
|
const optionLabel = (index) => defaultLabels[index] || `گزینه ${index + 1}`
|
||||||
|
|
||||||
const correctAnswerOptions = (question) =>
|
const correctOptions = (question) =>
|
||||||
question.answers.map((a, idx) => ({ value: a.id, label: optionLabel(idx) }))
|
question.options.map((o, idx) => ({ value: o.id, label: optionLabel(idx) }))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -209,6 +224,10 @@ const correctAnswerOptions = (question) =>
|
|||||||
border-radius: 1.5rem;
|
border-radius: 1.5rem;
|
||||||
padding: 0.875rem;
|
padding: 0.875rem;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
|
||||||
|
|
||||||
|
&--readonly {
|
||||||
|
background: rgba(0, 0, 0, 2%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__head {
|
&__head {
|
||||||
@@ -227,6 +246,12 @@ const correctAnswerOptions = (question) =>
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__lock {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #9c9c9c;
|
||||||
|
margin-inline-start: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
&__remove {
|
&__remove {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</TextField>
|
</TextField>
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.courseTemplateId"
|
v-model="form.courseId"
|
||||||
name="courseTemplateId"
|
name="courseId"
|
||||||
label="دوره الگو"
|
label="دوره"
|
||||||
:options="templateOptions"
|
:options="templateOptions"
|
||||||
option-label="title"
|
option-label="title"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
@click="onReset"
|
@click="onReset"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<SvgIcon name="close" :size="20" />
|
<SvgIcon name="close" color="black" :size="20" />
|
||||||
</template>
|
</template>
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
@@ -55,18 +55,18 @@ import TextField from '@/components/form/TextField.vue'
|
|||||||
import CircleButton from '@/components/CircleButton.vue'
|
import CircleButton from '@/components/CircleButton.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ title: '', courseTemplateId: '', fromDate: '', toDate: '' }),
|
default: () => ({ title: '', courseId: '', fromDate: '', toDate: '' }),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
||||||
|
|
||||||
const emptyForm = () => ({ title: '', courseTemplateId: '', fromDate: '', toDate: '' })
|
const emptyForm = () => ({ title: '', courseId: '', fromDate: '', toDate: '' })
|
||||||
const form = ref({ ...emptyForm(), ...props.modelValue })
|
const form = ref({ ...emptyForm(), ...props.modelValue })
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -82,10 +82,7 @@ const todayIso = new Date().toISOString()
|
|||||||
const templateSearch = ref('')
|
const templateSearch = ref('')
|
||||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||||
templateFilters,
|
|
||||||
templatePagination
|
|
||||||
)
|
|
||||||
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
||||||
|
|
||||||
const searchTemplates = useDebounce((q) => {
|
const searchTemplates = useDebounce((q) => {
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ const summaryItems = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'دوره مرتبط',
|
title: 'دوره مرتبط',
|
||||||
value: e.courseTemplate?.title || e.courseTemplateTitle || '—',
|
value: e.course?.title || e.courseTitle || '—',
|
||||||
numeric: false,
|
numeric: false,
|
||||||
},
|
},
|
||||||
{ title: 'وضعیت', value: e.statusLabel || e.faStatus || e.status || '—', numeric: false },
|
{ title: 'وضعیت', value: e.statusLabel || e.faStatus || e.status || '—', numeric: false },
|
||||||
{
|
{
|
||||||
title: 'مدت زمان',
|
title: 'حد نصاب قبولی',
|
||||||
value: e.durationMinutes == null ? '—' : `${e.durationMinutes} دقیقه`,
|
value: e.passScore == null ? '—' : `${e.passScore} نمره`,
|
||||||
numeric: true,
|
numeric: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,25 +127,17 @@ const summaryItems = computed(() => {
|
|||||||
const displayQuestions = computed(() => {
|
const displayQuestions = computed(() => {
|
||||||
const raw = exam.value?.questions || []
|
const raw = exam.value?.questions || []
|
||||||
return raw.map((question, index) => {
|
return raw.map((question, index) => {
|
||||||
const answersSource =
|
const optionsSource = Array.isArray(question.options) ? question.options : []
|
||||||
(Array.isArray(question.answers) && question.answers) ||
|
const answers = optionsSource.map((option, ai) => ({
|
||||||
(Array.isArray(question.options) && question.options) ||
|
id: option?.id ?? `${question?.id || index + 1}-${ai + 1}`,
|
||||||
[]
|
title: option?.optionText || '—',
|
||||||
const answers = answersSource.map((answer, ai) => {
|
isCorrect: !!option?.isCorrect,
|
||||||
const id = answer?.id ?? `${question?.id || index + 1}-${ai + 1}`
|
}))
|
||||||
const title =
|
|
||||||
(typeof answer === 'string' && answer) ||
|
|
||||||
answer?.title ||
|
|
||||||
answer?.text ||
|
|
||||||
answer?.label ||
|
|
||||||
'—'
|
|
||||||
return { id, title, isCorrect: String(id) === String(question?.correctAnswerId) }
|
|
||||||
})
|
|
||||||
return {
|
return {
|
||||||
id: question?.id ?? `question-${index + 1}`,
|
id: question?.id ?? `question-${index + 1}`,
|
||||||
order: index + 1,
|
order: question?.position ?? index + 1,
|
||||||
title: question?.title || question?.question || '—',
|
title: question?.questionText || '—',
|
||||||
score: question?.score ?? '',
|
score: '',
|
||||||
answers,
|
answers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,32 +31,17 @@
|
|||||||
:error="errors.title"
|
:error="errors.title"
|
||||||
@blur="validateAt('title', form.title)"
|
@blur="validateAt('title', form.title)"
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
v-model="form.durationMinutes"
|
|
||||||
name="durationMinutes"
|
|
||||||
label="مدت آزمون"
|
|
||||||
inputmode="numeric"
|
|
||||||
:convert-digits="true"
|
|
||||||
:error="errors.durationMinutes"
|
|
||||||
@blur="validateAt('durationMinutes', form.durationMinutes)"
|
|
||||||
/>
|
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.passingScore"
|
v-model="form.passingScore"
|
||||||
name="passingScore"
|
name="passingScore"
|
||||||
label="حداقل نمره قبولی"
|
label="حد نصاب قبولی"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
:convert-digits="true"
|
:convert-digits="true"
|
||||||
:error="errors.passingScore"
|
:error="errors.passingScore"
|
||||||
@blur="validateAt('passingScore', form.passingScore)"
|
@blur="validateAt('passingScore', form.passingScore)"
|
||||||
/>
|
/>
|
||||||
<DatePickerField
|
|
||||||
v-model="form.endDate"
|
|
||||||
name="endDate"
|
|
||||||
label="تاریخ اعتبار"
|
|
||||||
:error="errors.endDate"
|
|
||||||
/>
|
|
||||||
<div class="exam-form__toggle-cell">
|
<div class="exam-form__toggle-cell">
|
||||||
<ToggleSwitch v-model="form.randomize" label="به صورت رندوم باشد" />
|
<ToggleSwitch v-model="form.isActive" label="آزمون فعال باشد" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,13 +101,13 @@ import { examSchema } from '@/features/admin/exams/schema'
|
|||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
|
||||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import ExamQuestionBuilder from '@/features/admin/exams/components/ExamQuestionBuilder.vue'
|
import ExamQuestionBuilder from '@/features/admin/exams/components/ExamQuestionBuilder.vue'
|
||||||
import {
|
import {
|
||||||
adminExamsKeys,
|
adminExamsKeys,
|
||||||
useAddAdminExamMutation,
|
useAddAdminExamMutation,
|
||||||
|
useAddAdminExamQuestionMutation,
|
||||||
useAdminExamQuery,
|
useAdminExamQuery,
|
||||||
useUpdateAdminExamMutation,
|
useUpdateAdminExamMutation,
|
||||||
} from '@/services/query/admin-exams'
|
} from '@/services/query/admin-exams'
|
||||||
@@ -137,32 +122,27 @@ const isEditMode = computed(() => !!examId.value)
|
|||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
endDate: '',
|
|
||||||
durationMinutes: '',
|
|
||||||
passingScore: '',
|
passingScore: '',
|
||||||
randomize: true,
|
isActive: true,
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
|
||||||
|
const blankOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
|
||||||
|
|
||||||
const blankQuestion = () => ({
|
const blankQuestion = () => ({
|
||||||
id: createId('question'),
|
id: createId('question'),
|
||||||
title: '',
|
questionText: '',
|
||||||
score: '',
|
position: 1,
|
||||||
correctAnswerId: null,
|
options: [blankOption(), { ...blankOption(), id: createId('option') }],
|
||||||
answers: [
|
__local: true,
|
||||||
{ id: createId('answer'), title: '' },
|
|
||||||
{ id: createId('answer'), title: '' },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const questions = ref([blankQuestion()])
|
const questions = ref([blankQuestion()])
|
||||||
const questionError = ref('')
|
const questionError = ref('')
|
||||||
|
|
||||||
const schema = examSchema
|
const { validate, validateAt, errors } = useYup(examSchema)
|
||||||
|
|
||||||
const { validate, validateAt, errors } = useYup(schema)
|
|
||||||
|
|
||||||
const sessionSearch = ref('')
|
const sessionSearch = ref('')
|
||||||
const sessionFilters = computed(() => ({ title: sessionSearch.value }))
|
const sessionFilters = computed(() => ({ title: sessionSearch.value }))
|
||||||
@@ -185,20 +165,20 @@ const { data: existingExam } = useAdminExamQuery(examId, {
|
|||||||
enabled: () => !!examId.value,
|
enabled: () => !!examId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const normalizeQuestions = (raw = []) => {
|
const normalizeExistingQuestions = (raw = []) => {
|
||||||
if (!Array.isArray(raw) || raw.length === 0) return [blankQuestion()]
|
if (!Array.isArray(raw) || raw.length === 0) return [blankQuestion()]
|
||||||
return raw.map((q, qIdx) => {
|
return raw.map((q, qIdx) => {
|
||||||
const answersSrc = q.answers || q.options || q.choices || []
|
const options = Array.isArray(q.options) ? q.options : []
|
||||||
const answers = (Array.isArray(answersSrc) ? answersSrc : []).map((a, aIdx) => ({
|
|
||||||
id: a?.id || createId(`answer-${qIdx}-${aIdx}`),
|
|
||||||
title: typeof a === 'string' ? a : a?.title || a?.text || a?.label || '',
|
|
||||||
}))
|
|
||||||
return {
|
return {
|
||||||
id: q.id || createId(`question-${qIdx}`),
|
id: q.id ?? createId(`question-${qIdx}`),
|
||||||
title: q.title || q.question || q.text || '',
|
questionText: q.questionText || '',
|
||||||
score: q.score ?? q.barom ?? '',
|
position: q.position ?? qIdx + 1,
|
||||||
correctAnswerId: q.correctAnswerId || q.correctOptionId || q.correctAnswer?.id || null,
|
options: options.map((o, oIdx) => ({
|
||||||
answers: answers.length > 0 ? answers : blankQuestion().answers,
|
id: o.id ?? createId(`option-${qIdx}-${oIdx}`),
|
||||||
|
optionText: o.optionText || '',
|
||||||
|
isCorrect: !!o.isCorrect,
|
||||||
|
})),
|
||||||
|
// No `__local` flag — these came from the server, so the builder will lock them.
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -209,62 +189,89 @@ watch(existingExam, (exam) => {
|
|||||||
form.value = {
|
form.value = {
|
||||||
title: exam.title || '',
|
title: exam.title || '',
|
||||||
sessionId: exam.session?.id || exam.sessionId || '',
|
sessionId: exam.session?.id || exam.sessionId || '',
|
||||||
endDate: exam.endDate || '',
|
passingScore: exam.passScore ?? '',
|
||||||
durationMinutes: exam.durationMinutes ?? '',
|
isActive: exam.isActive ?? true,
|
||||||
passingScore: exam.passingScore ?? '',
|
|
||||||
randomize: exam.randomize ?? true,
|
|
||||||
description: exam.description || '',
|
description: exam.description || '',
|
||||||
}
|
}
|
||||||
questions.value = normalizeQuestions(exam.questions)
|
questions.value = normalizeExistingQuestions(exam.questions)
|
||||||
})
|
})
|
||||||
|
|
||||||
const validateQuestionList = () => {
|
const validateLocalQuestions = () => {
|
||||||
const list = questions.value
|
const localOnes = questions.value.filter((q) => q.__local === true)
|
||||||
if (list.some((q) => !String(q.title || '').trim() || !String(q.score || '').trim())) {
|
if (!isEditMode.value && localOnes.length === 0) {
|
||||||
questionError.value = 'لطفا متن سوال و بارم هر سوال را وارد کنید.'
|
questionError.value = 'حداقل یک سوال اضافه کنید.'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (list.some((q) => q.answers.filter((a) => a.title?.trim()).length < 2)) {
|
for (const q of localOnes) {
|
||||||
|
if (!String(q.questionText || '').trim()) {
|
||||||
|
questionError.value = 'متن همه سوالات را وارد کنید.'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const validOptions = q.options.filter((o) => String(o.optionText || '').trim())
|
||||||
|
if (validOptions.length < 2) {
|
||||||
questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.'
|
questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (
|
if (!validOptions.some((o) => o.isCorrect)) {
|
||||||
list.some((q) => {
|
questionError.value = 'گزینه صحیح هر سوال را انتخاب کنید.'
|
||||||
if (!q.correctAnswerId) return true
|
|
||||||
return !q.answers.some((a) => String(a.id) === String(q.correctAnswerId) && a.title?.trim())
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
questionError.value = 'گزینه صحیح هر سوال را از گزینههای موجود انتخاب کنید.'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
questionError.value = ''
|
questionError.value = ''
|
||||||
return list.map((q) => ({
|
return localOnes.map((q, idx) => ({
|
||||||
id: q.id,
|
questionText: q.questionText.trim(),
|
||||||
title: q.title.trim(),
|
position: Number(q.position) || idx + 1,
|
||||||
score: q.score,
|
options: q.options
|
||||||
correctAnswerId: q.correctAnswerId,
|
.filter((o) => String(o.optionText || '').trim())
|
||||||
answers: q.answers.filter((a) => a.title?.trim()).map((a) => ({ id: a.id, title: a.title })),
|
.map((o) => ({
|
||||||
|
optionText: o.optionText.trim(),
|
||||||
|
isCorrect: !!o.isCorrect,
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMutation = useAddAdminExamMutation()
|
const buildExamPayload = (values) => ({
|
||||||
const updateMutation = useUpdateAdminExamMutation()
|
sessionId: values.sessionId,
|
||||||
|
title: values.title,
|
||||||
|
description: values.description,
|
||||||
|
passingScore: Number(values.passingScore) || 0,
|
||||||
|
isActive: values.isActive,
|
||||||
|
})
|
||||||
|
|
||||||
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
const addExamMutation = useAddAdminExamMutation()
|
||||||
|
const updateExamMutation = useUpdateAdminExamMutation()
|
||||||
|
const addQuestionMutation = useAddAdminExamQuestionMutation()
|
||||||
|
|
||||||
|
const submitting = computed(
|
||||||
|
() =>
|
||||||
|
addExamMutation.isPending.value ||
|
||||||
|
updateExamMutation.isPending.value ||
|
||||||
|
addQuestionMutation.isPending.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const postQuestionsSequentially = async (id, list) => {
|
||||||
|
for (const payload of list) {
|
||||||
|
// Sequential so question position ordering is preserved on the backend.
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await addQuestionMutation.mutateAsync({ examId: id, payload })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid } = await validate(form.value)
|
||||||
const cleanQuestions = validateQuestionList()
|
const newQuestions = validateLocalQuestions()
|
||||||
if (!isValid || !cleanQuestions) return
|
if (!isValid || !newQuestions) return
|
||||||
const finalPayload = {
|
|
||||||
...payload,
|
const examPayload = buildExamPayload(form.value)
|
||||||
questions: cleanQuestions,
|
let targetExamId = examId.value
|
||||||
questionsCount: cleanQuestions.length,
|
|
||||||
}
|
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
await updateMutation.mutateAsync({ id: examId.value, payload: finalPayload })
|
await updateExamMutation.mutateAsync({ id: targetExamId, payload: examPayload })
|
||||||
} else {
|
} else {
|
||||||
await addMutation.mutateAsync(finalPayload)
|
const created = await addExamMutation.mutateAsync(examPayload)
|
||||||
|
targetExamId = created?.data?.id ?? created?.id ?? targetExamId
|
||||||
|
}
|
||||||
|
if (targetExamId && newQuestions.length > 0) {
|
||||||
|
await postQuestionsSequentially(targetExamId, newQuestions)
|
||||||
}
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all })
|
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all })
|
||||||
router.push({ name: 'admin-exams' })
|
router.push({ name: 'admin-exams' })
|
||||||
@@ -306,7 +313,7 @@ const onCancel = () => router.push({ name: 'admin-exams' })
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const router = useRouter()
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { openModal, isModal } = useModal()
|
const { openModal, isModal } = useModal()
|
||||||
|
|
||||||
const filters = ref({ title: '', courseTemplateId: '', fromDate: '', toDate: '' })
|
const filters = ref({ title: '', courseId: '', fromDate: '', toDate: '' })
|
||||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||||
|
|
||||||
const { data, isLoading } = useAdminExamsListQuery(filters, pagination, {
|
const { data, isLoading } = useAdminExamsListQuery(filters, pagination, {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { number, object, string } from 'yup'
|
import { boolean, number, object, string } from 'yup'
|
||||||
|
|
||||||
export const examSchema = object().shape({
|
export const examSchema = object().shape({
|
||||||
title: string().required().min(3),
|
title: string().required().min(3),
|
||||||
sessionId: string().required(),
|
sessionId: string().required(),
|
||||||
endDate: string().required(),
|
|
||||||
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(),
|
|
||||||
passingScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
passingScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
||||||
description: string().nullable().notRequired(),
|
description: string().nullable().notRequired(),
|
||||||
|
isActive: boolean().nullable().notRequired(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ticket-item">
|
<div class="ticket-item">
|
||||||
<div class="ticket-item__user">
|
<div class="ticket-item__user">
|
||||||
<div v-if="ticket.user?.avatarUrl" class="ticket-item__avatar">
|
<div v-if="ticket.student?.avatarUrl" class="ticket-item__avatar">
|
||||||
<img :src="ticket.user.avatarUrl" :alt="userName" />
|
<img :src="ticket.student.avatarUrl" :alt="userName" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="ticket-item__avatar ticket-item__avatar--placeholder">
|
<div v-else class="ticket-item__avatar ticket-item__avatar--placeholder">
|
||||||
<SvgIcon name="user" :size="24" color="#bcbcbc" />
|
<SvgIcon name="user" :size="24" color="#bcbcbc" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-item__info">
|
<div class="ticket-item__info">
|
||||||
<p class="ticket-item__name">{{ userName }}</p>
|
<p class="ticket-item__name">{{ userName }}</p>
|
||||||
<p class="ticket-item__title">{{ ticket.title || '—' }}</p>
|
<p class="ticket-item__title">{{ ticket.subject || '—' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ticket-item__meta">
|
<div class="ticket-item__meta">
|
||||||
<span
|
<span class="ticket-item__status" :class="`ticket-item__status--${ticket.status || 'open'}`">
|
||||||
class="ticket-item__status"
|
|
||||||
:class="`ticket-item__status--${ticket.status || 'pending'}`"
|
|
||||||
>
|
|
||||||
{{ statusLabel }}
|
{{ statusLabel }}
|
||||||
</span>
|
</span>
|
||||||
<div class="ticket-item__pill">
|
<div class="ticket-item__pill">
|
||||||
@@ -58,20 +55,22 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['show-details'])
|
const emit = defineEmits(['show-details'])
|
||||||
|
|
||||||
const userName = computed(() => {
|
const userName = computed(() => {
|
||||||
const u = props.ticket.user
|
const s = props.ticket.student
|
||||||
if (!u) return '—'
|
if (!s) return '—'
|
||||||
return `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.fullName || '—'
|
return s.name || `${s.firstName || ''} ${s.lastName || ''}`.trim() || s.fullName || '—'
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusLabel = computed(
|
const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
|
||||||
() => props.ticket.statusLabel || TICKET_STATUS[props.ticket.status] || '—'
|
|
||||||
)
|
|
||||||
|
|
||||||
const createdAt = computed(
|
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
|
||||||
() => props.ticket.faCreatedAt || formatJalaaliDate(props.ticket.createdAt) || '—'
|
|
||||||
)
|
|
||||||
|
|
||||||
const createdTime = computed(() => props.ticket.faCreatedTime || '')
|
const createdTime = computed(() => {
|
||||||
|
const iso = props.ticket.createdAt
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -160,7 +159,7 @@ const createdTime = computed(() => props.ticket.faCreatedTime || '')
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&--pending {
|
&--open {
|
||||||
background: rgba(204, 154, 40, 8%);
|
background: rgba(204, 154, 40, 8%);
|
||||||
color: #cc6f00;
|
color: #cc6f00;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
:class="`ticket-details__row--${senderClass(message)}`"
|
:class="`ticket-details__row--${senderClass(message)}`"
|
||||||
>
|
>
|
||||||
<div class="ticket-details__bubble">
|
<div class="ticket-details__bubble">
|
||||||
<p class="ticket-details__text">{{ message.text }}</p>
|
<p class="ticket-details__text">{{ message.message }}</p>
|
||||||
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,19 +26,6 @@
|
|||||||
|
|
||||||
<div class="ticket-details__divider" />
|
<div class="ticket-details__divider" />
|
||||||
|
|
||||||
<div v-if="attachment" class="ticket-details__attachment">
|
|
||||||
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" />
|
|
||||||
<span class="ticket-details__attachment-name">{{ attachment.name }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ticket-details__attachment-remove"
|
|
||||||
aria-label="حذف فایل"
|
|
||||||
@click="removeAttachment"
|
|
||||||
>
|
|
||||||
<SvgIcon name="close" :size="14" color="var(--color-error)" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="ticket-details__compose" @submit.prevent="onSend">
|
<form class="ticket-details__compose" @submit.prevent="onSend">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -65,20 +52,6 @@
|
|||||||
>
|
>
|
||||||
<SvgIcon name="mood" :size="30" color="currentColor" />
|
<SvgIcon name="mood" :size="30" color="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ticket-details__compose-btn"
|
|
||||||
aria-label="پیوست فایل"
|
|
||||||
@click="triggerFilePicker"
|
|
||||||
>
|
|
||||||
<SvgIcon name="attach-file" :size="30" color="currentColor" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
class="ticket-details__file-input"
|
|
||||||
@change="onFileSelected"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +83,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import {
|
import {
|
||||||
@@ -167,28 +139,21 @@ const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
|
|||||||
|
|
||||||
const messages = computed(() => ticket.value?.messages ?? [])
|
const messages = computed(() => ticket.value?.messages ?? [])
|
||||||
|
|
||||||
const ticketDate = computed(
|
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
||||||
() => ticket.value?.faCreatedAt || formatJalaaliDate(ticket.value?.createdAt) || '—'
|
|
||||||
)
|
|
||||||
|
|
||||||
const senderClass = (message) => (message.sender === 'admin' ? 'admin' : 'user')
|
// Anyone whose id matches the ticket's student is "the student"; everyone else
|
||||||
|
// (admin, counselor) renders on the opposite side of the thread.
|
||||||
|
const senderClass = (message) => (message.senderId === ticket.value?.studentId ? 'user' : 'admin')
|
||||||
|
|
||||||
const messageTime = (message) => message.time || message.faSentAt || message.sentAt || '—'
|
const messageTime = (message) => {
|
||||||
|
if (!message.createdAt) return '—'
|
||||||
|
const d = new Date(message.createdAt)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
const attachment = ref(null)
|
const canSend = computed(() => text.value.trim().length > 0)
|
||||||
const canSend = computed(() => text.value.trim().length > 0 || !!attachment.value)
|
|
||||||
|
|
||||||
const fileInput = ref(null)
|
|
||||||
const triggerFilePicker = () => fileInput.value?.click()
|
|
||||||
const onFileSelected = (event) => {
|
|
||||||
const file = event.target?.files?.[0]
|
|
||||||
if (file) attachment.value = file
|
|
||||||
if (event.target) event.target.value = ''
|
|
||||||
}
|
|
||||||
const removeAttachment = () => {
|
|
||||||
attachment.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const emojiOpen = ref(false)
|
const emojiOpen = ref(false)
|
||||||
const emojiButton = ref(null)
|
const emojiButton = ref(null)
|
||||||
@@ -244,12 +209,10 @@ const sendMutation = useSendAdminTicketMessageMutation()
|
|||||||
const onSend = async () => {
|
const onSend = async () => {
|
||||||
if (!canSend.value || !ticketId.value) return
|
if (!canSend.value || !ticketId.value) return
|
||||||
const value = text.value.trim()
|
const value = text.value.trim()
|
||||||
const file = attachment.value
|
|
||||||
text.value = ''
|
text.value = ''
|
||||||
attachment.value = null
|
|
||||||
emojiOpen.value = false
|
emojiOpen.value = false
|
||||||
const payload = file ? objectToFormData({ text: value, attachment: file }) : { text: value }
|
// Backend POST /admin/tickets/:id/messages — body is { message: string }.
|
||||||
await sendMutation.mutateAsync({ id: ticketId.value, payload })
|
await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } })
|
||||||
await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all })
|
await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,16 +54,28 @@ export default [
|
|||||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت دوره' },
|
meta: { layout: 'admin', role: 'admin', title: 'مدیریت دوره' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/add-course-template',
|
path: '/add-course',
|
||||||
name: 'admin-add-course-template',
|
name: 'admin-add-course',
|
||||||
component: () => import('@/features/admin/courses/pages/CourseTemplateFormPage.vue'),
|
component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
|
||||||
meta: { layout: 'admin', role: 'admin', title: 'افزودن دوره الگو' },
|
meta: { layout: 'admin', role: 'admin', title: 'افزودن دوره' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/edit-course-template/:id',
|
path: '/edit-course/:id',
|
||||||
name: 'admin-edit-course-template',
|
name: 'admin-edit-course',
|
||||||
component: () => import('@/features/admin/courses/pages/CourseTemplateFormPage.vue'),
|
component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
|
||||||
meta: { layout: 'admin', role: 'admin', title: 'ویرایش دوره الگو' },
|
meta: { layout: 'admin', role: 'admin', title: 'ویرایش دوره' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/terms/:termId/courses',
|
||||||
|
name: 'admin-term-courses',
|
||||||
|
component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'),
|
||||||
|
meta: { layout: 'admin', role: 'admin', title: 'دورههای ترم' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/courses/:courseId/sessions',
|
||||||
|
name: 'admin-course-sessions',
|
||||||
|
component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'),
|
||||||
|
meta: { layout: 'admin', role: 'admin', title: 'جلسات دوره' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/sessions',
|
path: '/sessions',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="session-item__meta">
|
<div class="session-item__meta">
|
||||||
<div class="session-item__pill">
|
<div class="session-item__pill">
|
||||||
<span class="session-item__pill-label">متعلق به دوره:</span>
|
<span class="session-item__pill-label">متعلق به دوره:</span>
|
||||||
<span class="session-item__pill-value">{{ session.courseTemplate?.title || '—' }}</span>
|
<span class="session-item__pill-value">{{ session.course?.title || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item__pill">
|
<div class="session-item__pill">
|
||||||
<span class="session-item__pill-label">مدت جلسه:</span>
|
<span class="session-item__pill-label">مدت جلسه:</span>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</TextField>
|
</TextField>
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.courseTemplateId"
|
v-model="form.courseId"
|
||||||
name="courseTemplateId"
|
name="courseId"
|
||||||
label="دوره الگو"
|
label="دوره"
|
||||||
:options="templateOptions"
|
:options="templateOptions"
|
||||||
option-label="title"
|
option-label="title"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
@@ -64,14 +64,14 @@ import TextField from '@/components/form/TextField.vue'
|
|||||||
import CircleButton from '@/components/CircleButton.vue'
|
import CircleButton from '@/components/CircleButton.vue'
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
courseId: '',
|
||||||
sessionType: '',
|
sessionType: '',
|
||||||
fromDate: '',
|
fromDate: '',
|
||||||
toDate: '',
|
toDate: '',
|
||||||
@@ -83,7 +83,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
|||||||
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
courseId: '',
|
||||||
sessionType: '',
|
sessionType: '',
|
||||||
fromDate: '',
|
fromDate: '',
|
||||||
toDate: '',
|
toDate: '',
|
||||||
@@ -109,10 +109,7 @@ const sessionTypeOptions = Object.entries(SESSION_TYPE).map(([value, label]) =>
|
|||||||
const templateSearch = ref('')
|
const templateSearch = ref('')
|
||||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||||
templateFilters,
|
|
||||||
templatePagination
|
|
||||||
)
|
|
||||||
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
||||||
|
|
||||||
const searchTemplates = useDebounce((q) => {
|
const searchTemplates = useDebounce((q) => {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="session-details__hero-info">
|
<div class="session-details__hero-info">
|
||||||
<p class="session-details__title">{{ session.title || '—' }}</p>
|
<p class="session-details__title">{{ session.title || '—' }}</p>
|
||||||
<p v-if="session.courseTemplate?.title" class="session-details__sub">
|
<p v-if="session.course?.title" class="session-details__sub">
|
||||||
دوره: {{ session.courseTemplate.title }}
|
دوره: {{ session.course.title }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,21 +11,10 @@
|
|||||||
</BoxedIconTitleBlock>
|
</BoxedIconTitleBlock>
|
||||||
|
|
||||||
<form class="session-form__form" @submit.prevent="onSubmit">
|
<form class="session-form__form" @submit.prevent="onSubmit">
|
||||||
<div class="session-form__grid">
|
|
||||||
<div class="session-form__image-col">
|
|
||||||
<ImageCropper
|
|
||||||
v-model="image"
|
|
||||||
name="image"
|
|
||||||
bg-color="#eeeeee"
|
|
||||||
@crop="onImageCropped"
|
|
||||||
@error="onImageError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="session-form__main-col">
|
|
||||||
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
|
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
|
||||||
<div class="session-form__row">
|
|
||||||
<div class="session-form__cell session-form__cell--third">
|
<!-- row 1 — title / startTime / endTime -->
|
||||||
|
<div class="session-form__row session-form__row--three">
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
name="title"
|
name="title"
|
||||||
@@ -37,21 +26,59 @@
|
|||||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||||
</template>
|
</template>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
<DatePickerField
|
||||||
|
v-model="form.startTime"
|
||||||
|
name="startTime"
|
||||||
|
label="زمان شروع"
|
||||||
|
type="datetime"
|
||||||
|
:error="errors.startTime"
|
||||||
|
@blur="validateAt('startTime', form.startTime)"
|
||||||
|
/>
|
||||||
|
<DatePickerField
|
||||||
|
v-model="form.endTime"
|
||||||
|
name="endTime"
|
||||||
|
label="زمان پایان"
|
||||||
|
type="datetime"
|
||||||
|
:error="errors.endTime"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
|
<!-- row 2 — durationMinutes / courseId / contentType -->
|
||||||
|
<div class="session-form__row session-form__row--three">
|
||||||
|
<TextField
|
||||||
|
v-model="form.durationMinutes"
|
||||||
|
name="durationMinutes"
|
||||||
|
label="مدت زمان جلسه (دقیقه)"
|
||||||
|
inputmode="numeric"
|
||||||
|
:convert-digits="true"
|
||||||
|
:error="errors.durationMinutes"
|
||||||
|
@blur="validateAt('durationMinutes', form.durationMinutes)"
|
||||||
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.courseTemplateId"
|
v-model="form.courseId"
|
||||||
name="courseTemplateId"
|
name="courseId"
|
||||||
label="دوره الگو"
|
label="دوره"
|
||||||
:options="templateOptions"
|
:options="courseOptions"
|
||||||
option-label="title"
|
option-label="title"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:on-search="searchTemplates"
|
:on-search="searchCourses"
|
||||||
:error="errors.courseTemplateId"
|
:error="errors.courseId"
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
v-model="form.contentType"
|
||||||
|
name="contentType"
|
||||||
|
label="محتوای جلسه"
|
||||||
|
:options="contentTypeOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
:error="errors.contentType"
|
||||||
|
@change="onContentTypeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
|
<!-- row 3 — sessionType / meetingLink -->
|
||||||
|
<div class="session-form__row session-form__row--two">
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.sessionType"
|
v-model="form.sessionType"
|
||||||
name="sessionType"
|
name="sessionType"
|
||||||
@@ -60,133 +87,67 @@
|
|||||||
option-label="label"
|
option-label="label"
|
||||||
option-value="value"
|
option-value="value"
|
||||||
:error="errors.sessionType"
|
:error="errors.sessionType"
|
||||||
|
@change="onSessionTypeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.durationMinutes"
|
v-model="form.meetingLink"
|
||||||
name="durationMinutes"
|
|
||||||
label="مدت زمان (دقیقه)"
|
|
||||||
inputmode="numeric"
|
|
||||||
:convert-digits="true"
|
|
||||||
:error="errors.durationMinutes"
|
|
||||||
@blur="validateAt('durationMinutes', form.durationMinutes)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
<TextField
|
|
||||||
v-model="form.order"
|
|
||||||
name="order"
|
|
||||||
label="ترتیب"
|
|
||||||
inputmode="numeric"
|
|
||||||
:convert-digits="true"
|
|
||||||
:error="errors.order"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="form.sessionType === 'online'">
|
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
<TextField
|
|
||||||
v-model="form.sessionConfig.meetingLink"
|
|
||||||
name="meetingLink"
|
name="meetingLink"
|
||||||
label="لینک جلسه"
|
label="لینک جلسه"
|
||||||
|
:disabled="form.sessionType !== 'online'"
|
||||||
|
:error="errors.meetingLink"
|
||||||
|
@blur="validateAt('meetingLink', form.meetingLink)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
<SelectField
|
|
||||||
v-model="form.sessionConfig.platform"
|
|
||||||
name="platform"
|
|
||||||
label="پلتفرم"
|
|
||||||
:options="platformOptions"
|
|
||||||
option-label="label"
|
|
||||||
option-value="value"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
<DatePickerField
|
|
||||||
v-model="form.sessionConfig.startTime"
|
|
||||||
name="startTime"
|
|
||||||
label="تاریخ و ساعت شروع"
|
|
||||||
type="datetime"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="form.sessionType === 'in_person'">
|
<!-- row 4 — description -->
|
||||||
<div class="session-form__cell session-form__cell--third">
|
<div class="session-form__row">
|
||||||
<DatePickerField
|
|
||||||
v-model="form.sessionConfig.startTime"
|
|
||||||
name="startTime"
|
|
||||||
label="تاریخ و ساعت شروع"
|
|
||||||
type="datetime"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="session-form__cell session-form__cell--full">
|
|
||||||
<TextField
|
|
||||||
v-model="form.sessionConfig.location"
|
|
||||||
name="location"
|
|
||||||
label="مکان جلسه"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="['video', 'audio'].includes(form.sessionType)">
|
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
<TextField
|
|
||||||
v-model="form.sessionConfig.minWatchedPercent"
|
|
||||||
name="minWatchedPercent"
|
|
||||||
label="حداقل درصد مشاهده (%)"
|
|
||||||
inputmode="numeric"
|
|
||||||
:convert-digits="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="session-form__cell session-form__cell--third session-form__toggle-cell">
|
|
||||||
<ToggleSwitch
|
|
||||||
v-model="form.sessionConfig.mustCompleteBeforeNext"
|
|
||||||
label="الزام تکمیل قبل از جلسه بعدی"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="['text', 'slide', 'pdf'].includes(form.sessionType)">
|
|
||||||
<div class="session-form__cell session-form__cell--third">
|
|
||||||
<TextField
|
|
||||||
v-model="form.sessionConfig.minReadPercent"
|
|
||||||
name="minReadPercent"
|
|
||||||
label="حداقل درصد مطالعه (%)"
|
|
||||||
inputmode="numeric"
|
|
||||||
:convert-digits="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="session-form__cell session-form__cell--third session-form__toggle-cell">
|
|
||||||
<ToggleSwitch
|
|
||||||
v-model="form.sessionConfig.mustCompleteBeforeNext"
|
|
||||||
label="الزام تکمیل قبل از جلسه بعدی"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="session-form__cell session-form__cell--full">
|
|
||||||
<TextareaField
|
<TextareaField
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
name="description"
|
name="description"
|
||||||
label="توضیحات"
|
label="توضیحات جلسه"
|
||||||
:row="5"
|
:row="5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="session-form__materials">
|
<!-- row 5 — content uploader -->
|
||||||
<LineTitleBlock title="فایلهای جلسه" title-en="Session Materials" />
|
<div class="session-form__row">
|
||||||
|
<label class="session-form__uploader-label">محتوای جلسه</label>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-model="materials"
|
v-model="contentFiles"
|
||||||
accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx"
|
:accept="contentAccept"
|
||||||
:multiple="true"
|
:multiple="false"
|
||||||
:max-files="10"
|
:max-files="1"
|
||||||
context="session"
|
:disabled="!form.contentType"
|
||||||
|
@select="onContentSelect"
|
||||||
|
@remove="onContentRemove"
|
||||||
|
@error="onContentError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- row 6 — preview of uploaded content -->
|
||||||
|
<div v-if="contentPreviewUrl" class="session-form__row session-form__preview">
|
||||||
|
<video
|
||||||
|
v-if="form.contentType === 'video'"
|
||||||
|
:src="contentPreviewUrl"
|
||||||
|
controls
|
||||||
|
class="session-form__media"
|
||||||
|
/>
|
||||||
|
<audio
|
||||||
|
v-else-if="form.contentType === 'voice'"
|
||||||
|
:src="contentPreviewUrl"
|
||||||
|
controls
|
||||||
|
class="session-form__media"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
:href="contentPreviewUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="session-form__file-link"
|
||||||
|
>
|
||||||
|
<SvgIcon name="file" :size="18" color="var(--color-primary)" />
|
||||||
|
<span>{{ contentFiles[0]?.name || 'فایل پیوست' }}</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-form__divider" />
|
<div class="session-form__divider" />
|
||||||
@@ -227,19 +188,17 @@ import { useQueryClient } from '@tanstack/vue-query'
|
|||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import TextField from '@/components/form/TextField.vue'
|
import TextField from '@/components/form/TextField.vue'
|
||||||
import { SESSION_PLATFORM, SESSION_TYPE } from '@/enums'
|
|
||||||
import SelectField from '@/components/form/SelectField.vue'
|
import SelectField from '@/components/form/SelectField.vue'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
|
||||||
import ImageCropper from '@/components/form/ImageCropper.vue'
|
|
||||||
import FileUploader from '@/components/form/FileUploader.vue'
|
import FileUploader from '@/components/form/FileUploader.vue'
|
||||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||||
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import { sessionSchema } from '@/features/admin/sessions/schema'
|
import { sessionSchema } from '@/features/admin/sessions/schema'
|
||||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
|
||||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||||
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
|
||||||
import {
|
import {
|
||||||
adminSessionsKeys,
|
adminSessionsKeys,
|
||||||
useAddAdminSessionMutation,
|
useAddAdminSessionMutation,
|
||||||
@@ -254,149 +213,146 @@ const queryClient = useQueryClient()
|
|||||||
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
|
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
|
||||||
const isEditMode = computed(() => !!sessionId.value)
|
const isEditMode = computed(() => !!sessionId.value)
|
||||||
|
|
||||||
const sessionTypeOptions = Object.entries(SESSION_TYPE).map(([value, label]) => ({
|
// نوع جلسه: only the two delivery modes — backend `type` collapses to online/offline.
|
||||||
value,
|
const sessionTypeOptions = [
|
||||||
label,
|
{ value: 'in_person', label: SESSION_TYPE.in_person },
|
||||||
}))
|
{ value: 'online', label: SESSION_TYPE.online },
|
||||||
const platformOptions = Object.entries(SESSION_PLATFORM).map(([value, label]) => ({
|
]
|
||||||
value,
|
|
||||||
label,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const emptySessionConfig = () => ({
|
// محتوای جلسه: voice / video / text — reuses the course content-type enum.
|
||||||
meetingLink: '',
|
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
|
||||||
platform: '',
|
value,
|
||||||
startTime: '',
|
label,
|
||||||
location: '',
|
}))
|
||||||
minWatchedPercent: '',
|
|
||||||
minReadPercent: '',
|
|
||||||
mustCompleteBeforeNext: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
startTime: '',
|
||||||
sessionType: '',
|
endTime: '',
|
||||||
durationMinutes: '',
|
durationMinutes: '',
|
||||||
order: '',
|
courseId: '',
|
||||||
|
contentType: '',
|
||||||
|
sessionType: '',
|
||||||
|
meetingLink: '',
|
||||||
description: '',
|
description: '',
|
||||||
imageId: null,
|
contentMediaId: null,
|
||||||
sessionConfig: emptySessionConfig(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const image = ref(null)
|
const contentFiles = ref([])
|
||||||
const materials = ref([])
|
|
||||||
|
|
||||||
const schema = sessionSchema
|
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
||||||
|
const contentPreviewUrl = computed(() => contentFiles.value[0]?.url || '')
|
||||||
|
|
||||||
const { validate, validateAt, errors } = useYup(schema)
|
const { validate, validateAt, errors } = useYup(sessionSchema)
|
||||||
|
|
||||||
const templateSearch = ref('')
|
const courseSearch = ref('')
|
||||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
const courseFilters = computed(() => ({ title: courseSearch.value }))
|
||||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
const coursePagination = ref({ page: 1, perPage: 30 })
|
||||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
|
||||||
templateFilters,
|
const selectedCourse = ref(null)
|
||||||
templatePagination
|
const courseOptions = computed(() => {
|
||||||
)
|
const base = coursesResponse.value?.data ?? []
|
||||||
const selectedTemplate = ref(null)
|
if (selectedCourse.value && !base.some((c) => c.id === selectedCourse.value.id)) {
|
||||||
const templateOptions = computed(() => {
|
return [...base, selectedCourse.value]
|
||||||
const base = templatesResponse.value?.data ?? []
|
|
||||||
if (selectedTemplate.value && !base.some((t) => t.id === selectedTemplate.value.id)) {
|
|
||||||
return [...base, selectedTemplate.value]
|
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchTemplates = useDebounce((q) => {
|
const searchCourses = useDebounce((q) => {
|
||||||
templateSearch.value = q || ''
|
courseSearch.value = q || ''
|
||||||
}, 400)
|
}, 400)
|
||||||
|
|
||||||
|
const onContentTypeChange = () => {
|
||||||
|
contentFiles.value = []
|
||||||
|
form.value.contentMediaId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSessionTypeChange = () => {
|
||||||
|
if (form.value.sessionType !== 'online') form.value.meetingLink = ''
|
||||||
|
}
|
||||||
|
|
||||||
const { data: existingSession } = useAdminSessionQuery(sessionId, {
|
const { data: existingSession } = useAdminSessionQuery(sessionId, {
|
||||||
enabled: () => !!sessionId.value,
|
enabled: () => !!sessionId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(existingSession, (session) => {
|
watch(existingSession, (session) => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (session.courseTemplate) {
|
if (session.course) selectedCourse.value = session.course
|
||||||
selectedTemplate.value = session.courseTemplate
|
|
||||||
}
|
|
||||||
form.value = {
|
form.value = {
|
||||||
title: session.title || '',
|
title: session.title || '',
|
||||||
courseTemplateId: session.courseTemplate?.id || session.courseTemplateId || '',
|
startTime: session.startsAt || session.sessionConfig?.startTime || '',
|
||||||
sessionType: session.sessionType || '',
|
endTime: session.endsAt || session.sessionConfig?.endTime || '',
|
||||||
durationMinutes: session.durationMinutes ?? '',
|
durationMinutes: session.durationMinutes ?? '',
|
||||||
order: session.order ?? '',
|
courseId: session.course?.id || session.courseId || '',
|
||||||
|
contentType: session.contentType || '',
|
||||||
|
sessionType: session.sessionType || '',
|
||||||
|
meetingLink: session.link || session.sessionConfig?.meetingLink || '',
|
||||||
description: session.description || '',
|
description: session.description || '',
|
||||||
imageId: session.imageId || null,
|
contentMediaId: session.contentMediaId || null,
|
||||||
sessionConfig: { ...emptySessionConfig(), ...session.sessionConfig },
|
|
||||||
}
|
}
|
||||||
if (session.image) image.value = { url: session.image }
|
if (session.contentMedia) {
|
||||||
if (Array.isArray(session.materials)) {
|
contentFiles.value = [
|
||||||
materials.value = session.materials.map((m) => ({
|
{
|
||||||
id: m.id,
|
id: session.contentMedia.id,
|
||||||
name: m.title || `فایل ${m.id}`,
|
name: session.contentMedia.fileName || session.contentMedia.name || 'file',
|
||||||
url: m.filePath || '',
|
size: session.contentMedia.fileSize ?? 0,
|
||||||
type: m.type,
|
url: session.contentMedia.url,
|
||||||
}))
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const uploadMutation = useUploadMediaMutation()
|
const uploadMutation = useUploadMediaMutation()
|
||||||
|
|
||||||
const onImageCropped = async (file) => {
|
const purposeForContentType = (contentType) => {
|
||||||
|
if (contentType === 'video') return 'video'
|
||||||
|
if (contentType === 'voice') return 'voice'
|
||||||
|
return 'attachment'
|
||||||
|
}
|
||||||
|
|
||||||
|
const onContentSelect = async (files) => {
|
||||||
|
const file = files?.[0]
|
||||||
|
if (!file) return
|
||||||
try {
|
try {
|
||||||
const formData = objectToFormData({ file, subType: 'avatar', context: 'session' })
|
const formData = objectToFormData({
|
||||||
|
file,
|
||||||
|
purpose: purposeForContentType(form.value.contentType),
|
||||||
|
context: 'session',
|
||||||
|
})
|
||||||
const response = await uploadMutation.mutateAsync(formData)
|
const response = await uploadMutation.mutateAsync(formData)
|
||||||
const payload = response?.data ?? response
|
const payload = response?.data ?? response
|
||||||
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
|
const id = payload?.id ?? payload?.uploadId
|
||||||
form.value.imageId = payload?.uploadId || payload?.id
|
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
|
||||||
|
form.value.contentMediaId = id
|
||||||
} catch {
|
} catch {
|
||||||
/* handled globally */
|
contentFiles.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onImageError = (msg) => toast.error(msg)
|
const onContentRemove = () => {
|
||||||
|
contentFiles.value = []
|
||||||
|
form.value.contentMediaId = null
|
||||||
|
}
|
||||||
|
|
||||||
const cleanSessionConfig = (config, type) => {
|
const onContentError = (msg) => toast.error(msg)
|
||||||
const result = {}
|
|
||||||
const allow = (key) => {
|
|
||||||
if (type === 'online') return ['meetingLink', 'platform', 'startTime'].includes(key)
|
|
||||||
if (type === 'in_person') return ['startTime', 'location'].includes(key)
|
|
||||||
if (['video', 'audio'].includes(type))
|
|
||||||
return ['minWatchedPercent', 'mustCompleteBeforeNext'].includes(key)
|
|
||||||
if (['text', 'slide', 'pdf'].includes(type))
|
|
||||||
return ['minReadPercent', 'mustCompleteBeforeNext'].includes(key)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.entries(config || {}).forEach(([k, v]) => {
|
|
||||||
if (!allow(k)) return
|
|
||||||
if (v === '' || v === null || v === undefined) return
|
|
||||||
result[k] = v
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPayload = (values) => {
|
const buildPayload = (values) => {
|
||||||
const sessionConfig = cleanSessionConfig(values.sessionConfig, values.sessionType)
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: values.title,
|
title: values.title,
|
||||||
courseTemplateId: values.courseTemplateId,
|
startTime: values.startTime,
|
||||||
sessionType: values.sessionType,
|
endTime: values.endTime,
|
||||||
durationMinutes: values.durationMinutes,
|
durationMinutes: values.durationMinutes,
|
||||||
order: values.order,
|
courseId: values.courseId,
|
||||||
|
contentType: values.contentType,
|
||||||
|
sessionType: values.sessionType,
|
||||||
|
meetingLink: values.sessionType === 'online' ? values.meetingLink : undefined,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
imageId: values.imageId,
|
contentMediaId: values.contentMediaId,
|
||||||
}
|
}
|
||||||
if (Object.keys(sessionConfig).length > 0) payload.sessionConfig = sessionConfig
|
|
||||||
payload.materials = materials.value.map((m, index) => ({
|
|
||||||
fileId: m.id,
|
|
||||||
isRequired: false,
|
|
||||||
type: m.type,
|
|
||||||
title: m.name,
|
|
||||||
order: index + 1,
|
|
||||||
}))
|
|
||||||
Object.keys(payload).forEach((key) => {
|
Object.keys(payload).forEach((key) => {
|
||||||
if (payload[key] === undefined || payload[key] === '') delete payload[key]
|
if (payload[key] === undefined || payload[key] === '' || payload[key] === null) {
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
@@ -436,82 +392,72 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
|
|||||||
background: rgba(255, 255, 255, 60%);
|
background: rgba(255, 255, 255, 60%);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
&__grid {
|
gap: 0.75rem;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
grid-template-columns: 4fr 8fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
|
||||||
grid-template-columns: 3fr 9fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__image-col {
|
|
||||||
order: 2;
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
order: 1;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__main-col {
|
|
||||||
order: 1;
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
order: 2;
|
|
||||||
padding-inline-start: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__row {
|
&__row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-flow: column wrap;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: stretch;
|
|
||||||
|
&--two {
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--three {
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__uploader-label {
|
||||||
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
@media (min-width: 768px) {
|
font-weight: 300;
|
||||||
flex-direction: row;
|
line-height: 1.5rem;
|
||||||
}
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-prim-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cell {
|
&__preview {
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&--third {
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
width: 49%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
|
||||||
width: 32.3%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--full {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__toggle-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__materials {
|
&__media {
|
||||||
margin-top: 1.5rem;
|
max-width: 100%;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__divider {
|
&__divider {
|
||||||
border-block-end: 1px solid var(--color-thd-gray);
|
border-block-end: 1px solid var(--color-thd-gray);
|
||||||
margin-block: 1.5rem;
|
margin-block: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
|
|||||||
@@ -47,12 +47,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import useModal from '@/composables/useModal'
|
import useModal from '@/composables/useModal'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { usePagination } from '@/composables/usePagination'
|
import { usePagination } from '@/composables/usePagination'
|
||||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||||
@@ -69,19 +69,31 @@ import {
|
|||||||
useDeleteAdminSessionMutation,
|
useDeleteAdminSessionMutation,
|
||||||
} from '@/services/query/admin-sessions'
|
} from '@/services/query/admin-sessions'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { openModal, isModal } = useModal()
|
const { openModal, isModal } = useModal()
|
||||||
|
|
||||||
|
const routeCourseId = computed(() => (route.params.courseId ? Number(route.params.courseId) : null))
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
title: '',
|
title: '',
|
||||||
courseTemplateId: '',
|
courseId: routeCourseId.value ?? '',
|
||||||
sessionType: '',
|
sessionType: '',
|
||||||
fromDate: '',
|
fromDate: '',
|
||||||
toDate: '',
|
toDate: '',
|
||||||
})
|
})
|
||||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||||
|
|
||||||
|
const syncRouteCourseId = (courseId) => {
|
||||||
|
if (!courseId) return
|
||||||
|
filters.value = { ...filters.value, courseId }
|
||||||
|
resetPagination()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => syncRouteCourseId(routeCourseId.value))
|
||||||
|
watch(routeCourseId, (val) => syncRouteCourseId(val))
|
||||||
|
|
||||||
const { data, isLoading } = useAdminSessionsListQuery(filters, pagination, {
|
const { data, isLoading } = useAdminSessionsListQuery(filters, pagination, {
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { number, object, string } from 'yup'
|
import { number, object, string } from 'yup'
|
||||||
|
|
||||||
export const sessionSchema = object().shape({
|
export const sessionSchema = object().shape({
|
||||||
courseTemplateId: string().required(),
|
|
||||||
title: string().required().min(3),
|
title: string().required().min(3),
|
||||||
sessionType: string().required(),
|
startTime: string().required(),
|
||||||
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(),
|
endTime: string().nullable().notRequired(),
|
||||||
order: number().typeError('ترتیب باید عدد باشد').nullable().notRequired(),
|
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required().min(1),
|
||||||
|
courseId: string().required(),
|
||||||
|
contentType: string().oneOf(['voice', 'video', 'text']).required(),
|
||||||
|
sessionType: string().oneOf(['in_person', 'online']).required(),
|
||||||
|
meetingLink: string().when('sessionType', {
|
||||||
|
is: 'online',
|
||||||
|
then: (schema) => schema.required(),
|
||||||
|
otherwise: (schema) => schema.nullable().notRequired(),
|
||||||
|
}),
|
||||||
description: string().nullable().notRequired(),
|
description: string().nullable().notRequired(),
|
||||||
|
contentMediaId: number().nullable().notRequired(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-item">
|
||||||
|
<div class="course-item__main">
|
||||||
|
<div v-if="course.image" class="course-item__image">
|
||||||
|
<img :src="course.image" :alt="course.title" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="course-item__image course-item__image--placeholder">
|
||||||
|
<SvgIcon name="book" :size="22" color="#bcbcbc" />
|
||||||
|
</div>
|
||||||
|
<div class="course-item__title-block">
|
||||||
|
<p class="course-item__title">
|
||||||
|
<span>دوره</span>
|
||||||
|
<strong>{{ course.title }}</strong>
|
||||||
|
</p>
|
||||||
|
<div class="course-item__teacher">
|
||||||
|
<SvgIcon name="user" :size="11" color="#bcbcbc" />
|
||||||
|
<span class="course-item__teacher-label">استاد:</span>
|
||||||
|
<span class="course-item__teacher-name">{{ teacherName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="course-item__meta">
|
||||||
|
<Badge
|
||||||
|
variant="cyan"
|
||||||
|
size="sm"
|
||||||
|
icon="calendar"
|
||||||
|
:label="`وضعیت دوره :`"
|
||||||
|
:value="`در حال گذراندن`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isActive" class="course-item__status">
|
||||||
|
<span class="course-item__status-badge">
|
||||||
|
<span class="course-item__status-dot" />
|
||||||
|
غیرفعال
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="course-item__actions">
|
||||||
|
<CircleButton
|
||||||
|
tooltip="حذف"
|
||||||
|
bg-color="rgba(104, 104, 104, 0.05)"
|
||||||
|
size="2.5rem"
|
||||||
|
@click="emit('delete', course)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||||
|
</template>
|
||||||
|
</CircleButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Badge from '@/components/Badge.vue'
|
||||||
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
|
import CircleButton from '@/components/CircleButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
course: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['delete'])
|
||||||
|
|
||||||
|
const teacherName = computed(() => {
|
||||||
|
const t = props.course.teacher
|
||||||
|
if (!t) return '—'
|
||||||
|
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isActive = computed(() => props.course.isActive ?? false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.course-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 50%);
|
||||||
|
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 90%);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
flex-flow: row wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
flex: 1 1 33%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
min-width: 3rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--placeholder {
|
||||||
|
background: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-block {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #4b4b4b;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__teacher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__teacher-label {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #838383;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__teacher-name {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #4b4b4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex: 1 1 33%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pill {
|
||||||
|
background: rgba(107, 107, 107, 5%);
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pill-label {
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #848484;
|
||||||
|
margin-inline-end: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pill-value {
|
||||||
|
font-family: var(--font-family-en);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
background: rgba(204, 40, 49, 6%);
|
||||||
|
color: var(--color-error);
|
||||||
|
font-family: var(--font-family-fa);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-dot {
|
||||||
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details-btn {
|
||||||
|
min-width: 8rem;
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,16 +43,6 @@
|
|||||||
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||||
</template>
|
</template>
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton
|
|
||||||
tooltip="کپی"
|
|
||||||
bg-color="rgba(104, 104, 104, 0.05)"
|
|
||||||
size="2.5rem"
|
|
||||||
@click="emit('clone', term)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<SvgIcon name="copy" :size="18" color="var(--color-sec-gray)" />
|
|
||||||
</template>
|
|
||||||
</CircleButton>
|
|
||||||
<CircleButton
|
<CircleButton
|
||||||
tooltip="ویرایش"
|
tooltip="ویرایش"
|
||||||
bg-color="rgba(104, 104, 104, 0.05)"
|
bg-color="rgba(104, 104, 104, 0.05)"
|
||||||
@@ -90,10 +80,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete', 'clone', 'change-status', 'show-details'])
|
const emit = defineEmits(['edit', 'delete', 'clone', 'change-status', 'show-details'])
|
||||||
|
|
||||||
const startDate = computed(
|
const startDate = computed(() => formatJalaaliDate(props.term.startsAt) || '')
|
||||||
() => props.term.faStartDate || formatJalaaliDate(props.term.startDate) || ''
|
const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
|
||||||
)
|
|
||||||
const endDate = computed(() => props.term.faEndDate || formatJalaaliDate(props.term.endDate) || '')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
@click="onReset"
|
@click="onReset"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<SvgIcon name="close" :size="20" />
|
<SvgIcon name="close" color="black" :size="20" />
|
||||||
</template>
|
</template>
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
|
|||||||
@@ -96,14 +96,16 @@ import NoItems from '@/components/blocks/NoItems.vue'
|
|||||||
import CircleButton from '@/components/CircleButton.vue'
|
import CircleButton from '@/components/CircleButton.vue'
|
||||||
import { adminTermsKeys } from '@/services/query/admin-terms'
|
import { adminTermsKeys } from '@/services/query/admin-terms'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
|
||||||
import {
|
import {
|
||||||
adminCoursesKeys,
|
adminCoursesKeys,
|
||||||
useAddAdminCourseMutation,
|
|
||||||
useAdminCoursesListQuery,
|
useAdminCoursesListQuery,
|
||||||
useDeleteAdminCourseMutation,
|
useUpdateAdminCourseMutation,
|
||||||
} from '@/services/query/admin-courses'
|
} from '@/services/query/admin-courses'
|
||||||
|
|
||||||
|
// TODO: repurpose this modal as "copy course from another term" — the original
|
||||||
|
// flow (link a stand-alone template to a term) no longer matches the unified
|
||||||
|
// course model. Until then, this stays a stand-alone-course → new-offered-course copy.
|
||||||
|
|
||||||
defineOptions({ name: 'AttachCourseToTermModal' })
|
defineOptions({ name: 'AttachCourseToTermModal' })
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -116,7 +118,7 @@ const searchQuery = ref('')
|
|||||||
const templateFilters = computed(() => ({ title: searchQuery.value }))
|
const templateFilters = computed(() => ({ title: searchQuery.value }))
|
||||||
const templatePagination = ref({ page: 1, perPage: 20 })
|
const templatePagination = ref({ page: 1, perPage: 20 })
|
||||||
|
|
||||||
const { data: templatesResponse, isLoading } = useAdminCourseTemplatesListQuery(
|
const { data: templatesResponse, isLoading } = useAdminCoursesListQuery(
|
||||||
templateFilters,
|
templateFilters,
|
||||||
templatePagination
|
templatePagination
|
||||||
)
|
)
|
||||||
@@ -124,18 +126,24 @@ const { data: templatesResponse, isLoading } = useAdminCourseTemplatesListQuery(
|
|||||||
const templates = computed(() => templatesResponse.value?.data ?? [])
|
const templates = computed(() => templatesResponse.value?.data ?? [])
|
||||||
|
|
||||||
const courseFilters = computed(() => ({ termId: termId.value }))
|
const courseFilters = computed(() => ({ termId: termId.value }))
|
||||||
const coursePagination = ref({ page: 1, perPage: 100 })
|
const coursePagination = ref({ page: 1, perPage: 10 })
|
||||||
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination, {
|
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination, {
|
||||||
enabled: () => !!termId.value,
|
enabled: () => !!termId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const attachedCourses = computed(() => coursesResponse.value?.data ?? [])
|
const attachedCourses = computed(() => coursesResponse.value?.data ?? [])
|
||||||
|
|
||||||
const attachedCourseByTemplate = (templateId) =>
|
// Tracks which stand-alone course (termId=null) has already been copied
|
||||||
attachedCourses.value.find((c) => c.template?.id === templateId || c.templateId === templateId)
|
// into this term. We match by title since the unified model no longer
|
||||||
|
// keeps a back-reference to the source course.
|
||||||
|
const attachedCourseByTemplate = (sourceId) => {
|
||||||
|
const source = templates.value.find((t) => t.id === sourceId)
|
||||||
|
if (!source) return null
|
||||||
|
return attachedCourses.value.find((c) => c.title === source.title)
|
||||||
|
}
|
||||||
|
|
||||||
const teacherName = (template) => {
|
const teacherName = (course) => {
|
||||||
const t = template.defaultTeacher || template.teacher
|
const t = course.teacher
|
||||||
if (!t) return '—'
|
if (!t) return '—'
|
||||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||||
}
|
}
|
||||||
@@ -147,8 +155,7 @@ const onSearch = useDebounce((event) => {
|
|||||||
|
|
||||||
const pendingId = ref(null)
|
const pendingId = ref(null)
|
||||||
|
|
||||||
const addMutation = useAddAdminCourseMutation()
|
const updateMutation = useUpdateAdminCourseMutation()
|
||||||
const deleteMutation = useDeleteAdminCourseMutation()
|
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
@@ -156,16 +163,12 @@ const invalidate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onAttach = async (template) => {
|
const onAttach = async (template) => {
|
||||||
|
console.log(template)
|
||||||
|
|
||||||
if (!termId.value) return
|
if (!termId.value) return
|
||||||
pendingId.value = template.id
|
pendingId.value = template.id
|
||||||
try {
|
try {
|
||||||
await addMutation.mutateAsync({
|
await updateMutation.mutateAsync({ id: template.id, payload: { termId: termId.value } })
|
||||||
termId: termId.value,
|
|
||||||
templateId: template.id,
|
|
||||||
title: template.title,
|
|
||||||
capacity: template.defaultCapacity ?? null,
|
|
||||||
isActive: template.isActiveByDefault ?? true,
|
|
||||||
})
|
|
||||||
invalidate()
|
invalidate()
|
||||||
} finally {
|
} finally {
|
||||||
pendingId.value = null
|
pendingId.value = null
|
||||||
@@ -177,7 +180,7 @@ const onDetach = async (template) => {
|
|||||||
if (!offered) return
|
if (!offered) return
|
||||||
pendingId.value = template.id
|
pendingId.value = template.id
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync(offered.id)
|
await updateMutation.mutateAsync({ id: template.id, payload: { termId: null } })
|
||||||
invalidate()
|
invalidate()
|
||||||
} finally {
|
} finally {
|
||||||
pendingId.value = null
|
pendingId.value = null
|
||||||
@@ -303,7 +306,7 @@ watch(termId, () => {
|
|||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__close-btn {
|
&__close-btn {
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
<LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" />
|
<LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" />
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="تاریخ شروع"
|
title="تاریخ شروع"
|
||||||
:numeric-desc="term.faStartDate || formatJalaaliDate(term.startDate) || '-'"
|
:numeric-desc="formatJalaaliDate(term.startsAt) || '-'"
|
||||||
/>
|
/>
|
||||||
<LineInfoBlock
|
<LineInfoBlock
|
||||||
title="تاریخ پایان"
|
title="تاریخ پایان"
|
||||||
:numeric-desc="term.faEndDate || formatJalaaliDate(term.endDate) || '-'"
|
:numeric-desc="formatJalaaliDate(term.endsAt) || '-'"
|
||||||
/>
|
/>
|
||||||
<LineInfoBlock title="تعداد دانشجویان" :numeric-desc="term.studentsCount ?? 0" />
|
<LineInfoBlock title="تعداد دانشجویان" :numeric-desc="term.studentsCount ?? 0" />
|
||||||
<LineInfoBlock title="تعداد دورهها" :numeric-desc="term.coursesCount ?? 0" />
|
<LineInfoBlock title="تعداد دورهها" :numeric-desc="term.coursesCount ?? 0" />
|
||||||
@@ -109,10 +109,7 @@
|
|||||||
v-for="course in termCourses"
|
v-for="course in termCourses"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
:course="course"
|
:course="course"
|
||||||
@edit="onEditCourse"
|
@delete="(course) => onAskDeleteCourse(course)"
|
||||||
@delete="onAskDeleteCourse"
|
|
||||||
@change-status="onChangeCourseStatus"
|
|
||||||
@show-details="onShowCourseDetails"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
||||||
@@ -138,6 +135,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import CourseItem from '../CourseItem.vue'
|
||||||
import useModal from '@/composables/useModal'
|
import useModal from '@/composables/useModal'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import BasicModal from '@/components/BasicModal.vue'
|
import BasicModal from '@/components/BasicModal.vue'
|
||||||
@@ -152,13 +150,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|||||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||||
import CourseItem from '@/features/admin/courses/components/CourseItem.vue'
|
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import {
|
import {
|
||||||
adminCoursesKeys,
|
adminCoursesKeys,
|
||||||
useAdminCoursesListQuery,
|
useAdminCoursesListQuery,
|
||||||
useChangeAdminCourseStatusMutation,
|
useUpdateAdminCourseMutation,
|
||||||
useDeleteAdminCourseMutation,
|
|
||||||
} from '@/services/query/admin-courses'
|
} from '@/services/query/admin-courses'
|
||||||
import {
|
import {
|
||||||
adminTermsKeys,
|
adminTermsKeys,
|
||||||
@@ -181,10 +177,10 @@ const { data: term } = useAdminTermQuery(termId, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ name: 'students', label: 'دانشجویان', icon: 'users' },
|
|
||||||
{ name: 'courses', label: 'دورهها', icon: 'list-bullets' },
|
{ name: 'courses', label: 'دورهها', icon: 'list-bullets' },
|
||||||
|
{ name: 'students', label: 'دانشجویان', icon: 'users' },
|
||||||
]
|
]
|
||||||
const activeTab = ref('students')
|
const activeTab = ref('courses')
|
||||||
|
|
||||||
const studentFilters = ref({})
|
const studentFilters = ref({})
|
||||||
const {
|
const {
|
||||||
@@ -230,15 +226,10 @@ const onToggleLeave = (student, isOnLeave) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onAskRemoveStudent = (student) => {
|
const onAskRemoveStudent = (student) => {
|
||||||
openModal('ConfirmModal', {
|
|
||||||
title: `حذف ${studentName(student)}`,
|
|
||||||
message: `آیا از حذف <strong>${studentName(student)}</strong> از این ترم اطمینان دارید؟`,
|
|
||||||
onConfirm: () =>
|
|
||||||
removeStudentMutation.mutate(
|
removeStudentMutation.mutate(
|
||||||
{ termId: termId.value, userId: student.id },
|
{ termId: termId.value, userId: student.id },
|
||||||
{ onSuccess: invalidate }
|
{ onSuccess: invalidate }
|
||||||
),
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const courseFilters = computed(() => ({ termId: termId.value }))
|
const courseFilters = computed(() => ({ termId: termId.value }))
|
||||||
@@ -265,31 +256,18 @@ const coursePaginationMeta = computed(() => ({
|
|||||||
|
|
||||||
const invalidateCourses = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
const invalidateCourses = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
|
|
||||||
const deleteCourseMutation = useDeleteAdminCourseMutation()
|
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
|
||||||
const changeCourseStatusMutation = useChangeAdminCourseStatusMutation()
|
const updateCourseMutation = useUpdateAdminCourseMutation()
|
||||||
|
|
||||||
const onOpenAddCourse = () => {
|
const onOpenAddCourse = () => {
|
||||||
openModal('AttachCourseToTermModal', { termId: termId.value })
|
openModal('AttachCourseToTermModal', { termId: termId.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEditCourse = (course) => {
|
|
||||||
openModal('AddOfferedCourseModal', { mode: 'edit', courseId: course.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onShowCourseDetails = (course) => {
|
|
||||||
openModal('CourseDetailsModal', { id: course.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onAskDeleteCourse = (course) => {
|
const onAskDeleteCourse = (course) => {
|
||||||
openModal('ConfirmModal', {
|
updateCourseMutation.mutate(
|
||||||
title: `حذف ${course.title}`,
|
{ id: course.id, payload: { termId: null } },
|
||||||
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا از حذف <strong>${course.title}</strong> اطمینان دارید؟`,
|
{ onSuccess: invalidateCourses }
|
||||||
onConfirm: () => deleteCourseMutation.mutate(course.id, { onSuccess: invalidateCourses }),
|
)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChangeCourseStatus = ({ id, isActive }) => {
|
|
||||||
changeCourseStatusMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidateCourses })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOpenAddStudent = () => {
|
const onOpenAddStudent = () => {
|
||||||
@@ -352,7 +330,7 @@ const onOpenAddStudent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,10 @@ const { data: existingTerm } = useAdminTermQuery(termId, {
|
|||||||
enabled: () => !!termId.value,
|
enabled: () => !!termId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(existingTerm, (term) => {
|
watch(
|
||||||
|
existingTerm,
|
||||||
|
(term) => {
|
||||||
|
console.log(term)
|
||||||
if (!term) return
|
if (!term) return
|
||||||
form.value = {
|
form.value = {
|
||||||
title: term.title || '',
|
title: term.title || '',
|
||||||
@@ -157,10 +160,11 @@ watch(existingTerm, (term) => {
|
|||||||
isActive: term.isActive ?? true,
|
isActive: term.isActive ?? true,
|
||||||
startsAt: term.startsAt || '',
|
startsAt: term.startsAt || '',
|
||||||
endsAt: term.endsAt || '',
|
endsAt: term.endsAt || '',
|
||||||
coverMediaId: term.coverMediaId || null,
|
|
||||||
}
|
}
|
||||||
if (term.coverUrl) image.value = { url: term.coverUrl }
|
if (term.coverUrl) image.value = { url: term.coverUrl }
|
||||||
})
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const uploadMutation = useUploadMediaMutation()
|
const uploadMutation = useUploadMediaMutation()
|
||||||
|
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ import AttachCourseToTermModal from '@/features/admin/terms/components/modals/At
|
|||||||
import {
|
import {
|
||||||
adminTermsKeys,
|
adminTermsKeys,
|
||||||
useAdminTermsListQuery,
|
useAdminTermsListQuery,
|
||||||
useChangeAdminTermStatusMutation,
|
|
||||||
useCloneAdminTermMutation,
|
|
||||||
useDeleteAdminTermMutation,
|
useDeleteAdminTermMutation,
|
||||||
|
useUpdateAdminTermMutation,
|
||||||
} from '@/services/query/admin-terms'
|
} from '@/services/query/admin-terms'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -110,8 +109,8 @@ const onEdit = (term) => {
|
|||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
|
||||||
|
|
||||||
const deleteMutation = useDeleteAdminTermMutation()
|
const deleteMutation = useDeleteAdminTermMutation()
|
||||||
const cloneMutation = useCloneAdminTermMutation()
|
// Backend has no dedicated status endpoint — PATCH /terms/:id with { isActive }.
|
||||||
const changeStatusMutation = useChangeAdminTermStatusMutation()
|
const updateMutation = useUpdateAdminTermMutation()
|
||||||
|
|
||||||
const onAskDelete = (term) => {
|
const onAskDelete = (term) => {
|
||||||
openModal('ConfirmModal', {
|
openModal('ConfirmModal', {
|
||||||
@@ -121,12 +120,8 @@ const onAskDelete = (term) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClone = (term) => {
|
|
||||||
cloneMutation.mutate(term.id, { onSuccess: invalidate })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChangeStatus = ({ id, isActive }) => {
|
const onChangeStatus = ({ id, isActive }) => {
|
||||||
changeStatusMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
|
updateMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onShowDetails = (term) => {
|
const onShowDetails = (term) => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Badge from '@/global-components/Badge.vue'
|
import Badge from '@/components/Badge.vue'
|
||||||
|
|
||||||
const TONE_MAP = {
|
const TONE_MAP = {
|
||||||
approved: 'success',
|
approved: 'success',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import Badge from '@/global-components/Badge.vue'
|
import Badge from '@/components/Badge.vue'
|
||||||
|
|
||||||
const TONE_MAP = {
|
const TONE_MAP = {
|
||||||
approved: 'success',
|
approved: 'success',
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ const educationRoutes = new Set([
|
|||||||
'admin-add-term',
|
'admin-add-term',
|
||||||
'admin-edit-term',
|
'admin-edit-term',
|
||||||
'admin-courses',
|
'admin-courses',
|
||||||
'admin-add-course-template',
|
'admin-add-course',
|
||||||
'admin-edit-course-template',
|
'admin-edit-course',
|
||||||
|
'admin-term-courses',
|
||||||
'admin-sessions',
|
'admin-sessions',
|
||||||
'admin-add-session',
|
'admin-add-session',
|
||||||
'admin-edit-session',
|
'admin-edit-session',
|
||||||
|
'admin-course-sessions',
|
||||||
'admin-assignments',
|
'admin-assignments',
|
||||||
'admin-exams',
|
'admin-exams',
|
||||||
'admin-add-exam',
|
'admin-add-exam',
|
||||||
@@ -104,14 +106,20 @@ const menuItems = computed(() => [
|
|||||||
to: { name: 'admin-courses' },
|
to: { name: 'admin-courses' },
|
||||||
active: [
|
active: [
|
||||||
'admin-courses',
|
'admin-courses',
|
||||||
'admin-add-course-template',
|
'admin-add-course',
|
||||||
'admin-edit-course-template',
|
'admin-edit-course',
|
||||||
|
'admin-term-courses',
|
||||||
].includes(route.name),
|
].includes(route.name),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مدیریت جلسه',
|
title: 'مدیریت جلسه',
|
||||||
to: { name: 'admin-sessions' },
|
to: { name: 'admin-sessions' },
|
||||||
active: ['admin-sessions', 'admin-add-session', 'admin-edit-session'].includes(route.name),
|
active: [
|
||||||
|
'admin-sessions',
|
||||||
|
'admin-add-session',
|
||||||
|
'admin-edit-session',
|
||||||
|
'admin-course-sessions',
|
||||||
|
].includes(route.name),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مدیریت تکالیف',
|
title: 'مدیریت تکالیف',
|
||||||
|
|||||||
@@ -7,17 +7,20 @@ export const apiShowAdminAssignment = (id) => http.get(buildUrl(endpoints.showAs
|
|||||||
|
|
||||||
export const apiAddAdminAssignment = (payload) => http.post(endpoints.addNewAssignment, payload)
|
export const apiAddAdminAssignment = (payload) => http.post(endpoints.addNewAssignment, payload)
|
||||||
|
|
||||||
|
// Backend uses PATCH /homeworks/:id (not PUT).
|
||||||
export const apiUpdateAdminAssignment = (id, payload) =>
|
export const apiUpdateAdminAssignment = (id, payload) =>
|
||||||
http.put(buildUrl(endpoints.updateAssignment, { id }), payload)
|
http.patch(buildUrl(endpoints.updateAssignment, { id }), payload)
|
||||||
|
|
||||||
export const apiDeleteAdminAssignment = (id) =>
|
export const apiDeleteAdminAssignment = (id) =>
|
||||||
http.delete(buildUrl(endpoints.deleteAssignment, { id }))
|
http.delete(buildUrl(endpoints.deleteAssignment, { id }))
|
||||||
|
|
||||||
export const apiGetAdminAssignmentSubmissions = (assignmentId, params) =>
|
export const apiGetAdminAssignmentSubmissions = (homeworkId, params) =>
|
||||||
http.get(buildUrl(endpoints.getAssignmentSubmissions, { assignmentId }), { params })
|
http.get(buildUrl(endpoints.getAssignmentSubmissions, { homeworkId }), { params })
|
||||||
|
|
||||||
export const apiShowAdminAssignmentSubmission = (assignmentId, submissionId) =>
|
// Backend exposes submissions flat under /homework-submissions/:submissionId —
|
||||||
http.get(buildUrl(endpoints.showAssignmentSubmission, { assignmentId, submissionId }))
|
// the parent `homeworkId` is not part of the URL, so we ignore it here.
|
||||||
|
export const apiShowAdminAssignmentSubmission = (_homeworkId, submissionId) =>
|
||||||
|
http.get(buildUrl(endpoints.showAssignmentSubmission, { submissionId }))
|
||||||
|
|
||||||
export const apiReviewAdminAssignmentSubmission = (assignmentId, submissionId, payload) =>
|
export const apiReviewAdminAssignmentSubmission = (_homeworkId, submissionId, payload) =>
|
||||||
http.post(buildUrl(endpoints.reviewAssignmentSubmission, { assignmentId, submissionId }), payload)
|
http.patch(buildUrl(endpoints.reviewAssignmentSubmission, { submissionId }), payload)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { http } from '@/services/api/http'
|
|
||||||
import { buildUrl, endpoints } from '@/services/api/endpoints'
|
|
||||||
|
|
||||||
export const apiGetAdminCourseTemplates = (params) => http.get(endpoints.getCoursesList, { params })
|
|
||||||
|
|
||||||
export const apiShowAdminCourseTemplate = (id) => http.get(buildUrl(endpoints.showCourse, { id }))
|
|
||||||
|
|
||||||
export const apiAddAdminCourseTemplate = (payload) => http.post(endpoints.addNewCourse, payload)
|
|
||||||
|
|
||||||
export const apiUpdateAdminCourseTemplate = (id, payload) =>
|
|
||||||
http.put(buildUrl(endpoints.updateCourseTemplate, { id }), payload)
|
|
||||||
|
|
||||||
export const apiDeleteAdminCourseTemplate = (id) =>
|
|
||||||
http.delete(buildUrl(endpoints.deleteCourseTemplate, { id }))
|
|
||||||
|
|
||||||
export const apiChangeAdminCourseTemplateStatus = (id, payload) =>
|
|
||||||
http.post(buildUrl(endpoints.changeStatusCourseTemplate, { id }), payload)
|
|
||||||
|
|
||||||
export const apiGetAdminTemplateStudents = (templateId, params) =>
|
|
||||||
http.get(buildUrl(endpoints.listTemplateStudents, { templateId }), { params })
|
|
||||||
|
|
||||||
export const apiAddAdminTemplateStudent = (templateId, payload) =>
|
|
||||||
http.post(buildUrl(endpoints.addTemplateStudent, { templateId }), payload)
|
|
||||||
|
|
||||||
export const apiRemoveAdminTemplateStudent = (templateId, userId) =>
|
|
||||||
http.delete(buildUrl(endpoints.removeTemplateStudent, { templateId, userId }))
|
|
||||||
|
|
||||||
export const apiGetAdminTemplateSessions = (templateId, params) =>
|
|
||||||
http.get(buildUrl(endpoints.listTemplateSessions, { templateId }), { params })
|
|
||||||
|
|
||||||
export const apiAttachAdminTemplateSession = (templateId, payload) =>
|
|
||||||
http.post(buildUrl(endpoints.attachTemplateSession, { templateId }), payload)
|
|
||||||
|
|
||||||
export const apiDetachAdminTemplateSession = (templateId, sessionId) =>
|
|
||||||
http.delete(buildUrl(endpoints.detachTemplateSession, { templateId, sessionId }))
|
|
||||||
@@ -12,5 +12,20 @@ export const apiUpdateAdminCourse = (id, payload) =>
|
|||||||
|
|
||||||
export const apiDeleteAdminCourse = (id) => http.delete(buildUrl(endpoints.deleteCourse, { id }))
|
export const apiDeleteAdminCourse = (id) => http.delete(buildUrl(endpoints.deleteCourse, { id }))
|
||||||
|
|
||||||
export const apiChangeAdminCourseStatus = (id, payload) =>
|
export const apiGetAdminCourseStudents = (courseId, params) =>
|
||||||
http.post(buildUrl(endpoints.changeStatusCourse, { id }), payload)
|
http.get(buildUrl(endpoints.listCourseStudents, { courseId }), { params })
|
||||||
|
|
||||||
|
export const apiAddAdminCourseStudent = (courseId, payload) =>
|
||||||
|
http.post(buildUrl(endpoints.addCourseStudent, { courseId }), payload)
|
||||||
|
|
||||||
|
export const apiRemoveAdminCourseStudent = (courseId, userId) =>
|
||||||
|
http.delete(buildUrl(endpoints.removeCourseStudent, { courseId, userId }))
|
||||||
|
|
||||||
|
export const apiGetAdminCourseSessions = (courseId, params) =>
|
||||||
|
http.get(buildUrl(endpoints.listCourseSessions, { courseId }), { params })
|
||||||
|
|
||||||
|
export const apiAttachAdminCourseSession = (courseId, payload) =>
|
||||||
|
http.post(buildUrl(endpoints.attachCourseSession, { courseId }), payload)
|
||||||
|
|
||||||
|
export const apiDetachAdminCourseSession = (courseId, sessionId) =>
|
||||||
|
http.delete(buildUrl(endpoints.detachCourseSession, { courseId, sessionId }))
|
||||||
|
|||||||
@@ -7,11 +7,22 @@ export const apiShowAdminExam = (id) => http.get(buildUrl(endpoints.showExam, {
|
|||||||
|
|
||||||
export const apiAddAdminExam = (payload) => http.post(endpoints.addNewExam, payload)
|
export const apiAddAdminExam = (payload) => http.post(endpoints.addNewExam, payload)
|
||||||
|
|
||||||
|
// Backend uses PATCH /exams/:id (not PUT).
|
||||||
export const apiUpdateAdminExam = (id, payload) =>
|
export const apiUpdateAdminExam = (id, payload) =>
|
||||||
http.put(buildUrl(endpoints.updateExam, { id }), payload)
|
http.patch(buildUrl(endpoints.updateExam, { id }), payload)
|
||||||
|
|
||||||
export const apiDeleteAdminExam = (id) => http.delete(buildUrl(endpoints.deleteExam, { id }))
|
export const apiDeleteAdminExam = (id) => http.delete(buildUrl(endpoints.deleteExam, { id }))
|
||||||
|
|
||||||
|
// POST /exams/:examId/questions — payload: { questionText, position, options: [{ optionText, isCorrect }] }
|
||||||
|
// Must include ≥2 options; question + options created atomically.
|
||||||
|
export const apiAddAdminExamQuestion = (examId, payload) =>
|
||||||
|
http.post(buildUrl(endpoints.addExamQuestion, { examId }), payload)
|
||||||
|
|
||||||
|
// POST /questions/:questionId/options — payload: { optionText, isCorrect }
|
||||||
|
export const apiAddAdminQuestionOption = (questionId, payload) =>
|
||||||
|
http.post(buildUrl(endpoints.addQuestionOption, { questionId }), payload)
|
||||||
|
|
||||||
|
// Mock-only — backend has no /exams/:id/participants endpoint yet.
|
||||||
export const apiGetAdminExamParticipants = (examId, params) =>
|
export const apiGetAdminExamParticipants = (examId, params) =>
|
||||||
http.get(buildUrl(endpoints.getExamParticipants, { examId }), { params })
|
http.get(buildUrl(endpoints.getExamParticipants, { examId }), { params })
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,5 @@ export const apiUpdateAdminSession = (id, payload) =>
|
|||||||
|
|
||||||
export const apiDeleteAdminSession = (id) => http.delete(buildUrl(endpoints.deleteSession, { id }))
|
export const apiDeleteAdminSession = (id) => http.delete(buildUrl(endpoints.deleteSession, { id }))
|
||||||
|
|
||||||
export const apiChangeAdminSessionStatus = (id, payload) =>
|
|
||||||
http.post(buildUrl(endpoints.changeStatusSession, { id }), payload)
|
|
||||||
|
|
||||||
export const apiGetSessionAttendance = (sessionId, params) =>
|
export const apiGetSessionAttendance = (sessionId, params) =>
|
||||||
http.get(buildUrl(endpoints.getSessionsAttendance, { sessionId }), { params })
|
http.get(buildUrl(endpoints.getSessionsAttendance, { sessionId }), { params })
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ export const apiDeleteAdminTerm = (id) => http.delete(buildUrl(endpoints.deleteT
|
|||||||
|
|
||||||
export const apiCloneAdminTerm = (id) => http.post(buildUrl(endpoints.cloneTerm, { id }))
|
export const apiCloneAdminTerm = (id) => http.post(buildUrl(endpoints.cloneTerm, { id }))
|
||||||
|
|
||||||
export const apiChangeAdminTermStatus = (id, payload) =>
|
|
||||||
http.post(buildUrl(endpoints.changeStatusTerm, { id }), payload)
|
|
||||||
|
|
||||||
export const apiGetAdminTermStudents = (termId, params) =>
|
export const apiGetAdminTermStudents = (termId, params) =>
|
||||||
http.get(buildUrl(endpoints.listUserTerm, { termId }), { params })
|
http.get(buildUrl(endpoints.listUserTerm, { termId }), { params })
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export const apiShowAdminTicket = (id) => http.get(buildUrl(endpoints.showTicket
|
|||||||
export const apiSendAdminTicketMessage = (id, payload) =>
|
export const apiSendAdminTicketMessage = (id, payload) =>
|
||||||
http.post(buildUrl(endpoints.sendTicketMessage, { id }), payload)
|
http.post(buildUrl(endpoints.sendTicketMessage, { id }), payload)
|
||||||
|
|
||||||
|
// Backend uses PATCH (not POST).
|
||||||
export const apiChangeAdminTicketStatus = (id, payload) =>
|
export const apiChangeAdminTicketStatus = (id, payload) =>
|
||||||
http.post(buildUrl(endpoints.changeTicketStatus, { id }), payload)
|
http.patch(buildUrl(endpoints.changeTicketStatus, { id }), payload)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const endpoints = {
|
export const endpoints = {
|
||||||
|
// ─── Auth & profile ────────────────────────────────────────────────────────
|
||||||
register: '/register',
|
register: '/register',
|
||||||
resendVerificationCodeForRegister: '/resend-verification-code',
|
resendVerificationCodeForRegister: '/resend-verification-code',
|
||||||
verifyCode: '/verify-code',
|
verifyCode: '/verify-code',
|
||||||
@@ -10,13 +11,14 @@ export const endpoints = {
|
|||||||
verifyForgotPasswordCode: '/verify-forgot-password-code',
|
verifyForgotPasswordCode: '/verify-forgot-password-code',
|
||||||
resetPassword: '/reset-password',
|
resetPassword: '/reset-password',
|
||||||
logout: '/logout',
|
logout: '/logout',
|
||||||
uploadMedia: '/media',
|
me: '/auth/me',
|
||||||
|
updateProfile: '/profile',
|
||||||
|
|
||||||
|
// ─── Geo ───────────────────────────────────────────────────────────────────
|
||||||
provinceList: '/provinces',
|
provinceList: '/provinces',
|
||||||
citiesList: '/provinces/:provinceId/cities',
|
citiesList: '/provinces/:provinceId/cities',
|
||||||
|
|
||||||
me: '/auth/me',
|
// ─── Student-facing ────────────────────────────────────────────────────────
|
||||||
updateProfile: '/profile',
|
|
||||||
getStudentCourses: '/student/courses',
|
getStudentCourses: '/student/courses',
|
||||||
showStudentCourse: '/student/courses/:id',
|
showStudentCourse: '/student/courses/:id',
|
||||||
getStudentTerms: '/student/terms',
|
getStudentTerms: '/student/terms',
|
||||||
@@ -45,12 +47,12 @@ export const endpoints = {
|
|||||||
getStudentCertificates: '/student/certificates',
|
getStudentCertificates: '/student/certificates',
|
||||||
downloadStudentCertificate: '/student/certificates/:id/download',
|
downloadStudentCertificate: '/student/certificates/:id/download',
|
||||||
|
|
||||||
|
// ─── Admin: users (out of scope of the Terms/Courses backend doc) ──────────
|
||||||
getPendingStudents: '/admin/pending-students',
|
getPendingStudents: '/admin/pending-students',
|
||||||
showPendingStudent: '/admin/pending-students/:id',
|
showPendingStudent: '/admin/pending-students/:id',
|
||||||
changeStatusPendingStudent: '/admin/pending-students/:id/status',
|
changeStatusPendingStudent: '/admin/pending-students/:id/status',
|
||||||
downloadPdfPendingStudentInfo: '/admin/pending-students/:id/pdf',
|
downloadPdfPendingStudentInfo: '/admin/pending-students/:id/pdf',
|
||||||
getApprovedUsers: '/admin/users',
|
getApprovedUsers: '/admin/users',
|
||||||
|
|
||||||
showUserDetails: '/admin/users/:id',
|
showUserDetails: '/admin/users/:id',
|
||||||
addNewUser: '/admin/users',
|
addNewUser: '/admin/users',
|
||||||
updateUser: '/admin/users/:id',
|
updateUser: '/admin/users/:id',
|
||||||
@@ -59,63 +61,92 @@ export const endpoints = {
|
|||||||
changeUserStatus: '/admin/users/:id/status',
|
changeUserStatus: '/admin/users/:id/status',
|
||||||
deleteUser: '/admin/users/:id',
|
deleteUser: '/admin/users/:id',
|
||||||
|
|
||||||
|
// ─── Backend-aligned: Terms ────────────────────────────────────────────────
|
||||||
getTermsList: '/terms',
|
getTermsList: '/terms',
|
||||||
addNewTerm: '/terms',
|
addNewTerm: '/terms',
|
||||||
showTerm: '/terms/:id',
|
showTerm: '/terms/:id',
|
||||||
updateTerm: '/terms/:id',
|
updateTerm: '/terms/:id',
|
||||||
deleteTerm: '/terms/:id',
|
deleteTerm: '/terms/:id',
|
||||||
cloneTerm: '/admin/terms/:id/clone',
|
|
||||||
changeStatusTerm: '/admin/terms/:id/status',
|
|
||||||
|
|
||||||
listUserTerm: '/admin/terms/:termId/students',
|
|
||||||
addUserTerm: '/admin/terms/:termId/students',
|
|
||||||
removeUserTerm: '/admin/terms/:termId/students/:userId',
|
|
||||||
changeLeaveStatus: '/admin/terms/:termId/students/:userId/toggle-leave',
|
|
||||||
|
|
||||||
listCourseTerm: '/admin/terms/:termId/courses',
|
|
||||||
addCourseTerm: '/admin/terms/:termId/courses',
|
|
||||||
removeCourseTerm: '/admin/terms/:termId/courses/:courseId',
|
|
||||||
|
|
||||||
|
// ─── Backend-aligned: Courses ──────────────────────────────────────────────
|
||||||
getCoursesList: '/courses',
|
getCoursesList: '/courses',
|
||||||
addNewCourse: '/courses',
|
addNewCourse: '/courses',
|
||||||
showCourse: '/courses/:id',
|
showCourse: '/courses/:id',
|
||||||
updateCourse: '/courses/:id',
|
updateCourse: '/courses/:id',
|
||||||
deleteCourse: '/courses/:id',
|
deleteCourse: '/courses/:id',
|
||||||
changeStatusCourse: '/admin/courses/:id/toggle-status',
|
|
||||||
|
|
||||||
getCourseTemplatesList: '/admin/courses',
|
|
||||||
addNewCourseTemplate: '/admin/courses',
|
|
||||||
showCourseTemplate: '/admin/courses/:id',
|
|
||||||
updateCourseTemplate: '/admin/courses/:id',
|
|
||||||
deleteCourseTemplate: '/admin/courses/:id',
|
|
||||||
changeStatusCourseTemplate: '/admin/course-templates/:id/status',
|
|
||||||
|
|
||||||
listTemplateStudents: '/admin/course-templates/:templateId/students',
|
|
||||||
addTemplateStudent: '/admin/course-templates/:templateId/students',
|
|
||||||
removeTemplateStudent: '/admin/course-templates/:templateId/students/:userId',
|
|
||||||
|
|
||||||
listTemplateSessions: '/admin/course-templates/:templateId/sessions',
|
|
||||||
attachTemplateSession: '/admin/course-templates/:templateId/sessions',
|
|
||||||
detachTemplateSession: '/admin/course-templates/:templateId/sessions/:sessionId',
|
|
||||||
|
|
||||||
|
// ─── Backend-aligned: Sessions ─────────────────────────────────────────────
|
||||||
getSessionsList: '/sessions',
|
getSessionsList: '/sessions',
|
||||||
addNewSession: '/sessions',
|
addNewSession: '/sessions',
|
||||||
showSession: '/sessions/:id',
|
showSession: '/sessions/:id',
|
||||||
updateSession: '/sessions/:id',
|
updateSession: '/sessions/:id',
|
||||||
deleteSession: '/sessions/:id',
|
deleteSession: '/sessions/:id',
|
||||||
changeStatusSession: '/admin/sessions/:id/toggle-status',
|
|
||||||
|
|
||||||
|
// ─── Backend-aligned: Exams ────────────────────────────────────────────────
|
||||||
|
// `getExamsList` is not yet in the backend Postman doc, but per the user
|
||||||
|
// the backend will add it at /exams — wired up now so the FE doesn't need
|
||||||
|
// to change when the endpoint lands.
|
||||||
|
getExamsList: '/exams',
|
||||||
|
showExam: '/exams/:id',
|
||||||
|
addNewExam: '/exams',
|
||||||
|
updateExam: '/exams/:id',
|
||||||
|
deleteExam: '/exams/:id',
|
||||||
|
addExamQuestion: '/exams/:examId/questions',
|
||||||
|
addQuestionOption: '/questions/:questionId/options',
|
||||||
|
submitExam: '/exams/:examId/submit',
|
||||||
|
|
||||||
|
// ─── Backend-aligned: Homeworks (FE calls them "assignments") ──────────────
|
||||||
|
addNewAssignment: '/homeworks',
|
||||||
|
updateAssignment: '/homeworks/:id',
|
||||||
|
deleteAssignment: '/homeworks/:id',
|
||||||
|
submitHomework: '/homeworks/:homeworkId/submit',
|
||||||
|
reviewAssignmentSubmission: '/homework-submissions/:submissionId/review',
|
||||||
|
|
||||||
|
// ─── Backend-aligned: Media ────────────────────────────────────────────────
|
||||||
|
uploadMedia: '/media',
|
||||||
|
downloadMedia: '/media/:id/download',
|
||||||
|
deleteMedia: '/media/:id',
|
||||||
|
|
||||||
|
// ─── FE mock-only — NOT in backend Postman doc ─────────────────────────────
|
||||||
|
// Kept so existing UI screens continue to work against the mock layer.
|
||||||
|
// See docs/backend-vs-ui-gaps.md for the list of decisions pending.
|
||||||
|
|
||||||
|
// Terms — clone + status + students-in-term + courses-in-term
|
||||||
|
cloneTerm: '/admin/terms/:id/clone',
|
||||||
|
listUserTerm: '/admin/terms/:termId/students',
|
||||||
|
addUserTerm: '/admin/terms/:termId/students',
|
||||||
|
removeUserTerm: '/admin/terms/:termId/students/:userId',
|
||||||
|
changeLeaveStatus: '/admin/terms/:termId/students/:userId/toggle-leave',
|
||||||
|
listCourseTerm: '/admin/terms/:termId/courses',
|
||||||
|
addCourseTerm: '/admin/terms/:termId/courses',
|
||||||
|
removeCourseTerm: '/admin/terms/:termId/courses/:courseId',
|
||||||
|
|
||||||
|
// Courses — students-in-course + attach/detach sessions (backend uses 1:N via session.course_id)
|
||||||
|
listCourseStudents: '/admin/courses/:courseId/students',
|
||||||
|
addCourseStudent: '/admin/courses/:courseId/students',
|
||||||
|
removeCourseStudent: '/admin/courses/:courseId/students/:userId',
|
||||||
|
listCourseSessions: '/admin/courses/:courseId/sessions',
|
||||||
|
attachCourseSession: '/admin/courses/:courseId/sessions',
|
||||||
|
detachCourseSession: '/admin/courses/:courseId/sessions/:sessionId',
|
||||||
|
|
||||||
|
// Sessions — attendance roster
|
||||||
getSessionsAttendance: '/admin/sessions/:sessionId/attendances',
|
getSessionsAttendance: '/admin/sessions/:sessionId/attendances',
|
||||||
|
|
||||||
getAssignmentsList: '/admin/assignments',
|
// Exams — participants only (backend has /exams/:id but no participants endpoints)
|
||||||
addNewAssignment: '/admin/assignments',
|
getExamParticipants: '/admin/exams/:examId/participants',
|
||||||
showAssignment: '/admin/assignments/:id',
|
showExamParticipant: '/admin/exams/:examId/participants/:participantId',
|
||||||
updateAssignment: '/admin/assignments/:id',
|
|
||||||
deleteAssignment: '/admin/assignments/:id',
|
|
||||||
getAssignmentSubmissions: '/admin/assignments/:assignmentId/submissions',
|
|
||||||
showAssignmentSubmission: '/admin/assignments/:assignmentId/submissions/:submissionId',
|
|
||||||
reviewAssignmentSubmission: '/admin/assignments/:assignmentId/submissions/:submissionId/review',
|
|
||||||
|
|
||||||
|
// Homeworks — list, show, submissions list, show submission
|
||||||
|
// Paths aligned to backend `/homeworks` and `/homework-submissions`. The
|
||||||
|
// backend Postman doc currently exposes only create/update/delete/submit/review;
|
||||||
|
// list + show + submissions list + show-submission stay mock-only until backend
|
||||||
|
// adds them, but URL shape is locked in.
|
||||||
|
getAssignmentsList: '/homeworks',
|
||||||
|
showAssignment: '/homeworks/:id',
|
||||||
|
getAssignmentSubmissions: '/homeworks/:homeworkId/submissions',
|
||||||
|
showAssignmentSubmission: '/homework-submissions/:submissionId',
|
||||||
|
|
||||||
|
// ─── Tickets / Consultations (out of scope of Terms/Courses backend doc) ───
|
||||||
getTicketsList: '/admin/tickets',
|
getTicketsList: '/admin/tickets',
|
||||||
showTicket: '/admin/tickets/:id',
|
showTicket: '/admin/tickets/:id',
|
||||||
sendTicketMessage: '/admin/tickets/:id/messages',
|
sendTicketMessage: '/admin/tickets/:id/messages',
|
||||||
@@ -125,14 +156,6 @@ export const endpoints = {
|
|||||||
showConsultation: '/admin/consultations/:id',
|
showConsultation: '/admin/consultations/:id',
|
||||||
sendConsultationMessage: '/admin/consultations/:id/messages',
|
sendConsultationMessage: '/admin/consultations/:id/messages',
|
||||||
changeConsultationStatus: '/admin/consultations/:id/status',
|
changeConsultationStatus: '/admin/consultations/:id/status',
|
||||||
|
|
||||||
getExamsList: '/admin/exams',
|
|
||||||
addNewExam: '/admin/exams',
|
|
||||||
showExam: '/admin/exams/:id',
|
|
||||||
updateExam: '/admin/exams/:id',
|
|
||||||
deleteExam: '/admin/exams/:id',
|
|
||||||
getExamParticipants: '/admin/exams/:examId/participants',
|
|
||||||
showExamParticipant: '/admin/exams/:examId/participants/:participantId',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildUrl = (template, params = {}) =>
|
export const buildUrl = (template, params = {}) =>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ export const adminAssignments = [
|
|||||||
id: 301,
|
id: 301,
|
||||||
title: 'یادداشتبرداری از جلسه اول',
|
title: 'یادداشتبرداری از جلسه اول',
|
||||||
description: 'خلاصهای از مباحث جلسه اول را در دو صفحه بنویسید.',
|
description: 'خلاصهای از مباحث جلسه اول را در دو صفحه بنویسید.',
|
||||||
courseTemplate: { id: 1, title: 'اصول اخلاق اسلامی' },
|
course: { id: 1, title: 'اصول اخلاق اسلامی' },
|
||||||
courseTemplateTitle: 'اصول اخلاق اسلامی',
|
courseTitle: 'اصول اخلاق اسلامی',
|
||||||
session: { id: 101, title: 'مقدمهای بر اخلاق اسلامی' },
|
session: { id: 101, title: 'مقدمهای بر اخلاق اسلامی' },
|
||||||
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
||||||
startDate: '2025-09-26T00:00:00.000Z',
|
startDate: '2025-09-26T00:00:00.000Z',
|
||||||
@@ -19,8 +19,8 @@ export const adminAssignments = [
|
|||||||
id: 302,
|
id: 302,
|
||||||
title: 'تحلیل تفسیری سوره حمد',
|
title: 'تحلیل تفسیری سوره حمد',
|
||||||
description: 'تحلیل سه آیه از سوره حمد را ارسال نمایید.',
|
description: 'تحلیل سه آیه از سوره حمد را ارسال نمایید.',
|
||||||
courseTemplate: { id: 2, title: 'مفاهیم قرآنی' },
|
course: { id: 2, title: 'مفاهیم قرآنی' },
|
||||||
courseTemplateTitle: 'مفاهیم قرآنی',
|
courseTitle: 'مفاهیم قرآنی',
|
||||||
session: { id: 102, title: 'تفسیر سوره حمد' },
|
session: { id: 102, title: 'تفسیر سوره حمد' },
|
||||||
sessionTitle: 'تفسیر سوره حمد',
|
sessionTitle: 'تفسیر سوره حمد',
|
||||||
startDate: '2025-10-05T00:00:00.000Z',
|
startDate: '2025-10-05T00:00:00.000Z',
|
||||||
@@ -55,7 +55,7 @@ export const assignmentSubmissions = new Map([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
termTitle: 'ترم پاییز ۱۴۰۴',
|
termTitle: 'ترم پاییز ۱۴۰۴',
|
||||||
courseTemplateTitle: 'اصول اخلاق اسلامی',
|
courseTitle: 'اصول اخلاق اسلامی',
|
||||||
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
||||||
userDescription: 'یادداشتهای جلسه اول به همراه برداشت شخصی ضمیمه است.',
|
userDescription: 'یادداشتهای جلسه اول به همراه برداشت شخصی ضمیمه است.',
|
||||||
attachments: [{ id: 1, title: 'یادداشت-جلسه-اول.pdf', fileUrl: '#', type: 'document' }],
|
attachments: [{ id: 1, title: 'یادداشت-جلسه-اول.pdf', fileUrl: '#', type: 'document' }],
|
||||||
@@ -82,7 +82,7 @@ export const assignmentSubmissions = new Map([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
termTitle: 'ترم پاییز ۱۴۰۴',
|
termTitle: 'ترم پاییز ۱۴۰۴',
|
||||||
courseTemplateTitle: 'اصول اخلاق اسلامی',
|
courseTitle: 'اصول اخلاق اسلامی',
|
||||||
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
||||||
userDescription: 'خلاصهای از جلسه به همراه پرسش پایان.',
|
userDescription: 'خلاصهای از جلسه به همراه پرسش پایان.',
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
|||||||
@@ -1,50 +1,5 @@
|
|||||||
export const adminCourseTemplates = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'اصول اخلاق اسلامی',
|
|
||||||
description: 'دوره مقدماتی برای آشنایی با اخلاق اسلامی.',
|
|
||||||
image: 'https://picsum.photos/seed/course1/200/200',
|
|
||||||
defaultTeacher: { id: 5, firstName: 'علی', lastName: 'حسنی', fullName: 'علی حسنی' },
|
|
||||||
defaultCapacity: 30,
|
|
||||||
isActiveByDefault: true,
|
|
||||||
prerequisitesCount: 0,
|
|
||||||
prerequisites: [],
|
|
||||||
createdAt: '2025-08-10T08:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'مفاهیم قرآنی',
|
|
||||||
description: 'بررسی مفاهیم قرآنی به همراه تفسیر مختصر.',
|
|
||||||
image: 'https://picsum.photos/seed/course2/200/200',
|
|
||||||
defaultTeacher: { id: 6, firstName: 'حسین', lastName: 'مرادی', fullName: 'حسین مرادی' },
|
|
||||||
defaultCapacity: 25,
|
|
||||||
isActiveByDefault: true,
|
|
||||||
prerequisitesCount: 1,
|
|
||||||
prerequisites: [{ courseId: 1, course: { id: 1, title: 'اصول اخلاق اسلامی' } }],
|
|
||||||
createdAt: '2025-09-12T08:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'فقه عبادات',
|
|
||||||
description: 'مرور احکام عملی نماز و روزه.',
|
|
||||||
image: '',
|
|
||||||
defaultTeacher: { id: 7, firstName: 'مهدی', lastName: 'سهرابی', fullName: 'مهدی سهرابی' },
|
|
||||||
defaultCapacity: 20,
|
|
||||||
isActiveByDefault: false,
|
|
||||||
prerequisitesCount: 0,
|
|
||||||
prerequisites: [],
|
|
||||||
createdAt: '2026-01-05T08:00:00.000Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// Offered courses carry both the spec keys (term_id/teacher_id/
|
|
||||||
// description/capacity/is_active/cover_url) and the UI-only keys the
|
|
||||||
// current frontend reads (image, template/templateId, term/teacher
|
|
||||||
// nested objects, prerequisitesCount, startDate, endDate). See
|
|
||||||
// docs/backend-api-todo.md.
|
|
||||||
const makeTeacherSnapshot = (overrides) => ({
|
const makeTeacherSnapshot = (overrides) => ({
|
||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
// --- spec ---
|
|
||||||
name: overrides.name ?? `${overrides.firstName ?? ''} ${overrides.lastName ?? ''}`.trim(),
|
name: overrides.name ?? `${overrides.firstName ?? ''} ${overrides.lastName ?? ''}`.trim(),
|
||||||
email: overrides.email ?? '',
|
email: overrides.email ?? '',
|
||||||
phone: overrides.phone ?? null,
|
phone: overrides.phone ?? null,
|
||||||
@@ -52,16 +7,70 @@ const makeTeacherSnapshot = (overrides) => ({
|
|||||||
avatarUrl: overrides.avatarUrl ?? null,
|
avatarUrl: overrides.avatarUrl ?? null,
|
||||||
avatarDownloadUrl: overrides.avatarDownloadUrl ?? null,
|
avatarDownloadUrl: overrides.avatarDownloadUrl ?? null,
|
||||||
createdAt: overrides.createdAt ?? '',
|
createdAt: overrides.createdAt ?? '',
|
||||||
// --- ui-only ---
|
|
||||||
firstName: overrides.firstName ?? '',
|
firstName: overrides.firstName ?? '',
|
||||||
lastName: overrides.lastName ?? '',
|
lastName: overrides.lastName ?? '',
|
||||||
fullName:
|
fullName: overrides.fullName ?? `${overrides.firstName ?? ''} ${overrides.lastName ?? ''}`.trim(),
|
||||||
overrides.fullName ?? `${overrides.firstName ?? ''} ${overrides.lastName ?? ''}`.trim(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adminOfferedCourses = [
|
export const adminCourses = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
termId: null,
|
||||||
|
teacherId: 5,
|
||||||
|
title: 'اصول اخلاق اسلامی',
|
||||||
|
description: 'دوره مقدماتی برای آشنایی با اخلاق اسلامی.',
|
||||||
|
capacity: 30,
|
||||||
|
isActive: true,
|
||||||
|
coverUrl: 'https://picsum.photos/seed/course1/200/200',
|
||||||
|
image: 'https://picsum.photos/seed/course1/200/200',
|
||||||
|
teacher: makeTeacherSnapshot({ id: 5, firstName: 'علی', lastName: 'حسنی' }),
|
||||||
|
term: null,
|
||||||
|
sessionsCount: 0,
|
||||||
|
prerequisitesCount: 0,
|
||||||
|
prerequisites: [],
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
createdAt: '2025-08-10T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
termId: null,
|
||||||
|
teacherId: 6,
|
||||||
|
title: 'مفاهیم قرآنی',
|
||||||
|
description: 'بررسی مفاهیم قرآنی به همراه تفسیر مختصر.',
|
||||||
|
capacity: 25,
|
||||||
|
isActive: true,
|
||||||
|
coverUrl: 'https://picsum.photos/seed/course2/200/200',
|
||||||
|
image: 'https://picsum.photos/seed/course2/200/200',
|
||||||
|
teacher: makeTeacherSnapshot({ id: 6, firstName: 'حسین', lastName: 'مرادی' }),
|
||||||
|
term: null,
|
||||||
|
sessionsCount: 0,
|
||||||
|
prerequisitesCount: 1,
|
||||||
|
prerequisites: [{ courseId: 1, course: { id: 1, title: 'اصول اخلاق اسلامی' } }],
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
createdAt: '2025-09-12T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
termId: null,
|
||||||
|
teacherId: 7,
|
||||||
|
title: 'فقه عبادات',
|
||||||
|
description: 'مرور احکام عملی نماز و روزه.',
|
||||||
|
capacity: 20,
|
||||||
|
isActive: false,
|
||||||
|
coverUrl: '',
|
||||||
|
image: '',
|
||||||
|
teacher: makeTeacherSnapshot({ id: 7, firstName: 'مهدی', lastName: 'سهرابی' }),
|
||||||
|
term: null,
|
||||||
|
sessionsCount: 0,
|
||||||
|
prerequisitesCount: 0,
|
||||||
|
prerequisites: [],
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
createdAt: '2026-01-05T08:00:00.000Z',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// --- spec ---
|
|
||||||
id: 11,
|
id: 11,
|
||||||
termId: 1,
|
termId: 1,
|
||||||
teacherId: 5,
|
teacherId: 5,
|
||||||
@@ -70,20 +79,17 @@ export const adminOfferedCourses = [
|
|||||||
capacity: 30,
|
capacity: 30,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
coverUrl: 'https://picsum.photos/seed/offered1/200/200',
|
coverUrl: 'https://picsum.photos/seed/offered1/200/200',
|
||||||
|
|
||||||
// --- ui-only ---
|
|
||||||
image: 'https://picsum.photos/seed/offered1/200/200',
|
image: 'https://picsum.photos/seed/offered1/200/200',
|
||||||
template: { id: 1, title: 'اصول اخلاق اسلامی' },
|
|
||||||
templateId: 1,
|
|
||||||
term: { id: 1, title: 'ترم پاییز ۱۴۰۴' },
|
|
||||||
teacher: makeTeacherSnapshot({ id: 5, firstName: 'علی', lastName: 'حسنی' }),
|
teacher: makeTeacherSnapshot({ id: 5, firstName: 'علی', lastName: 'حسنی' }),
|
||||||
|
term: { id: 1, title: 'ترم پاییز ۱۴۰۴' },
|
||||||
|
sessionsCount: 0,
|
||||||
prerequisitesCount: 0,
|
prerequisitesCount: 0,
|
||||||
|
prerequisites: [],
|
||||||
startDate: '2025-09-23T00:00:00.000Z',
|
startDate: '2025-09-23T00:00:00.000Z',
|
||||||
endDate: '2025-11-20T00:00:00.000Z',
|
endDate: '2025-11-20T00:00:00.000Z',
|
||||||
createdAt: '2025-09-01T08:00:00.000Z',
|
createdAt: '2025-09-01T08:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// --- spec ---
|
|
||||||
id: 12,
|
id: 12,
|
||||||
termId: 1,
|
termId: 1,
|
||||||
teacherId: 6,
|
teacherId: 6,
|
||||||
@@ -92,20 +98,17 @@ export const adminOfferedCourses = [
|
|||||||
capacity: 25,
|
capacity: 25,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
coverUrl: 'https://picsum.photos/seed/offered2/200/200',
|
coverUrl: 'https://picsum.photos/seed/offered2/200/200',
|
||||||
|
|
||||||
// --- ui-only ---
|
|
||||||
image: 'https://picsum.photos/seed/offered2/200/200',
|
image: 'https://picsum.photos/seed/offered2/200/200',
|
||||||
template: { id: 2, title: 'مفاهیم قرآنی' },
|
|
||||||
templateId: 2,
|
|
||||||
term: { id: 1, title: 'ترم پاییز ۱۴۰۴' },
|
|
||||||
teacher: makeTeacherSnapshot({ id: 6, firstName: 'حسین', lastName: 'مرادی' }),
|
teacher: makeTeacherSnapshot({ id: 6, firstName: 'حسین', lastName: 'مرادی' }),
|
||||||
|
term: { id: 1, title: 'ترم پاییز ۱۴۰۴' },
|
||||||
|
sessionsCount: 0,
|
||||||
prerequisitesCount: 1,
|
prerequisitesCount: 1,
|
||||||
|
prerequisites: [{ courseId: 11, course: { id: 11, title: 'اصول اخلاق اسلامی - پاییز' } }],
|
||||||
startDate: '2025-10-01T00:00:00.000Z',
|
startDate: '2025-10-01T00:00:00.000Z',
|
||||||
endDate: '2025-12-15T00:00:00.000Z',
|
endDate: '2025-12-15T00:00:00.000Z',
|
||||||
createdAt: '2025-09-10T08:00:00.000Z',
|
createdAt: '2025-09-10T08:00:00.000Z',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// --- spec ---
|
|
||||||
id: 13,
|
id: 13,
|
||||||
termId: 2,
|
termId: 2,
|
||||||
teacherId: 7,
|
teacherId: 7,
|
||||||
@@ -114,14 +117,12 @@ export const adminOfferedCourses = [
|
|||||||
capacity: 20,
|
capacity: 20,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
|
|
||||||
// --- ui-only ---
|
|
||||||
image: '',
|
image: '',
|
||||||
template: { id: 3, title: 'فقه عبادات' },
|
|
||||||
templateId: 3,
|
|
||||||
term: { id: 2, title: 'ترم زمستان ۱۴۰۴' },
|
|
||||||
teacher: makeTeacherSnapshot({ id: 7, firstName: 'مهدی', lastName: 'سهرابی' }),
|
teacher: makeTeacherSnapshot({ id: 7, firstName: 'مهدی', lastName: 'سهرابی' }),
|
||||||
|
term: { id: 2, title: 'ترم زمستان ۱۴۰۴' },
|
||||||
|
sessionsCount: 0,
|
||||||
prerequisitesCount: 0,
|
prerequisitesCount: 0,
|
||||||
|
prerequisites: [],
|
||||||
startDate: '2026-01-22T00:00:00.000Z',
|
startDate: '2026-01-22T00:00:00.000Z',
|
||||||
endDate: '2026-03-15T00:00:00.000Z',
|
endDate: '2026-03-15T00:00:00.000Z',
|
||||||
createdAt: '2025-12-10T08:00:00.000Z',
|
createdAt: '2025-12-10T08:00:00.000Z',
|
||||||
|
|||||||
@@ -1,67 +1,83 @@
|
|||||||
const sampleQuestions = (seed) => [
|
// Shape mirrors backend Postman doc for /exams:
|
||||||
{
|
// exam: { id, sessionId, title, description, passScore, isActive,
|
||||||
id: `${seed}-q1`,
|
// questions: [{ id, questionText, position,
|
||||||
title: 'کدام گزینه به مفهوم تقوا نزدیکتر است؟',
|
// options: [{ id, optionText, isCorrect }] }] }
|
||||||
score: 5,
|
// (Backend hides `is_correct` from non-admin reads; the admin mock shows it.)
|
||||||
correctAnswerId: `${seed}-q1-a2`,
|
|
||||||
answers: [
|
const buildQuestion = (id, questionText, position, options, correctIndex) => ({
|
||||||
{ id: `${seed}-q1-a1`, title: 'پرهیز از خطا و دوری از گناه' },
|
id,
|
||||||
{ id: `${seed}-q1-a2`, title: 'خودنگهداری در محضر خداوند' },
|
questionText,
|
||||||
{ id: `${seed}-q1-a3`, title: 'پرهیز از خوراکیهای مضر' },
|
position,
|
||||||
],
|
options: options.map((optionText, idx) => ({
|
||||||
},
|
id: id * 100 + idx + 1,
|
||||||
{
|
optionText,
|
||||||
id: `${seed}-q2`,
|
isCorrect: idx === correctIndex,
|
||||||
title: 'منبع اصلی احکام شیعه چیست؟',
|
})),
|
||||||
score: 5,
|
})
|
||||||
correctAnswerId: `${seed}-q2-a1`,
|
|
||||||
answers: [
|
const ethicsQuestions = [
|
||||||
{ id: `${seed}-q2-a1`, title: 'قرآن و سنت اهل بیت(ع)' },
|
buildQuestion(
|
||||||
{ id: `${seed}-q2-a2`, title: 'فقط قرآن کریم' },
|
1,
|
||||||
{ id: `${seed}-q2-a3`, title: 'اجماع علما' },
|
'کدام گزینه به مفهوم تقوا نزدیکتر است؟',
|
||||||
],
|
1,
|
||||||
},
|
['پرهیز از خطا و دوری از گناه', 'خودنگهداری در محضر خداوند', 'پرهیز از خوراکیهای مضر'],
|
||||||
|
1
|
||||||
|
),
|
||||||
|
buildQuestion(
|
||||||
|
2,
|
||||||
|
'منبع اصلی احکام شیعه چیست؟',
|
||||||
|
2,
|
||||||
|
['قرآن و سنت اهل بیت(ع)', 'فقط قرآن کریم', 'اجماع علما'],
|
||||||
|
0
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const quranQuestions = [
|
||||||
|
buildQuestion(3, 'سوره حمد چند آیه دارد؟', 1, ['۵ آیه', '۶ آیه', '۷ آیه', '۸ آیه'], 2),
|
||||||
|
buildQuestion(
|
||||||
|
4,
|
||||||
|
'کدام آیه به نام آیةالکرسی شناخته میشود؟',
|
||||||
|
2,
|
||||||
|
['آیه ۲۵۵ سوره بقره', 'آیه اول سوره فاتحه', 'آیه ۱۸ سوره آل عمران'],
|
||||||
|
0
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
export const adminExams = [
|
export const adminExams = [
|
||||||
{
|
{
|
||||||
id: 201,
|
id: 201,
|
||||||
|
sessionId: 101,
|
||||||
title: 'آزمون پایان فصل اول اخلاق',
|
title: 'آزمون پایان فصل اول اخلاق',
|
||||||
description: 'آزمون چهار گزینهای از مفاهیم درسهای ۱ تا ۳.',
|
description: 'آزمون چهار گزینهای از مفاهیم درسهای ۱ تا ۳.',
|
||||||
courseTemplate: { id: 1, title: 'اصول اخلاق اسلامی' },
|
passScore: 12,
|
||||||
courseTemplateTitle: 'اصول اخلاق اسلامی',
|
isActive: true,
|
||||||
|
questions: ethicsQuestions,
|
||||||
|
// ── UI-only fields for ExamItem / ExamDetailsModal (not in backend). ──
|
||||||
|
course: { id: 1, title: 'اصول اخلاق اسلامی' },
|
||||||
|
courseTitle: 'اصول اخلاق اسلامی',
|
||||||
session: { id: 101, title: 'مقدمهای بر اخلاق اسلامی' },
|
session: { id: 101, title: 'مقدمهای بر اخلاق اسلامی' },
|
||||||
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
||||||
durationMinutes: 25,
|
questionsCount: ethicsQuestions.length,
|
||||||
passingScore: 12,
|
|
||||||
questionsCount: 2,
|
|
||||||
randomize: true,
|
|
||||||
endDate: '2026-05-15T00:00:00.000Z',
|
|
||||||
startDate: '2026-05-01T00:00:00.000Z',
|
|
||||||
createdAt: '2026-04-20T08:00:00.000Z',
|
createdAt: '2026-04-20T08:00:00.000Z',
|
||||||
questions: sampleQuestions(201),
|
|
||||||
usedInTerms: [{ termId: 1 }],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 202,
|
id: 202,
|
||||||
|
sessionId: 102,
|
||||||
title: 'آزمون مفاهیم قرآنی - میانترم',
|
title: 'آزمون مفاهیم قرآنی - میانترم',
|
||||||
description: 'آزمون میانترم برای مرور آیات کلیدی.',
|
description: 'آزمون میانترم برای مرور آیات کلیدی.',
|
||||||
courseTemplate: { id: 2, title: 'مفاهیم قرآنی' },
|
passScore: 14,
|
||||||
courseTemplateTitle: 'مفاهیم قرآنی',
|
isActive: true,
|
||||||
|
questions: quranQuestions,
|
||||||
|
course: { id: 2, title: 'مفاهیم قرآنی' },
|
||||||
|
courseTitle: 'مفاهیم قرآنی',
|
||||||
session: { id: 102, title: 'تفسیر سوره حمد' },
|
session: { id: 102, title: 'تفسیر سوره حمد' },
|
||||||
sessionTitle: 'تفسیر سوره حمد',
|
sessionTitle: 'تفسیر سوره حمد',
|
||||||
durationMinutes: 30,
|
questionsCount: quranQuestions.length,
|
||||||
passingScore: 14,
|
|
||||||
questionsCount: 2,
|
|
||||||
randomize: false,
|
|
||||||
endDate: '2026-06-10T00:00:00.000Z',
|
|
||||||
startDate: '2026-05-25T00:00:00.000Z',
|
|
||||||
createdAt: '2026-05-10T08:00:00.000Z',
|
createdAt: '2026-05-10T08:00:00.000Z',
|
||||||
questions: sampleQuestions(202),
|
|
||||||
usedInTerms: [{ termId: 1 }],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// FE-mock-only — no backend endpoint for exam participants.
|
||||||
export const examParticipants = new Map([
|
export const examParticipants = new Map([
|
||||||
[
|
[
|
||||||
201,
|
201,
|
||||||
@@ -85,10 +101,6 @@ export const examParticipants = new Map([
|
|||||||
date: '2026-05-05T10:30:00.000Z',
|
date: '2026-05-05T10:30:00.000Z',
|
||||||
score: 17,
|
score: 17,
|
||||||
scoreTone: 'good',
|
scoreTone: 'good',
|
||||||
questions: sampleQuestions(201).map((q) => ({
|
|
||||||
...q,
|
|
||||||
userAnswerId: q.correctAnswerId,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -111,10 +123,6 @@ export const examParticipants = new Map([
|
|||||||
date: '2026-05-06T11:00:00.000Z',
|
date: '2026-05-06T11:00:00.000Z',
|
||||||
score: 9,
|
score: 9,
|
||||||
scoreTone: 'bad',
|
scoreTone: 'bad',
|
||||||
questions: sampleQuestions(201).map((q, i) => ({
|
|
||||||
...q,
|
|
||||||
userAnswerId: i === 0 ? q.answers[0].id : q.correctAnswerId,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Maps the FE's 7-value `sessionType` to the spec's 3-value `type`.
|
|
||||||
const SESSION_TYPE_TO_SPEC = {
|
const SESSION_TYPE_TO_SPEC = {
|
||||||
in_person: 'offline',
|
in_person: 'offline',
|
||||||
online: 'online',
|
online: 'online',
|
||||||
@@ -13,29 +12,26 @@ const makeSession = (overrides) => {
|
|||||||
const sessionType = overrides.sessionType ?? ''
|
const sessionType = overrides.sessionType ?? ''
|
||||||
const sessionConfig = overrides.sessionConfig ?? {}
|
const sessionConfig = overrides.sessionConfig ?? {}
|
||||||
const startsAt = overrides.startsAt ?? sessionConfig.startTime ?? null
|
const startsAt = overrides.startsAt ?? sessionConfig.startTime ?? null
|
||||||
const location = overrides.location ?? sessionConfig.location ?? null
|
const endsAt = overrides.endsAt ?? sessionConfig.endTime ?? null
|
||||||
const link = overrides.link ?? sessionConfig.meetingLink ?? null
|
const link = overrides.link ?? sessionConfig.meetingLink ?? null
|
||||||
return {
|
return {
|
||||||
// --- spec ---
|
|
||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
courseId: overrides.courseId ?? null,
|
courseId: overrides.courseId ?? null,
|
||||||
title: overrides.title ?? '',
|
title: overrides.title ?? '',
|
||||||
description: overrides.description ?? '',
|
description: overrides.description ?? '',
|
||||||
type: overrides.type ?? SESSION_TYPE_TO_SPEC[sessionType] ?? null,
|
type: overrides.type ?? SESSION_TYPE_TO_SPEC[sessionType] ?? null,
|
||||||
startsAt,
|
startsAt,
|
||||||
location,
|
endsAt,
|
||||||
link,
|
link,
|
||||||
media: overrides.media ?? [],
|
media: overrides.media ?? [],
|
||||||
|
|
||||||
// --- ui-only ---
|
|
||||||
image: overrides.image ?? '',
|
image: overrides.image ?? '',
|
||||||
courseTemplate: overrides.courseTemplate ?? null,
|
course: overrides.course ?? null,
|
||||||
sessionType,
|
sessionType,
|
||||||
sessionTypeFa: overrides.sessionTypeFa ?? '',
|
sessionTypeFa: overrides.sessionTypeFa ?? '',
|
||||||
|
contentType: overrides.contentType ?? '',
|
||||||
|
contentMediaId: overrides.contentMediaId ?? null,
|
||||||
durationMinutes: overrides.durationMinutes ?? 0,
|
durationMinutes: overrides.durationMinutes ?? 0,
|
||||||
order: overrides.order ?? 1,
|
|
||||||
sessionConfig,
|
sessionConfig,
|
||||||
materials: overrides.materials ?? [],
|
|
||||||
usedInTerms: overrides.usedInTerms ?? [],
|
usedInTerms: overrides.usedInTerms ?? [],
|
||||||
createdAt: overrides.createdAt ?? '',
|
createdAt: overrides.createdAt ?? '',
|
||||||
}
|
}
|
||||||
@@ -48,7 +44,7 @@ export const adminSessions = [
|
|||||||
title: 'مقدمهای بر اخلاق اسلامی',
|
title: 'مقدمهای بر اخلاق اسلامی',
|
||||||
description: 'جلسه نخست؛ تعاریف و چارچوب دوره.',
|
description: 'جلسه نخست؛ تعاریف و چارچوب دوره.',
|
||||||
image: 'https://picsum.photos/seed/session1/200/200',
|
image: 'https://picsum.photos/seed/session1/200/200',
|
||||||
courseTemplate: { id: 1, title: 'اصول اخلاق اسلامی' },
|
course: { id: 11, title: 'اصول اخلاق اسلامی - پاییز' },
|
||||||
sessionType: 'in_person',
|
sessionType: 'in_person',
|
||||||
sessionTypeFa: 'حضوری',
|
sessionTypeFa: 'حضوری',
|
||||||
durationMinutes: 90,
|
durationMinutes: 90,
|
||||||
@@ -65,7 +61,7 @@ export const adminSessions = [
|
|||||||
courseId: 12,
|
courseId: 12,
|
||||||
title: 'تفسیر سوره حمد',
|
title: 'تفسیر سوره حمد',
|
||||||
description: 'تحلیل آیات سوره حمد.',
|
description: 'تحلیل آیات سوره حمد.',
|
||||||
courseTemplate: { id: 2, title: 'مفاهیم قرآنی' },
|
course: { id: 12, title: 'مفاهیم قرآنی - پاییز' },
|
||||||
sessionType: 'online',
|
sessionType: 'online',
|
||||||
sessionTypeFa: 'آنلاین',
|
sessionTypeFa: 'آنلاین',
|
||||||
durationMinutes: 75,
|
durationMinutes: 75,
|
||||||
@@ -83,7 +79,7 @@ export const adminSessions = [
|
|||||||
courseId: 13,
|
courseId: 13,
|
||||||
title: 'احکام نماز جماعت',
|
title: 'احکام نماز جماعت',
|
||||||
description: 'مرور احکام و شرایط نماز جماعت.',
|
description: 'مرور احکام و شرایط نماز جماعت.',
|
||||||
courseTemplate: { id: 3, title: 'فقه عبادات' },
|
course: { id: 13, title: 'فقه عبادات - زمستان' },
|
||||||
sessionType: 'video',
|
sessionType: 'video',
|
||||||
sessionTypeFa: 'ویدئو',
|
sessionTypeFa: 'ویدئو',
|
||||||
durationMinutes: 50,
|
durationMinutes: 50,
|
||||||
|
|||||||
@@ -1,92 +1,148 @@
|
|||||||
|
// Shape mirrors backend Postman doc for /admin/tickets:
|
||||||
|
// ticket: { id, student_id, assigned_to_user_id, target_role, status, subject,
|
||||||
|
// created_at, student, assignee, messages: [{ id, ticket_id,
|
||||||
|
// sender_id, message, created_at, sender }] }
|
||||||
|
// Field names use camelCase here (snake_case bridge happens at the HTTP layer
|
||||||
|
// when we swap the mock for the real backend).
|
||||||
|
|
||||||
|
const makeUser = (overrides) => ({
|
||||||
|
id: overrides.id,
|
||||||
|
name: overrides.name ?? '',
|
||||||
|
email: overrides.email ?? '',
|
||||||
|
phone: overrides.phone ?? null,
|
||||||
|
roles: overrides.roles ?? ['student'],
|
||||||
|
avatarUrl: overrides.avatarUrl ?? null,
|
||||||
|
avatarDownloadUrl: overrides.avatarDownloadUrl ?? null,
|
||||||
|
createdAt: overrides.createdAt ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const studentJane = makeUser({
|
||||||
|
id: 100,
|
||||||
|
name: 'فاطمه رضایی',
|
||||||
|
email: 'fateme@example.com',
|
||||||
|
phone: '+989121111111',
|
||||||
|
roles: ['student'],
|
||||||
|
avatarUrl: '',
|
||||||
|
createdAt: '2026-02-01T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const studentMaryam = makeUser({
|
||||||
|
id: 102,
|
||||||
|
name: 'مریم احمدی',
|
||||||
|
email: 'maryam@example.com',
|
||||||
|
phone: '+989122222222',
|
||||||
|
roles: ['student'],
|
||||||
|
avatarUrl: '',
|
||||||
|
createdAt: '2026-02-05T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const studentAli = makeUser({
|
||||||
|
id: 103,
|
||||||
|
name: 'علی علوی',
|
||||||
|
email: 'ali@example.com',
|
||||||
|
phone: '+989123333333',
|
||||||
|
roles: ['student'],
|
||||||
|
avatarUrl: '',
|
||||||
|
createdAt: '2026-02-10T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUser = makeUser({
|
||||||
|
id: 1,
|
||||||
|
name: 'مدیر سامانه',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
roles: ['admin'],
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
|
||||||
export const adminTickets = [
|
export const adminTickets = [
|
||||||
{
|
{
|
||||||
id: 401,
|
id: 401,
|
||||||
title: 'پیگیری وضعیت ثبتنام',
|
studentId: studentJane.id,
|
||||||
user: {
|
assignedToUserId: adminUser.id,
|
||||||
id: 100,
|
targetRole: 'admin',
|
||||||
firstName: 'فاطمه',
|
|
||||||
lastName: 'رضایی',
|
|
||||||
fullName: 'فاطمه رضایی',
|
|
||||||
avatarUrl: '',
|
|
||||||
},
|
|
||||||
status: 'answered',
|
status: 'answered',
|
||||||
statusLabel: 'پاسخ داده شده',
|
subject: 'پیگیری وضعیت ثبتنام',
|
||||||
createdAt: '2026-05-09T20:28:00.000Z',
|
createdAt: '2026-05-09T10:30:00.000Z',
|
||||||
faCreatedAt: '۱۴۰۵/۰۲/۱۹',
|
student: studentJane,
|
||||||
faCreatedTime: '۱۰:۳۰',
|
assignee: adminUser,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
sender: 'user',
|
ticketId: 401,
|
||||||
text: 'سلام وقت بخیر، در فرایند ثبتنام به مشکل برخوردم.',
|
senderId: studentJane.id,
|
||||||
time: '۱۲:۳۵',
|
message: 'سلام وقت بخیر، در فرایند ثبتنام به مشکل برخوردم.',
|
||||||
|
createdAt: '2026-05-09T10:35:00.000Z',
|
||||||
|
sender: studentJane,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
sender: 'admin',
|
ticketId: 401,
|
||||||
text: 'سلام و عرض ادب. لطفا کد پیگیری ثبتنام را ارسال کنید.',
|
senderId: adminUser.id,
|
||||||
time: '۱۲:۴۰',
|
message: 'سلام و عرض ادب. لطفا کد پیگیری ثبتنام را ارسال کنید.',
|
||||||
|
createdAt: '2026-05-09T10:40:00.000Z',
|
||||||
|
sender: adminUser,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
sender: 'user',
|
ticketId: 401,
|
||||||
text: 'کد ثبتنام: ۱۲۳۴۵۶۷۸',
|
senderId: studentJane.id,
|
||||||
time: '۱۲:۴۲',
|
message: 'کد ثبتنام: ۱۲۳۴۵۶۷۸',
|
||||||
|
createdAt: '2026-05-09T10:42:00.000Z',
|
||||||
|
sender: studentJane,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 402,
|
id: 402,
|
||||||
title: 'سوال درباره جلسه آموزشی',
|
studentId: studentMaryam.id,
|
||||||
user: {
|
assignedToUserId: null,
|
||||||
id: 102,
|
targetRole: 'admin',
|
||||||
firstName: 'مریم',
|
status: 'open',
|
||||||
lastName: 'احمدی',
|
subject: 'سوال درباره جلسه آموزشی',
|
||||||
fullName: 'مریم احمدی',
|
|
||||||
avatarUrl: '',
|
|
||||||
},
|
|
||||||
status: 'pending',
|
|
||||||
statusLabel: 'در انتظار پاسخ',
|
|
||||||
createdAt: '2026-05-08T09:15:00.000Z',
|
createdAt: '2026-05-08T09:15:00.000Z',
|
||||||
faCreatedAt: '۱۴۰۵/۰۲/۱۸',
|
student: studentMaryam,
|
||||||
faCreatedTime: '۱۲:۴۵',
|
assignee: null,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
sender: 'user',
|
ticketId: 402,
|
||||||
text: 'سلام، درباره ساعت برگزاری جلسه چهارم سوال داشتم.',
|
senderId: studentMaryam.id,
|
||||||
time: '۱۲:۴۵',
|
message: 'سلام، درباره ساعت برگزاری جلسه چهارم سوال داشتم.',
|
||||||
|
createdAt: '2026-05-08T09:15:00.000Z',
|
||||||
|
sender: studentMaryam,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 403,
|
id: 403,
|
||||||
title: 'درخواست بستن تیکت',
|
studentId: studentAli.id,
|
||||||
user: {
|
assignedToUserId: adminUser.id,
|
||||||
id: 103,
|
targetRole: 'admin',
|
||||||
firstName: 'علی',
|
|
||||||
lastName: 'علوی',
|
|
||||||
fullName: 'علی علوی',
|
|
||||||
avatarUrl: '',
|
|
||||||
},
|
|
||||||
status: 'closed',
|
status: 'closed',
|
||||||
statusLabel: 'بسته شده',
|
subject: 'درخواست بستن تیکت',
|
||||||
createdAt: '2026-05-07T14:10:00.000Z',
|
createdAt: '2026-05-07T14:10:00.000Z',
|
||||||
faCreatedAt: '۱۴۰۵/۰۲/۱۷',
|
student: studentAli,
|
||||||
faCreatedTime: '۱۷:۴۰',
|
assignee: adminUser,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
sender: 'user',
|
ticketId: 403,
|
||||||
text: 'مشکل برطرف شد. لطفا تیکت بسته شود.',
|
senderId: studentAli.id,
|
||||||
time: '۱۷:۴۰',
|
message: 'مشکل برطرف شد. لطفا تیکت بسته شود.',
|
||||||
|
createdAt: '2026-05-07T17:40:00.000Z',
|
||||||
|
sender: studentAli,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
sender: 'admin',
|
ticketId: 403,
|
||||||
text: 'با تشکر از شما. تیکت بسته شد.',
|
senderId: adminUser.id,
|
||||||
time: '۱۷:۴۲',
|
message: 'با تشکر از شما. تیکت بسته شد.',
|
||||||
|
createdAt: '2026-05-07T17:42:00.000Z',
|
||||||
|
sender: adminUser,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// The signed-in admin used as `sender` when a message is posted from the FE.
|
||||||
|
export const currentAdminUser = adminUser
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { register } from '@/services/mock/registry'
|
import { register } from '@/services/mock/registry'
|
||||||
import { endpoints } from '@/services/api/endpoints'
|
import { endpoints } from '@/services/api/endpoints'
|
||||||
|
import { adminCourses } from '@/services/mock/fixtures/admin-courses'
|
||||||
import { adminSessions } from '@/services/mock/fixtures/admin-sessions'
|
import { adminSessions } from '@/services/mock/fixtures/admin-sessions'
|
||||||
import { adminCourseTemplates } from '@/services/mock/fixtures/admin-courses'
|
|
||||||
import { adminAssignments, assignmentSubmissions } from '@/services/mock/fixtures/admin-assignments'
|
import { adminAssignments, assignmentSubmissions } from '@/services/mock/fixtures/admin-assignments'
|
||||||
import {
|
import {
|
||||||
filterDateRange,
|
filterDateRange,
|
||||||
@@ -23,7 +23,7 @@ const computeDurationDays = (start, end) => {
|
|||||||
register('GET', endpoints.getAssignmentsList, ({ query }) => {
|
register('GET', endpoints.getAssignmentsList, ({ query }) => {
|
||||||
let list = filterItems(adminAssignments, query, {
|
let list = filterItems(adminAssignments, query, {
|
||||||
title: 'includes',
|
title: 'includes',
|
||||||
courseTemplateId: (item, v) => String(item.courseTemplate?.id) === String(v),
|
courseId: (item, v) => String(item.course?.id) === String(v),
|
||||||
sessionId: (item, v) => String(item.session?.id) === String(v),
|
sessionId: (item, v) => String(item.session?.id) === String(v),
|
||||||
})
|
})
|
||||||
list = filterDateRange(list, query)
|
list = filterDateRange(list, query)
|
||||||
@@ -35,14 +35,14 @@ register('GET', endpoints.showAssignment, ({ params }) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
register('POST', endpoints.addNewAssignment, ({ data }) => {
|
register('POST', endpoints.addNewAssignment, ({ data }) => {
|
||||||
const tpl = adminCourseTemplates.find((c) => c.id === Number(data.courseTemplateId))
|
const course = adminCourses.find((c) => c.id === Number(data.courseId))
|
||||||
const sess = adminSessions.find((s) => s.id === Number(data.sessionId))
|
const sess = adminSessions.find((s) => s.id === Number(data.sessionId))
|
||||||
const item = {
|
const item = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
courseTemplate: tpl ? { id: tpl.id, title: tpl.title } : null,
|
course: course ? { id: course.id, title: course.title } : null,
|
||||||
courseTemplateTitle: tpl?.title || '',
|
courseTitle: course?.title || '',
|
||||||
session: sess ? { id: sess.id, title: sess.title } : null,
|
session: sess ? { id: sess.id, title: sess.title } : null,
|
||||||
sessionTitle: sess?.title || '',
|
sessionTitle: sess?.title || '',
|
||||||
startDate: data.startDate || '',
|
startDate: data.startDate || '',
|
||||||
@@ -57,15 +57,15 @@ register('POST', endpoints.addNewAssignment, ({ data }) => {
|
|||||||
return { data: item }
|
return { data: item }
|
||||||
})
|
})
|
||||||
|
|
||||||
register('PUT', endpoints.updateAssignment, ({ params, data }) => {
|
register('PATCH', endpoints.updateAssignment, ({ params, data }) => {
|
||||||
const tpl = adminCourseTemplates.find((c) => c.id === Number(data.courseTemplateId))
|
const course = adminCourses.find((c) => c.id === Number(data.courseId))
|
||||||
const sess = adminSessions.find((s) => s.id === Number(data.sessionId))
|
const sess = adminSessions.find((s) => s.id === Number(data.sessionId))
|
||||||
return {
|
return {
|
||||||
data: updateById(adminAssignments, params.id, {
|
data: updateById(adminAssignments, params.id, {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
courseTemplate: tpl ? { id: tpl.id, title: tpl.title } : undefined,
|
course: course ? { id: course.id, title: course.title } : undefined,
|
||||||
courseTemplateTitle: tpl?.title,
|
courseTitle: course?.title,
|
||||||
session: sess ? { id: sess.id, title: sess.title } : undefined,
|
session: sess ? { id: sess.id, title: sess.title } : undefined,
|
||||||
sessionTitle: sess?.title,
|
sessionTitle: sess?.title,
|
||||||
startDate: data.startDate,
|
startDate: data.startDate,
|
||||||
@@ -83,19 +83,26 @@ register('DELETE', endpoints.deleteAssignment, ({ params }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
register('GET', endpoints.getAssignmentSubmissions, ({ params, query }) => {
|
register('GET', endpoints.getAssignmentSubmissions, ({ params, query }) => {
|
||||||
const list = assignmentSubmissions.get(Number(params.assignmentId)) || []
|
const list = assignmentSubmissions.get(Number(params.homeworkId)) || []
|
||||||
return paginate(list, query)
|
return paginate(list, query)
|
||||||
})
|
})
|
||||||
|
|
||||||
register('GET', endpoints.showAssignmentSubmission, ({ params }) => {
|
// Backend `/homework-submissions/:submissionId` — submissions live flat, so we
|
||||||
const list = assignmentSubmissions.get(Number(params.assignmentId)) || []
|
// scan every homework's submission list to find the matching id.
|
||||||
const found = list.find((s) => String(s.id) === String(params.submissionId))
|
const findSubmission = (submissionId) => {
|
||||||
return { data: found || null }
|
for (const list of assignmentSubmissions.values()) {
|
||||||
})
|
const found = list.find((s) => String(s.id) === String(submissionId))
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
register('POST', endpoints.reviewAssignmentSubmission, ({ params, data }) => {
|
register('GET', endpoints.showAssignmentSubmission, ({ params }) => ({
|
||||||
const list = assignmentSubmissions.get(Number(params.assignmentId)) || []
|
data: findSubmission(params.submissionId),
|
||||||
const sub = list.find((s) => String(s.id) === String(params.submissionId))
|
}))
|
||||||
|
|
||||||
|
register('PATCH', endpoints.reviewAssignmentSubmission, ({ params, data }) => {
|
||||||
|
const sub = findSubmission(params.submissionId)
|
||||||
if (sub) {
|
if (sub) {
|
||||||
Object.assign(sub, {
|
Object.assign(sub, {
|
||||||
score: data.score ?? sub.score,
|
score: data.score ?? sub.score,
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { register } from '@/services/mock/registry'
|
|||||||
import { endpoints } from '@/services/api/endpoints'
|
import { endpoints } from '@/services/api/endpoints'
|
||||||
import { adminTerms } from '@/services/mock/fixtures/admin-terms'
|
import { adminTerms } from '@/services/mock/fixtures/admin-terms'
|
||||||
import { adminUsers } from '@/services/mock/fixtures/admin-users'
|
import { adminUsers } from '@/services/mock/fixtures/admin-users'
|
||||||
import {
|
import { adminCourses, makeTeacherSnapshot } from '@/services/mock/fixtures/admin-courses'
|
||||||
adminCourseTemplates,
|
|
||||||
adminOfferedCourses,
|
|
||||||
makeTeacherSnapshot,
|
|
||||||
} from '@/services/mock/fixtures/admin-courses'
|
|
||||||
import {
|
import {
|
||||||
filterDateRange,
|
filterDateRange,
|
||||||
filterItems,
|
filterItems,
|
||||||
@@ -18,92 +14,48 @@ import {
|
|||||||
updateById,
|
updateById,
|
||||||
} from '@/services/mock/helpers'
|
} from '@/services/mock/helpers'
|
||||||
|
|
||||||
register('GET', endpoints.getCourseTemplatesList, ({ query }) => {
|
const resolveTermId = (raw) => {
|
||||||
let list = filterItems(adminCourseTemplates, query, {
|
if (raw === undefined || raw === null || raw === '') return null
|
||||||
title: 'includes',
|
if (raw === 'null') return null
|
||||||
status: (item, v) => String(item.isActiveByDefault ? 1 : 0) === String(v),
|
const n = Number(raw)
|
||||||
})
|
return Number.isFinite(n) ? n : null
|
||||||
list = filterDateRange(list, query)
|
|
||||||
return paginate(list, query)
|
|
||||||
})
|
|
||||||
|
|
||||||
register('GET', endpoints.showCourseTemplate, ({ params }) => ({
|
|
||||||
data: findOrThrow(adminCourseTemplates, params.id),
|
|
||||||
}))
|
|
||||||
|
|
||||||
register('POST', endpoints.addNewCourseTemplate, ({ data }) => {
|
|
||||||
const teacher = adminUsers.find((u) => u.id === Number(data.defaultTeacherId))
|
|
||||||
const item = {
|
|
||||||
id: makeId(),
|
|
||||||
title: data.title || '',
|
|
||||||
description: data.description || '',
|
|
||||||
image: data.imageId ? `https://picsum.photos/seed/course-${data.imageId}/200/200` : '',
|
|
||||||
defaultTeacher: teacher
|
|
||||||
? {
|
|
||||||
id: teacher.id,
|
|
||||||
firstName: teacher.firstName,
|
|
||||||
lastName: teacher.lastName,
|
|
||||||
fullName: `${teacher.firstName} ${teacher.lastName}`,
|
|
||||||
}
|
}
|
||||||
: null,
|
|
||||||
defaultCapacity: Number(data.defaultCapacity) || 0,
|
|
||||||
isActiveByDefault: !!data.isActiveByDefault,
|
|
||||||
prerequisitesCount: (data.prerequisites || []).length,
|
|
||||||
prerequisites: (data.prerequisites || []).map((id) => ({
|
|
||||||
courseId: id,
|
|
||||||
course: adminCourseTemplates.find((c) => c.id === Number(id)) || { id },
|
|
||||||
})),
|
|
||||||
createdAt: isoNow(),
|
|
||||||
}
|
|
||||||
adminCourseTemplates.unshift(item)
|
|
||||||
return { data: item }
|
|
||||||
})
|
|
||||||
|
|
||||||
register('PUT', endpoints.updateCourseTemplate, ({ params, data }) => {
|
const resolveTeacher = (rawId) => {
|
||||||
const teacher = adminUsers.find((u) => u.id === Number(data.defaultTeacherId))
|
const id = Number(rawId)
|
||||||
return {
|
const source = adminUsers.find((u) => u.id === id)
|
||||||
data: updateById(adminCourseTemplates, params.id, {
|
if (!source) return null
|
||||||
title: data.title,
|
return makeTeacherSnapshot({
|
||||||
description: data.description,
|
id: source.id,
|
||||||
image: data.imageId ? `https://picsum.photos/seed/course-${data.imageId}/200/200` : undefined,
|
firstName: source.firstName,
|
||||||
defaultTeacher: teacher
|
lastName: source.lastName,
|
||||||
? {
|
name: source.name,
|
||||||
id: teacher.id,
|
email: source.email,
|
||||||
firstName: teacher.firstName,
|
phone: source.phone,
|
||||||
lastName: teacher.lastName,
|
roles: source.roles,
|
||||||
fullName: `${teacher.firstName} ${teacher.lastName}`,
|
avatarUrl: source.avatarUrl,
|
||||||
}
|
avatarDownloadUrl: source.avatarDownloadUrl,
|
||||||
: undefined,
|
createdAt: source.createdAt,
|
||||||
defaultCapacity: Number(data.defaultCapacity) || 0,
|
|
||||||
isActiveByDefault: !!data.isActiveByDefault,
|
|
||||||
prerequisites: (data.prerequisites || []).map((id) => ({
|
|
||||||
courseId: id,
|
|
||||||
course: adminCourseTemplates.find((c) => c.id === Number(id)) || { id },
|
|
||||||
})),
|
|
||||||
prerequisitesCount: (data.prerequisites || []).length,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
register('DELETE', endpoints.deleteCourseTemplate, ({ params }) => {
|
|
||||||
removeById(adminCourseTemplates, params.id)
|
|
||||||
return { data: { message: 'حذف موفق' } }
|
|
||||||
})
|
|
||||||
|
|
||||||
register('POST', endpoints.changeStatusCourseTemplate, ({ params, data }) => ({
|
|
||||||
data: updateById(adminCourseTemplates, params.id, {
|
|
||||||
isActiveByDefault: !!data.isActiveByDefault,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
register('GET', endpoints.getCoursesList, ({ query }) => {
|
register('GET', endpoints.getCoursesList, ({ query }) => {
|
||||||
let list = filterItems(adminOfferedCourses, query, {
|
let list = adminCourses
|
||||||
|
if (query?.termId !== undefined && query?.termId !== '') {
|
||||||
|
if (String(query.termId) === 'null') {
|
||||||
|
list = list.filter((c) => c.termId == null)
|
||||||
|
} else {
|
||||||
|
const tid = Number(query.termId)
|
||||||
|
list = list.filter((c) => c.termId === tid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { termId: _omitted, ...rest } = query || {}
|
||||||
|
list = filterItems(list, rest, {
|
||||||
title: 'includes',
|
title: 'includes',
|
||||||
termId: 'eq',
|
|
||||||
status: (item, v) => String(item.isActive ? 1 : 0) === String(v),
|
status: (item, v) => String(item.isActive ? 1 : 0) === String(v),
|
||||||
})
|
})
|
||||||
list = filterDateRange(list, query, 'startDate')
|
list = filterDateRange(list, rest, 'startDate')
|
||||||
const { data: items, meta } = paginate(list, query)
|
const { data: items, meta } = paginate(list, rest)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'OK',
|
message: 'OK',
|
||||||
@@ -114,54 +66,40 @@ register('GET', endpoints.getCoursesList, ({ query }) => {
|
|||||||
register('GET', endpoints.showCourse, ({ params }) => ({
|
register('GET', endpoints.showCourse, ({ params }) => ({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'OK',
|
message: 'OK',
|
||||||
data: findOrThrow(adminOfferedCourses, params.id),
|
data: findOrThrow(adminCourses, params.id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
register('POST', endpoints.addNewCourse, ({ data }) => {
|
register('POST', endpoints.addNewCourse, ({ data }) => {
|
||||||
const template = adminCourseTemplates.find((t) => t.id === Number(data.templateId))
|
const termId = resolveTermId(data.termId)
|
||||||
const term = adminTerms.find((t) => t.id === Number(data.termId))
|
const term = termId == null ? null : adminTerms.find((t) => t.id === termId)
|
||||||
const teacherSource = adminUsers.find((u) => u.id === Number(data.teacherId))
|
const teacher = resolveTeacher(data.teacherId)
|
||||||
const teacher = teacherSource
|
|
||||||
? makeTeacherSnapshot({
|
|
||||||
id: teacherSource.id,
|
|
||||||
firstName: teacherSource.firstName,
|
|
||||||
lastName: teacherSource.lastName,
|
|
||||||
name: teacherSource.name,
|
|
||||||
email: teacherSource.email,
|
|
||||||
phone: teacherSource.phone,
|
|
||||||
roles: teacherSource.roles,
|
|
||||||
avatarUrl: teacherSource.avatarUrl,
|
|
||||||
avatarDownloadUrl: teacherSource.avatarDownloadUrl,
|
|
||||||
createdAt: teacherSource.createdAt,
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
const coverFromUpload = data.imageId
|
const coverFromUpload = data.imageId
|
||||||
? `https://picsum.photos/seed/offered-${data.imageId}/200/200`
|
? `https://picsum.photos/seed/course-${data.imageId}/200/200`
|
||||||
: ''
|
: ''
|
||||||
const coverUrl = data.coverUrl ?? coverFromUpload
|
const coverUrl = data.coverUrl ?? coverFromUpload
|
||||||
const item = {
|
const item = {
|
||||||
// --- spec ---
|
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
termId: term?.id ?? Number(data.termId) ?? null,
|
termId: term?.id ?? termId ?? null,
|
||||||
teacherId: teacher?.id ?? (Number(data.teacherId) || null),
|
teacherId: teacher?.id ?? (Number(data.teacherId) || null),
|
||||||
title: data.title || template?.title || '',
|
title: data.title || '',
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
capacity: Number(data.capacity) || 0,
|
capacity: Number(data.capacity) || 0,
|
||||||
isActive: data.isActive !== undefined ? !!data.isActive : true,
|
isActive: data.isActive === undefined ? true : !!data.isActive,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
|
|
||||||
// --- ui-only ---
|
|
||||||
image: coverUrl,
|
image: coverUrl,
|
||||||
template: template ? { id: template.id, title: template.title } : null,
|
|
||||||
templateId: template?.id,
|
|
||||||
term: term ? { id: term.id, title: term.title } : null,
|
|
||||||
teacher,
|
teacher,
|
||||||
prerequisitesCount: 0,
|
term: term ? { id: term.id, title: term.title } : null,
|
||||||
|
sessionsCount: Number(data.sessionsCount) || 0,
|
||||||
|
prerequisitesCount: (data.prerequisites || []).length,
|
||||||
|
prerequisites: (data.prerequisites || []).map((id) => ({
|
||||||
|
courseId: id,
|
||||||
|
course: adminCourses.find((c) => c.id === Number(id)) || { id },
|
||||||
|
})),
|
||||||
startDate: term?.startDate || '',
|
startDate: term?.startDate || '',
|
||||||
endDate: term?.endDate || '',
|
endDate: term?.endDate || '',
|
||||||
createdAt: isoNow(),
|
createdAt: isoNow(),
|
||||||
}
|
}
|
||||||
adminOfferedCourses.unshift(item)
|
adminCourses.unshift(item)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Course created.',
|
message: 'Course created.',
|
||||||
@@ -174,43 +112,34 @@ register('PATCH', endpoints.updateCourse, ({ params, data }) => {
|
|||||||
if (data.title !== undefined) patch.title = data.title
|
if (data.title !== undefined) patch.title = data.title
|
||||||
if (data.description !== undefined) patch.description = data.description
|
if (data.description !== undefined) patch.description = data.description
|
||||||
if (data.capacity !== undefined) patch.capacity = Number(data.capacity) || 0
|
if (data.capacity !== undefined) patch.capacity = Number(data.capacity) || 0
|
||||||
|
if (data.sessionsCount !== undefined) patch.sessionsCount = Number(data.sessionsCount) || 0
|
||||||
if (data.isActive !== undefined) patch.isActive = !!data.isActive
|
if (data.isActive !== undefined) patch.isActive = !!data.isActive
|
||||||
if (data.termId !== undefined) {
|
if (data.termId !== undefined) {
|
||||||
patch.termId = Number(data.termId) || null
|
const termId = resolveTermId(data.termId)
|
||||||
const term = adminTerms.find((t) => t.id === Number(data.termId))
|
patch.termId = termId
|
||||||
if (term) patch.term = { id: term.id, title: term.title }
|
const term = termId == null ? null : adminTerms.find((t) => t.id === termId)
|
||||||
|
patch.term = term ? { id: term.id, title: term.title } : null
|
||||||
}
|
}
|
||||||
if (data.teacherId !== undefined) {
|
if (data.teacherId !== undefined) {
|
||||||
patch.teacherId = Number(data.teacherId) || null
|
const teacher = resolveTeacher(data.teacherId)
|
||||||
const t = adminUsers.find((u) => u.id === Number(data.teacherId))
|
patch.teacherId = teacher?.id ?? null
|
||||||
if (t) {
|
patch.teacher = teacher
|
||||||
patch.teacher = makeTeacherSnapshot({
|
|
||||||
id: t.id,
|
|
||||||
firstName: t.firstName,
|
|
||||||
lastName: t.lastName,
|
|
||||||
name: t.name,
|
|
||||||
email: t.email,
|
|
||||||
phone: t.phone,
|
|
||||||
roles: t.roles,
|
|
||||||
avatarUrl: t.avatarUrl,
|
|
||||||
avatarDownloadUrl: t.avatarDownloadUrl,
|
|
||||||
createdAt: t.createdAt,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
if (data.prerequisites !== undefined) {
|
||||||
if (data.templateId !== undefined) {
|
patch.prerequisites = (data.prerequisites || []).map((id) => ({
|
||||||
const template = adminCourseTemplates.find((t) => t.id === Number(data.templateId))
|
courseId: id,
|
||||||
patch.templateId = template?.id
|
course: adminCourses.find((c) => c.id === Number(id)) || { id },
|
||||||
if (template) patch.template = { id: template.id, title: template.title }
|
}))
|
||||||
|
patch.prerequisitesCount = (data.prerequisites || []).length
|
||||||
}
|
}
|
||||||
if (data.coverUrl !== undefined || data.imageId !== undefined) {
|
if (data.coverUrl !== undefined || data.imageId !== undefined) {
|
||||||
const url =
|
const url =
|
||||||
data.coverUrl ??
|
data.coverUrl ??
|
||||||
(data.imageId ? `https://picsum.photos/seed/offered-${data.imageId}/200/200` : '')
|
(data.imageId ? `https://picsum.photos/seed/course-${data.imageId}/200/200` : '')
|
||||||
patch.coverUrl = url
|
patch.coverUrl = url
|
||||||
patch.image = url
|
patch.image = url
|
||||||
}
|
}
|
||||||
const updated = updateById(adminOfferedCourses, params.id, patch)
|
const updated = updateById(adminCourses, params.id, patch)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Course updated.',
|
message: 'Course updated.',
|
||||||
@@ -219,14 +148,10 @@ register('PATCH', endpoints.updateCourse, ({ params, data }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
register('DELETE', endpoints.deleteCourse, ({ params }) => {
|
register('DELETE', endpoints.deleteCourse, ({ params }) => {
|
||||||
removeById(adminOfferedCourses, params.id)
|
removeById(adminCourses, params.id)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Course deleted.',
|
message: 'Course deleted.',
|
||||||
data: null,
|
data: null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
register('POST', endpoints.changeStatusCourse, ({ params, data }) => ({
|
|
||||||
data: updateById(adminOfferedCourses, params.id, { isActive: !!data.isActive }),
|
|
||||||
}))
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { register } from '@/services/mock/registry'
|
import { register } from '@/services/mock/registry'
|
||||||
import { endpoints } from '@/services/api/endpoints'
|
import { endpoints } from '@/services/api/endpoints'
|
||||||
|
import { adminCourses } from '@/services/mock/fixtures/admin-courses'
|
||||||
import { adminSessions } from '@/services/mock/fixtures/admin-sessions'
|
import { adminSessions } from '@/services/mock/fixtures/admin-sessions'
|
||||||
import { adminCourseTemplates } from '@/services/mock/fixtures/admin-courses'
|
|
||||||
import { adminExams, examParticipants } from '@/services/mock/fixtures/admin-exams'
|
import { adminExams, examParticipants } from '@/services/mock/fixtures/admin-exams'
|
||||||
import {
|
import {
|
||||||
filterDateRange,
|
filterDateRange,
|
||||||
@@ -14,74 +14,139 @@ import {
|
|||||||
updateById,
|
updateById,
|
||||||
} from '@/services/mock/helpers'
|
} from '@/services/mock/helpers'
|
||||||
|
|
||||||
|
const resolveCourseFromSession = (session) => {
|
||||||
|
const courseId = session?.course?.id ?? session?.courseId
|
||||||
|
if (courseId == null) return null
|
||||||
|
return adminCourses.find((c) => c.id === Number(courseId)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionSnapshot = (session) => (session ? { id: session.id, title: session.title } : null)
|
||||||
|
|
||||||
|
const courseSnapshot = (course) => (course ? { id: course.id, title: course.title } : null)
|
||||||
|
|
||||||
register('GET', endpoints.getExamsList, ({ query }) => {
|
register('GET', endpoints.getExamsList, ({ query }) => {
|
||||||
let list = filterItems(adminExams, query, {
|
let list = filterItems(adminExams, query, {
|
||||||
title: 'includes',
|
title: 'includes',
|
||||||
courseTemplateId: (item, v) => String(item.courseTemplate?.id) === String(v),
|
courseId: (item, v) => String(item.course?.id) === String(v),
|
||||||
|
sessionId: (item, v) => String(item.sessionId) === String(v),
|
||||||
})
|
})
|
||||||
list = filterDateRange(list, query)
|
list = filterDateRange(list, query)
|
||||||
return paginate(list, query)
|
const { data: items, meta } = paginate(list, query)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'OK',
|
||||||
|
data: items,
|
||||||
|
meta,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
register('GET', endpoints.showExam, ({ params }) => ({
|
register('GET', endpoints.showExam, ({ params }) => ({
|
||||||
|
success: true,
|
||||||
|
message: 'OK',
|
||||||
data: findOrThrow(adminExams, params.id),
|
data: findOrThrow(adminExams, params.id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
register('POST', endpoints.addNewExam, ({ data }) => {
|
register('POST', endpoints.addNewExam, ({ data }) => {
|
||||||
const session = adminSessions.find((s) => s.id === Number(data.sessionId))
|
const session = adminSessions.find((s) => s.id === Number(data.sessionId))
|
||||||
const template = session?.courseTemplate
|
const course = resolveCourseFromSession(session)
|
||||||
? adminCourseTemplates.find((c) => c.id === Number(session.courseTemplate.id))
|
|
||||||
: null
|
|
||||||
const item = {
|
const item = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
|
sessionId: session?.id ?? Number(data.sessionId) ?? null,
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
session: session ? { id: session.id, title: session.title } : null,
|
passScore: Number(data.passingScore ?? data.passScore) || 0,
|
||||||
|
isActive: data.isActive ?? true,
|
||||||
|
questions: [],
|
||||||
|
// UI-only fallbacks for ExamItem / ExamDetailsModal.
|
||||||
|
session: sessionSnapshot(session),
|
||||||
sessionTitle: session?.title || '',
|
sessionTitle: session?.title || '',
|
||||||
courseTemplate: template ? { id: template.id, title: template.title } : null,
|
course: courseSnapshot(course),
|
||||||
courseTemplateTitle: template?.title || '',
|
courseTitle: course?.title || '',
|
||||||
durationMinutes: Number(data.durationMinutes) || 0,
|
questionsCount: 0,
|
||||||
passingScore: Number(data.passingScore) || 0,
|
|
||||||
questionsCount: data.questions?.length || 0,
|
|
||||||
randomize: !!data.randomize,
|
|
||||||
endDate: data.endDate || '',
|
|
||||||
startDate: isoNow(),
|
|
||||||
createdAt: isoNow(),
|
createdAt: isoNow(),
|
||||||
questions: data.questions || [],
|
|
||||||
usedInTerms: [],
|
|
||||||
}
|
}
|
||||||
adminExams.unshift(item)
|
adminExams.unshift(item)
|
||||||
return { data: item }
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Exam created.',
|
||||||
|
data: item,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
register('PUT', endpoints.updateExam, ({ params, data }) => {
|
register('PATCH', endpoints.updateExam, ({ params, data }) => {
|
||||||
|
const patch = {}
|
||||||
|
if (data.title !== undefined) patch.title = data.title
|
||||||
|
if (data.description !== undefined) patch.description = data.description
|
||||||
|
if (data.passingScore !== undefined) patch.passScore = Number(data.passingScore) || 0
|
||||||
|
if (data.passScore !== undefined) patch.passScore = Number(data.passScore) || 0
|
||||||
|
if (data.isActive !== undefined) patch.isActive = !!data.isActive
|
||||||
|
if (data.sessionId !== undefined) {
|
||||||
const session = adminSessions.find((s) => s.id === Number(data.sessionId))
|
const session = adminSessions.find((s) => s.id === Number(data.sessionId))
|
||||||
const template = session?.courseTemplate
|
const course = resolveCourseFromSession(session)
|
||||||
? adminCourseTemplates.find((c) => c.id === Number(session.courseTemplate.id))
|
patch.sessionId = session?.id ?? Number(data.sessionId) ?? null
|
||||||
: null
|
patch.session = sessionSnapshot(session)
|
||||||
|
patch.sessionTitle = session?.title ?? ''
|
||||||
|
patch.course = courseSnapshot(course)
|
||||||
|
patch.courseTitle = course?.title ?? ''
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
data: updateById(adminExams, params.id, {
|
success: true,
|
||||||
title: data.title,
|
message: 'Exam updated.',
|
||||||
description: data.description,
|
data: updateById(adminExams, params.id, patch),
|
||||||
session: session ? { id: session.id, title: session.title } : undefined,
|
|
||||||
sessionTitle: session?.title,
|
|
||||||
courseTemplate: template ? { id: template.id, title: template.title } : undefined,
|
|
||||||
courseTemplateTitle: template?.title,
|
|
||||||
durationMinutes: Number(data.durationMinutes) || 0,
|
|
||||||
passingScore: Number(data.passingScore) || 0,
|
|
||||||
questionsCount: data.questions?.length || 0,
|
|
||||||
randomize: !!data.randomize,
|
|
||||||
endDate: data.endDate,
|
|
||||||
questions: data.questions || [],
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
register('DELETE', endpoints.deleteExam, ({ params }) => {
|
register('DELETE', endpoints.deleteExam, ({ params }) => {
|
||||||
removeById(adminExams, params.id)
|
removeById(adminExams, params.id)
|
||||||
return { data: { message: 'حذف موفق' } }
|
return { success: true, message: 'Exam deleted.', data: null }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /exams/:examId/questions — atomic question + options create.
|
||||||
|
register('POST', endpoints.addExamQuestion, ({ params, data }) => {
|
||||||
|
const exam = findOrThrow(adminExams, params.examId)
|
||||||
|
const questionId = makeId()
|
||||||
|
const options = Array.isArray(data.options) ? data.options : []
|
||||||
|
const question = {
|
||||||
|
id: questionId,
|
||||||
|
questionText: data.questionText || '',
|
||||||
|
position: data.position ?? (exam.questions?.length ?? 0) + 1,
|
||||||
|
options: options.map((opt, idx) => ({
|
||||||
|
id: questionId * 100 + idx + 1,
|
||||||
|
optionText: opt.optionText || '',
|
||||||
|
isCorrect: !!opt.isCorrect,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
exam.questions = [...(exam.questions || []), question]
|
||||||
|
exam.questionsCount = exam.questions.length
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Question added.',
|
||||||
|
data: question,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /questions/:questionId/options — single option append.
|
||||||
|
register('POST', endpoints.addQuestionOption, ({ params, data }) => {
|
||||||
|
for (const exam of adminExams) {
|
||||||
|
const question = (exam.questions || []).find((q) => String(q.id) === String(params.questionId))
|
||||||
|
if (question) {
|
||||||
|
const option = {
|
||||||
|
id: makeId(),
|
||||||
|
optionText: data.optionText || '',
|
||||||
|
isCorrect: !!data.isCorrect,
|
||||||
|
}
|
||||||
|
question.options = [...(question.options || []), option]
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Option added.',
|
||||||
|
data: option,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, message: 'Question not found.', data: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
// FE-mock-only.
|
||||||
register('GET', endpoints.getExamParticipants, ({ params, query }) => {
|
register('GET', endpoints.getExamParticipants, ({ params, query }) => {
|
||||||
const list = examParticipants.get(Number(params.examId)) || []
|
const list = examParticipants.get(Number(params.examId)) || []
|
||||||
return paginate(list, query)
|
return paginate(list, query)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SESSION_TYPE } from '@/enums'
|
import { SESSION_TYPE } from '@/enums'
|
||||||
import { register } from '@/services/mock/registry'
|
import { register } from '@/services/mock/registry'
|
||||||
import { endpoints } from '@/services/api/endpoints'
|
import { endpoints } from '@/services/api/endpoints'
|
||||||
import { adminCourseTemplates, adminOfferedCourses } from '@/services/mock/fixtures/admin-courses'
|
import { adminCourses } from '@/services/mock/fixtures/admin-courses'
|
||||||
import {
|
import {
|
||||||
adminSessions,
|
adminSessions,
|
||||||
makeSession,
|
makeSession,
|
||||||
@@ -23,25 +23,10 @@ const enrich = (session) => ({
|
|||||||
sessionTypeFa: SESSION_TYPE[session.sessionType] || session.sessionTypeFa || '',
|
sessionTypeFa: SESSION_TYPE[session.sessionType] || session.sessionTypeFa || '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const courseSnapshot = (offered) =>
|
|
||||||
offered
|
|
||||||
? {
|
|
||||||
id: offered.id,
|
|
||||||
termId: offered.termId,
|
|
||||||
teacherId: offered.teacherId,
|
|
||||||
title: offered.title,
|
|
||||||
description: offered.description,
|
|
||||||
capacity: offered.capacity,
|
|
||||||
isActive: offered.isActive,
|
|
||||||
coverUrl: offered.coverUrl,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
|
|
||||||
register('GET', endpoints.getSessionsList, ({ query }) => {
|
register('GET', endpoints.getSessionsList, ({ query }) => {
|
||||||
let list = filterItems(adminSessions, query, {
|
let list = filterItems(adminSessions, query, {
|
||||||
title: 'includes',
|
title: 'includes',
|
||||||
courseTemplateId: (item, v) => String(item.courseTemplate?.id) === String(v),
|
courseId: (item, v) => String(item.course?.id ?? item.courseId) === String(v),
|
||||||
courseId: 'eq',
|
|
||||||
sessionType: 'eq',
|
sessionType: 'eq',
|
||||||
type: 'eq',
|
type: 'eq',
|
||||||
})
|
})
|
||||||
@@ -57,35 +42,41 @@ register('GET', endpoints.getSessionsList, ({ query }) => {
|
|||||||
|
|
||||||
register('GET', endpoints.showSession, ({ params }) => {
|
register('GET', endpoints.showSession, ({ params }) => {
|
||||||
const session = enrich(findOrThrow(adminSessions, params.id))
|
const session = enrich(findOrThrow(adminSessions, params.id))
|
||||||
const offered = adminOfferedCourses.find((c) => c.id === session.courseId)
|
const course = adminCourses.find((c) => c.id === session.courseId)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'OK',
|
message: 'OK',
|
||||||
data: { ...session, course: courseSnapshot(offered) },
|
data: { ...session, course: course ? { id: course.id, title: course.title } : session.course },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Form sends a flat payload (startTime, endTime, meetingLink, contentType, etc.).
|
||||||
|
// We mirror those into the legacy `sessionConfig` shape too so SessionDetailsModal
|
||||||
|
// (which reads `sessionConfig.startTime` / `sessionConfig.meetingLink`) keeps working.
|
||||||
|
const buildSessionConfig = (data) => ({
|
||||||
|
startTime: data.startTime ?? data.sessionConfig?.startTime ?? null,
|
||||||
|
endTime: data.endTime ?? data.sessionConfig?.endTime ?? null,
|
||||||
|
meetingLink: data.meetingLink ?? data.sessionConfig?.meetingLink ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
register('POST', endpoints.addNewSession, ({ data }) => {
|
register('POST', endpoints.addNewSession, ({ data }) => {
|
||||||
const tpl = adminCourseTemplates.find((c) => c.id === Number(data.courseTemplateId))
|
const course = adminCourses.find((c) => c.id === Number(data.courseId))
|
||||||
const offered =
|
const sessionConfig = buildSessionConfig(data)
|
||||||
adminOfferedCourses.find((c) => c.id === Number(data.courseId)) ||
|
|
||||||
(tpl ? adminOfferedCourses.find((c) => c.templateId === tpl.id) : null)
|
|
||||||
const item = makeSession({
|
const item = makeSession({
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
courseId: data.courseId ?? offered?.id ?? null,
|
courseId: course?.id ?? (data.courseId ? Number(data.courseId) : null),
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
type: data.type,
|
type: data.sessionType === 'online' ? 'online' : 'offline',
|
||||||
startsAt: data.startsAt ?? data.sessionConfig?.startTime ?? null,
|
startsAt: sessionConfig.startTime,
|
||||||
location: data.location ?? data.sessionConfig?.location ?? null,
|
endsAt: sessionConfig.endTime,
|
||||||
link: data.link ?? data.sessionConfig?.meetingLink ?? null,
|
link: sessionConfig.meetingLink,
|
||||||
image: data.imageId ? `https://picsum.photos/seed/session-${data.imageId}/200/200` : '',
|
course: course ? { id: course.id, title: course.title } : null,
|
||||||
courseTemplate: tpl ? { id: tpl.id, title: tpl.title } : null,
|
|
||||||
sessionType: data.sessionType || 'in_person',
|
sessionType: data.sessionType || 'in_person',
|
||||||
|
contentType: data.contentType || '',
|
||||||
|
contentMediaId: data.contentMediaId ?? null,
|
||||||
durationMinutes: Number(data.durationMinutes) || 0,
|
durationMinutes: Number(data.durationMinutes) || 0,
|
||||||
order: Number(data.order) || 1,
|
sessionConfig,
|
||||||
sessionConfig: data.sessionConfig || {},
|
|
||||||
materials: data.materials || [],
|
|
||||||
createdAt: isoNow(),
|
createdAt: isoNow(),
|
||||||
})
|
})
|
||||||
adminSessions.unshift(item)
|
adminSessions.unshift(item)
|
||||||
@@ -100,35 +91,34 @@ register('PATCH', endpoints.updateSession, ({ params, data }) => {
|
|||||||
const patch = {}
|
const patch = {}
|
||||||
if (data.title !== undefined) patch.title = data.title
|
if (data.title !== undefined) patch.title = data.title
|
||||||
if (data.description !== undefined) patch.description = data.description
|
if (data.description !== undefined) patch.description = data.description
|
||||||
if (data.type !== undefined) patch.type = data.type
|
if (data.startTime !== undefined) patch.startsAt = data.startTime
|
||||||
if (data.startsAt !== undefined) patch.startsAt = data.startsAt
|
if (data.endTime !== undefined) patch.endsAt = data.endTime
|
||||||
if (data.location !== undefined) patch.location = data.location
|
if (data.meetingLink !== undefined) patch.link = data.meetingLink
|
||||||
if (data.link !== undefined) patch.link = data.link
|
if (data.courseId !== undefined) {
|
||||||
if (data.courseId !== undefined) patch.courseId = Number(data.courseId) || null
|
patch.courseId = Number(data.courseId) || null
|
||||||
if (data.courseTemplateId !== undefined) {
|
const course = adminCourses.find((c) => c.id === Number(data.courseId))
|
||||||
const tpl = adminCourseTemplates.find((c) => c.id === Number(data.courseTemplateId))
|
patch.course = course ? { id: course.id, title: course.title } : null
|
||||||
if (tpl) patch.courseTemplate = { id: tpl.id, title: tpl.title }
|
|
||||||
}
|
}
|
||||||
if (data.sessionType !== undefined) patch.sessionType = data.sessionType
|
if (data.sessionType !== undefined) {
|
||||||
|
patch.sessionType = data.sessionType
|
||||||
|
patch.type = data.sessionType === 'online' ? 'online' : 'offline'
|
||||||
|
}
|
||||||
|
if (data.contentType !== undefined) patch.contentType = data.contentType
|
||||||
|
if (data.contentMediaId !== undefined) patch.contentMediaId = data.contentMediaId
|
||||||
if (data.durationMinutes !== undefined) patch.durationMinutes = Number(data.durationMinutes) || 0
|
if (data.durationMinutes !== undefined) patch.durationMinutes = Number(data.durationMinutes) || 0
|
||||||
if (data.order !== undefined) patch.order = Number(data.order) || 1
|
// Keep sessionConfig in sync for SessionDetailsModal display fallback.
|
||||||
if (data.sessionConfig !== undefined) {
|
if (
|
||||||
patch.sessionConfig = data.sessionConfig
|
data.startTime !== undefined ||
|
||||||
if (data.startsAt === undefined && data.sessionConfig.startTime !== undefined) {
|
data.endTime !== undefined ||
|
||||||
patch.startsAt = data.sessionConfig.startTime
|
data.meetingLink !== undefined
|
||||||
|
) {
|
||||||
|
const existing = findOrThrow(adminSessions, params.id)?.sessionConfig || {}
|
||||||
|
patch.sessionConfig = {
|
||||||
|
...existing,
|
||||||
|
startTime: data.startTime ?? existing.startTime,
|
||||||
|
endTime: data.endTime ?? existing.endTime,
|
||||||
|
meetingLink: data.meetingLink ?? existing.meetingLink,
|
||||||
}
|
}
|
||||||
if (data.location === undefined && data.sessionConfig.location !== undefined) {
|
|
||||||
patch.location = data.sessionConfig.location
|
|
||||||
}
|
|
||||||
if (data.link === undefined && data.sessionConfig.meetingLink !== undefined) {
|
|
||||||
patch.link = data.sessionConfig.meetingLink
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.materials !== undefined) patch.materials = data.materials
|
|
||||||
if (data.imageId !== undefined) {
|
|
||||||
patch.image = data.imageId
|
|
||||||
? `https://picsum.photos/seed/session-${data.imageId}/200/200`
|
|
||||||
: ''
|
|
||||||
}
|
}
|
||||||
const updated = updateById(adminSessions, params.id, patch)
|
const updated = updateById(adminSessions, params.id, patch)
|
||||||
return {
|
return {
|
||||||
@@ -147,11 +137,6 @@ register('DELETE', endpoints.deleteSession, ({ params }) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
register('POST', endpoints.changeStatusSession, ({ params, data }) => {
|
|
||||||
const updated = updateById(adminSessions, params.id, { isActive: !!data.isActive })
|
|
||||||
return { data: enrich(updated) }
|
|
||||||
})
|
|
||||||
|
|
||||||
register('GET', endpoints.getSessionsAttendance, ({ params, query }) => {
|
register('GET', endpoints.getSessionsAttendance, ({ params, query }) => {
|
||||||
const list = sessionAttendance.get(Number(params.sessionId)) || []
|
const list = sessionAttendance.get(Number(params.sessionId)) || []
|
||||||
return paginate(list, query)
|
return paginate(list, query)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { register } from '@/services/mock/registry'
|
import { register } from '@/services/mock/registry'
|
||||||
import { endpoints } from '@/services/api/endpoints'
|
import { endpoints } from '@/services/api/endpoints'
|
||||||
import { adminUsers } from '@/services/mock/fixtures/admin-users'
|
import { adminUsers } from '@/services/mock/fixtures/admin-users'
|
||||||
import { adminOfferedCourses } from '@/services/mock/fixtures/admin-courses'
|
import { adminCourses } from '@/services/mock/fixtures/admin-courses'
|
||||||
import { adminTerms, makeTerm, termStudentLinks } from '@/services/mock/fixtures/admin-terms'
|
import { adminTerms, makeTerm, termStudentLinks } from '@/services/mock/fixtures/admin-terms'
|
||||||
import {
|
import {
|
||||||
filterDateRange,
|
filterDateRange,
|
||||||
@@ -106,10 +106,6 @@ register('POST', endpoints.cloneTerm, ({ params }) => {
|
|||||||
return { data: clone }
|
return { data: clone }
|
||||||
})
|
})
|
||||||
|
|
||||||
register('POST', endpoints.changeStatusTerm, ({ params, data }) => ({
|
|
||||||
data: updateById(adminTerms, params.id, { isActive: !!data.isActive }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const studentSnapshot = (link) => {
|
const studentSnapshot = (link) => {
|
||||||
const user = adminUsers.find((u) => u.id === link.userId)
|
const user = adminUsers.find((u) => u.id === link.userId)
|
||||||
return user ? { ...user, isOnLeave: link.isOnLeave } : null
|
return user ? { ...user, isOnLeave: link.isOnLeave } : null
|
||||||
@@ -161,7 +157,7 @@ register('POST', endpoints.changeLeaveStatus, ({ params, data }) => {
|
|||||||
|
|
||||||
register('GET', endpoints.listCourseTerm, ({ params, query }) => {
|
register('GET', endpoints.listCourseTerm, ({ params, query }) => {
|
||||||
const termId = Number(params.termId)
|
const termId = Number(params.termId)
|
||||||
const list = adminOfferedCourses.filter((c) => Number(c.termId) === termId)
|
const list = adminCourses.filter((c) => Number(c.termId) === termId)
|
||||||
return paginate(list, query)
|
return paginate(list, query)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,7 +166,7 @@ register('POST', endpoints.addCourseTerm, ({ params, data }) => {
|
|||||||
const ids = Array.isArray(data.courseIds) ? data.courseIds : []
|
const ids = Array.isArray(data.courseIds) ? data.courseIds : []
|
||||||
const linked = []
|
const linked = []
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
const course = adminOfferedCourses.find((c) => Number(c.id) === Number(id))
|
const course = adminCourses.find((c) => Number(c.id) === Number(id))
|
||||||
if (course) {
|
if (course) {
|
||||||
course.termId = termId
|
course.termId = termId
|
||||||
course.term = adminTerms.some((t) => t.id === termId)
|
course.term = adminTerms.some((t) => t.id === termId)
|
||||||
@@ -184,7 +180,7 @@ register('POST', endpoints.addCourseTerm, ({ params, data }) => {
|
|||||||
|
|
||||||
register('DELETE', endpoints.removeCourseTerm, ({ params }) => {
|
register('DELETE', endpoints.removeCourseTerm, ({ params }) => {
|
||||||
const courseId = Number(params.courseId)
|
const courseId = Number(params.courseId)
|
||||||
const course = adminOfferedCourses.find((c) => c.id === courseId)
|
const course = adminCourses.find((c) => c.id === courseId)
|
||||||
if (course) {
|
if (course) {
|
||||||
course.termId = null
|
course.termId = null
|
||||||
course.term = null
|
course.term = null
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { register } from '@/services/mock/registry'
|
import { register } from '@/services/mock/registry'
|
||||||
import { endpoints } from '@/services/api/endpoints'
|
import { endpoints } from '@/services/api/endpoints'
|
||||||
import { adminTickets } from '@/services/mock/fixtures/admin-tickets'
|
import { adminTickets, currentAdminUser } from '@/services/mock/fixtures/admin-tickets'
|
||||||
import {
|
import {
|
||||||
filterDateRange,
|
filterDateRange,
|
||||||
filterItems,
|
filterItems,
|
||||||
@@ -14,16 +14,28 @@ import {
|
|||||||
register('GET', endpoints.getTicketsList, ({ query }) => {
|
register('GET', endpoints.getTicketsList, ({ query }) => {
|
||||||
let list = filterItems(adminTickets, query, {
|
let list = filterItems(adminTickets, query, {
|
||||||
status: 'eq',
|
status: 'eq',
|
||||||
|
subject: (item, v) =>
|
||||||
|
String(item.subject || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(String(v).toLowerCase()),
|
||||||
userName: (item, v) =>
|
userName: (item, v) =>
|
||||||
`${item.user?.firstName || ''} ${item.user?.lastName || ''}`
|
String(item.student?.name || '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(String(v).toLowerCase()),
|
.includes(String(v).toLowerCase()),
|
||||||
})
|
})
|
||||||
list = filterDateRange(list, query)
|
list = filterDateRange(list, query)
|
||||||
return paginate(list, query)
|
const { data: items, meta } = paginate(list, query)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'OK',
|
||||||
|
data: items,
|
||||||
|
meta,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
register('GET', endpoints.showTicket, ({ params }) => ({
|
register('GET', endpoints.showTicket, ({ params }) => ({
|
||||||
|
success: true,
|
||||||
|
message: 'OK',
|
||||||
data: findOrThrow(adminTickets, params.id),
|
data: findOrThrow(adminTickets, params.id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -31,25 +43,25 @@ register('POST', endpoints.sendTicketMessage, ({ params, data }) => {
|
|||||||
const ticket = findOrThrow(adminTickets, params.id)
|
const ticket = findOrThrow(adminTickets, params.id)
|
||||||
const message = {
|
const message = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
sender: 'admin',
|
ticketId: ticket.id,
|
||||||
text: data.text || '',
|
senderId: currentAdminUser.id,
|
||||||
time: 'همین الان',
|
message: data.message || '',
|
||||||
sentAt: isoNow(),
|
createdAt: isoNow(),
|
||||||
|
sender: currentAdminUser,
|
||||||
}
|
}
|
||||||
ticket.messages = [...(ticket.messages || []), message]
|
ticket.messages = [...(ticket.messages || []), message]
|
||||||
ticket.status = 'answered'
|
ticket.status = 'answered'
|
||||||
ticket.statusLabel = 'پاسخ داده شده'
|
ticket.assigneeId = currentAdminUser.id
|
||||||
return { data: ticket }
|
ticket.assignee = currentAdminUser
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Message posted.',
|
||||||
|
data: message,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
register('POST', endpoints.changeTicketStatus, ({ params, data }) => ({
|
register('PATCH', endpoints.changeTicketStatus, ({ params, data }) => ({
|
||||||
data: updateById(adminTickets, params.id, {
|
success: true,
|
||||||
status: data.status,
|
message: 'Ticket status updated.',
|
||||||
statusLabel:
|
data: updateById(adminTickets, params.id, { status: data.status }),
|
||||||
data.status === 'closed'
|
|
||||||
? 'بسته شده'
|
|
||||||
: data.status === 'answered'
|
|
||||||
? 'پاسخ داده شده'
|
|
||||||
: 'در انتظار پاسخ',
|
|
||||||
}),
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import { cleanFilters } from '@/utils/clean-filters'
|
|
||||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
|
||||||
import {
|
|
||||||
apiAddAdminCourseTemplate,
|
|
||||||
apiAddAdminTemplateStudent,
|
|
||||||
apiAttachAdminTemplateSession,
|
|
||||||
apiChangeAdminCourseTemplateStatus,
|
|
||||||
apiDeleteAdminCourseTemplate,
|
|
||||||
apiDetachAdminTemplateSession,
|
|
||||||
apiGetAdminCourseTemplates,
|
|
||||||
apiGetAdminTemplateSessions,
|
|
||||||
apiGetAdminTemplateStudents,
|
|
||||||
apiRemoveAdminTemplateStudent,
|
|
||||||
apiShowAdminCourseTemplate,
|
|
||||||
apiUpdateAdminCourseTemplate,
|
|
||||||
} from '@/services/api/admin-course-templates'
|
|
||||||
|
|
||||||
export const adminCourseTemplatesKeys = {
|
|
||||||
all: ['admin', 'course-templates'],
|
|
||||||
list: (filters, pagination) => ['admin', 'course-templates', 'list', filters, pagination],
|
|
||||||
detail: (id) => ['admin', 'course-templates', 'detail', id],
|
|
||||||
students: (templateId, filters, pagination) => [
|
|
||||||
'admin',
|
|
||||||
'course-templates',
|
|
||||||
'students',
|
|
||||||
templateId,
|
|
||||||
filters,
|
|
||||||
pagination,
|
|
||||||
],
|
|
||||||
sessions: (templateId, filters, pagination) => [
|
|
||||||
'admin',
|
|
||||||
'course-templates',
|
|
||||||
'sessions',
|
|
||||||
templateId,
|
|
||||||
filters,
|
|
||||||
pagination,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAdminCourseTemplatesListQuery = (filtersRef, paginationRef, options = {}) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ['admin', 'course-templates', 'list', filtersRef, paginationRef],
|
|
||||||
queryFn: () =>
|
|
||||||
apiGetAdminCourseTemplates({
|
|
||||||
...cleanFilters(filtersRef.value),
|
|
||||||
...paginationRef.value,
|
|
||||||
}),
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useAdminCourseTemplateQuery = (idRef, options = {}) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ['admin', 'course-templates', 'detail', idRef],
|
|
||||||
queryFn: () => apiShowAdminCourseTemplate(idRef.value),
|
|
||||||
select: (response) => response?.data ?? response,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useAddAdminCourseTemplateMutation = () =>
|
|
||||||
useMutation({ mutationFn: (payload) => apiAddAdminCourseTemplate(payload) })
|
|
||||||
|
|
||||||
export const useUpdateAdminCourseTemplateMutation = () =>
|
|
||||||
useMutation({
|
|
||||||
mutationFn: ({ id, payload }) => apiUpdateAdminCourseTemplate(id, payload),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useDeleteAdminCourseTemplateMutation = () =>
|
|
||||||
useMutation({ mutationFn: (id) => apiDeleteAdminCourseTemplate(id) })
|
|
||||||
|
|
||||||
export const useChangeAdminCourseTemplateStatusMutation = () =>
|
|
||||||
useMutation({
|
|
||||||
mutationFn: ({ id, payload }) => apiChangeAdminCourseTemplateStatus(id, payload),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useAdminTemplateStudentsQuery = (
|
|
||||||
templateIdRef,
|
|
||||||
filtersRef,
|
|
||||||
paginationRef,
|
|
||||||
options = {}
|
|
||||||
) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ['admin', 'course-templates', 'students', templateIdRef, filtersRef, paginationRef],
|
|
||||||
queryFn: () =>
|
|
||||||
apiGetAdminTemplateStudents(templateIdRef.value, {
|
|
||||||
...cleanFilters(filtersRef?.value || {}),
|
|
||||||
...paginationRef?.value,
|
|
||||||
}),
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useAddAdminTemplateStudentMutation = () =>
|
|
||||||
useMutation({
|
|
||||||
mutationFn: ({ templateId, payload }) => apiAddAdminTemplateStudent(templateId, payload),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useRemoveAdminTemplateStudentMutation = () =>
|
|
||||||
useMutation({
|
|
||||||
mutationFn: ({ templateId, userId }) => apiRemoveAdminTemplateStudent(templateId, userId),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useAdminTemplateSessionsQuery = (
|
|
||||||
templateIdRef,
|
|
||||||
filtersRef,
|
|
||||||
paginationRef,
|
|
||||||
options = {}
|
|
||||||
) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: ['admin', 'course-templates', 'sessions', templateIdRef, filtersRef, paginationRef],
|
|
||||||
queryFn: () =>
|
|
||||||
apiGetAdminTemplateSessions(templateIdRef.value, {
|
|
||||||
...cleanFilters(filtersRef?.value || {}),
|
|
||||||
...paginationRef?.value,
|
|
||||||
}),
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useAttachAdminTemplateSessionMutation = () =>
|
|
||||||
useMutation({
|
|
||||||
mutationFn: ({ templateId, payload }) => apiAttachAdminTemplateSession(templateId, payload),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useDetachAdminTemplateSessionMutation = () =>
|
|
||||||
useMutation({
|
|
||||||
mutationFn: ({ templateId, sessionId }) => apiDetachAdminTemplateSession(templateId, sessionId),
|
|
||||||
})
|
|
||||||
@@ -2,9 +2,14 @@ import { cleanFilters } from '@/utils/clean-filters'
|
|||||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||||
import {
|
import {
|
||||||
apiAddAdminCourse,
|
apiAddAdminCourse,
|
||||||
apiChangeAdminCourseStatus,
|
apiAddAdminCourseStudent,
|
||||||
|
apiAttachAdminCourseSession,
|
||||||
apiDeleteAdminCourse,
|
apiDeleteAdminCourse,
|
||||||
|
apiDetachAdminCourseSession,
|
||||||
|
apiGetAdminCourseSessions,
|
||||||
|
apiGetAdminCourseStudents,
|
||||||
apiGetAdminCourses,
|
apiGetAdminCourses,
|
||||||
|
apiRemoveAdminCourseStudent,
|
||||||
apiShowAdminCourse,
|
apiShowAdminCourse,
|
||||||
apiUpdateAdminCourse,
|
apiUpdateAdminCourse,
|
||||||
} from '@/services/api/admin-courses'
|
} from '@/services/api/admin-courses'
|
||||||
@@ -13,6 +18,22 @@ export const adminCoursesKeys = {
|
|||||||
all: ['admin', 'courses'],
|
all: ['admin', 'courses'],
|
||||||
list: (filters, pagination) => ['admin', 'courses', 'list', filters, pagination],
|
list: (filters, pagination) => ['admin', 'courses', 'list', filters, pagination],
|
||||||
detail: (id) => ['admin', 'courses', 'detail', id],
|
detail: (id) => ['admin', 'courses', 'detail', id],
|
||||||
|
students: (courseId, filters, pagination) => [
|
||||||
|
'admin',
|
||||||
|
'courses',
|
||||||
|
'students',
|
||||||
|
courseId,
|
||||||
|
filters,
|
||||||
|
pagination,
|
||||||
|
],
|
||||||
|
sessions: (courseId, filters, pagination) => [
|
||||||
|
'admin',
|
||||||
|
'courses',
|
||||||
|
'sessions',
|
||||||
|
courseId,
|
||||||
|
filters,
|
||||||
|
pagination,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAdminCoursesListQuery = (filtersRef, paginationRef, options = {}) =>
|
export const useAdminCoursesListQuery = (filtersRef, paginationRef, options = {}) =>
|
||||||
@@ -46,7 +67,44 @@ export const useUpdateAdminCourseMutation = () =>
|
|||||||
export const useDeleteAdminCourseMutation = () =>
|
export const useDeleteAdminCourseMutation = () =>
|
||||||
useMutation({ mutationFn: (id) => apiDeleteAdminCourse(id) })
|
useMutation({ mutationFn: (id) => apiDeleteAdminCourse(id) })
|
||||||
|
|
||||||
export const useChangeAdminCourseStatusMutation = () =>
|
export const useAdminCourseStudentsQuery = (courseIdRef, filtersRef, paginationRef, options = {}) =>
|
||||||
useMutation({
|
useQuery({
|
||||||
mutationFn: ({ id, payload }) => apiChangeAdminCourseStatus(id, payload),
|
queryKey: ['admin', 'courses', 'students', courseIdRef, filtersRef, paginationRef],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGetAdminCourseStudents(courseIdRef.value, {
|
||||||
|
...cleanFilters(filtersRef?.value || {}),
|
||||||
|
...paginationRef?.value,
|
||||||
|
}),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useAddAdminCourseStudentMutation = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: ({ courseId, payload }) => apiAddAdminCourseStudent(courseId, payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useRemoveAdminCourseStudentMutation = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: ({ courseId, userId }) => apiRemoveAdminCourseStudent(courseId, userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useAdminCourseSessionsQuery = (courseIdRef, filtersRef, paginationRef, options = {}) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['admin', 'courses', 'sessions', courseIdRef, filtersRef, paginationRef],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGetAdminCourseSessions(courseIdRef.value, {
|
||||||
|
...cleanFilters(filtersRef?.value || {}),
|
||||||
|
...paginationRef?.value,
|
||||||
|
}),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useAttachAdminCourseSessionMutation = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: ({ courseId, payload }) => apiAttachAdminCourseSession(courseId, payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useDetachAdminCourseSessionMutation = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: ({ courseId, sessionId }) => apiDetachAdminCourseSession(courseId, sessionId),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { cleanFilters } from '@/utils/clean-filters'
|
|||||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||||
import {
|
import {
|
||||||
apiAddAdminExam,
|
apiAddAdminExam,
|
||||||
|
apiAddAdminExamQuestion,
|
||||||
|
apiAddAdminQuestionOption,
|
||||||
apiDeleteAdminExam,
|
apiDeleteAdminExam,
|
||||||
apiGetAdminExams,
|
apiGetAdminExams,
|
||||||
apiGetAdminExamParticipants,
|
apiGetAdminExamParticipants,
|
||||||
@@ -36,6 +38,10 @@ export const useAdminExamsListQuery = (filtersRef, paginationRef, options = {})
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['admin', 'exams', 'list', filtersRef, paginationRef],
|
queryKey: ['admin', 'exams', 'list', filtersRef, paginationRef],
|
||||||
queryFn: () => apiGetAdminExams({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
queryFn: () => apiGetAdminExams({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||||
|
select: (response) => ({
|
||||||
|
data: response?.data?.items ?? response?.data ?? [],
|
||||||
|
meta: response?.data?.meta ?? response?.meta,
|
||||||
|
}),
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,3 +80,11 @@ export const useUpdateAdminExamMutation = () =>
|
|||||||
|
|
||||||
export const useDeleteAdminExamMutation = () =>
|
export const useDeleteAdminExamMutation = () =>
|
||||||
useMutation({ mutationFn: (id) => apiDeleteAdminExam(id) })
|
useMutation({ mutationFn: (id) => apiDeleteAdminExam(id) })
|
||||||
|
|
||||||
|
export const useAddAdminExamQuestionMutation = () =>
|
||||||
|
useMutation({ mutationFn: ({ examId, payload }) => apiAddAdminExamQuestion(examId, payload) })
|
||||||
|
|
||||||
|
export const useAddAdminQuestionOptionMutation = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: ({ questionId, payload }) => apiAddAdminQuestionOption(questionId, payload),
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { cleanFilters } from '@/utils/clean-filters'
|
|||||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||||
import {
|
import {
|
||||||
apiAddAdminSession,
|
apiAddAdminSession,
|
||||||
apiChangeAdminSessionStatus,
|
|
||||||
apiDeleteAdminSession,
|
apiDeleteAdminSession,
|
||||||
apiGetAdminSessions,
|
apiGetAdminSessions,
|
||||||
apiGetSessionAttendance,
|
apiGetSessionAttendance,
|
||||||
@@ -70,8 +69,3 @@ export const useUpdateAdminSessionMutation = () =>
|
|||||||
|
|
||||||
export const useDeleteAdminSessionMutation = () =>
|
export const useDeleteAdminSessionMutation = () =>
|
||||||
useMutation({ mutationFn: (id) => apiDeleteAdminSession(id) })
|
useMutation({ mutationFn: (id) => apiDeleteAdminSession(id) })
|
||||||
|
|
||||||
export const useChangeAdminSessionStatusMutation = () =>
|
|
||||||
useMutation({
|
|
||||||
mutationFn: ({ id, payload }) => apiChangeAdminSessionStatus(id, payload),
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
apiAddAdminTerm,
|
apiAddAdminTerm,
|
||||||
apiAddAdminTermCourses,
|
apiAddAdminTermCourses,
|
||||||
apiAddAdminTermStudents,
|
apiAddAdminTermStudents,
|
||||||
apiChangeAdminTermStatus,
|
|
||||||
apiCloneAdminTerm,
|
apiCloneAdminTerm,
|
||||||
apiDeleteAdminTerm,
|
apiDeleteAdminTerm,
|
||||||
apiGetAdminTerms,
|
apiGetAdminTerms,
|
||||||
@@ -70,9 +69,6 @@ export const useDeleteAdminTermMutation = () =>
|
|||||||
export const useCloneAdminTermMutation = () =>
|
export const useCloneAdminTermMutation = () =>
|
||||||
useMutation({ mutationFn: (id) => apiCloneAdminTerm(id) })
|
useMutation({ mutationFn: (id) => apiCloneAdminTerm(id) })
|
||||||
|
|
||||||
export const useChangeAdminTermStatusMutation = () =>
|
|
||||||
useMutation({ mutationFn: ({ id, payload }) => apiChangeAdminTermStatus(id, payload) })
|
|
||||||
|
|
||||||
export const useAdminTermStudentsQuery = (termIdRef, filtersRef, paginationRef, options = {}) =>
|
export const useAdminTermStudentsQuery = (termIdRef, filtersRef, paginationRef, options = {}) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['admin', 'terms', 'students', termIdRef, filtersRef, paginationRef],
|
queryKey: ['admin', 'terms', 'students', termIdRef, filtersRef, paginationRef],
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const useAdminTicketsListQuery = (filtersRef, paginationRef, options = {}
|
|||||||
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
queryKey: ['admin', 'tickets', 'list', filtersRef, paginationRef],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
apiGetAdminTickets({ ...cleanFilters(filtersRef.value), ...paginationRef.value }),
|
||||||
|
select: (response) => ({
|
||||||
|
data: response?.data ?? [],
|
||||||
|
meta: response?.meta,
|
||||||
|
}),
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user