@@ -109,7 +109,7 @@ import BaseButton from '@/components/BaseButton.vue'
|
|||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
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 { useAdminExamParticipantQuery } from '@/services/query/admin-exams'
|
import { useAdminExamAttemptQuery } from '@/services/query/admin-exams'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ExamParticipantDetailsModal' })
|
defineOptions({ name: 'ExamParticipantDetailsModal' })
|
||||||
@@ -125,16 +125,18 @@ const TONE_MAP = {
|
|||||||
const { getModal } = useModal()
|
const { getModal } = useModal()
|
||||||
|
|
||||||
const modalData = computed(() => getModal('ExamParticipantDetailsModal')?.data ?? {})
|
const modalData = computed(() => getModal('ExamParticipantDetailsModal')?.data ?? {})
|
||||||
const examId = computed(() => modalData.value.examId ?? null)
|
const attemptId = computed(() => modalData.value.attemptId ?? null)
|
||||||
const participantId = computed(() => modalData.value.participantId ?? null)
|
|
||||||
const participant = computed(() => modalData.value.participant ?? null)
|
const participant = computed(() => modalData.value.participant ?? null)
|
||||||
|
|
||||||
const { data: detail, isLoading } = useAdminExamParticipantQuery(examId, participantId, {
|
const { data: detail, isLoading } = useAdminExamAttemptQuery(attemptId, {
|
||||||
enabled: () => !!examId.value && !!participantId.value,
|
enabled: () => !!attemptId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const rawAttempts = computed(
|
// The review endpoint returns one attempt; the template renders an accordion
|
||||||
() => detail.value?.attempts || detail.value?.data?.attempts || participant.value?.attempts || []
|
// 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(() =>
|
const attempts = computed(() =>
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ const onShowDetails = (attempt) => {
|
|||||||
const u = userOf(attempt)
|
const u = userOf(attempt)
|
||||||
openModal('ExamParticipantDetailsModal', {
|
openModal('ExamParticipantDetailsModal', {
|
||||||
examId: examId.value,
|
examId: examId.value,
|
||||||
|
attemptId: attempt.id,
|
||||||
participantId: u.id,
|
participantId: u.id,
|
||||||
participant: {
|
participant: {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</BoxedIconTitleBlock>
|
</BoxedIconTitleBlock>
|
||||||
|
|
||||||
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="2" :cols-per-row="1" />
|
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="2" :cols-per-row="1" />
|
||||||
<StudentExamSummary v-else-if="exam" :exam="exam" @start="onStart" />
|
<StudentExamSummary v-else-if="exam" :exam="examSummary" @start="onStart" />
|
||||||
|
|
||||||
<SimpleTitleIconBlock title="تعداد تلاش ها برای این آزمون" class="se__list-title">
|
<SimpleTitleIconBlock title="تعداد تلاش ها برای این آزمون" class="se__list-title">
|
||||||
<template #header-icon>
|
<template #header-icon>
|
||||||
@@ -32,14 +32,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import { useStudentExamQuery } from '@/services/query/student-exams'
|
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
||||||
import StudentExamSummary from '@/features/student/exams/components/StudentExamSummary.vue'
|
import StudentExamSummary from '@/features/student/exams/components/StudentExamSummary.vue'
|
||||||
|
import { useStudentExamQuery, useStudentExamResultsQuery } from '@/services/query/student-exams'
|
||||||
import StudentExamAttemptItem from '@/features/student/exams/components/StudentExamAttemptItem.vue'
|
import StudentExamAttemptItem from '@/features/student/exams/components/StudentExamAttemptItem.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -51,7 +51,40 @@ const { data: exam, isLoading } = useStudentExamQuery(examId, {
|
|||||||
enabled: () => !!examId.value,
|
enabled: () => !!examId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const attempts = computed(() => exam.value?.attempts ?? [])
|
const resultsFilters = ref({})
|
||||||
|
const resultsPagination = ref({ page: 1, perPage: 50 })
|
||||||
|
const { data: results } = useStudentExamResultsQuery(resultsFilters, resultsPagination)
|
||||||
|
|
||||||
|
// Attempts on this exam, newest first, shaped for StudentExamAttemptItem.
|
||||||
|
const attempts = computed(() =>
|
||||||
|
(results.value?.data ?? [])
|
||||||
|
.filter((a) => String(a.examId) === String(examId.value))
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
title: exam.value?.title || 'آزمون',
|
||||||
|
score: a.score,
|
||||||
|
date: a.submittedAt,
|
||||||
|
status: a.isPassed ? 'passed' : 'failed',
|
||||||
|
statusLabel: a.isPassed ? 'قبول شده' : 'مردود',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const examSummary = computed(() => {
|
||||||
|
const passed = attempts.value.some((a) => a.status === 'passed')
|
||||||
|
const status = passed ? 'passed' : attempts.value.length > 0 ? 'failed' : 'not_started'
|
||||||
|
const statusLabel = passed
|
||||||
|
? 'قبول شده اید'
|
||||||
|
: attempts.value.length > 0
|
||||||
|
? 'قبول نشده اید'
|
||||||
|
: 'شروع نشده'
|
||||||
|
return {
|
||||||
|
...exam.value,
|
||||||
|
attempts: attempts.value,
|
||||||
|
attemptsCount: attempts.value.length,
|
||||||
|
status,
|
||||||
|
statusLabel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onStart = () => {
|
const onStart = () => {
|
||||||
router.push({ name: 'student-take-exam', params: { id: examId.value } }).catch(() => {})
|
router.push({ name: 'student-take-exam', params: { id: examId.value } }).catch(() => {})
|
||||||
|
|||||||
@@ -52,11 +52,12 @@ import { toast } from 'vue3-toastify'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
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, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import StudentExamQuestion from '@/features/student/exams/components/StudentExamQuestion.vue'
|
import StudentExamQuestion from '@/features/student/exams/components/StudentExamQuestion.vue'
|
||||||
import {
|
import {
|
||||||
|
useStartStudentExamMutation,
|
||||||
useStudentExamQuery,
|
useStudentExamQuery,
|
||||||
useSubmitStudentExamAttemptMutation,
|
useSubmitStudentExamAttemptMutation,
|
||||||
} from '@/services/query/student-exams'
|
} from '@/services/query/student-exams'
|
||||||
@@ -79,12 +80,13 @@ const onAnswer = (questionId, value) => {
|
|||||||
else answers[questionId] = value
|
else answers[questionId] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startMutation = useStartStudentExamMutation()
|
||||||
const remaining = ref(0)
|
const remaining = ref(0)
|
||||||
let intervalId = null
|
let intervalId = null
|
||||||
|
|
||||||
const startTimer = (minutes) => {
|
const tickFrom = (seconds) => {
|
||||||
if (intervalId) clearInterval(intervalId)
|
if (intervalId) clearInterval(intervalId)
|
||||||
remaining.value = Math.max(0, Math.floor(minutes * 60))
|
remaining.value = Math.max(0, Math.floor(seconds))
|
||||||
intervalId = setInterval(() => {
|
intervalId = setInterval(() => {
|
||||||
if (remaining.value <= 0) {
|
if (remaining.value <= 0) {
|
||||||
clearInterval(intervalId)
|
clearInterval(intervalId)
|
||||||
@@ -96,13 +98,20 @@ const startTimer = (minutes) => {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
// Begin (or resume) the attempt; the server returns deadlineAt which drives the
|
||||||
() => exam.value?.durationMinutes,
|
// countdown. Falls back to the exam duration if no deadline comes back.
|
||||||
(minutes) => {
|
const beginAttempt = async () => {
|
||||||
if (minutes != null) startTimer(minutes)
|
try {
|
||||||
},
|
const res = await startMutation.mutateAsync(examId.value)
|
||||||
{ immediate: true }
|
const attempt = res?.data ?? res
|
||||||
)
|
const deadlineMs = attempt?.deadlineAt ? new Date(attempt.deadlineAt).getTime() : null
|
||||||
|
if (deadlineMs) tickFrom((deadlineMs - Date.now()) / 1000)
|
||||||
|
else if (exam.value?.durationMinutes != null) tickFrom(exam.value.durationMinutes * 60)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error?.response?.data?.message || 'امکان شروع آزمون وجود ندارد.')
|
||||||
|
router.push({ name: 'student-exam', params: { id: examId.value } }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (intervalId) clearInterval(intervalId)
|
if (intervalId) clearInterval(intervalId)
|
||||||
@@ -149,7 +158,11 @@ const onTimeout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!examId.value) router.push({ name: 'student-dashboard' })
|
if (!examId.value) {
|
||||||
|
router.push({ name: 'student-dashboard' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
beginAttempt()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
<div class="ssd__prompt">
|
<div class="ssd__prompt">
|
||||||
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__prompt-icon" />
|
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__prompt-icon" />
|
||||||
<p class="ssd__prompt-text">
|
<p class="ssd__prompt-text">
|
||||||
{{ session?.homeworkPrompt || 'متنی برای این جلسه ثبت نشده است.' }}
|
{{
|
||||||
|
session?.homeworkPrompt || homework?.description || 'متنی برای این جلسه ثبت نشده است.'
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@
|
|||||||
<div class="ssd__hint">
|
<div class="ssd__hint">
|
||||||
<SvgIcon name="warning" :size="22" color="#b8b8b8" />
|
<SvgIcon name="warning" :size="22" color="#b8b8b8" />
|
||||||
<p class="ssd__hint-text">
|
<p class="ssd__hint-text">
|
||||||
{{ session?.homeworkHint || '' }}
|
{{ session?.homeworkHint || homework?.title || '' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ import ImageUploader from '@/components/form/ImageUploader.vue'
|
|||||||
import VideoPlayerBlock from '@/components/blocks/VideoPlayerBlock.vue'
|
import VideoPlayerBlock from '@/components/blocks/VideoPlayerBlock.vue'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
import { useStudentHomeworksListQuery } from '@/services/query/student-homeworks'
|
||||||
import {
|
import {
|
||||||
useStudentSessionQuery,
|
useStudentSessionQuery,
|
||||||
useSubmitStudentHomeworkMutation,
|
useSubmitStudentHomeworkMutation,
|
||||||
@@ -116,6 +119,15 @@ const { data: session, isLoading } = useStudentSessionQuery(sessionId, {
|
|||||||
enabled: () => !!sessionId.value,
|
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 contentKind = computed(() => {
|
||||||
const s = session.value
|
const s = session.value
|
||||||
if (!s) return ''
|
if (!s) return ''
|
||||||
@@ -135,7 +147,9 @@ const canSubmit = computed(() => !!(homeworkAudio.value || homeworkImage.value))
|
|||||||
const submitMutation = useSubmitStudentHomeworkMutation()
|
const submitMutation = useSubmitStudentHomeworkMutation()
|
||||||
const uploadMediaMutation = useUploadMediaMutation()
|
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 uploadHomeworkFile = async (file) => {
|
||||||
const fd = objectToFormData({ file, purpose: 'homework_file', context: 'homework' })
|
const fd = objectToFormData({ file, purpose: 'homework_file', context: 'homework' })
|
||||||
|
|||||||
@@ -29,3 +29,8 @@ export const apiGetAdminExamAttempts = (examId, params) =>
|
|||||||
|
|
||||||
export const apiShowAdminExamParticipant = (examId, participantId) =>
|
export const apiShowAdminExamParticipant = (examId, participantId) =>
|
||||||
http.get(buildUrl(endpoints.showExamParticipant, { 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 }))
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export const endpoints = {
|
|||||||
// dedicated student route (returns the membership row with the nested term).
|
// dedicated student route (returns the membership row with the nested term).
|
||||||
getStudentTerms: '/student/my-terms',
|
getStudentTerms: '/student/my-terms',
|
||||||
showStudentTerm: '/student/my-terms/:id',
|
showStudentTerm: '/student/my-terms/:id',
|
||||||
|
getStudentExamResults: '/student/exam-results',
|
||||||
|
getStudentHomeworks: '/student/homeworks',
|
||||||
|
showHomework: '/homeworks/:id',
|
||||||
|
startExam: '/exams/:examId/start',
|
||||||
|
|
||||||
getMissionaryProfile: '/student/missionary/profile',
|
getMissionaryProfile: '/student/missionary/profile',
|
||||||
getMissionaryRequests: '/student/missionary/requests',
|
getMissionaryRequests: '/student/missionary/requests',
|
||||||
@@ -127,6 +131,7 @@ export const endpoints = {
|
|||||||
// attempting user embedded; multiple rows per user are possible. The
|
// attempting user embedded; multiple rows per user are possible. The
|
||||||
// single-attempt detail endpoint is still mock-only.
|
// single-attempt detail endpoint is still mock-only.
|
||||||
getExamAttempts: '/exams/:examId/attempts',
|
getExamAttempts: '/exams/:examId/attempts',
|
||||||
|
showExamAttempt: '/exam-attempts/:id',
|
||||||
showExamParticipant: '/admin/exams/:examId/participants/:participantId',
|
showExamParticipant: '/admin/exams/:examId/participants/:participantId',
|
||||||
|
|
||||||
// Homeworks — list, show, submissions list, show submission
|
// Homeworks — list, show, submissions list, show submission
|
||||||
|
|||||||
@@ -5,5 +5,13 @@ export const apiGetStudentExams = (params) => http.get(endpoints.getStudentExams
|
|||||||
|
|
||||||
export const apiShowStudentExam = (id) => http.get(buildUrl(endpoints.showExam, { id }))
|
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) =>
|
export const apiSubmitStudentExamAttempt = (id, payload) =>
|
||||||
http.post(buildUrl(endpoints.submitExam, { examId: 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 })
|
||||||
|
|||||||
@@ -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 }))
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
// options: [{ id, optionText, isCorrect }] }] }
|
// options: [{ id, optionText, isCorrect }] }] }
|
||||||
// (Backend hides `is_correct` from non-admin reads; the admin mock shows it.)
|
// (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,
|
id,
|
||||||
questionText,
|
questionText,
|
||||||
position,
|
position,
|
||||||
|
score,
|
||||||
options: options.map((optionText, idx) => ({
|
options: options.map((optionText, idx) => ({
|
||||||
id: id * 100 + idx + 1,
|
id: id * 100 + idx + 1,
|
||||||
optionText,
|
optionText,
|
||||||
@@ -49,7 +50,10 @@ export const adminExams = [
|
|||||||
sessionId: 101,
|
sessionId: 101,
|
||||||
title: 'آزمون پایان فصل اول اخلاق',
|
title: 'آزمون پایان فصل اول اخلاق',
|
||||||
description: 'آزمون چهار گزینهای از مفاهیم درسهای ۱ تا ۳.',
|
description: 'آزمون چهار گزینهای از مفاهیم درسهای ۱ تا ۳.',
|
||||||
passScore: 12,
|
score: 20,
|
||||||
|
minimumScore: 12,
|
||||||
|
durationMinutes: 30,
|
||||||
|
isRandom: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
questions: ethicsQuestions,
|
questions: ethicsQuestions,
|
||||||
// ── UI-only fields for ExamItem / ExamDetailsModal (not in backend). ──
|
// ── UI-only fields for ExamItem / ExamDetailsModal (not in backend). ──
|
||||||
@@ -65,7 +69,10 @@ export const adminExams = [
|
|||||||
sessionId: 102,
|
sessionId: 102,
|
||||||
title: 'آزمون مفاهیم قرآنی - میانترم',
|
title: 'آزمون مفاهیم قرآنی - میانترم',
|
||||||
description: 'آزمون میانترم برای مرور آیات کلیدی.',
|
description: 'آزمون میانترم برای مرور آیات کلیدی.',
|
||||||
passScore: 14,
|
score: 20,
|
||||||
|
minimumScore: 14,
|
||||||
|
durationMinutes: 20,
|
||||||
|
isRandom: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
questions: quranQuestions,
|
questions: quranQuestions,
|
||||||
course: { id: 2, title: 'مفاهیم قرآنی' },
|
course: { id: 2, title: 'مفاهیم قرآنی' },
|
||||||
@@ -86,8 +93,10 @@ export const examAttempts = new Map([
|
|||||||
{
|
{
|
||||||
id: 14,
|
id: 14,
|
||||||
examId: 201,
|
examId: 201,
|
||||||
score: 85,
|
score: 20,
|
||||||
isPassed: true,
|
isPassed: true,
|
||||||
|
correctCount: 2,
|
||||||
|
totalQuestions: 2,
|
||||||
startedAt: '2026-04-12T15:00:00+03:30',
|
startedAt: '2026-04-12T15:00:00+03:30',
|
||||||
submittedAt: '2026-04-12T15:30:00+03:30',
|
submittedAt: '2026-04-12T15:30:00+03:30',
|
||||||
deadlineAt: '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,
|
id: 15,
|
||||||
examId: 201,
|
examId: 201,
|
||||||
score: 42,
|
score: 10,
|
||||||
isPassed: false,
|
isPassed: false,
|
||||||
|
correctCount: 1,
|
||||||
|
totalQuestions: 2,
|
||||||
startedAt: '2026-04-13T09:00:00+03:30',
|
startedAt: '2026-04-13T09:00:00+03:30',
|
||||||
submittedAt: '2026-04-13T09:25:00+03:30',
|
submittedAt: '2026-04-13T09:25:00+03:30',
|
||||||
deadlineAt: '2026-04-13T09:30: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).
|
// Kept for the FE-mock-only single-participant detail endpoint (no backend yet).
|
||||||
export const examParticipants = new Map([
|
export const examParticipants = new Map([
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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, 'احکام نماز جماعت', 'فقه عبادات', 'ترم زمستان ۱۴۰۴'),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -2,7 +2,12 @@ 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 { adminCourses } from '@/services/mock/fixtures/admin-courses'
|
||||||
import { adminSessions } from '@/services/mock/fixtures/admin-sessions'
|
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 {
|
import {
|
||||||
filterDateRange,
|
filterDateRange,
|
||||||
filterItems,
|
filterItems,
|
||||||
@@ -59,7 +64,10 @@ register('POST', endpoints.addNewExam, ({ data }) => {
|
|||||||
sessionId: session?.id ?? Number(data.sessionId) ?? null,
|
sessionId: session?.id ?? Number(data.sessionId) ?? null,
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
description: data.description || '',
|
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,
|
isActive: data.isActive ?? true,
|
||||||
questions: [],
|
questions: [],
|
||||||
// UI-only fallbacks for ExamItem / ExamDetailsModal.
|
// UI-only fallbacks for ExamItem / ExamDetailsModal.
|
||||||
@@ -82,8 +90,10 @@ register('PATCH', endpoints.updateExam, ({ 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.passingScore !== undefined) patch.passScore = Number(data.passingScore) || 0
|
if (data.score !== undefined) patch.score = Number(data.score) || 0
|
||||||
if (data.passScore !== undefined) patch.passScore = Number(data.passScore) || 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.isActive !== undefined) patch.isActive = !!data.isActive
|
||||||
if (data.sessionId !== undefined) {
|
if (data.sessionId !== undefined) {
|
||||||
const session = adminSessions.find((s) => s.id === Number(data.sessionId))
|
const session = adminSessions.find((s) => s.id === Number(data.sessionId))
|
||||||
@@ -115,6 +125,7 @@ register('POST', endpoints.addExamQuestion, ({ params, data }) => {
|
|||||||
id: questionId,
|
id: questionId,
|
||||||
questionText: data.questionText || '',
|
questionText: data.questionText || '',
|
||||||
position: data.position ?? (exam.questions?.length ?? 0) + 1,
|
position: data.position ?? (exam.questions?.length ?? 0) + 1,
|
||||||
|
score: Number(data.score) || 0,
|
||||||
options: options.map((opt, idx) => ({
|
options: options.map((opt, idx) => ({
|
||||||
id: questionId * 100 + idx + 1,
|
id: questionId * 100 + idx + 1,
|
||||||
optionText: opt.optionText || '',
|
optionText: opt.optionText || '',
|
||||||
@@ -157,6 +168,12 @@ register('GET', endpoints.getExamAttempts, ({ params, query }) => {
|
|||||||
return { success: true, message: 'OK', data: { items, meta } }
|
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.
|
// FE-mock-only — single-participant detail not yet on the backend.
|
||||||
register('GET', endpoints.showExamParticipant, ({ params }) => {
|
register('GET', endpoints.showExamParticipant, ({ params }) => {
|
||||||
const list = examParticipants.get(Number(params.examId)) || []
|
const list = examParticipants.get(Number(params.examId)) || []
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
apiGetAdminExams,
|
apiGetAdminExams,
|
||||||
apiGetAdminExamAttempts,
|
apiGetAdminExamAttempts,
|
||||||
apiShowAdminExam,
|
apiShowAdminExam,
|
||||||
|
apiShowAdminExamAttempt,
|
||||||
apiShowAdminExamParticipant,
|
apiShowAdminExamParticipant,
|
||||||
apiUpdateAdminExam,
|
apiUpdateAdminExam,
|
||||||
} from '@/services/api/admin-exams'
|
} from '@/services/api/admin-exams'
|
||||||
@@ -76,6 +77,43 @@ export const useAdminExamParticipantQuery = (examIdRef, participantIdRef, option
|
|||||||
...options,
|
...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 = () =>
|
export const useAddAdminExamMutation = () =>
|
||||||
useMutation({ mutationFn: (payload) => apiAddAdminExam(payload) })
|
useMutation({ mutationFn: (payload) => apiAddAdminExam(payload) })
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { cleanFilters } from '@/utils/clean-filters'
|
import { cleanFilters } from '@/utils/clean-filters'
|
||||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||||
import {
|
import {
|
||||||
|
apiGetStudentExamResults,
|
||||||
apiGetStudentExams,
|
apiGetStudentExams,
|
||||||
apiShowStudentExam,
|
apiShowStudentExam,
|
||||||
|
apiStartStudentExam,
|
||||||
apiSubmitStudentExamAttempt,
|
apiSubmitStudentExamAttempt,
|
||||||
} from '@/services/api/student-exams'
|
} from '@/services/api/student-exams'
|
||||||
|
|
||||||
export const studentExamsKeys = {
|
export const studentExamsKeys = {
|
||||||
list: (filters, pagination) => ['student', 'exams', 'list', filters, pagination],
|
list: (filters, pagination) => ['student', 'exams', 'list', filters, pagination],
|
||||||
detail: (id) => ['student', 'exams', 'detail', id],
|
detail: (id) => ['student', 'exams', 'detail', id],
|
||||||
|
results: (filters, pagination) => ['student', 'exams', 'results', filters, pagination],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStudentExamsListQuery = (filtersRef, paginationRef, options = {}) =>
|
export const useStudentExamsListQuery = (filtersRef, paginationRef, options = {}) =>
|
||||||
@@ -30,10 +33,46 @@ export const useStudentExamQuery = (idRef, options = {}) =>
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['student', 'exams', 'detail', idRef],
|
queryKey: ['student', 'exams', 'detail', idRef],
|
||||||
queryFn: () => apiShowStudentExam(idRef.value),
|
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,
|
...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 = () =>
|
export const useSubmitStudentExamAttemptMutation = () =>
|
||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: ({ id, payload }) => apiSubmitStudentExamAttempt(id, payload),
|
mutationFn: ({ id, payload }) => apiSubmitStudentExamAttempt(id, payload),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user