diff --git a/src/features/admin/exams/components/modals/ExamParticipantDetailsModal.vue b/src/features/admin/exams/components/modals/ExamParticipantDetailsModal.vue index 8a22c6e..745a86a 100644 --- a/src/features/admin/exams/components/modals/ExamParticipantDetailsModal.vue +++ b/src/features/admin/exams/components/modals/ExamParticipantDetailsModal.vue @@ -109,7 +109,7 @@ import BaseButton from '@/components/BaseButton.vue' import SvgIcon from '@/components/icons/SvgIcon.vue' import NoItems from '@/components/blocks/NoItems.vue' import { formatJalaaliDate } from '@/utils/date-utils' -import { useAdminExamParticipantQuery } from '@/services/query/admin-exams' +import { useAdminExamAttemptQuery } from '@/services/query/admin-exams' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' defineOptions({ name: 'ExamParticipantDetailsModal' }) @@ -125,16 +125,18 @@ const TONE_MAP = { const { getModal } = useModal() const modalData = computed(() => getModal('ExamParticipantDetailsModal')?.data ?? {}) -const examId = computed(() => modalData.value.examId ?? null) -const participantId = computed(() => modalData.value.participantId ?? null) +const attemptId = computed(() => modalData.value.attemptId ?? null) const participant = computed(() => modalData.value.participant ?? null) -const { data: detail, isLoading } = useAdminExamParticipantQuery(examId, participantId, { - enabled: () => !!examId.value && !!participantId.value, +const { data: detail, isLoading } = useAdminExamAttemptQuery(attemptId, { + enabled: () => !!attemptId.value, }) -const rawAttempts = computed( - () => detail.value?.attempts || detail.value?.data?.attempts || participant.value?.attempts || [] +// The review endpoint returns one attempt; the template renders an accordion +// list, so wrap it as a single-element list (falling back to any attempts that +// were passed through on the modal data). +const rawAttempts = computed(() => + detail.value ? [detail.value] : participant.value?.attempts || [] ) const attempts = computed(() => diff --git a/src/features/admin/exams/components/modals/ExamParticipantsModal.vue b/src/features/admin/exams/components/modals/ExamParticipantsModal.vue index f3f5adc..6ed58f2 100644 --- a/src/features/admin/exams/components/modals/ExamParticipantsModal.vue +++ b/src/features/admin/exams/components/modals/ExamParticipantsModal.vue @@ -130,6 +130,7 @@ const onShowDetails = (attempt) => { const u = userOf(attempt) openModal('ExamParticipantDetailsModal', { examId: examId.value, + attemptId: attempt.id, participantId: u.id, participant: { id: u.id, diff --git a/src/features/student/exams/pages/StudentExamPage.vue b/src/features/student/exams/pages/StudentExamPage.vue index 07251a9..c974725 100644 --- a/src/features/student/exams/pages/StudentExamPage.vue +++ b/src/features/student/exams/pages/StudentExamPage.vue @@ -11,7 +11,7 @@ - + diff --git a/src/features/student/sessions/pages/StudentSessionDetailsPage.vue b/src/features/student/sessions/pages/StudentSessionDetailsPage.vue index 88eb5f9..5594706 100644 --- a/src/features/student/sessions/pages/StudentSessionDetailsPage.vue +++ b/src/features/student/sessions/pages/StudentSessionDetailsPage.vue @@ -15,7 +15,9 @@

- {{ session?.homeworkPrompt || 'متنی برای این جلسه ثبت نشده است.' }} + {{ + session?.homeworkPrompt || homework?.description || 'متنی برای این جلسه ثبت نشده است.' + }}

@@ -69,7 +71,7 @@

- {{ session?.homeworkHint || '' }} + {{ session?.homeworkHint || homework?.title || '' }}

