@@ -5,12 +5,12 @@
|
||||
<div class="exam-item__sub">
|
||||
<span class="exam-item__sub-label">دوره:</span>
|
||||
<span class="exam-item__sub-value">
|
||||
{{ exam.course?.title || exam.courseTitle || '—' }}
|
||||
{{ exam?.session?.course?.title || '—' }}
|
||||
</span>
|
||||
<span class="exam-item__dot">|</span>
|
||||
<span class="exam-item__sub-label">جلسه:</span>
|
||||
<span class="exam-item__sub-value">
|
||||
{{ exam.session?.title || exam.sessionTitle || '—' }}
|
||||
{{ exam.session?.title || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,16 +51,6 @@
|
||||
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
tooltip="ویرایش"
|
||||
bg-color="rgba(104, 104, 104, 0.05)"
|
||||
size="2.5rem"
|
||||
@click="emit('edit', exam)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="pencil" :size="18" color="var(--color-sec-gray)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<BaseButton
|
||||
text="جزئیات آزمون"
|
||||
custom-class="exam-item__details-btn"
|
||||
|
||||
@@ -306,6 +306,7 @@ const correctOptions = (question) =>
|
||||
font-size: 0.9rem;
|
||||
color: #4b4b4b;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 1px var(--color-validate, var(--color-primary));
|
||||
|
||||
@@ -121,7 +121,7 @@ const todayIso = new Date().toISOString()
|
||||
|
||||
const termSearch = ref('')
|
||||
const termFilters = computed(() => ({ title: termSearch.value }))
|
||||
const termPagination = ref({ page: 1, perPage: 100 })
|
||||
const termPagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true">
|
||||
<BasicModal
|
||||
title="جزئیات آزمون"
|
||||
title-en="Details"
|
||||
width="95%"
|
||||
max-width="64rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<div class="exam-details">
|
||||
<LineTitleBlock title="جزئیات آزمون" title-en="Details" />
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
|
||||
<template v-else-if="exam">
|
||||
<div class="exam-details__summary">
|
||||
@@ -75,7 +80,6 @@ import BasicModal from '@/components/BasicModal.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { useAdminExamQuery } from '@/services/query/admin-exams'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
@@ -93,6 +97,7 @@ const { data: exam, isLoading } = useAdminExamQuery(examId, {
|
||||
|
||||
const summaryItems = computed(() => {
|
||||
const e = exam.value || {}
|
||||
const course = e.session?.course || e.course
|
||||
return [
|
||||
{ title: 'عنوان آزمون', value: e.title || '—', numeric: false },
|
||||
{
|
||||
@@ -102,13 +107,17 @@ const summaryItems = computed(() => {
|
||||
},
|
||||
{
|
||||
title: 'دوره مرتبط',
|
||||
value: e.course?.title || e.courseTitle || '—',
|
||||
value: course?.title || e.courseTitle || '—',
|
||||
numeric: false,
|
||||
},
|
||||
{ title: 'وضعیت', value: e.statusLabel || e.faStatus || e.status || '—', numeric: false },
|
||||
{
|
||||
title: 'نمره کل',
|
||||
value: e.score == null ? '—' : `${e.score} نمره`,
|
||||
numeric: true,
|
||||
},
|
||||
{
|
||||
title: 'حد نصاب قبولی',
|
||||
value: e.passScore == null ? '—' : `${e.passScore} نمره`,
|
||||
value: e.minimumScore == null ? '—' : `${e.minimumScore} نمره`,
|
||||
numeric: true,
|
||||
},
|
||||
{
|
||||
@@ -137,7 +146,7 @@ const displayQuestions = computed(() => {
|
||||
id: question?.id ?? `question-${index + 1}`,
|
||||
order: question?.position ?? index + 1,
|
||||
title: question?.questionText || '—',
|
||||
score: '',
|
||||
score: question?.score ?? '',
|
||||
answers,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,36 +6,38 @@
|
||||
<p v-if="data?.title" class="exam-participants__exam">{{ data.title }}</p>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
|
||||
<div v-else-if="participants.length > 0" class="exam-participants__list">
|
||||
<div
|
||||
v-for="participant in participants"
|
||||
:key="participant.id"
|
||||
class="exam-participants__row"
|
||||
>
|
||||
<div v-else-if="attempts.length > 0" class="exam-participants__list">
|
||||
<div v-for="attempt in attempts" :key="attempt.id" class="exam-participants__row">
|
||||
<div class="exam-participants__user">
|
||||
<div v-if="participant.avatarUrl" class="exam-participants__avatar">
|
||||
<img :src="participant.avatarUrl" :alt="userName(participant)" />
|
||||
<div v-if="avatarOf(attempt)" class="exam-participants__avatar">
|
||||
<img :src="avatarOf(attempt)" :alt="userName(attempt)" />
|
||||
</div>
|
||||
<div v-else class="exam-participants__avatar exam-participants__avatar--placeholder">
|
||||
<SvgIcon name="user" :size="20" color="#bcbcbc" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="exam-participants__name">{{ userName(participant) }}</p>
|
||||
<p class="exam-participants__location">
|
||||
{{ participant.location || locationFromAddress(participant) || '—' }}
|
||||
</p>
|
||||
<p class="exam-participants__name">{{ userName(attempt) }}</p>
|
||||
<p class="exam-participants__location">{{ locationOf(attempt) || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exam-participants__pill">
|
||||
<span class="exam-participants__pill-label">تاریخ شرکت:</span>
|
||||
<span class="exam-participants__pill-value">{{ registeredAt(participant) }}</span>
|
||||
<span class="exam-participants__pill-value">{{ submittedAt(attempt) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="exam-participants__score"
|
||||
:class="`exam-participants__score--${attempt.isPassed ? 'pass' : 'fail'}`"
|
||||
>
|
||||
<span class="exam-participants__score-label">نمره:</span>
|
||||
<span class="exam-participants__score-value">{{ attempt.score ?? '—' }}</span>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
text="جزئیات"
|
||||
custom-class="exam-participants__details-btn"
|
||||
@click="onShowDetails(participant)"
|
||||
@click="onShowDetails(attempt)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="caret-left" :size="14" color="#fff" />
|
||||
@@ -72,7 +74,7 @@ import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import { useAdminExamParticipantsQuery } from '@/services/query/admin-exams'
|
||||
import { useAdminExamAttemptsQuery } from '@/services/query/admin-exams'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
|
||||
defineOptions({ name: 'ExamParticipantsModal' })
|
||||
@@ -85,33 +87,53 @@ const examId = computed(() => modalData.value.id ?? null)
|
||||
const filters = ref({})
|
||||
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data, isLoading } = useAdminExamParticipantsQuery(examId, filters, pagination, {
|
||||
const { data, isLoading } = useAdminExamAttemptsQuery(examId, filters, pagination, {
|
||||
enabled: () => !!examId.value,
|
||||
keepPreviousData: true,
|
||||
})
|
||||
|
||||
const participants = computed(() => data.value?.data ?? [])
|
||||
const attempts = computed(() => data.value?.data ?? [])
|
||||
const paginationMeta = computed(() => ({
|
||||
page: pagination.value.page,
|
||||
perPage: pagination.value.perPage,
|
||||
...data.value?.meta,
|
||||
}))
|
||||
|
||||
const userName = (p) => `${p.firstName || ''} ${p.lastName || ''}`.trim() || p.fullName || '—'
|
||||
const userOf = (attempt) => attempt?.user ?? {}
|
||||
|
||||
const locationFromAddress = (p) => {
|
||||
const province = p.address?.province?.name || ''
|
||||
const city = p.address?.city?.name || ''
|
||||
const userName = (attempt) => {
|
||||
const u = userOf(attempt)
|
||||
return u.name || `${u.firstName || ''} ${u.lastName || ''}`.trim() || '—'
|
||||
}
|
||||
|
||||
const avatarOf = (attempt) => {
|
||||
const u = userOf(attempt)
|
||||
return u.avatarDownloadUrl || u.avatarUrl || ''
|
||||
}
|
||||
|
||||
const locationOf = (attempt) => {
|
||||
const u = userOf(attempt)
|
||||
const province = u.province?.name || ''
|
||||
const city = u.city?.name || ''
|
||||
return [province, city].filter(Boolean).join('، ')
|
||||
}
|
||||
|
||||
const registeredAt = (p) => p.faRegisteredAt || formatJalaaliDate(p.registeredAt) || '—'
|
||||
const submittedAt = (attempt) =>
|
||||
attempt.faSubmittedAt || formatJalaaliDate(attempt.submittedAt) || '—'
|
||||
|
||||
const onShowDetails = (participant) => {
|
||||
const onShowDetails = (attempt) => {
|
||||
const u = userOf(attempt)
|
||||
openModal('ExamParticipantDetailsModal', {
|
||||
examId: examId.value,
|
||||
participantId: participant.id,
|
||||
participant,
|
||||
participantId: u.id,
|
||||
participant: {
|
||||
id: u.id,
|
||||
firstName: u.firstName ?? u.name ?? '',
|
||||
lastName: u.lastName ?? '',
|
||||
fullName: u.name ?? '',
|
||||
avatarUrl: avatarOf(attempt),
|
||||
},
|
||||
attempt,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -210,6 +232,36 @@ const onShowDetails = (participant) => {
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__score {
|
||||
border-radius: 0.875rem;
|
||||
padding: 0.375rem 1rem;
|
||||
text-align: center;
|
||||
min-width: 6rem;
|
||||
white-space: nowrap;
|
||||
|
||||
&--pass {
|
||||
background: rgba(46, 160, 67, 10%);
|
||||
color: #2ea043;
|
||||
}
|
||||
|
||||
&--fail {
|
||||
background: rgba(229, 72, 77, 10%);
|
||||
color: #e5484d;
|
||||
}
|
||||
}
|
||||
|
||||
&__score-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__score-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__details-btn {
|
||||
min-width: 5rem;
|
||||
padding: 0 1rem;
|
||||
|
||||
@@ -32,16 +32,34 @@
|
||||
@blur="validateAt('title', form.title)"
|
||||
/>
|
||||
<TextField
|
||||
v-model="form.passingScore"
|
||||
name="passingScore"
|
||||
v-model="form.minimumScore"
|
||||
name="minimumScore"
|
||||
label="حد نصاب قبولی"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.passingScore"
|
||||
@blur="validateAt('passingScore', form.passingScore)"
|
||||
:error="errors.minimumScore"
|
||||
@blur="validateAt('minimumScore', form.minimumScore)"
|
||||
/>
|
||||
<TextField
|
||||
v-model="form.score"
|
||||
name="score"
|
||||
label="کل نمره آزمون"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.score"
|
||||
@blur="validateAt('score', form.score)"
|
||||
/>
|
||||
<TextField
|
||||
v-model="form.durationMinutes"
|
||||
name="durationMinutes"
|
||||
label="مدت زمان (دقیقه)"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.durationMinutes"
|
||||
@blur="validateAt('durationMinutes', form.durationMinutes)"
|
||||
/>
|
||||
<div class="exam-form__toggle-cell">
|
||||
<ToggleSwitch v-model="form.isActive" label="آزمون فعال باشد" />
|
||||
<ToggleSwitch v-model="form.isRandom" label="به صــــورت رندوم باشد." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,8 +140,10 @@ const isEditMode = computed(() => !!examId.value)
|
||||
const form = ref({
|
||||
title: '',
|
||||
sessionId: '',
|
||||
passingScore: '',
|
||||
isActive: true,
|
||||
minimumScore: '',
|
||||
score: '',
|
||||
durationMinutes: '',
|
||||
isRandom: true,
|
||||
description: '',
|
||||
})
|
||||
|
||||
@@ -145,7 +165,7 @@ const questionError = ref('')
|
||||
const { validate, validateAt, errors } = useYup(examSchema)
|
||||
|
||||
const sessionSearch = ref('')
|
||||
const sessionFilters = computed(() => ({ title: sessionSearch.value }))
|
||||
const sessionFilters = computed(() => ({ search: sessionSearch.value }))
|
||||
const sessionPagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination)
|
||||
const selectedSession = ref(null)
|
||||
@@ -183,18 +203,24 @@ const normalizeExistingQuestions = (raw = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(existingExam, (exam) => {
|
||||
if (!exam) return
|
||||
if (exam.session) selectedSession.value = exam.session
|
||||
form.value = {
|
||||
title: exam.title || '',
|
||||
sessionId: exam.session?.id || exam.sessionId || '',
|
||||
passingScore: exam.passScore ?? '',
|
||||
isActive: exam.isActive ?? true,
|
||||
description: exam.description || '',
|
||||
}
|
||||
questions.value = normalizeExistingQuestions(exam.questions)
|
||||
})
|
||||
watch(
|
||||
existingExam,
|
||||
(exam) => {
|
||||
if (!exam) return
|
||||
if (exam.session) selectedSession.value = exam.session
|
||||
form.value = {
|
||||
title: exam.title || '',
|
||||
sessionId: exam.session?.id || exam.sessionId || '',
|
||||
minimumScore: exam.minimumScore ?? exam.passScore ?? '',
|
||||
score: exam.score ?? '',
|
||||
durationMinutes: exam.durationMinutes ?? '',
|
||||
isRandom: exam.isRandom ?? true,
|
||||
description: exam.description || '',
|
||||
}
|
||||
questions.value = normalizeExistingQuestions(exam.questions)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const validateLocalQuestions = () => {
|
||||
const localOnes = questions.value.filter((q) => q.__local === true)
|
||||
@@ -235,8 +261,10 @@ const buildExamPayload = (values) => ({
|
||||
sessionId: values.sessionId,
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
passingScore: Number(values.passingScore) || 0,
|
||||
isActive: values.isActive,
|
||||
minimumScore: Number(values.minimumScore) || 0,
|
||||
score: Number(values.score) || 0,
|
||||
durationMinutes: Number(values.durationMinutes) || 0,
|
||||
isRandom: values.isRandom,
|
||||
})
|
||||
|
||||
const addExamMutation = useAddAdminExamMutation()
|
||||
@@ -298,7 +326,6 @@ const onCancel = () => router.push({ name: 'admin-exams' })
|
||||
}
|
||||
|
||||
&__card {
|
||||
background: rgba(255, 255, 255, 60%);
|
||||
border-radius: 1.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
|
||||
@@ -322,6 +349,14 @@ const onCancel = () => router.push({ name: 'admin-exams' })
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-height: 4.25rem;
|
||||
margin-right: 40px;
|
||||
margin-top: 10px;
|
||||
|
||||
> div {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { boolean, number, object, string } from 'yup'
|
||||
export const examSchema = object().shape({
|
||||
title: string().required().min(3),
|
||||
sessionId: string().required(),
|
||||
passingScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
||||
minimumScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
||||
score: number().typeError('کل نمره آزمون باید عدد باشد').required(),
|
||||
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(),
|
||||
description: string().nullable().notRequired(),
|
||||
isActive: boolean().nullable().notRequired(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user