+1
-1
@@ -43,7 +43,7 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'no-alert': 'error',
|
'no-alert': 'error',
|
||||||
'no-shadow': 'off',
|
'no-shadow': 'off',
|
||||||
'no-console': 'off',
|
'no-console': 'error',
|
||||||
'import/order': 'off',
|
'import/order': 'off',
|
||||||
'dot-notation': 'error',
|
'dot-notation': 'error',
|
||||||
'no-else-return': 'off',
|
'no-else-return': 'off',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="toggle-switch">
|
<div class="toggle-switch">
|
||||||
<label class="toggle-switch__label">
|
<label class="toggle-switch__label">
|
||||||
|
<span class="toggle-switch__text">{{ label }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -15,7 +16,6 @@
|
|||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span class="toggle-switch__text">{{ label }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div v-if="error" class="toggle-switch__error dir-rtl" :class="{ 'animate-shake': vibration }">
|
<div v-if="error" class="toggle-switch__error dir-rtl" :class="{ 'animate-shake': vibration }">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="assignment-item__meta">
|
<div class="assignment-item__meta">
|
||||||
<div class="assignment-item__pill">
|
<div class="assignment-item__pill">
|
||||||
<span class="assignment-item__pill-label">مدت زمان ارسال:</span>
|
<span class="assignment-item__pill-label">تاریخ سررسید:</span>
|
||||||
<span class="assignment-item__pill-value">{{ duration }}</span>
|
<span class="assignment-item__pill-value">{{ duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="assignment-item__pill">
|
<div class="assignment-item__pill">
|
||||||
@@ -87,9 +87,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete', 'show-details', 'show-submissions'])
|
const emit = defineEmits(['edit', 'delete', 'show-details', 'show-submissions'])
|
||||||
|
|
||||||
const duration = computed(() =>
|
const duration = computed(() => formatJalaaliDate(props.assignment.deadline) || '—')
|
||||||
props.assignment.durationDays == null ? '—' : `${props.assignment.durationDays} روز`
|
|
||||||
)
|
|
||||||
|
|
||||||
const submissions = computed(() =>
|
const submissions = computed(() =>
|
||||||
props.assignment.submittersCount == null ? '—' : `${props.assignment.submittersCount} نفر`
|
props.assignment.submittersCount == null ? '—' : `${props.assignment.submittersCount} نفر`
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const todayIso = new Date().toISOString()
|
|||||||
|
|
||||||
const termSearch = ref('')
|
const termSearch = ref('')
|
||||||
const termFilters = computed(() => ({ title: termSearch.value }))
|
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 { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||||
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
||||||
|
|
||||||
|
|||||||
@@ -59,16 +59,10 @@
|
|||||||
:error="errors.sessionId"
|
:error="errors.sessionId"
|
||||||
/>
|
/>
|
||||||
<DatePickerField
|
<DatePickerField
|
||||||
v-model="form.startDate"
|
v-model="form.deadline"
|
||||||
name="startDate"
|
name="deadline"
|
||||||
label="تاریخ شروع"
|
label="ددلاین"
|
||||||
:error="errors.startDate"
|
:error="errors.deadline"
|
||||||
/>
|
|
||||||
<DatePickerField
|
|
||||||
v-model="form.endDate"
|
|
||||||
name="endDate"
|
|
||||||
label="تاریخ پایان"
|
|
||||||
:error="errors.endDate"
|
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.priority"
|
v-model="form.priority"
|
||||||
@@ -177,8 +171,7 @@ const form = ref({
|
|||||||
termId: '',
|
termId: '',
|
||||||
courseId: '',
|
courseId: '',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
startDate: '',
|
deadline: '',
|
||||||
endDate: '',
|
|
||||||
priority: 'mandatory',
|
priority: 'mandatory',
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
@@ -191,7 +184,7 @@ const { validate, validateAt, errors, resetErrors } = useYup(schema)
|
|||||||
|
|
||||||
const termSearch = ref('')
|
const termSearch = ref('')
|
||||||
const termFilters = computed(() => ({ title: termSearch.value }))
|
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 { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||||
const selectedTerm = ref(null)
|
const selectedTerm = ref(null)
|
||||||
const termOptions = computed(() => {
|
const termOptions = computed(() => {
|
||||||
@@ -288,7 +281,9 @@ const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, {
|
|||||||
enabled: () => !!assignmentId.value,
|
enabled: () => !!assignmentId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(existingAssignment, (assignment) => {
|
watch(
|
||||||
|
existingAssignment,
|
||||||
|
(assignment) => {
|
||||||
if (!assignment) return
|
if (!assignment) return
|
||||||
const sessionEntity = assignment.session
|
const sessionEntity = assignment.session
|
||||||
const tpl = sessionEntity?.course || assignment.course
|
const tpl = sessionEntity?.course || assignment.course
|
||||||
@@ -309,8 +304,7 @@ watch(existingAssignment, (assignment) => {
|
|||||||
termId,
|
termId,
|
||||||
courseId: tpl?.id || sessionEntity?.courseId || assignment.courseId || '',
|
courseId: tpl?.id || sessionEntity?.courseId || assignment.courseId || '',
|
||||||
sessionId: sessionEntity?.id || assignment.sessionId || '',
|
sessionId: sessionEntity?.id || assignment.sessionId || '',
|
||||||
startDate: assignment.startDate || '',
|
deadline: assignment.deadline || assignment.endDate || '',
|
||||||
endDate: assignment.endDate || assignment.deadline || '',
|
|
||||||
priority: priorityValue,
|
priority: priorityValue,
|
||||||
description: assignment.description || '',
|
description: assignment.description || '',
|
||||||
}
|
}
|
||||||
@@ -322,7 +316,9 @@ watch(existingAssignment, (assignment) => {
|
|||||||
url: m.url || m.downloadUrl || '',
|
url: m.url || m.downloadUrl || '',
|
||||||
}))
|
}))
|
||||||
: []
|
: []
|
||||||
})
|
},
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
const addMutation = useAddAdminAssignmentMutation()
|
const addMutation = useAddAdminAssignmentMutation()
|
||||||
const updateMutation = useUpdateAdminAssignmentMutation()
|
const updateMutation = useUpdateAdminAssignmentMutation()
|
||||||
@@ -336,7 +332,7 @@ const onSubmit = async (close) => {
|
|||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
description: payload.description ?? '',
|
description: payload.description ?? '',
|
||||||
deadline: payload.endDate || null,
|
deadline: payload.deadline || null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isPriority: payload.priority === 'mandatory',
|
isPriority: payload.priority === 'mandatory',
|
||||||
mediaIds: attachments.value.map((file) => file.id).filter((id) => id != null),
|
mediaIds: attachments.value.map((file) => file.id).filter((id) => id != null),
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ export const assignmentSchema = object().shape({
|
|||||||
title: string().required().min(3),
|
title: string().required().min(3),
|
||||||
courseId: string().required(),
|
courseId: string().required(),
|
||||||
sessionId: string().required(),
|
sessionId: string().required(),
|
||||||
startDate: string().nullable().notRequired(),
|
deadline: string().notRequired(),
|
||||||
endDate: string().required(),
|
|
||||||
priority: string().required(),
|
priority: string().required(),
|
||||||
description: string().nullable().notRequired(),
|
description: string().nullable().notRequired(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const statusOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const termFilters = ref({})
|
const termFilters = ref({})
|
||||||
const termPagination = ref({ page: 1, perPage: 100 })
|
const termPagination = ref({ page: 1, perPage: 10 })
|
||||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination, {
|
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination, {
|
||||||
enabled: () => props.showTermFilter,
|
enabled: () => props.showTermFilter,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<div class="exam-item__sub">
|
<div class="exam-item__sub">
|
||||||
<span class="exam-item__sub-label">دوره:</span>
|
<span class="exam-item__sub-label">دوره:</span>
|
||||||
<span class="exam-item__sub-value">
|
<span class="exam-item__sub-value">
|
||||||
{{ exam.course?.title || exam.courseTitle || '—' }}
|
{{ exam?.session?.course?.title || '—' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="exam-item__dot">|</span>
|
<span class="exam-item__dot">|</span>
|
||||||
<span class="exam-item__sub-label">جلسه:</span>
|
<span class="exam-item__sub-label">جلسه:</span>
|
||||||
<span class="exam-item__sub-value">
|
<span class="exam-item__sub-value">
|
||||||
{{ exam.session?.title || exam.sessionTitle || '—' }}
|
{{ exam.session?.title || '—' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,16 +51,6 @@
|
|||||||
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||||
</template>
|
</template>
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton
|
|
||||||
tooltip="ویرایش"
|
|
||||||
bg-color="rgba(104, 104, 104, 0.05)"
|
|
||||||
size="2.5rem"
|
|
||||||
@click="emit('edit', exam)"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<SvgIcon name="pencil" :size="18" color="var(--color-sec-gray)" />
|
|
||||||
</template>
|
|
||||||
</CircleButton>
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
text="جزئیات آزمون"
|
text="جزئیات آزمون"
|
||||||
custom-class="exam-item__details-btn"
|
custom-class="exam-item__details-btn"
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ const correctOptions = (question) =>
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #4b4b4b;
|
color: #4b4b4b;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: 0 0 0 1px var(--color-validate, var(--color-primary));
|
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 termSearch = ref('')
|
||||||
const termFilters = computed(() => ({ title: termSearch.value }))
|
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 { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||||
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<template>
|
<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 }">
|
<template #default="{ close }">
|
||||||
<div class="exam-details">
|
<div class="exam-details">
|
||||||
<LineTitleBlock title="جزئیات آزمون" title-en="Details" />
|
|
||||||
|
|
||||||
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
|
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
|
||||||
<template v-else-if="exam">
|
<template v-else-if="exam">
|
||||||
<div class="exam-details__summary">
|
<div class="exam-details__summary">
|
||||||
@@ -75,7 +80,6 @@ import BasicModal from '@/components/BasicModal.vue'
|
|||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||||
import NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
|
||||||
import { useAdminExamQuery } from '@/services/query/admin-exams'
|
import { useAdminExamQuery } from '@/services/query/admin-exams'
|
||||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
@@ -93,6 +97,7 @@ const { data: exam, isLoading } = useAdminExamQuery(examId, {
|
|||||||
|
|
||||||
const summaryItems = computed(() => {
|
const summaryItems = computed(() => {
|
||||||
const e = exam.value || {}
|
const e = exam.value || {}
|
||||||
|
const course = e.session?.course || e.course
|
||||||
return [
|
return [
|
||||||
{ title: 'عنوان آزمون', value: e.title || '—', numeric: false },
|
{ title: 'عنوان آزمون', value: e.title || '—', numeric: false },
|
||||||
{
|
{
|
||||||
@@ -102,13 +107,17 @@ const summaryItems = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'دوره مرتبط',
|
title: 'دوره مرتبط',
|
||||||
value: e.course?.title || e.courseTitle || '—',
|
value: course?.title || e.courseTitle || '—',
|
||||||
numeric: false,
|
numeric: false,
|
||||||
},
|
},
|
||||||
{ title: 'وضعیت', value: e.statusLabel || e.faStatus || e.status || '—', numeric: false },
|
{
|
||||||
|
title: 'نمره کل',
|
||||||
|
value: e.score == null ? '—' : `${e.score} نمره`,
|
||||||
|
numeric: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'حد نصاب قبولی',
|
title: 'حد نصاب قبولی',
|
||||||
value: e.passScore == null ? '—' : `${e.passScore} نمره`,
|
value: e.minimumScore == null ? '—' : `${e.minimumScore} نمره`,
|
||||||
numeric: true,
|
numeric: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -137,7 +146,7 @@ const displayQuestions = computed(() => {
|
|||||||
id: question?.id ?? `question-${index + 1}`,
|
id: question?.id ?? `question-${index + 1}`,
|
||||||
order: question?.position ?? index + 1,
|
order: question?.position ?? index + 1,
|
||||||
title: question?.questionText || '—',
|
title: question?.questionText || '—',
|
||||||
score: '',
|
score: question?.score ?? '',
|
||||||
answers,
|
answers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,36 +6,38 @@
|
|||||||
<p v-if="data?.title" class="exam-participants__exam">{{ data.title }}</p>
|
<p v-if="data?.title" class="exam-participants__exam">{{ data.title }}</p>
|
||||||
|
|
||||||
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
|
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
|
||||||
<div v-else-if="participants.length > 0" class="exam-participants__list">
|
<div v-else-if="attempts.length > 0" class="exam-participants__list">
|
||||||
<div
|
<div v-for="attempt in attempts" :key="attempt.id" class="exam-participants__row">
|
||||||
v-for="participant in participants"
|
|
||||||
:key="participant.id"
|
|
||||||
class="exam-participants__row"
|
|
||||||
>
|
|
||||||
<div class="exam-participants__user">
|
<div class="exam-participants__user">
|
||||||
<div v-if="participant.avatarUrl" class="exam-participants__avatar">
|
<div v-if="avatarOf(attempt)" class="exam-participants__avatar">
|
||||||
<img :src="participant.avatarUrl" :alt="userName(participant)" />
|
<img :src="avatarOf(attempt)" :alt="userName(attempt)" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="exam-participants__avatar exam-participants__avatar--placeholder">
|
<div v-else class="exam-participants__avatar exam-participants__avatar--placeholder">
|
||||||
<SvgIcon name="user" :size="20" color="#bcbcbc" />
|
<SvgIcon name="user" :size="20" color="#bcbcbc" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="exam-participants__name">{{ userName(participant) }}</p>
|
<p class="exam-participants__name">{{ userName(attempt) }}</p>
|
||||||
<p class="exam-participants__location">
|
<p class="exam-participants__location">{{ locationOf(attempt) || '—' }}</p>
|
||||||
{{ participant.location || locationFromAddress(participant) || '—' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="exam-participants__pill">
|
<div class="exam-participants__pill">
|
||||||
<span class="exam-participants__pill-label">تاریخ شرکت:</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
text="جزئیات"
|
text="جزئیات"
|
||||||
custom-class="exam-participants__details-btn"
|
custom-class="exam-participants__details-btn"
|
||||||
@click="onShowDetails(participant)"
|
@click="onShowDetails(attempt)"
|
||||||
>
|
>
|
||||||
<template #appendIcon>
|
<template #appendIcon>
|
||||||
<SvgIcon name="caret-left" :size="14" color="#fff" />
|
<SvgIcon name="caret-left" :size="14" color="#fff" />
|
||||||
@@ -72,7 +74,7 @@ import { formatJalaaliDate } from '@/utils/date-utils'
|
|||||||
import { usePagination } from '@/composables/usePagination'
|
import { usePagination } from '@/composables/usePagination'
|
||||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||||
import PaginationBlock from '@/components/blocks/PaginationBlock.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'
|
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ExamParticipantsModal' })
|
defineOptions({ name: 'ExamParticipantsModal' })
|
||||||
@@ -85,33 +87,53 @@ const examId = computed(() => modalData.value.id ?? null)
|
|||||||
const filters = ref({})
|
const filters = ref({})
|
||||||
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
|
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,
|
enabled: () => !!examId.value,
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const participants = computed(() => data.value?.data ?? [])
|
const attempts = computed(() => data.value?.data ?? [])
|
||||||
const paginationMeta = computed(() => ({
|
const paginationMeta = computed(() => ({
|
||||||
page: pagination.value.page,
|
page: pagination.value.page,
|
||||||
perPage: pagination.value.perPage,
|
perPage: pagination.value.perPage,
|
||||||
...data.value?.meta,
|
...data.value?.meta,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const userName = (p) => `${p.firstName || ''} ${p.lastName || ''}`.trim() || p.fullName || '—'
|
const userOf = (attempt) => attempt?.user ?? {}
|
||||||
|
|
||||||
const locationFromAddress = (p) => {
|
const userName = (attempt) => {
|
||||||
const province = p.address?.province?.name || ''
|
const u = userOf(attempt)
|
||||||
const city = p.address?.city?.name || ''
|
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('، ')
|
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', {
|
openModal('ExamParticipantDetailsModal', {
|
||||||
examId: examId.value,
|
examId: examId.value,
|
||||||
participantId: participant.id,
|
participantId: u.id,
|
||||||
participant,
|
participant: {
|
||||||
|
id: u.id,
|
||||||
|
firstName: u.firstName ?? u.name ?? '',
|
||||||
|
lastName: u.lastName ?? '',
|
||||||
|
fullName: u.name ?? '',
|
||||||
|
avatarUrl: avatarOf(attempt),
|
||||||
|
},
|
||||||
|
attempt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -210,6 +232,36 @@ const onShowDetails = (participant) => {
|
|||||||
color: #535353;
|
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 {
|
&__details-btn {
|
||||||
min-width: 5rem;
|
min-width: 5rem;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|||||||
@@ -32,16 +32,34 @@
|
|||||||
@blur="validateAt('title', form.title)"
|
@blur="validateAt('title', form.title)"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.passingScore"
|
v-model="form.minimumScore"
|
||||||
name="passingScore"
|
name="minimumScore"
|
||||||
label="حد نصاب قبولی"
|
label="حد نصاب قبولی"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
:convert-digits="true"
|
:convert-digits="true"
|
||||||
:error="errors.passingScore"
|
:error="errors.minimumScore"
|
||||||
@blur="validateAt('passingScore', form.passingScore)"
|
@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">
|
<div class="exam-form__toggle-cell">
|
||||||
<ToggleSwitch v-model="form.isActive" label="آزمون فعال باشد" />
|
<ToggleSwitch v-model="form.isRandom" label="به صــــورت رندوم باشد." />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,8 +140,10 @@ const isEditMode = computed(() => !!examId.value)
|
|||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
passingScore: '',
|
minimumScore: '',
|
||||||
isActive: true,
|
score: '',
|
||||||
|
durationMinutes: '',
|
||||||
|
isRandom: true,
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -145,7 +165,7 @@ const questionError = ref('')
|
|||||||
const { validate, validateAt, errors } = useYup(examSchema)
|
const { validate, validateAt, errors } = useYup(examSchema)
|
||||||
|
|
||||||
const sessionSearch = ref('')
|
const sessionSearch = ref('')
|
||||||
const sessionFilters = computed(() => ({ title: sessionSearch.value }))
|
const sessionFilters = computed(() => ({ search: sessionSearch.value }))
|
||||||
const sessionPagination = ref({ page: 1, perPage: 10 })
|
const sessionPagination = ref({ page: 1, perPage: 10 })
|
||||||
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination)
|
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination)
|
||||||
const selectedSession = ref(null)
|
const selectedSession = ref(null)
|
||||||
@@ -183,18 +203,24 @@ const normalizeExistingQuestions = (raw = []) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(existingExam, (exam) => {
|
watch(
|
||||||
|
existingExam,
|
||||||
|
(exam) => {
|
||||||
if (!exam) return
|
if (!exam) return
|
||||||
if (exam.session) selectedSession.value = exam.session
|
if (exam.session) selectedSession.value = exam.session
|
||||||
form.value = {
|
form.value = {
|
||||||
title: exam.title || '',
|
title: exam.title || '',
|
||||||
sessionId: exam.session?.id || exam.sessionId || '',
|
sessionId: exam.session?.id || exam.sessionId || '',
|
||||||
passingScore: exam.passScore ?? '',
|
minimumScore: exam.minimumScore ?? exam.passScore ?? '',
|
||||||
isActive: exam.isActive ?? true,
|
score: exam.score ?? '',
|
||||||
|
durationMinutes: exam.durationMinutes ?? '',
|
||||||
|
isRandom: exam.isRandom ?? true,
|
||||||
description: exam.description || '',
|
description: exam.description || '',
|
||||||
}
|
}
|
||||||
questions.value = normalizeExistingQuestions(exam.questions)
|
questions.value = normalizeExistingQuestions(exam.questions)
|
||||||
})
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const validateLocalQuestions = () => {
|
const validateLocalQuestions = () => {
|
||||||
const localOnes = questions.value.filter((q) => q.__local === true)
|
const localOnes = questions.value.filter((q) => q.__local === true)
|
||||||
@@ -235,8 +261,10 @@ const buildExamPayload = (values) => ({
|
|||||||
sessionId: values.sessionId,
|
sessionId: values.sessionId,
|
||||||
title: values.title,
|
title: values.title,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
passingScore: Number(values.passingScore) || 0,
|
minimumScore: Number(values.minimumScore) || 0,
|
||||||
isActive: values.isActive,
|
score: Number(values.score) || 0,
|
||||||
|
durationMinutes: Number(values.durationMinutes) || 0,
|
||||||
|
isRandom: values.isRandom,
|
||||||
})
|
})
|
||||||
|
|
||||||
const addExamMutation = useAddAdminExamMutation()
|
const addExamMutation = useAddAdminExamMutation()
|
||||||
@@ -298,7 +326,6 @@ const onCancel = () => router.push({ name: 'admin-exams' })
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
background: rgba(255, 255, 255, 60%);
|
|
||||||
border-radius: 1.5rem;
|
border-radius: 1.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
|
||||||
@@ -322,6 +349,14 @@ const onCancel = () => router.push({ name: 'admin-exams' })
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
min-height: 4.25rem;
|
min-height: 4.25rem;
|
||||||
|
margin-right: 40px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__error {
|
&__error {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { boolean, number, object, string } from 'yup'
|
|||||||
export const examSchema = object().shape({
|
export const examSchema = object().shape({
|
||||||
title: string().required().min(3),
|
title: string().required().min(3),
|
||||||
sessionId: string().required(),
|
sessionId: string().required(),
|
||||||
passingScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
minimumScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
||||||
|
score: number().typeError('کل نمره آزمون باید عدد باشد').required(),
|
||||||
|
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(),
|
||||||
description: string().nullable().notRequired(),
|
description: string().nullable().notRequired(),
|
||||||
isActive: boolean().nullable().notRequired(),
|
isActive: boolean().nullable().notRequired(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
<div class="session-form__main-col">
|
<div class="session-form__main-col">
|
||||||
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
|
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
|
||||||
|
|
||||||
<!-- row 1 — title / startsAt / endsAt -->
|
<!-- row 1 — title -->
|
||||||
<div class="session-form__row session-form__row--three">
|
<div class="session-form__row">
|
||||||
<TextField
|
<TextField
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
name="title"
|
name="title"
|
||||||
@@ -38,34 +38,10 @@
|
|||||||
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
|
||||||
</template>
|
</template>
|
||||||
</TextField>
|
</TextField>
|
||||||
<DatePickerField
|
|
||||||
v-model="form.startsAt"
|
|
||||||
name="startsAt"
|
|
||||||
label="زمان شروع"
|
|
||||||
type="datetime"
|
|
||||||
:error="errors.startsAt"
|
|
||||||
@blur="validateAt('startsAt', form.startsAt)"
|
|
||||||
/>
|
|
||||||
<DatePickerField
|
|
||||||
v-model="form.endsAt"
|
|
||||||
name="endsAt"
|
|
||||||
label="زمان پایان"
|
|
||||||
type="datetime"
|
|
||||||
:error="errors.endsAt"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- row 2 — durationMinutes / courseId / contentType -->
|
<!-- row 2 — courseId / contentType -->
|
||||||
<div class="session-form__row session-form__row--three">
|
<div class="session-form__row session-form__row--two">
|
||||||
<TextField
|
|
||||||
v-model="form.durationMinutes"
|
|
||||||
name="durationMinutes"
|
|
||||||
label="مدت زمان جلسه (دقیقه)"
|
|
||||||
inputmode="numeric"
|
|
||||||
:convert-digits="true"
|
|
||||||
:error="errors.durationMinutes"
|
|
||||||
@blur="validateAt('durationMinutes', form.durationMinutes)"
|
|
||||||
/>
|
|
||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.courseId"
|
v-model="form.courseId"
|
||||||
name="courseId"
|
name="courseId"
|
||||||
@@ -184,7 +160,6 @@ import { objectToFormData } from '@/utils/object-to-formdata'
|
|||||||
import { useUploadMediaMutation } from '@/services/query/auth'
|
import { useUploadMediaMutation } from '@/services/query/auth'
|
||||||
import TextareaField from '@/components/form/TextareaField.vue'
|
import TextareaField from '@/components/form/TextareaField.vue'
|
||||||
import { sessionSchema } from '@/features/admin/sessions/schema'
|
import { sessionSchema } from '@/features/admin/sessions/schema'
|
||||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
|
||||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
|
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
|
||||||
@@ -214,9 +189,6 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
|
|||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
startsAt: '',
|
|
||||||
endsAt: '',
|
|
||||||
durationMinutes: '',
|
|
||||||
courseId: '',
|
courseId: '',
|
||||||
contentType: '',
|
contentType: '',
|
||||||
type: '',
|
type: '',
|
||||||
@@ -274,9 +246,6 @@ watch(
|
|||||||
const contentType = collectionToContentType(contentMedia?.collectionName)
|
const contentType = collectionToContentType(contentMedia?.collectionName)
|
||||||
form.value = {
|
form.value = {
|
||||||
title: session.title ?? '',
|
title: session.title ?? '',
|
||||||
startsAt: session.startsAt ?? '',
|
|
||||||
endsAt: session.endsAt ?? '',
|
|
||||||
durationMinutes: session.durationMinutes ?? '',
|
|
||||||
courseId: session.course?.id ?? '',
|
courseId: session.course?.id ?? '',
|
||||||
contentType,
|
contentType,
|
||||||
type: session.type ?? '',
|
type: session.type ?? '',
|
||||||
@@ -359,9 +328,6 @@ const buildPayload = (values) => {
|
|||||||
const mediaIds = [coverMediaId.value, contentMediaId.value].filter((id) => id != null)
|
const mediaIds = [coverMediaId.value, contentMediaId.value].filter((id) => id != null)
|
||||||
const payload = {
|
const payload = {
|
||||||
title: values.title,
|
title: values.title,
|
||||||
startsAt: values.startsAt,
|
|
||||||
endsAt: values.endsAt,
|
|
||||||
durationMinutes: values.durationMinutes,
|
|
||||||
courseId: values.courseId,
|
courseId: values.courseId,
|
||||||
type: values.type,
|
type: values.type,
|
||||||
link: values.type === 'online' ? values.link : undefined,
|
link: values.type === 'online' ? values.link : undefined,
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { number, object, string } from 'yup'
|
import { object, string } from 'yup'
|
||||||
|
|
||||||
export const sessionSchema = object().shape({
|
export const sessionSchema = object().shape({
|
||||||
title: string().required().min(3),
|
title: string().required().min(3),
|
||||||
startsAt: string().required(),
|
|
||||||
endsAt: string().nullable().notRequired(),
|
|
||||||
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required().min(1),
|
|
||||||
courseId: string().required(),
|
courseId: string().required(),
|
||||||
type: string().oneOf(['offline', 'online']).required(),
|
type: string().oneOf(['offline', 'online']).required(),
|
||||||
link: string().when('sessionType', {
|
link: string().when('sessionType', {
|
||||||
|
|||||||
@@ -58,6 +58,28 @@
|
|||||||
:error="errors.endsAt"
|
:error="errors.endsAt"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="term-form__cell term-form__cell--third">
|
||||||
|
<TextField
|
||||||
|
v-model="form.minimumScore"
|
||||||
|
name="minimumScore"
|
||||||
|
label="حد نصاب قبولی"
|
||||||
|
inputmode="numeric"
|
||||||
|
:convert-digits="true"
|
||||||
|
:error="errors.minimumScore"
|
||||||
|
@blur="validateAt('minimumScore', form.minimumScore)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="term-form__cell term-form__cell--third">
|
||||||
|
<TextField
|
||||||
|
v-model="form.score"
|
||||||
|
name="score"
|
||||||
|
label="کل نمره آزمون"
|
||||||
|
inputmode="numeric"
|
||||||
|
:convert-digits="true"
|
||||||
|
:error="errors.score"
|
||||||
|
@blur="validateAt('score', form.score)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="term-form__cell term-form__cell--full">
|
<div class="term-form__cell term-form__cell--full">
|
||||||
<TextareaField
|
<TextareaField
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
@@ -138,6 +160,8 @@ const form = ref({
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
startsAt: '',
|
startsAt: '',
|
||||||
endsAt: '',
|
endsAt: '',
|
||||||
|
minimumScore: '',
|
||||||
|
score: '',
|
||||||
coverMediaId: null,
|
coverMediaId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,7 +176,6 @@ const { data: existingTerm } = useAdminTermQuery(termId, {
|
|||||||
watch(
|
watch(
|
||||||
existingTerm,
|
existingTerm,
|
||||||
(term) => {
|
(term) => {
|
||||||
console.log(term)
|
|
||||||
if (!term) return
|
if (!term) return
|
||||||
form.value = {
|
form.value = {
|
||||||
title: term.title || '',
|
title: term.title || '',
|
||||||
@@ -160,6 +183,8 @@ watch(
|
|||||||
isActive: term.isActive ?? true,
|
isActive: term.isActive ?? true,
|
||||||
startsAt: term.startsAt || '',
|
startsAt: term.startsAt || '',
|
||||||
endsAt: term.endsAt || '',
|
endsAt: term.endsAt || '',
|
||||||
|
minimumScore: term.minimumScore ?? '',
|
||||||
|
score: term.score ?? '',
|
||||||
}
|
}
|
||||||
if (term.coverUrl) image.value = { url: term.coverUrl }
|
if (term.coverUrl) image.value = { url: term.coverUrl }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export const termSchema = object().shape({
|
|||||||
isActive: boolean().default(true),
|
isActive: boolean().default(true),
|
||||||
startsAt: string().required(),
|
startsAt: string().required(),
|
||||||
endsAt: string().required(),
|
endsAt: string().required(),
|
||||||
|
minimumScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
||||||
|
score: number().typeError('کل نمره آزمون باید عدد باشد').required(),
|
||||||
coverMediaId: number().nullable().notRequired(),
|
coverMediaId: number().nullable().notRequired(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
<span class="student-lesson-item__decor" aria-hidden="true">
|
<span class="student-lesson-item__decor" aria-hidden="true">
|
||||||
<SvgIcon name="users-three" :size="20" color="rgba(0, 112, 116, 0.31)" />
|
<SvgIcon name="users-three" :size="20" color="rgba(0, 112, 116, 0.31)" />
|
||||||
</span>
|
</span>
|
||||||
|
<div>
|
||||||
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
|
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
|
||||||
|
<div class="student-lesson-item__hours">
|
||||||
|
<SvgIcon name="calendar" :size="11" color="#007074" />
|
||||||
|
<span class="student-lesson-item__hours-label">زمان یادگیری :</span>
|
||||||
|
<span class="student-lesson-item__hours-value">{{ hoursText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="student-lesson-item__stats">
|
<div class="student-lesson-item__stats">
|
||||||
@@ -25,12 +32,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="student-lesson-item__hours">
|
|
||||||
<SvgIcon name="calendar" :size="11" color="#007074" />
|
|
||||||
<span class="student-lesson-item__hours-label">زمان یادگیری :</span>
|
|
||||||
<span class="student-lesson-item__hours-value">{{ hoursText }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="student-lesson-item__actions">
|
<div class="student-lesson-item__actions">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
text="جزئیات درس"
|
text="جزئیات درس"
|
||||||
@@ -93,7 +94,7 @@ const studentsText = computed(() =>
|
|||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
flex: 1 1 22%;
|
flex: 1 1 22%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__decor {
|
&__decor {
|
||||||
|
|||||||
@@ -13,9 +13,8 @@
|
|||||||
<p class="student-term-summary__title">{{ term.title || '—' }}</p>
|
<p class="student-term-summary__title">{{ term.title || '—' }}</p>
|
||||||
|
|
||||||
<div class="student-term-summary__teacher">
|
<div class="student-term-summary__teacher">
|
||||||
<span class="student-term-summary__teacher-label">استـــــــاد:</span>
|
<span class="student-term-summary__teacher-label">تعداد دوره ها:</span>
|
||||||
<span class="student-term-summary__teacher-name">{{ teacherName }}</span>
|
<span class="student-term-summary__teacher-name">{{ courseCount }}</span>
|
||||||
<SvgIcon name="user" :size="13" color="#bcbcbc" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { STUDENT_COURSE_STATUS } from '@/enums'
|
import { STUDENT_COURSE_STATUS } from '@/enums'
|
||||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
|
||||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -49,11 +47,7 @@ const status = computed(() => props.enrollment.status || 'active')
|
|||||||
|
|
||||||
const statusLabel = computed(() => STUDENT_COURSE_STATUS[status.value] || '—')
|
const statusLabel = computed(() => STUDENT_COURSE_STATUS[status.value] || '—')
|
||||||
|
|
||||||
const teacherName = computed(() => {
|
const courseCount = computed(() => term.value?.coursesCount ?? '—')
|
||||||
const t = term.value.teacher
|
|
||||||
if (t) return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
|
||||||
return term.value.teacherName || '—'
|
|
||||||
})
|
|
||||||
|
|
||||||
const startDate = computed(() => formatJalaaliDate(term.value.startsAt) || '—')
|
const startDate = computed(() => formatJalaaliDate(term.value.startsAt) || '—')
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</SimpleTitleIconBlock>
|
</SimpleTitleIconBlock>
|
||||||
|
|
||||||
<SkeletonLoaderBlock v-if="isLoading && lessons.length === 0" :rows="3" :cols-per-row="1" />
|
<SkeletonLoaderBlock
|
||||||
|
v-if="lessonsLoading && lessons.length === 0"
|
||||||
|
:rows="3"
|
||||||
|
:cols-per-row="1"
|
||||||
|
/>
|
||||||
<div v-else-if="lessons.length > 0">
|
<div v-else-if="lessons.length > 0">
|
||||||
<StudentLessonItem
|
<StudentLessonItem
|
||||||
v-for="lesson in lessons"
|
v-for="lesson in lessons"
|
||||||
@@ -33,11 +37,12 @@
|
|||||||
</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 NoItems from '@/components/blocks/NoItems.vue'
|
import NoItems from '@/components/blocks/NoItems.vue'
|
||||||
import { useStudentTermQuery } from '@/services/query/student-terms'
|
import { useStudentTermQuery } from '@/services/query/student-terms'
|
||||||
|
import { useStudentCoursesListQuery } from '@/services/query/student-courses'
|
||||||
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'
|
||||||
@@ -53,7 +58,15 @@ const { data: enrollment, isLoading } = useStudentTermQuery(termId, {
|
|||||||
enabled: () => !!termId.value,
|
enabled: () => !!termId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
const lessons = computed(() => enrollment.value?.term?.courses ?? [])
|
const coursesFilters = computed(() => ({ termId: termId.value }))
|
||||||
|
const coursesPagination = ref({ page: 1, perPage: 10 })
|
||||||
|
const { data: coursesData, isLoading: lessonsLoading } = useStudentCoursesListQuery(
|
||||||
|
coursesFilters,
|
||||||
|
coursesPagination,
|
||||||
|
{ enabled: () => !!termId.value, keepPreviousData: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const lessons = computed(() => coursesData.value?.data?.items ?? [])
|
||||||
|
|
||||||
const onShowLesson = (lesson) => {
|
const onShowLesson = (lesson) => {
|
||||||
router.push({ name: 'student-lesson-details', params: { id: lesson.id } }).catch(() => {})
|
router.push({ name: 'student-lesson-details', params: { id: lesson.id } }).catch(() => {})
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ const { data: endedData, isLoading: endedPending } = useStudentTermsListQuery(
|
|||||||
|
|
||||||
const currentTerms = computed(() => currentData.value?.data ?? [])
|
const currentTerms = computed(() => currentData.value?.data ?? [])
|
||||||
const endedTerms = computed(() => endedData.value?.data ?? [])
|
const endedTerms = computed(() => endedData.value?.data ?? [])
|
||||||
console.log({ endedTerms: endedData.value })
|
|
||||||
|
|
||||||
const currentMeta = computed(() => ({
|
const currentMeta = computed(() => ({
|
||||||
page: currentPagination.value.page,
|
page: currentPagination.value.page,
|
||||||
@@ -112,7 +111,7 @@ const endedMeta = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const onShowDetails = (term) => {
|
const onShowDetails = (term) => {
|
||||||
router.push({ name: 'student-term-details', params: { id: term.id } }).catch(() => {})
|
router.push({ name: 'student-term-details', params: { id: term?.term?.id } }).catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ export const apiAddAdminExamQuestion = (examId, payload) =>
|
|||||||
export const apiAddAdminQuestionOption = (questionId, payload) =>
|
export const apiAddAdminQuestionOption = (questionId, payload) =>
|
||||||
http.post(buildUrl(endpoints.addQuestionOption, { questionId }), payload)
|
http.post(buildUrl(endpoints.addQuestionOption, { questionId }), payload)
|
||||||
|
|
||||||
// Mock-only — backend has no /exams/:id/participants endpoint yet.
|
// GET /exams/:examId/attempts — paginated list, each row is one attempt with
|
||||||
export const apiGetAdminExamParticipants = (examId, params) =>
|
// the attempting user embedded (multiple rows per user possible).
|
||||||
http.get(buildUrl(endpoints.getExamParticipants, { examId }), { params })
|
export const apiGetAdminExamAttempts = (examId, params) =>
|
||||||
|
http.get(buildUrl(endpoints.getExamAttempts, { 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 }))
|
||||||
|
|||||||
@@ -122,8 +122,10 @@ export const endpoints = {
|
|||||||
// Sessions — attendance roster
|
// Sessions — attendance roster
|
||||||
getSessionsAttendance: '/admin/sessions/:sessionId/attendances',
|
getSessionsAttendance: '/admin/sessions/:sessionId/attendances',
|
||||||
|
|
||||||
// Exams — participants only (backend has /exams/:id but no participants endpoints)
|
// Exams — admin/teacher view of attempts. Each row is one attempt with the
|
||||||
getExamParticipants: '/admin/exams/:examId/participants',
|
// attempting user embedded; multiple rows per user are possible. The
|
||||||
|
// single-attempt detail endpoint is still mock-only.
|
||||||
|
getExamAttempts: '/exams/:examId/attempts',
|
||||||
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
|
||||||
|
|||||||
@@ -77,13 +77,69 @@ export const adminExams = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// FE-mock-only — no backend endpoint for exam participants.
|
// Mirrors GET /exams/:examId/attempts. Each row is one attempt with the user
|
||||||
|
// embedded; multiple rows per user are possible.
|
||||||
|
export const examAttempts = new Map([
|
||||||
|
[
|
||||||
|
201,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
examId: 201,
|
||||||
|
score: 85,
|
||||||
|
isPassed: true,
|
||||||
|
startedAt: '2026-04-12T15:00:00+03:30',
|
||||||
|
submittedAt: '2026-04-12T15:30:00+03:30',
|
||||||
|
deadlineAt: '2026-04-12T15:30:00+03:30',
|
||||||
|
user: {
|
||||||
|
id: 100,
|
||||||
|
name: 'فاطمه رضایی',
|
||||||
|
email: 'fatemeh.r@example.com',
|
||||||
|
phone: '+989121234567',
|
||||||
|
provinceId: 8,
|
||||||
|
cityId: 47,
|
||||||
|
province: { id: 8, name: 'تهران' },
|
||||||
|
city: { id: 47, provinceId: 8, name: 'تهران' },
|
||||||
|
roles: ['student'],
|
||||||
|
avatarUrl: null,
|
||||||
|
avatarDownloadUrl: null,
|
||||||
|
createdAt: '2026-02-01T10:00:00+00:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 15,
|
||||||
|
examId: 201,
|
||||||
|
score: 42,
|
||||||
|
isPassed: false,
|
||||||
|
startedAt: '2026-04-13T09:00:00+03:30',
|
||||||
|
submittedAt: '2026-04-13T09:25:00+03:30',
|
||||||
|
deadlineAt: '2026-04-13T09:30:00+03:30',
|
||||||
|
user: {
|
||||||
|
id: 102,
|
||||||
|
name: 'مریم احمدی',
|
||||||
|
email: 'maryam.a@example.com',
|
||||||
|
phone: '+989121111111',
|
||||||
|
provinceId: 15,
|
||||||
|
cityId: 90,
|
||||||
|
province: { id: 15, name: 'اصفهان' },
|
||||||
|
city: { id: 90, provinceId: 15, name: 'اصفهان' },
|
||||||
|
roles: ['student'],
|
||||||
|
avatarUrl: null,
|
||||||
|
avatarDownloadUrl: null,
|
||||||
|
createdAt: '2026-02-03T10:00:00+00:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
// Kept for the FE-mock-only single-participant detail endpoint (no backend yet).
|
||||||
export const examParticipants = new Map([
|
export const examParticipants = new Map([
|
||||||
[
|
[
|
||||||
201,
|
201,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 100,
|
||||||
userId: 100,
|
userId: 100,
|
||||||
firstName: 'فاطمه',
|
firstName: 'فاطمه',
|
||||||
lastName: 'رضایی',
|
lastName: 'رضایی',
|
||||||
@@ -91,21 +147,21 @@ export const examParticipants = new Map([
|
|||||||
avatarUrl: '',
|
avatarUrl: '',
|
||||||
address: { province: { name: 'تهران' }, city: { name: 'تهران' } },
|
address: { province: { name: 'تهران' }, city: { name: 'تهران' } },
|
||||||
location: 'تهران، تهران',
|
location: 'تهران، تهران',
|
||||||
submittedAt: '2026-05-05T10:30:00.000Z',
|
submittedAt: '2026-04-12T15:30:00+03:30',
|
||||||
attempts: [
|
attempts: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 14,
|
||||||
title: 'تلاش اول',
|
title: 'تلاش اول',
|
||||||
courseTitle: 'اصول اخلاق اسلامی',
|
courseTitle: 'اصول اخلاق اسلامی',
|
||||||
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
||||||
date: '2026-05-05T10:30:00.000Z',
|
date: '2026-04-12T15:30:00+03:30',
|
||||||
score: 17,
|
score: 17,
|
||||||
scoreTone: 'good',
|
scoreTone: 'good',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 102,
|
||||||
userId: 102,
|
userId: 102,
|
||||||
firstName: 'مریم',
|
firstName: 'مریم',
|
||||||
lastName: 'احمدی',
|
lastName: 'احمدی',
|
||||||
@@ -113,14 +169,14 @@ export const examParticipants = new Map([
|
|||||||
avatarUrl: '',
|
avatarUrl: '',
|
||||||
address: { province: { name: 'اصفهان' }, city: { name: 'اصفهان' } },
|
address: { province: { name: 'اصفهان' }, city: { name: 'اصفهان' } },
|
||||||
location: 'اصفهان، اصفهان',
|
location: 'اصفهان، اصفهان',
|
||||||
submittedAt: '2026-05-06T11:00:00.000Z',
|
submittedAt: '2026-04-13T09:25:00+03:30',
|
||||||
attempts: [
|
attempts: [
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 15,
|
||||||
title: 'تلاش اول',
|
title: 'تلاش اول',
|
||||||
courseTitle: 'اصول اخلاق اسلامی',
|
courseTitle: 'اصول اخلاق اسلامی',
|
||||||
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
sessionTitle: 'مقدمهای بر اخلاق اسلامی',
|
||||||
date: '2026-05-06T11:00:00.000Z',
|
date: '2026-04-13T09:25:00+03:30',
|
||||||
score: 9,
|
score: 9,
|
||||||
scoreTone: 'bad',
|
scoreTone: 'bad',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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, examParticipants } from '@/services/mock/fixtures/admin-exams'
|
import { adminExams, examAttempts, examParticipants } from '@/services/mock/fixtures/admin-exams'
|
||||||
import {
|
import {
|
||||||
filterDateRange,
|
filterDateRange,
|
||||||
filterItems,
|
filterItems,
|
||||||
@@ -151,12 +151,13 @@ register('POST', endpoints.addQuestionOption, ({ params, data }) => {
|
|||||||
return { success: false, message: 'Question not found.', data: null }
|
return { success: false, message: 'Question not found.', data: null }
|
||||||
})
|
})
|
||||||
|
|
||||||
// FE-mock-only.
|
register('GET', endpoints.getExamAttempts, ({ params, query }) => {
|
||||||
register('GET', endpoints.getExamParticipants, ({ params, query }) => {
|
const list = examAttempts.get(Number(params.examId)) || []
|
||||||
const list = examParticipants.get(Number(params.examId)) || []
|
const { data: items, meta } = paginate(list, query)
|
||||||
return paginate(list, query)
|
return { success: true, message: 'OK', data: { items, meta } }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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)) || []
|
||||||
const found = list.find((p) => String(p.id) === String(params.participantId))
|
const found = list.find((p) => String(p.id) === String(params.participantId))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
apiAddAdminQuestionOption,
|
apiAddAdminQuestionOption,
|
||||||
apiDeleteAdminExam,
|
apiDeleteAdminExam,
|
||||||
apiGetAdminExams,
|
apiGetAdminExams,
|
||||||
apiGetAdminExamParticipants,
|
apiGetAdminExamAttempts,
|
||||||
apiShowAdminExam,
|
apiShowAdminExam,
|
||||||
apiShowAdminExamParticipant,
|
apiShowAdminExamParticipant,
|
||||||
apiUpdateAdminExam,
|
apiUpdateAdminExam,
|
||||||
@@ -16,10 +16,10 @@ export const adminExamsKeys = {
|
|||||||
all: ['admin', 'exams'],
|
all: ['admin', 'exams'],
|
||||||
list: (filters, pagination) => ['admin', 'exams', 'list', filters, pagination],
|
list: (filters, pagination) => ['admin', 'exams', 'list', filters, pagination],
|
||||||
detail: (id) => ['admin', 'exams', 'detail', id],
|
detail: (id) => ['admin', 'exams', 'detail', id],
|
||||||
participants: (examId, filters, pagination) => [
|
attempts: (examId, filters, pagination) => [
|
||||||
'admin',
|
'admin',
|
||||||
'exams',
|
'exams',
|
||||||
'participants',
|
'attempts',
|
||||||
examId,
|
examId,
|
||||||
filters,
|
filters,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -53,14 +53,18 @@ export const useAdminExamQuery = (idRef, options = {}) =>
|
|||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useAdminExamParticipantsQuery = (examIdRef, filtersRef, paginationRef, options = {}) =>
|
export const useAdminExamAttemptsQuery = (examIdRef, filtersRef, paginationRef, options = {}) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['admin', 'exams', 'participants', examIdRef, filtersRef, paginationRef],
|
queryKey: ['admin', 'exams', 'attempts', examIdRef, filtersRef, paginationRef],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiGetAdminExamParticipants(examIdRef.value, {
|
apiGetAdminExamAttempts(examIdRef.value, {
|
||||||
...cleanFilters(filtersRef?.value || {}),
|
...cleanFilters(filtersRef?.value || {}),
|
||||||
...paginationRef?.value,
|
...paginationRef?.value,
|
||||||
}),
|
}),
|
||||||
|
select: (response) => ({
|
||||||
|
data: response?.data?.items ?? response?.data ?? [],
|
||||||
|
meta: response?.data?.meta ?? response?.meta,
|
||||||
|
}),
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user