@@ -103,6 +105,7 @@ import ImageUploader from '@/components/form/ImageUploader.vue' import VideoPlayerBlock from '@/components/blocks/VideoPlayerBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' +import { useStudentHomeworksListQuery } from '@/services/query/student-homeworks' import { useStudentSessionQuery, useSubmitStudentHomeworkMutation, @@ -116,6 +119,15 @@ const { data: session, isLoading } = useStudentSessionQuery(sessionId, { enabled: () => !!sessionId.value, }) +// The homework for this session comes from the dedicated endpoint (the session +// payload itself doesn't embed homeworks). +const homeworkFilters = computed(() => ({ sessionId: sessionId.value })) +const homeworkPagination = ref({ page: 1, perPage: 20 }) +const { data: homeworksData } = useStudentHomeworksListQuery(homeworkFilters, homeworkPagination, { + enabled: () => !!sessionId.value, +}) +const homework = computed(() => (homeworksData.value?.data ?? [])[0] ?? null) + const contentKind = computed(() => { const s = session.value if (!s) return '' @@ -135,7 +147,9 @@ const canSubmit = computed(() => !!(homeworkAudio.value || homeworkImage.value)) const submitMutation = useSubmitStudentHomeworkMutation() const uploadMediaMutation = useUploadMediaMutation() -const homeworkId = computed(() => session.value?.homeworkId ?? session.value?.homework?.id ?? null) +const homeworkId = computed( + () => homework.value?.id ?? session.value?.homeworkId ?? session.value?.homework?.id ?? null +) const uploadHomeworkFile = async (file) => { const fd = objectToFormData({ file, purpose: 'homework_file', context: 'homework' }) diff --git a/src/services/api/admin-exams.js b/src/services/api/admin-exams.js index 901c837..e999622 100644 --- a/src/services/api/admin-exams.js +++ b/src/services/api/admin-exams.js @@ -29,3 +29,8 @@ export const apiGetAdminExamAttempts = (examId, params) => export const apiShowAdminExamParticipant = (examId, participantId) => http.get(buildUrl(endpoints.showExamParticipant, { examId, participantId })) + +// GET /exam-attempts/:id — full per-attempt review: scoring, the user, the +// term/course/session context, and every question with all options carrying +// isCorrect (answer key) and isSelected (what this user picked). +export const apiShowAdminExamAttempt = (id) => http.get(buildUrl(endpoints.showExamAttempt, { id })) diff --git a/src/services/api/endpoints.js b/src/services/api/endpoints.js index 79c71ba..dc4bbef 100644 --- a/src/services/api/endpoints.js +++ b/src/services/api/endpoints.js @@ -30,6 +30,10 @@ export const endpoints = { // dedicated student route (returns the membership row with the nested term). getStudentTerms: '/student/my-terms', showStudentTerm: '/student/my-terms/:id', + getStudentExamResults: '/student/exam-results', + getStudentHomeworks: '/student/homeworks', + showHomework: '/homeworks/:id', + startExam: '/exams/:examId/start', getMissionaryProfile: '/student/missionary/profile', getMissionaryRequests: '/student/missionary/requests', @@ -127,6 +131,7 @@ export const endpoints = { // attempting user embedded; multiple rows per user are possible. The // single-attempt detail endpoint is still mock-only. getExamAttempts: '/exams/:examId/attempts', + showExamAttempt: '/exam-attempts/:id', showExamParticipant: '/admin/exams/:examId/participants/:participantId', // Homeworks — list, show, submissions list, show submission diff --git a/src/services/api/student-exams.js b/src/services/api/student-exams.js index 1991c71..0089624 100644 --- a/src/services/api/student-exams.js +++ b/src/services/api/student-exams.js @@ -5,5 +5,13 @@ export const apiGetStudentExams = (params) => http.get(endpoints.getStudentExams export const apiShowStudentExam = (id) => http.get(buildUrl(endpoints.showExam, { id })) +// POST /exams/:examId/start — begins (or resumes) an attempt; returns the +// attempt with startedAt + deadlineAt. Idempotent on the backend. +export const apiStartStudentExam = (id) => http.post(buildUrl(endpoints.startExam, { examId: id })) + export const apiSubmitStudentExamAttempt = (id, payload) => http.post(buildUrl(endpoints.submitExam, { examId: id }), payload) + +// GET /student/exam-results — paginated attempts across the student's exams. +export const apiGetStudentExamResults = (params) => + http.get(endpoints.getStudentExamResults, { params }) diff --git a/src/services/api/student-homeworks.js b/src/services/api/student-homeworks.js new file mode 100644 index 0000000..c0b9365 --- /dev/null +++ b/src/services/api/student-homeworks.js @@ -0,0 +1,9 @@ +import { http } from '@/services/api/http' +import { buildUrl, endpoints } from '@/services/api/endpoints' + +// GET /student/homeworks — active homeworks for sessions in the student's +// enrolled terms; filterable by term_id / course_id / session_id. +export const apiGetStudentHomeworks = (params) => + http.get(endpoints.getStudentHomeworks, { params }) + +export const apiShowStudentHomework = (id) => http.get(buildUrl(endpoints.showHomework, { id })) diff --git a/src/services/mock/fixtures/admin-exams.js b/src/services/mock/fixtures/admin-exams.js index a63330b..1f99a57 100644 --- a/src/services/mock/fixtures/admin-exams.js +++ b/src/services/mock/fixtures/admin-exams.js @@ -4,10 +4,11 @@ // options: [{ id, optionText, isCorrect }] }] } // (Backend hides `is_correct` from non-admin reads; the admin mock shows it.) -const buildQuestion = (id, questionText, position, options, correctIndex) => ({ +const buildQuestion = (id, questionText, position, options, correctIndex, score = 10) => ({ id, questionText, position, + score, options: options.map((optionText, idx) => ({ id: id * 100 + idx + 1, optionText, @@ -49,7 +50,10 @@ export const adminExams = [ sessionId: 101, title: 'آزمون پایان فصل اول اخلاق', description: 'آزمون چهار گزینه‌ای از مفاهیم درس‌های ۱ تا ۳.', - passScore: 12, + score: 20, + minimumScore: 12, + durationMinutes: 30, + isRandom: false, isActive: true, questions: ethicsQuestions, // ── UI-only fields for ExamItem / ExamDetailsModal (not in backend). ── @@ -65,7 +69,10 @@ export const adminExams = [ sessionId: 102, title: 'آزمون مفاهیم قرآنی - میان‌ترم', description: 'آزمون میان‌ترم برای مرور آیات کلیدی.', - passScore: 14, + score: 20, + minimumScore: 14, + durationMinutes: 20, + isRandom: false, isActive: true, questions: quranQuestions, course: { id: 2, title: 'مفاهیم قرآنی' }, @@ -86,8 +93,10 @@ export const examAttempts = new Map([ { id: 14, examId: 201, - score: 85, + score: 20, isPassed: true, + correctCount: 2, + totalQuestions: 2, startedAt: '2026-04-12T15:00:00+03:30', submittedAt: '2026-04-12T15:30:00+03:30', deadlineAt: '2026-04-12T15:30:00+03:30', @@ -109,8 +118,10 @@ export const examAttempts = new Map([ { id: 15, examId: 201, - score: 42, + score: 10, isPassed: false, + correctCount: 1, + totalQuestions: 2, startedAt: '2026-04-13T09:00:00+03:30', submittedAt: '2026-04-13T09:25:00+03:30', deadlineAt: '2026-04-13T09:30:00+03:30', @@ -133,6 +144,92 @@ export const examAttempts = new Map([ ], ]) +// Mirrors GET /exam-attempts/:id — full per-attempt review. Questions carry +// every option with isCorrect (answer key) + isSelected (what the user picked). +const reviewQuestions = (questions, selectedByQuestionId) => + questions.map((q) => ({ + id: q.id, + questionText: q.questionText, + position: q.position, + score: q.score, + selectedOptionId: selectedByQuestionId[q.id] ?? null, + options: q.options.map((o) => ({ + id: o.id, + optionText: o.optionText, + isCorrect: o.isCorrect, + isSelected: selectedByQuestionId[q.id] === o.id, + })), + })) + +const examContext = { + id: 201, + title: 'آزمون پایان فصل اول اخلاق', + description: null, + score: 20, + minimumScore: 12, + durationMinutes: 30, + session: { + id: 101, + title: 'مقدمه‌ای بر اخلاق اسلامی', + course: { + id: 1, + title: 'اصول اخلاق اسلامی', + term: { id: 1, title: 'ترم بهار ۱۴۰۵', score: 20, minimumScore: 12 }, + }, + }, +} + +export const examAttemptReviews = new Map([ + [ + 14, + { + id: 14, + examId: 201, + userId: 100, + user: { + id: 100, + name: 'فاطمه رضایی', + phone: '+989121234567', + roles: ['student'], + avatarUrl: null, + }, + score: 20, + isPassed: true, + correctCount: 2, + totalQuestions: 2, + startedAt: '2026-04-12T15:00:00+03:30', + submittedAt: '2026-04-12T15:30:00+03:30', + exam: examContext, + // both answers correct (q1 → option 102, q2 → option 201) + questions: reviewQuestions(ethicsQuestions, { 1: 102, 2: 201 }), + }, + ], + [ + 15, + { + id: 15, + examId: 201, + userId: 102, + user: { + id: 102, + name: 'مریم احمدی', + phone: '+989121111111', + roles: ['student'], + avatarUrl: null, + }, + score: 10, + isPassed: false, + correctCount: 1, + totalQuestions: 2, + startedAt: '2026-04-13T09:00:00+03:30', + submittedAt: '2026-04-13T09:25:00+03:30', + exam: examContext, + // q1 correct (102), q2 wrong (203) + questions: reviewQuestions(ethicsQuestions, { 1: 102, 2: 203 }), + }, + ], +]) + // Kept for the FE-mock-only single-participant detail endpoint (no backend yet). export const examParticipants = new Map([ [ diff --git a/src/services/mock/fixtures/student-homeworks.js b/src/services/mock/fixtures/student-homeworks.js new file mode 100644 index 0000000..2c891e8 --- /dev/null +++ b/src/services/mock/fixtures/student-homeworks.js @@ -0,0 +1,40 @@ +// Mirrors the backend Homework shape (camelized). is_priority=true means the +// homework is required to pass the term; effective_deadline falls back to the +// term end date when no explicit deadline is set. +const sessionContext = (id, title, courseTitle, termTitle) => ({ + id, + title, + course: { id: id - 90, title: courseTitle, term: { id: 1, title: termTitle } }, +}) + +export const studentHomeworks = [ + { + id: 301, + sessionId: 101, + title: 'تکلیف جلسه اول اخلاق', + description: + 'یک متن ۵۰۰ کلمه‌ای دربارهٔ مفهوم تقوا بنویسید و فایل صوتی توضیح آن را بارگذاری کنید.', + deadline: '2026-05-01T23:59:00+03:30', + effectiveDeadline: '2026-05-01T23:59:00+03:30', + isActive: true, + isPriority: true, + submittersCount: 8, + createdAt: '2026-04-01T10:00:00.000Z', + media: [], + session: sessionContext(101, 'مقدمه‌ای بر اخلاق اسلامی', 'اصول اخلاق اسلامی', 'ترم پاییز ۱۴۰۴'), + }, + { + id: 302, + sessionId: 103, + title: 'تکلیف احکام نماز جماعت', + description: 'خلاصه‌ای از احکام نماز جماعت را به همراه یک نمونهٔ صوتی ارائه دهید.', + deadline: null, + effectiveDeadline: '2026-04-20T00:00:00.000Z', + isActive: true, + isPriority: false, + submittersCount: 3, + createdAt: '2026-04-05T10:00:00.000Z', + media: [], + session: sessionContext(103, 'احکام نماز جماعت', 'فقه عبادات', 'ترم زمستان ۱۴۰۴'), + }, +] diff --git a/src/services/mock/routes/admin-exams.js b/src/services/mock/routes/admin-exams.js index 3fa8d64..632cd3a 100644 --- a/src/services/mock/routes/admin-exams.js +++ b/src/services/mock/routes/admin-exams.js @@ -2,7 +2,12 @@ import { register } from '@/services/mock/registry' import { endpoints } from '@/services/api/endpoints' import { adminCourses } from '@/services/mock/fixtures/admin-courses' import { adminSessions } from '@/services/mock/fixtures/admin-sessions' -import { adminExams, examAttempts, examParticipants } from '@/services/mock/fixtures/admin-exams' +import { + adminExams, + examAttempts, + examAttemptReviews, + examParticipants, +} from '@/services/mock/fixtures/admin-exams' import { filterDateRange, filterItems, @@ -59,7 +64,10 @@ register('POST', endpoints.addNewExam, ({ data }) => { sessionId: session?.id ?? Number(data.sessionId) ?? null, title: data.title || '', description: data.description || '', - passScore: Number(data.passingScore ?? data.passScore) || 0, + score: Number(data.score) || 0, + minimumScore: Number(data.minimumScore ?? data.passingScore ?? data.passScore) || 0, + durationMinutes: Number(data.durationMinutes) || 0, + isRandom: data.isRandom ?? false, isActive: data.isActive ?? true, questions: [], // UI-only fallbacks for ExamItem / ExamDetailsModal. @@ -82,8 +90,10 @@ 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.score !== undefined) patch.score = Number(data.score) || 0 + if (data.minimumScore !== undefined) patch.minimumScore = Number(data.minimumScore) || 0 + if (data.durationMinutes !== undefined) patch.durationMinutes = Number(data.durationMinutes) || 0 + if (data.isRandom !== undefined) patch.isRandom = !!data.isRandom if (data.isActive !== undefined) patch.isActive = !!data.isActive if (data.sessionId !== undefined) { const session = adminSessions.find((s) => s.id === Number(data.sessionId)) @@ -115,6 +125,7 @@ register('POST', endpoints.addExamQuestion, ({ params, data }) => { id: questionId, questionText: data.questionText || '', position: data.position ?? (exam.questions?.length ?? 0) + 1, + score: Number(data.score) || 0, options: options.map((opt, idx) => ({ id: questionId * 100 + idx + 1, optionText: opt.optionText || '', @@ -157,6 +168,12 @@ register('GET', endpoints.getExamAttempts, ({ params, query }) => { return { success: true, message: 'OK', data: { items, meta } } }) +// GET /exam-attempts/:id — full per-attempt review payload. +register('GET', endpoints.showExamAttempt, ({ params }) => { + const review = examAttemptReviews.get(Number(params.id)) + return { success: true, message: 'OK', data: review || null } +}) + // FE-mock-only — single-participant detail not yet on the backend. register('GET', endpoints.showExamParticipant, ({ params }) => { const list = examParticipants.get(Number(params.examId)) || [] diff --git a/src/services/mock/routes/student-exams.js b/src/services/mock/routes/student-exams.js new file mode 100644 index 0000000..97504bc --- /dev/null +++ b/src/services/mock/routes/student-exams.js @@ -0,0 +1,99 @@ +import { register } from '@/services/mock/registry' +import { endpoints } from '@/services/api/endpoints' +import { adminExams } from '@/services/mock/fixtures/admin-exams' +import { adminCourses } from '@/services/mock/fixtures/admin-courses' +import { filterItems, isoNow, makeId, paginate } from '@/services/mock/helpers' + +// In-memory attempt history for the mock student. Submissions append here and +// surface on the exam page via GET /student/exam-results. +const studentResults = [ + { + id: 9001, + examId: 201, + score: 20, + isPassed: true, + correctCount: 2, + totalQuestions: 2, + startedAt: '2026-04-12T15:00:00+03:30', + submittedAt: '2026-04-12T15:30:00+03:30', + deadlineAt: '2026-04-12T15:30:00+03:30', + }, +] + +// examId → un-submitted attempt, so POST /start is idempotent. +const openAttempts = new Map() + +const termIdOfExam = (exam) => { + const course = adminCourses.find((c) => c.id === Number(exam.course?.id)) + return course?.termId ?? course?.term?.id ?? null +} + +register('GET', endpoints.getStudentExamsList, ({ query }) => { + const list = filterItems(adminExams, query, { + sessionId: (item, v) => String(item.sessionId) === String(v), + courseId: (item, v) => String(item.course?.id) === String(v), + termId: (item, v) => String(termIdOfExam(item)) === String(v), + }) + const { data: items, meta } = paginate(list, query) + return { success: true, message: 'OK', data: { items, meta } } +}) + +register('GET', endpoints.getStudentExamResults, ({ query }) => { + const { data: items, meta } = paginate([...studentResults].reverse(), query) + return { success: true, message: 'OK', data: { items, meta } } +}) + +register('POST', endpoints.startExam, ({ params }) => { + const examId = Number(params.examId) + const existing = openAttempts.get(examId) + if (existing) return { success: true, message: 'Exam started.', data: existing } + const exam = adminExams.find((e) => String(e.id) === String(examId)) + const duration = exam?.durationMinutes || 30 + const attempt = { + id: makeId(), + examId, + score: 0, + isPassed: false, + correctCount: 0, + totalQuestions: (exam?.questions || []).length, + startedAt: isoNow(), + submittedAt: null, + deadlineAt: new Date(Date.now() + duration * 60_000).toISOString(), + } + openAttempts.set(examId, attempt) + return { success: true, message: 'Exam started.', data: attempt } +}) + +register('POST', endpoints.submitExam, ({ params, data }) => { + const examId = Number(params.examId) + const exam = adminExams.find((e) => String(e.id) === String(examId)) + const questions = exam?.questions || [] + const answers = Array.isArray(data.answers) ? data.answers : [] + + let score = 0 + let correctCount = 0 + for (const q of questions) { + const correct = (q.options || []).find((o) => o.isCorrect) + const ans = answers.find((a) => String(a.questionId) === String(q.id)) + if (ans && correct && String(ans.selectedOptionId) === String(correct.id)) { + score += q.score || 0 + correctCount += 1 + } + } + + const open = openAttempts.get(examId) + const attempt = { + id: open?.id ?? makeId(), + examId, + score, + isPassed: score >= (exam?.minimumScore ?? 0), + correctCount, + totalQuestions: questions.length, + startedAt: open?.startedAt ?? isoNow(), + submittedAt: isoNow(), + deadlineAt: open?.deadlineAt ?? isoNow(), + } + studentResults.push(attempt) + openAttempts.delete(examId) + return { success: true, message: 'Exam submitted.', data: attempt } +}) diff --git a/src/services/mock/routes/student-homeworks.js b/src/services/mock/routes/student-homeworks.js new file mode 100644 index 0000000..d6f1904 --- /dev/null +++ b/src/services/mock/routes/student-homeworks.js @@ -0,0 +1,43 @@ +import { register } from '@/services/mock/registry' +import { endpoints } from '@/services/api/endpoints' +import { studentHomeworks } from '@/services/mock/fixtures/student-homeworks' +import { filterItems, findOrThrow, isoNow, makeId, paginate } from '@/services/mock/helpers' + +register('GET', endpoints.getStudentHomeworks, ({ query }) => { + const list = filterItems(studentHomeworks, query, { + sessionId: (item, v) => String(item.sessionId) === String(v), + courseId: (item, v) => String(item.session?.course?.id) === String(v), + termId: (item, v) => String(item.session?.course?.term?.id) === String(v), + }) + const { data: items, meta } = paginate(list, query) + return { success: true, message: 'OK', data: { items, meta } } +}) + +register('GET', endpoints.showHomework, ({ params }) => ({ + success: true, + message: 'OK', + data: findOrThrow(studentHomeworks, params.id), +})) + +register('POST', endpoints.submitHomework, ({ params, data }) => ({ + success: true, + message: 'Homework submitted.', + data: { + id: makeId(), + homeworkId: Number(params.homeworkId), + userId: 5, + status: 'pending', + teacherFeedback: null, + reviewedAt: null, + media: [ + { + id: Number(data.mediaId) || makeId(), + collectionName: 'submission', + fileName: 'submission', + url: null, + downloadUrl: null, + }, + ], + submittedAt: isoNow(), + }, +})) diff --git a/src/services/mock/routes/student-terms.js b/src/services/mock/routes/student-terms.js new file mode 100644 index 0000000..f7970c6 --- /dev/null +++ b/src/services/mock/routes/student-terms.js @@ -0,0 +1,29 @@ +import { paginate } from '@/services/mock/helpers' +import { register } from '@/services/mock/registry' +import { endpoints } from '@/services/api/endpoints' +import { adminTerms } from '@/services/mock/fixtures/admin-terms' + +const STUDENT_ID = 5 + +// TermEnrollment rows for the mock student, each embedding its term. Two active +// (in-progress) terms and the rest completed, mirroring GET /student/my-terms. +const enrollments = adminTerms.map((term, i) => ({ + id: 10 + i, + userId: STUDENT_ID, + termId: term.id, + status: i < 2 ? 'active' : 'completed', + completedAt: i < 2 ? null : '2026-01-20T00:00:00.000Z', + term, +})) + +register('GET', endpoints.getStudentTerms, ({ query }) => { + let list = enrollments + if (query.status) list = list.filter((e) => e.status === String(query.status)) + const { data: items, meta } = paginate(list, query) + return { success: true, message: 'OK', data: { items, meta } } +}) + +register('GET', endpoints.showStudentTerm, ({ params }) => { + const found = enrollments.find((e) => String(e.termId) === String(params.id)) + return { success: true, message: 'OK', data: found || null } +}) diff --git a/src/services/query/admin-exams.js b/src/services/query/admin-exams.js index 52770a1..69f740d 100644 --- a/src/services/query/admin-exams.js +++ b/src/services/query/admin-exams.js @@ -8,6 +8,7 @@ import { apiGetAdminExams, apiGetAdminExamAttempts, apiShowAdminExam, + apiShowAdminExamAttempt, apiShowAdminExamParticipant, apiUpdateAdminExam, } from '@/services/api/admin-exams' @@ -76,6 +77,43 @@ export const useAdminExamParticipantQuery = (examIdRef, participantIdRef, option ...options, }) +// GET /exam-attempts/:id — single attempt review. Adapts the backend payload +// into the shape ExamParticipantDetailsModal already renders (one accordion row +// with questions → answers, plus correct/selected ids for the dot states). +export const useAdminExamAttemptQuery = (idRef, options = {}) => + useQuery({ + queryKey: ['admin', 'exams', 'attempt', idRef], + queryFn: () => apiShowAdminExamAttempt(idRef.value), + select: (response) => { + const a = response?.data ?? response + if (!a) return null + const exam = a.exam || {} + const session = exam.session || {} + const course = session.course || {} + return { + id: a.id, + title: exam.title || 'آزمون', + courseTitle: course.title || '—', + sessionTitle: session.title || '—', + date: a.submittedAt, + score: a.score, + scoreTone: a.isPassed ? 'good' : 'bad', + questions: (a.questions || []).map((q) => { + const correct = (q.options || []).find((o) => o.isCorrect) + const selected = (q.options || []).find((o) => o.isSelected) + return { + id: q.id, + title: q.questionText, + answers: (q.options || []).map((o) => ({ id: o.id, title: o.optionText })), + correctAnswerId: correct?.id ?? null, + userAnswerId: q.selectedOptionId ?? selected?.id ?? null, + } + }), + } + }, + ...options, + }) + export const useAddAdminExamMutation = () => useMutation({ mutationFn: (payload) => apiAddAdminExam(payload) }) diff --git a/src/services/query/student-exams.js b/src/services/query/student-exams.js index 5c25bab..3fafdc6 100644 --- a/src/services/query/student-exams.js +++ b/src/services/query/student-exams.js @@ -1,14 +1,17 @@ import { cleanFilters } from '@/utils/clean-filters' import { useMutation, useQuery } from '@tanstack/vue-query' import { + apiGetStudentExamResults, apiGetStudentExams, apiShowStudentExam, + apiStartStudentExam, apiSubmitStudentExamAttempt, } from '@/services/api/student-exams' export const studentExamsKeys = { list: (filters, pagination) => ['student', 'exams', 'list', filters, pagination], detail: (id) => ['student', 'exams', 'detail', id], + results: (filters, pagination) => ['student', 'exams', 'results', filters, pagination], } export const useStudentExamsListQuery = (filtersRef, paginationRef, options = {}) => @@ -30,10 +33,46 @@ export const useStudentExamQuery = (idRef, options = {}) => useQuery({ queryKey: ['student', 'exams', 'detail', idRef], queryFn: () => apiShowStudentExam(idRef.value), - select: (response) => response?.data ?? response, + // Bridge the backend exam shape (questionText / options[].optionText) into + // the fields the frozen take-exam UI reads (title / answers[].title), and + // expose passingScore for the summary card. + select: (response) => { + const exam = response?.data ?? response + if (!exam) return exam + return { + ...exam, + passingScore: exam.minimumScore ?? exam.passingScore, + questions: (exam.questions || []).map((q) => ({ + ...q, + title: q.questionText ?? q.title, + answers: (q.options || q.answers || []).map((o) => ({ + id: o.id, + title: o.optionText ?? o.title, + })), + })), + } + }, ...options, }) +export const useStudentExamResultsQuery = (filtersRef, paginationRef, options = {}) => + useQuery({ + queryKey: ['student', 'exams', 'results', filtersRef, paginationRef], + queryFn: () => + apiGetStudentExamResults({ + ...cleanFilters(filtersRef?.value || {}), + ...paginationRef?.value, + }), + select: (response) => ({ + data: response?.data?.items ?? response?.data ?? [], + meta: response?.data?.meta ?? response?.meta, + }), + ...options, + }) + +export const useStartStudentExamMutation = () => + useMutation({ mutationFn: (id) => apiStartStudentExam(id) }) + export const useSubmitStudentExamAttemptMutation = () => useMutation({ mutationFn: ({ id, payload }) => apiSubmitStudentExamAttempt(id, payload), diff --git a/src/services/query/student-homeworks.js b/src/services/query/student-homeworks.js new file mode 100644 index 0000000..4b550e8 --- /dev/null +++ b/src/services/query/student-homeworks.js @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/vue-query' +import { cleanFilters } from '@/utils/clean-filters' +import { apiGetStudentHomeworks, apiShowStudentHomework } from '@/services/api/student-homeworks' + +export const studentHomeworksKeys = { + list: (filters, pagination) => ['student', 'homeworks', 'list', filters, pagination], + detail: (id) => ['student', 'homeworks', 'detail', id], +} + +export const useStudentHomeworksListQuery = (filtersRef, paginationRef, options = {}) => + useQuery({ + queryKey: ['student', 'homeworks', 'list', filtersRef, paginationRef], + queryFn: () => + apiGetStudentHomeworks({ + ...cleanFilters(filtersRef?.value || {}), + ...paginationRef?.value, + }), + select: (response) => ({ + data: response?.data?.items ?? response?.data ?? [], + meta: response?.data?.meta ?? response?.meta, + }), + ...options, + }) + +export const useStudentHomeworkQuery = (idRef, options = {}) => + useQuery({ + queryKey: ['student', 'homeworks', 'detail', idRef], + queryFn: () => apiShowStudentHomework(idRef.value), + select: (response) => response?.data ?? response, + ...options, + })