This commit is contained in:
sajjadtalkhabi
2026-05-15 17:12:51 +03:30
parent b8ced45375
commit c82701888a
101 changed files with 8125 additions and 96 deletions
@@ -0,0 +1,236 @@
<template>
<div class="cert">
<div class="cert__course">
<div class="cert__thumb">
<img v-if="certificate.thumbnailUrl" :src="certificate.thumbnailUrl" :alt="courseTitle" />
<SvgIcon v-else name="scroll" :size="24" color="#f55d6d" />
</div>
<div class="cert__course-info">
<p class="cert__title">{{ courseTitle }}</p>
<div class="cert__teacher">
<SvgIcon name="user" :size="11" color="#bcbcbc" />
<span class="cert__teacher-label">استاد :</span>
<span class="cert__teacher-name">{{ teacherName }}</span>
</div>
</div>
</div>
<div class="cert__dates">
<div class="cert__date">
<span class="cert__date-label">تاریخ شروع :</span>
<span class="cert__date-value">{{ certificate.startDate || '—' }}</span>
</div>
<span class="cert__date-divider" />
<div class="cert__date">
<span class="cert__date-label">تاریخ پایان :</span>
<span class="cert__date-value">{{ certificate.endDate || '—' }}</span>
</div>
</div>
<div class="cert__actions">
<BaseButton
ref="downloadBtnRef"
text="دانلود گواهینامه"
custom-class="cert__download-btn"
@click="toggleMenu"
>
<template #appendIcon>
<SvgIcon name="caret-down" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
<DropdownMenu
v-model="menuOpen"
:reference="menuTrigger"
:items="downloadItems"
placement="bottom-end"
min-width="14rem"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import DropdownMenu from '@/components/DropdownMenu.vue'
const props = defineProps({
certificate: { type: Object, required: true },
})
const emit = defineEmits(['download'])
const courseTitle = computed(() => props.certificate.courseTitle || '—')
const teacherName = computed(() => props.certificate.teacherName || '—')
const downloadBtnRef = ref(null)
const menuTrigger = ref(null)
const menuOpen = ref(false)
const toggleMenu = (event) => {
menuTrigger.value = event.currentTarget
menuOpen.value = !menuOpen.value
}
const onDownload = (format) => {
emit('download', { certificate: props.certificate, format })
}
const downloadItems = computed(() => [
{
key: 'pdf',
label: 'دانلود به صورت PDF',
icon: 'upload',
onClick: () => onDownload('pdf'),
},
{
key: 'jpg',
label: 'دانلود به صورت JPG',
icon: 'upload',
onClick: () => onDownload('jpg'),
},
])
</script>
<style lang="scss" scoped>
.cert {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
}
&__course {
display: flex;
align-items: center;
gap: 0.75rem;
@media (min-width: 1024px) {
flex: 1;
order: 3;
min-width: 12rem;
}
}
&__thumb {
width: 3rem;
height: 3.25rem;
border-radius: 0.625rem;
background: #f4f4f4;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__course-info {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: end;
gap: 0.25rem;
min-width: 0;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__teacher {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: var(--font-family-fa);
font-size: 0.65rem;
color: #4b4b4b;
}
&__teacher-label {
font-weight: 400;
}
&__teacher-name {
font-weight: 500;
}
&__dates {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
@media (min-width: 1024px) {
order: 2;
}
}
&__date {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
&__date-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__date-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__date-divider {
width: 1px;
height: 0.55rem;
background: #dadada;
}
&__actions {
display: flex;
justify-content: center;
@media (min-width: 1024px) {
order: 1;
justify-content: flex-start;
}
}
&__download-btn {
min-width: 9rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<div class="student-certificates">
<BoxedIconTitleBlock
class="student-certificates__heading"
title="گواهینامه های شما"
desc="در این قسمت شما میتوانید گواهینامه هر دوره ای را که گذرانده اید را مشاهده کنید."
>
<template #icon>
<SvgIcon name="scroll" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SimpleTitleIconBlock title="گواهینامه های شما" class="student-certificates__section-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="certificates.length > 0">
<CertificateItem
v-for="certificate in certificates"
:key="certificate.id"
:certificate="certificate"
@download="onDownload"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز گواهینامه‌ای ندارید." />
<PaginationBlock :pagination="meta" @update:page="setPage" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import CertificateItem from '@/features/student/certificates/components/CertificateItem.vue'
import {
useDownloadStudentCertificateMutation,
useStudentCertificatesQuery,
} from '@/services/query/student-certificates'
const filters = ref({})
const { pagination, setPage } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useStudentCertificatesQuery(filters, pagination, {
keepPreviousData: true,
})
const certificates = computed(() => data.value?.data ?? [])
const meta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const downloadMutation = useDownloadStudentCertificateMutation()
const onDownload = async ({ certificate, format }) => {
try {
const response = await downloadMutation.mutateAsync({ id: certificate.id, format })
const url = response?.data?.url
if (url && url !== '#') {
window.open(url, '_blank')
} else {
toast.info('فایل گواهینامه در دسترس نیست.')
}
} catch {
toast.error('دانلود گواهینامه با خطا مواجه شد.')
}
}
</script>
<style lang="scss" scoped>
.student-certificates {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__section-title {
margin-block: 0.25rem;
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="cns" @click="emit('show-details', consultant)">
<div class="cns__heading">
<p class="cns__title">{{ consultant.title }}</p>
<p class="cns__subtitle">شماره درخواست : {{ consultant.requestNumber }}</p>
</div>
<div class="cns__meta">
<Badge
variant="neutral"
size="sm"
icon="calendar"
:label="`زمان :`"
:value="`${consultant.timeLabel} | ${consultant.dateLabel}`"
/>
<Badge :variant="tone" size="sm" dot :value="consultant.statusLabel" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/global-components/Badge.vue'
const TONE_MAP = {
approved: 'success',
rejected: 'danger',
pending: 'neutral',
closed: 'info',
}
const props = defineProps({
consultant: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const tone = computed(() => TONE_MAP[props.consultant.status] || 'neutral')
</script>
<style lang="scss" scoped>
.cns {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
}
&__heading {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: end;
gap: 0.125rem;
@media (min-width: 1024px) {
min-width: 7rem;
}
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="rcf">
<form class="rcf__form" @submit.prevent="onSubmit">
<div class="rcf__row">
<div class="rcf__cell">
<SelectField
v-model="form.title"
name="title"
label="عنوان مشاوره درخواستی"
placeholder="انتخاب کنید"
:options="titleOptions"
option-label="label"
option-value="value"
:error="errors.title"
/>
</div>
</div>
<div class="rcf__row">
<div class="rcf__cell rcf__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات خود را وارد نمایید"
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
:row="5"
:error="errors.description"
/>
</div>
</div>
<div class="rcf__divider" />
<div class="rcf__actions">
<BaseButton
type="submit"
text="درخواست مشاوره"
custom-class="rcf__submit"
:loading="submitting"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</div>
</template>
<script setup>
import * as yup from 'yup'
import { computed, ref } from 'vue'
import useYup from '@/composables/useYup'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import { useStudentConsultantTitlesQuery } from '@/services/query/student-consultants'
defineProps({
submitting: { type: Boolean, default: false },
})
const emit = defineEmits(['submit'])
const form = ref({
title: '',
description: '',
})
const schema = yup.object({
title: yup.string().required('عنوان مشاوره را انتخاب کنید'),
description: yup
.string()
.trim()
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
.required('توضیحات را وارد کنید'),
})
const { validate, errors } = useYup(schema)
const { data: titles } = useStudentConsultantTitlesQuery()
const titleOptions = computed(() => titles.value ?? [])
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
emit('submit', { ...payload })
}
</script>
<style lang="scss" scoped>
.rcf {
display: flex;
flex-direction: column;
gap: 1rem;
border-radius: 0.75rem;
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
&__cell {
display: flex;
flex-direction: column;
&--full {
grid-column: 1 / -1;
}
}
&__divider {
height: 0.75px;
background: #ddd;
margin-top: 0.25rem;
}
&__actions {
display: flex;
justify-content: center;
@media (min-width: 768px) {
justify-content: flex-end;
}
}
&__submit {
min-width: 14rem;
height: 2.5rem;
padding: 0 2.5rem;
}
}
</style>
@@ -0,0 +1,110 @@
<template>
<div class="student-consultants">
<BoxedIconTitleBlock
class="student-consultants__heading"
title="مشاوره"
desc="درخواست مشاوره جدید ثبت کنید یا تاریخچه مشاوره‌های خود را مشاهده نمایید."
>
<template #icon>
<SvgIcon name="chat" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #create>
<RequestConsultantForm :submitting="createMutation.isPending.value" @submit="onSubmit" />
</template>
<template #history>
<SkeletonLoaderBlock v-if="historyLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="consultants.length > 0">
<ConsultantItem
v-for="consultant in consultants"
:key="consultant.id"
:consultant="consultant"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواست مشاوره‌ای ثبت نکرده‌اید." />
<PaginationBlock :pagination="historyMeta" @update:page="setHistoryPage" />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ConsultantItem from '@/features/student/consultants/components/ConsultantItem.vue'
import RequestConsultantForm from '@/features/student/consultants/components/RequestConsultantForm.vue'
import {
studentConsultantsKeys,
useCreateStudentConsultantMutation,
useStudentConsultantsQuery,
} from '@/services/query/student-consultants'
const queryClient = useQueryClient()
const tabs = [
{ name: 'create', label: 'درخواست مشاوره', icon: 'chat' },
{ name: 'history', label: 'تاریخچه مشاوره ها', icon: 'list-bullets' },
]
const activeTab = ref('create')
const historyFilters = ref({})
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: historyData, isLoading: historyLoading } = useStudentConsultantsQuery(
historyFilters,
historyPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const consultants = computed(() => historyData.value?.data ?? [])
const historyMeta = computed(() => ({
page: historyPagination.value.page,
perPage: historyPagination.value.perPage,
...historyData.value?.meta,
}))
const createMutation = useCreateStudentConsultantMutation()
const onSubmit = async (payload) => {
try {
await createMutation.mutateAsync(payload)
toast.success('درخواست مشاوره با موفقیت ثبت شد.')
await queryClient.invalidateQueries({ queryKey: studentConsultantsKeys.all })
activeTab.value = 'history'
} catch {
toast.error('ثبت درخواست با خطا مواجه شد.')
}
}
const onShowDetails = () => {}
</script>
<style lang="scss" scoped>
.student-consultants {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
@@ -53,7 +53,7 @@ import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useStudentCoursesListQuery } from '@/services/query/student-courses'
import StudentCourseItem from '@/features/student/components/StudentCourseItem.vue'
import StudentCourseItem from '@/features/student/courses/components/StudentCourseItem.vue'
const router = useRouter()
@@ -0,0 +1,147 @@
<template>
<div class="student-exam-attempt">
<p class="student-exam-attempt__title">{{ attempt.title || 'آزمون' }}</p>
<div class="student-exam-attempt__meta">
<div class="student-exam-attempt__stat">
<SvgIcon name="scroll" :size="11" color="#5d5d5d" />
<span class="student-exam-attempt__stat-label">نمره :</span>
<span class="student-exam-attempt__stat-value">{{ scoreText }}</span>
</div>
<div class="student-exam-attempt__stat">
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
<span class="student-exam-attempt__stat-label">زمان آزمون :</span>
<span class="student-exam-attempt__stat-value">{{ dateText }}</span>
</div>
<div class="student-exam-attempt__status" :class="`student-exam-attempt__status--${tone}`">
<SvgIcon name="pencil" :size="11" :color="toneColor" />
<span class="student-exam-attempt__status-label">وضعیت :</span>
<span class="student-exam-attempt__status-value">{{ statusLabel }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
const TONE_MAP = { passed: 'success', failed: 'danger' }
const TONE_COLOR = { success: '#007074', danger: '#740002' }
const props = defineProps({
attempt: { type: Object, required: true },
})
const scoreText = computed(() => (props.attempt.score == null ? '—' : `${props.attempt.score}`))
const dateText = computed(() => {
if (props.attempt.faTime && props.attempt.faDate)
return `${props.attempt.faTime} ${props.attempt.faDate}`
if (props.attempt.faDate) return props.attempt.faDate
return formatJalaaliDate(props.attempt.date) || '—'
})
const tone = computed(() => TONE_MAP[props.attempt.status] || 'success')
const toneColor = computed(() => TONE_COLOR[tone.value] || '#5d5d5d')
const statusLabel = computed(
() => props.attempt.statusLabel || (tone.value === 'success' ? 'قبول شده' : 'قبول نشده')
)
</script>
<style lang="scss" scoped>
.student-exam-attempt {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.625rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
text-align: end;
flex: 0 0 auto;
order: 4;
@media (min-width: 1024px) {
order: 0;
}
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
flex: 1;
justify-content: center;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__stat-label {
font-weight: 400;
font-size: 0.65rem;
color: #5d5d5d;
}
&__stat-value {
font-family: var(--font-family-en);
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
&--success {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--danger {
background: rgba(116, 0, 2, 4%);
color: #740002;
}
}
&__status-label {
font-weight: 400;
font-size: 0.65rem;
}
&__status-value {
font-weight: 500;
font-size: 0.75rem;
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<div class="student-exam-item">
<p class="student-exam-item__title">{{ exam.title || '—' }}</p>
<div class="student-exam-item__stats">
<div class="student-exam-item__stat student-exam-item__stat--duration">
<span class="student-exam-item__stat-value">{{ durationText }}</span>
<span class="student-exam-item__stat-label">مدت آزمون :</span>
<SvgIcon name="calendar" :size="11" color="#5d5d5d" />
</div>
<div class="student-exam-item__stat">
<span class="student-exam-item__stat-label">نمره قبولی :</span>
<span class="student-exam-item__stat-value">{{ passingScoreText }}</span>
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
</div>
<div class="student-exam-item__stat">
<span class="student-exam-item__stat-label">تعداد سوال :</span>
<span class="student-exam-item__stat-value">{{ questionsCountText }}</span>
<SvgIcon name="file" :size="11" color="#5d5d5d" />
</div>
</div>
<div class="student-exam-item__actions">
<BaseButton
text="شروع آزمون"
custom-class="student-exam-item__btn"
@click="emit('start', exam)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
exam: { type: Object, required: true },
})
const emit = defineEmits(['start'])
const durationText = computed(() =>
props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه`
)
const passingScoreText = computed(() =>
props.exam.passingScore == null ? '—' : `${props.exam.passingScore}`
)
const questionsCountText = computed(() =>
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
)
</script>
<style lang="scss" scoped>
.student-exam-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.625rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__title {
flex: 1 1 18%;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
text-align: end;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
order: 3;
@media (min-width: 1024px) {
order: 0;
}
}
&__stats {
flex: 1 1 50%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__stat--duration {
border-radius: 1.375rem;
}
&__stat-label {
font-weight: 300;
font-size: 0.7rem;
color: #535353;
}
&__stat-value {
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
&__btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,66 @@
<template>
<div class="seq seq--full">
<p class="seq__title">{{ orderText }}{{ question.title }}</p>
<div class="seq__answers">
<CheckboxField
v-for="answer in question.answers"
:key="answer.id"
:model-value="selectedId === answer.id"
:label="answer.title"
@change="onSelect(answer.id)"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import CheckboxField from '@/components/form/CheckboxField.vue'
const props = defineProps({
question: { type: Object, required: true },
order: { type: Number, default: 0 },
modelValue: { type: [String, Number, null], default: null },
})
const emit = defineEmits(['update:modelValue'])
const selectedId = computed(() => props.modelValue)
const orderText = computed(() => (props.order ? `${props.order} - ` : ''))
const onSelect = (id) => {
emit('update:modelValue', id === selectedId.value ? null : id)
}
</script>
<style lang="scss" scoped>
.seq {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 0;
&--full {
grid-column: 1 / -1;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.875rem;
line-height: 1.55;
color: #a7a7a7;
margin: 0;
}
&__answers {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem 1.5rem;
}
}
</style>
@@ -0,0 +1,216 @@
<template>
<div class="ses">
<div class="ses__info">
<p class="ses__title">{{ exam.title || '—' }}</p>
<div class="ses__stats">
<div class="ses__stat">
<SvgIcon name="check" :size="12" color="#5d5d5d" />
<span class="ses__stat-label">تعداد آزمونها :</span>
<span class="ses__stat-value">{{ attemptsCountText }}</span>
</div>
<div class="ses__stat">
<SvgIcon name="scroll" :size="12" color="#5d5d5d" />
<span class="ses__stat-label">تعداد سوال :</span>
<span class="ses__stat-value">{{ questionsCountText }}</span>
</div>
<div class="ses__stat">
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
<span class="ses__stat-label">نمره قبولی :</span>
<span class="ses__stat-value">{{ passingScoreText }}</span>
</div>
<div class="ses__stat ses__stat--pill">
<SvgIcon name="calendar" :size="11" color="#535353" />
<span class="ses__stat-label">مدت آزمون :</span>
<span class="ses__stat-value">{{ durationText }}</span>
</div>
<div class="ses__status" :class="`ses__status--${statusTone}`">
<span class="ses__status-label">وضعیت :</span>
<span class="ses__status-value">{{ statusLabel }}</span>
</div>
</div>
</div>
<div class="ses__action">
<BaseButton
:text="startButtonText"
:loading="starting"
custom-class="ses__start-btn"
@click="emit('start')"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const TONE_MAP = {
passed: 'success',
failed: 'danger',
in_progress: 'success',
not_started: 'neutral',
}
const props = defineProps({
exam: { type: Object, required: true },
starting: { type: Boolean, default: false },
})
const emit = defineEmits(['start'])
const attemptsCountText = computed(() =>
props.exam.attemptsCount == null
? `${(props.exam.attempts || []).length} آزمون`
: `${props.exam.attemptsCount} آزمون`
)
const questionsCountText = computed(() =>
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
)
const passingScoreText = computed(() =>
props.exam.passingScore == null ? '—' : `${props.exam.passingScore}`
)
const durationText = computed(() =>
props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه`
)
const statusLabel = computed(() => props.exam.statusLabel || '—')
const statusTone = computed(() => TONE_MAP[props.exam.status] || 'neutral')
const startButtonText = computed(() =>
(props.exam.attempts || []).length > 0 ? 'شروع آزمون مجدد' : 'شروع آزمون'
)
</script>
<style lang="scss" scoped>
.ses {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
padding: 1.25rem;
background: rgba(255, 255, 255, 40%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
min-height: 9.9rem;
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
text-align: end;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.5rem;
color: #4b4b4b;
margin: 0;
line-height: 1.246;
}
&__stats {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
&--pill {
border-radius: 1.375rem;
}
}
&__stat-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__stat-value {
font-family: var(--font-family-en);
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
&--success {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--danger {
background: rgba(116, 0, 2, 4%);
color: #740002;
}
&--neutral {
background: rgba(107, 107, 107, 4%);
color: #535353;
}
}
&__status-label {
font-weight: 400;
font-size: 0.65rem;
}
&__status-value {
font-weight: 500;
font-size: 0.75rem;
}
&__action {
display: flex;
justify-content: center;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__start-btn {
min-width: 12rem;
padding: 0 2rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="se">
<BoxedIconTitleBlock
class="se__heading"
:title="exam?.title || 'آزمون'"
:desc="exam?.desc || 'جزئیات آزمون را مشاهده و سپس شروع آزمون را بزنید'"
>
<template #icon>
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="2" :cols-per-row="1" />
<StudentExamSummary
v-else-if="exam"
:exam="exam"
:starting="startMutation.isPending.value"
@start="onStart"
/>
<SimpleTitleIconBlock title="تعداد تلاش ها برای این آزمون" class="se__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading && attempts.length === 0" :rows="2" :cols-per-row="1" />
<div v-else-if="attempts.length > 0">
<StudentExamAttemptItem v-for="attempt in attempts" :key="attempt.id" :attempt="attempt" />
</div>
<div v-else class="se__empty">
<SvgIcon name="scroll" :size="128" color="#c3c3c3" />
<p class="se__empty-title">دانشجوی گرامی</p>
<p class="se__empty-desc">شما تاکنون هیچ آزمونی را به شروع نکرده اید.</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { toast } from 'vue3-toastify'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import StudentExamSummary from '@/features/student/exams/components/StudentExamSummary.vue'
import StudentExamAttemptItem from '@/features/student/exams/components/StudentExamAttemptItem.vue'
import {
useStartStudentExamAttemptMutation,
useStudentExamQuery,
} from '@/services/query/student-exams'
const route = useRoute()
const router = useRouter()
const examId = computed(() => route.params.id)
const { data: exam, isLoading } = useStudentExamQuery(examId, {
enabled: () => !!examId.value,
})
const attempts = computed(() => exam.value?.attempts ?? [])
const startMutation = useStartStudentExamAttemptMutation()
const onStart = async () => {
try {
await startMutation.mutateAsync({ id: examId.value })
router.push({ name: 'student-take-exam', params: { id: examId.value } })
} catch {
toast.error('شروع آزمون با خطا مواجه شد.')
}
}
</script>
<style lang="scss" scoped>
.se {
display: flex;
flex-direction: column;
gap: 0.75rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-title {
margin-block: 0.25rem;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
border-radius: 0.75rem;
}
&__empty-title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1rem;
color: #8e8e8e;
margin: 0.5rem 0 0;
}
&__empty-desc {
font-family: var(--font-family-fa);
font-weight: 400;
color: #4b4b4b;
font-size: 0.875rem;
margin: 0;
}
}
</style>
@@ -0,0 +1,258 @@
<template>
<div class="ste">
<div class="ste__header">
<BoxedIconTitleBlock
class="exam-form__heading"
:title="exam?.title"
desc="لطفا سوالات به دقت خوانده و جواب درست را علامت بزنید"
>
<template #icon>
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<div class="ste__timer">
<span class="ste__timer-label">زمان باقی مانده :</span>
<span class="ste__timer-value">{{ timerText }}</span>
</div>
</div>
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="3" :cols-per-row="2" />
<div v-else class="ste__body">
<div class="ste__questions">
<StudentExamQuestion
v-for="(question, index) in questions"
:key="question.id"
:question="question"
:order="index + 1"
:model-value="answers[question.id] ?? null"
@update:model-value="(value) => onAnswer(question.id, value)"
/>
</div>
<div class="ste__actions">
<BaseButton
text="اتمام آزمون"
:loading="submitMutation.isPending.value"
custom-class="ste__submit"
@click="onSubmit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import { useRoute, useRouter } from 'vue-router'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.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 {
useStudentExamQuery,
useSubmitStudentExamAttemptMutation,
} from '@/services/query/student-exams'
const route = useRoute()
const router = useRouter()
const examId = computed(() => route.params.id)
const { data: exam, isLoading } = useStudentExamQuery(examId, {
enabled: () => !!examId.value,
})
const questions = computed(() => exam.value?.questions ?? [])
const answers = reactive({})
const onAnswer = (questionId, value) => {
if (value === null || value === undefined) delete answers[questionId]
else answers[questionId] = value
}
const remaining = ref(0)
let intervalId = null
const startTimer = (minutes) => {
if (intervalId) clearInterval(intervalId)
remaining.value = Math.max(0, Math.floor(minutes * 60))
intervalId = setInterval(() => {
if (remaining.value <= 0) {
clearInterval(intervalId)
intervalId = null
onTimeout()
return
}
remaining.value -= 1
}, 1000)
}
watch(
() => exam.value?.durationMinutes,
(minutes) => {
if (minutes != null) startTimer(minutes)
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (intervalId) clearInterval(intervalId)
})
const pad = (n) => String(n).padStart(2, '0')
const timerText = computed(() => {
const total = remaining.value
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
})
const submitMutation = useSubmitStudentExamAttemptMutation()
const submit = async () => {
try {
await submitMutation.mutateAsync({
id: examId.value,
payload: { answers: { ...answers } },
})
toast.success('پاسخ‌های شما با موفقیت ثبت شد.')
router.push({ name: 'student-exam', params: { id: examId.value } })
} catch {
toast.error('ثبت پاسخ‌ها با خطا مواجه شد.')
}
}
const onSubmit = () => {
if (Object.keys(answers).length === 0) {
toast.warning('حداقل به یک سوال پاسخ دهید.')
return
}
submit()
}
const onTimeout = () => {
toast.info('زمان آزمون به پایان رسید.')
submit()
}
onMounted(() => {
if (!examId.value) router.push({ name: 'student-dashboard' })
})
</script>
<style lang="scss" scoped>
.ste {
display: flex;
flex-direction: column;
gap: 1rem;
&__header {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-radius: 0.75rem;
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__heading {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
text-align: end;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.125rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.8125rem;
color: #8c8c8c;
margin: 0;
}
&__timer {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.875rem;
border-radius: 0.75rem;
background: rgba(245, 93, 109, 8%);
align-self: flex-end;
font-family: var(--font-family-fa);
white-space: nowrap;
@media (min-width: 768px) {
align-self: center;
}
}
&__timer-label {
font-weight: 400;
font-size: 0.75rem;
color: #f55d6d;
}
&__timer-value {
font-family: var(--font-family-en);
font-weight: 500;
font-size: 0.875rem;
color: #f55d6d;
direction: ltr;
}
&__body {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem 1.5rem;
border-radius: 0.75rem;
}
&__questions {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem 2rem;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
&__actions {
display: flex;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid var(--color-thd-gray);
}
&__submit {
min-width: 14rem;
padding: 0 2rem;
height: 2.25rem;
}
}
</style>
@@ -0,0 +1,190 @@
<template>
<div class="student-lesson-item">
<div class="student-lesson-item__head">
<span class="student-lesson-item__decor" aria-hidden="true">
<SvgIcon name="users-three" :size="20" color="rgba(0, 112, 116, 0.31)" />
</span>
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
</div>
<div class="student-lesson-item__stats">
<div class="student-lesson-item__stat">
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
<span class="student-lesson-item__stat-label">تعداد دانشجویان :</span>
<span class="student-lesson-item__stat-value">{{ studentsText }}</span>
</div>
<div class="student-lesson-item__stat">
<SvgIcon name="file" :size="12" color="#5d5d5d" />
<span class="student-lesson-item__stat-label">تعداد آزمون :</span>
<span class="student-lesson-item__stat-value">{{ quizzesText }}</span>
</div>
<div class="student-lesson-item__stat">
<SvgIcon name="list-bullets" :size="12" color="#5d5d5d" />
<span class="student-lesson-item__stat-label">تعداد جلسات :</span>
<span class="student-lesson-item__stat-value">{{ sessionsText }}</span>
</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">
<BaseButton
text="جزئیات درس"
custom-class="student-lesson-item__details-btn"
@click="emit('show-details', lesson)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
lesson: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const hoursText = computed(() =>
props.lesson.learningHours == null ? '—' : `${props.lesson.learningHours} ساعت`
)
const sessionsText = computed(() =>
props.lesson.sessionsCount == null ? '—' : `${props.lesson.sessionsCount} جلسه`
)
const quizzesText = computed(() =>
props.lesson.quizzesCount == null ? '—' : `${props.lesson.quizzesCount}`
)
const studentsText = computed(() =>
props.lesson.studentsCount == null ? '—' : `${props.lesson.studentsCount} دانشجو`
)
</script>
<style lang="scss" scoped>
.student-lesson-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__head {
display: flex;
align-items: center;
gap: 0.625rem;
flex: 1 1 22%;
min-width: 0;
justify-content: flex-end;
}
&__decor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
flex-shrink: 0;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
&__stats {
flex: 1 1 38%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__stat-label {
font-weight: 300;
font-size: 0.7rem;
color: #5d5d5d;
}
&__stat-value {
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
&__hours {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__hours-label {
font-weight: 300;
font-size: 0.7rem;
color: #007074;
}
&__hours-value {
font-weight: 500;
font-size: 0.75rem;
color: #007074;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
&__details-btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,226 @@
<template>
<div class="student-lesson-details">
<BoxedIconTitleBlock
class="student-lesson-details__heading"
title="جزئیات دوره‌ آموزشی"
desc="اطلاعات کامل این دوره را مشاهده کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading && !lesson" :rows="2" :cols-per-row="1" />
<div v-else-if="lesson" class="student-lesson-details__summary">
<span class="student-lesson-details__decor" aria-hidden="true">
<SvgIcon name="users-three" :size="40" color="rgba(0, 112, 116, 0.31)" />
</span>
<div class="student-lesson-details__summary-content">
<p class="student-lesson-details__summary-title">{{ lesson.title || '—' }}</p>
<div class="student-lesson-details__summary-stats">
<div class="student-lesson-details__summary-stat">
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
<span class="student-lesson-details__summary-stat-label">تعداد دانشجویان :</span>
<span class="student-lesson-details__summary-stat-value">
{{ studentsText }}
</span>
</div>
<div class="student-lesson-details__summary-stat">
<SvgIcon name="file" :size="12" color="#5d5d5d" />
<span class="student-lesson-details__summary-stat-label">تعداد آزمون :</span>
<span class="student-lesson-details__summary-stat-value">
{{ quizzesText }}
</span>
</div>
<div class="student-lesson-details__summary-stat">
<SvgIcon name="list-bullets" :size="12" color="#5d5d5d" />
<span class="student-lesson-details__summary-stat-label">تعداد جلسات :</span>
<span class="student-lesson-details__summary-stat-value">
{{ sessionsText }}
</span>
</div>
<div class="student-lesson-details__summary-stat">
<SvgIcon name="calendar" :size="12" color="#535353" />
<span class="student-lesson-details__summary-stat-label">تاریخ پایان :</span>
<span class="student-lesson-details__summary-stat-value">{{ endDateText }}</span>
</div>
</div>
</div>
</div>
<TabsBlock :tabs="tabs" v-model="activeTab">
<template #sessions>
<SkeletonLoaderBlock
v-if="isLoading && sessions.length === 0"
:rows="3"
:cols-per-row="1"
/>
<div v-else-if="sessions.length > 0">
<StudentSessionItem
v-for="session in sessions"
:key="session.id"
:session="session"
@start="onStartSession"
/>
</div>
<NoItems v-else title="جلسه‌ای نیست" desc="هنوز جلسه‌ای برای این درس ثبت نشده است." />
</template>
<template #exams>
<SkeletonLoaderBlock v-if="isLoading && exams.length === 0" :rows="3" :cols-per-row="1" />
<div v-else-if="exams.length > 0">
<StudentExamItem v-for="exam in exams" :key="exam.id" :exam="exam" @start="onStartExam" />
</div>
<NoItems v-else title="آزمونی نیست" desc="هنوز آزمونی برای این درس ثبت نشده است." />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { useStudentLessonQuery } from '@/services/query/student-lessons'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import StudentExamItem from '@/features/student/exams/components/StudentExamItem.vue'
import StudentSessionItem from '@/features/student/sessions/components/StudentSessionItem.vue'
const route = useRoute()
const router = useRouter()
const lessonId = computed(() => route.params.id)
const { data: lesson, isLoading } = useStudentLessonQuery(lessonId, {
enabled: () => !!lessonId.value,
})
const sessions = computed(() => lesson.value?.sessions ?? [])
const exams = computed(() => lesson.value?.exams ?? [])
const sessionsText = computed(() =>
lesson.value?.sessionsCount == null ? '—' : `${lesson.value.sessionsCount} جلسه`
)
const quizzesText = computed(() =>
lesson.value?.quizzesCount == null ? '—' : `${lesson.value.quizzesCount}`
)
const studentsText = computed(() =>
lesson.value?.studentsCount == null ? '—' : `${lesson.value.studentsCount} دانشجو`
)
const endDateText = computed(
() => lesson.value?.faEndDate || formatJalaaliDate(lesson.value?.endDate) || '—'
)
const tabs = [
{ name: 'sessions', label: 'همه جلسه ها', icon: 'list-bullets' },
{ name: 'exams', label: 'آزمون هــــــــا', icon: 'file' },
]
const activeTab = ref('sessions')
const onStartSession = (session) => {
router.push({ name: 'student-session-details', params: { id: session.id } }).catch(() => {})
}
const onStartExam = (exam) => {
router.push({ name: 'student-exam', params: { id: exam.id } }).catch(() => {})
}
</script>
<style lang="scss" scoped>
.student-lesson-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__summary {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
gap: 0.75rem;
padding: 1.25rem 1rem;
background: rgba(255, 255, 255, 40%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
&-content {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.5rem;
width: 100%;
}
}
&__decor {
display: none;
width: 5.5rem;
height: 5.5rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
align-items: center;
justify-content: center;
flex-shrink: 0;
@media (min-width: 1024px) {
display: inline-flex;
}
}
&__summary-title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.5rem;
color: #4b4b4b;
margin: 0;
text-align: center;
flex: 0 0 100%;
@media (min-width: 1024px) {
flex: 0 0 auto;
text-align: end;
}
}
&__summary-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__summary-stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__summary-stat-label {
font-weight: 300;
font-size: 0.7rem;
color: #5d5d5d;
}
&__summary-stat-value {
font-weight: 500;
font-size: 0.75rem;
color: #5d5d5d;
}
}
</style>
@@ -0,0 +1,174 @@
<template>
<div class="mdis" @click="emit('show-details', dispatch)">
<div class="mdis__heading">
<span class="mdis__code">{{ dispatch.code }}</span>
<div class="mdis__title-wrap">
<p class="mdis__title">{{ dispatch.title }}</p>
<div class="mdis__location">
<SvgIcon name="paper-plane-right" :size="11" color="#989898" />
<span>{{ dispatch.location }}</span>
</div>
</div>
</div>
<div class="mdis__meta">
<div class="mdis__pill">
<span class="mdis__pill-label">تاریخ اعزام :</span>
<span class="mdis__pill-value">{{ dispatch.dispatchDate }}</span>
</div>
</div>
<div class="mdis__actions" @click.stop>
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="pencil" :size="14" color="#007074" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="mdis__btn"
@click="emit('show-details', dispatch)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
dispatch: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
</script>
<style lang="scss" scoped>
.mdis {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 8rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.35rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1rem;
color: #4b4b4b;
margin: 0;
}
&__location {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #989898;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__pill-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__actions {
display: flex;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 8rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,177 @@
<template>
<div class="mnar" @click="emit('show-details', narrative)">
<div class="mnar__heading">
<span class="mnar__code">{{ narrative.code }}</span>
<div class="mnar__title-wrap">
<p class="mnar__title">{{ narrative.title }}</p>
<p class="mnar__subtitle">تاریخ ثبت: {{ narrative.submittedAt }}</p>
</div>
</div>
<div class="mnar__meta">
<div class="mnar__pill" :class="`mnar__pill--${narrative.kindTone}`">
<span class="mnar__pill-label">روایت :</span>
<span class="mnar__pill-value">{{ narrative.kindLabel }}</span>
</div>
</div>
<div class="mnar__actions">
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="check" :size="14" color="#007074" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(243, 102, 117, 0.06)" size="2rem">
<template #icon>
<SvgIcon name="close" :size="14" color="var(--color-error)" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(107, 107, 107, 0.04)" size="2rem">
<template #icon>
<SvgIcon name="pencil" :size="14" color="#535353" />
</template>
</CircleButton>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
defineProps({
narrative: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
</script>
<style lang="scss" scoped>
.mnar {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 9rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.2rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.55rem;
color: #4b4b4b;
margin: 0;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__pill-label {
font-weight: 400;
font-size: 0.65rem;
}
&__pill-value {
font-weight: 400;
font-size: 0.75rem;
}
&__pill--pink {
background: rgba(243, 102, 117, 7%);
.mnar__pill-label,
.mnar__pill-value {
color: #f55d6d;
}
}
&__pill--gold {
background: rgba(116, 102, 0, 4%);
.mnar__pill-label,
.mnar__pill-value {
color: #b6842d;
}
}
&__actions {
display: flex;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 7rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,257 @@
<template>
<div class="mreq" @click="emit('show-details', request)">
<div class="mreq__heading">
<span class="mreq__code">{{ request.code }}</span>
<div class="mreq__title-wrap">
<p class="mreq__title">{{ request.title }}</p>
<p class="mreq__subtitle">شماره درخواست : {{ request.requestNumber }}</p>
</div>
</div>
<div class="mreq__meta">
<div class="mreq__pill mreq__pill--date">
<span class="mreq__pill-label">تاریخ درخواست :</span>
<span class="mreq__pill-value">{{ request.requestDate }}</span>
</div>
<div class="mreq__pill mreq__pill--name">
<span class="mreq__pill-label">نام درخواست کننده :</span>
<span class="mreq__pill-value">{{ request.requesterName }}</span>
</div>
<div class="mreq__pill mreq__pill--status" :class="`mreq__pill--${tone}`">
<span class="mreq__dot" />
<span class="mreq__pill-value mreq__pill-value--status">{{ request.statusLabel }}</span>
</div>
</div>
<div class="mreq__actions" @click.stop>
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2rem">
<template #icon>
<SvgIcon name="check" :size="14" color="#007074" />
</template>
</CircleButton>
<CircleButton bg-color="rgba(243, 102, 117, 0.06)" size="2rem">
<template #icon>
<SvgIcon name="close" :size="14" color="var(--color-error)" />
</template>
</CircleButton>
<BaseButton
text="جزئیات درخواست"
custom-class="mreq__btn"
@click="emit('show-details', request)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const TONE_MAP = {
rejected: 'danger',
approved: 'success',
pending: 'warning',
cancelled: 'neutral',
}
const props = defineProps({
request: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const tone = computed(() => TONE_MAP[props.request.status] || 'neutral')
</script>
<style lang="scss" scoped>
.mreq {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
}
&__heading {
display: flex;
align-items: center;
gap: 0.625rem;
@media (min-width: 1024px) {
min-width: 9rem;
}
}
&__title-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.125rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__code {
font-family: var(--font-family-en);
font-weight: 800;
font-size: 2.75rem;
line-height: 1;
color: #f0f0f0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
flex-wrap: nowrap;
}
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__pill-label {
font-weight: 400;
font-size: 0.65rem;
color: #535353;
}
&__pill-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__pill-value--status {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.65rem;
}
&__dot {
width: 0.32rem;
height: 0.32rem;
border-radius: 9999px;
flex-shrink: 0;
}
&__pill--status {
padding: 0.3rem 0.75rem;
}
&__pill--danger {
background: rgba(243, 102, 117, 6%);
.mreq__pill-value {
color: #cc2831;
}
.mreq__dot {
background: #cc2831;
}
}
&__pill--success {
background: rgba(0, 112, 116, 6%);
.mreq__pill-value {
color: #007074;
}
.mreq__dot {
background: #007074;
}
}
&__pill--warning {
background: rgba(182, 132, 45, 7%);
.mreq__pill-value {
color: #b6842d;
}
.mreq__dot {
background: #b6842d;
}
}
&__pill--neutral {
background: rgba(107, 107, 107, 6%);
.mreq__pill-value {
color: #535353;
}
.mreq__dot {
background: #989898;
}
}
&__actions {
display: flex;
justify-content: center;
gap: 6px;
@media (min-width: 1024px) {
justify-content: flex-start;
}
}
&__btn {
min-width: 8rem;
height: 2rem;
padding: 0 1.25rem;
}
}
</style>
@@ -0,0 +1,257 @@
<template>
<div class="muc">
<div class="muc__profile">
<div class="muc__avatar">
<img v-if="avatar" :src="avatar" alt="" />
<SvgIcon v-else name="user" :size="40" color="#c2c2c2" />
</div>
<div class="muc__profile-info">
<div class="muc__level">
<span class="muc__level-label">سطح کاربر :</span>
<span class="muc__level-value">{{ level }}</span>
</div>
<p class="muc__name">{{ fullName }}</p>
<div class="muc__location">
<SvgIcon name="map-pin-simple-area" :size="12" />
<span>{{ city }}</span>
</div>
</div>
</div>
<div class="muc__stats">
<div class="muc__stat">
<span class="muc__stat-label">مدت عضویت</span>
<div class="muc__stat-value">
<span class="muc__stat-num">{{ membershipDays }}</span>
<span class="muc__stat-unit">روز</span>
</div>
</div>
<span class="muc__divider" />
<div class="muc__stat">
<span class="muc__stat-label">امتیاز کاربر</span>
<div class="muc__stat-value">
<span class="muc__stat-num">{{ rating }}</span>
</div>
</div>
<span class="muc__divider" />
<div class="muc__stat">
<span class="muc__stat-label">تعداد درخواستها</span>
<div class="muc__stat-value">
<span class="muc__stat-num">{{ requestsCount }}</span>
<span class="muc__stat-unit">درخواست</span>
</div>
</div>
</div>
<div class="muc__actions">
<CircleButton bg-color="rgba(0, 112, 116, 0.08)" size="2.5rem">
<template #icon>
<SvgIcon name="pencil" :size="20" color="#007074" />
</template>
</CircleButton>
<BaseButton text="تکمیل اطلاعات" custom-class="muc__btn" @click="emit('complete-info')">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({
profile: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['complete-info', 'settings'])
const fullName = computed(() => props.profile.fullName || '—')
const city = computed(() => props.profile.city || '—')
const level = computed(() => props.profile.level || '—')
const avatar = computed(() => props.profile.avatar || '')
const membershipDays = computed(() => props.profile.membershipDays ?? '—')
const rating = computed(() => props.profile.rating ?? '—')
const requestsCount = computed(() => props.profile.requestsCount ?? '—')
</script>
<style lang="scss" scoped>
.muc {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: #fff;
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
}
&__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__btn {
min-width: 9rem;
height: 2.5rem;
padding: 0 1.25rem;
}
&__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
background: rgba(0, 112, 116, 4%);
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(0, 112, 116, 8%);
}
}
&__stats {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
@media (min-width: 1024px) {
gap: 1.25rem;
}
}
&__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
min-width: 3rem;
}
&__stat-label {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.7rem;
color: #a7a7a7;
white-space: nowrap;
}
&__stat-value {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
}
&__stat-num {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 1.2rem;
color: #818181;
line-height: 1.4;
}
&__stat-unit {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.6rem;
color: #929292;
}
&__divider {
width: 1px;
height: 1.5rem;
background: #eee;
}
&__profile {
display: flex;
align-items: center;
gap: 0.875rem;
}
&__profile-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
text-align: center;
}
&__name {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.15rem;
color: #4b4b4b;
margin: 0;
}
&__location {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #989898;
}
&__level {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.875rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
font-family: var(--font-family-fa);
color: #007074;
}
&__level-label {
font-weight: 400;
font-size: 0.65rem;
}
&__level-value {
font-weight: 500;
font-size: 0.75rem;
}
&__avatar {
width: 5.625rem;
height: 5.625rem;
border-radius: 9999px;
background: #fff;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
</style>
@@ -0,0 +1,72 @@
<template>
<BasicModal
title="جزئیات اعزام"
title-en="Dispatch Details"
width="95%"
max-width="56rem"
min-width="auto"
>
<div class="dispatch-details">
<div class="dispatch-details__row dispatch-details__row--summary">
<LineInfoBlock
title="نام مأموریت/اعزام"
:desc="dispatch.missionName || dispatch.title || '—'"
/>
<LineInfoBlock title="کد اعزام" :numeric-desc="dispatch.code || ''" />
<LineInfoBlock title="تاریخ اعزام" :numeric-desc="dispatch.dispatchDate || ''" />
<LineInfoBlock title="وضعیت فعلی" :desc="dispatch.statusLabel || '—'" />
<LineInfoBlock title="نام درخواست کننده" :desc="dispatch.requesterName || '—'" />
</div>
<div class="dispatch-details__row">
<LineInfoBlock title="آدرس" :desc="dispatch.address || '—'" />
</div>
<div class="dispatch-details__row dispatch-details__row--body">
<LineInfoBlock title="نتیجه یا دستاورد مأموریت" :desc="dispatch.result || '—'" />
</div>
</div>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'DispatchDetailsModal' })
const { getModal } = useModal()
const dispatch = computed(() => getModal('DispatchDetailsModal')?.data ?? {})
</script>
<style lang="scss" scoped>
.dispatch-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-family: var(--font-family-fa);
&__row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
&--summary {
flex-direction: row;
justify-content: flex-start;
:deep(.line-info) {
flex: 1 1 auto;
min-width: 8rem;
}
}
&--body :deep(.line-info__desc) {
line-height: 1.7;
}
}
}
</style>
@@ -0,0 +1,123 @@
<template>
<BasicModal
title="جزئیات روایت شما"
title-en="Story Details"
width="95%"
max-width="56rem"
min-width="auto"
>
<div class="narrative-details">
<div class="narrative-details__head">
<div class="narrative-details__title-block">
<LineInfoBlock title="عنوان روایت" :desc="narrative.title || '—'" />
</div>
<div class="narrative-details__stats">
<div class="narrative-details__stat">
<span class="narrative-details__stat-value">{{ narrative.comments ?? 0 }}</span>
<SvgIcon name="chat-centered-dots" :size="20" />
</div>
<span class="narrative-details__stat-divider" />
<div class="narrative-details__stat">
<span class="narrative-details__stat-value">{{ narrative.likes ?? 0 }}</span>
<SvgIcon name="heart" :size="20" />
</div>
</div>
</div>
<div class="narrative-details__body">
<LineInfoBlock title="نتیجه یا دستاورد مأموریت" :desc="narrative.body || '—'" />
</div>
</div>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'NarrativeDetailsModal' })
const { getModal } = useModal()
const narrative = computed(() => getModal('NarrativeDetailsModal')?.data ?? {})
</script>
<style lang="scss" scoped>
.narrative-details {
display: flex;
flex-direction: column;
gap: 1rem;
font-family: var(--font-family-fa);
&__head {
display: flex;
flex-direction: column;
gap: 0.75rem;
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__title-block {
flex: 1;
min-width: 0;
}
&__stats {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: #fff;
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
align-self: flex-end;
min-height: 2.25rem;
}
&__stat {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.25rem;
}
&__stat-divider {
width: 1.5px;
height: 1.5rem;
background: #efefef;
border-radius: 9999px;
}
&__stat-value {
font-family: var(--font-family-en);
font-weight: 300;
font-size: 0.95rem;
color: #b3b3b3;
}
&__body :deep(.line-info__desc) {
white-space: pre-line;
line-height: 1.7;
color: #4e4e4e;
}
&__footer {
display: flex;
justify-content: flex-end;
padding-top: 0.5rem;
}
&__close-btn {
min-width: 10rem;
height: 2.25rem;
}
}
</style>
@@ -0,0 +1,71 @@
<template>
<BasicModal
title="جزئیات درخواست"
title-en="Request Details"
width="95%"
max-width="56rem"
min-width="auto"
>
<div class="request-details">
<div class="request-details__row request-details__row--summary">
<LineInfoBlock
title="نام و نام خانوادگی درخواست کننده"
:desc="request.requesterName || '—'"
/>
<LineInfoBlock title="تاریخ ثبت درخواست" :numeric-desc="request.requestDate || ''" />
<LineInfoBlock title="شماره تماس" :numeric-desc="request.phone || ''" />
<LineInfoBlock title="کد پستی" :numeric-desc="request.postalCode || ''" />
</div>
<div class="request-details__row">
<LineInfoBlock title="آدرس" :desc="request.address || '—'" />
</div>
<div class="request-details__row request-details__row--body">
<LineInfoBlock title="توضیحات" :desc="request.description || '—'" />
</div>
</div>
</BasicModal>
</template>
<script setup>
import { computed } from 'vue'
import useModal from '@/composables/useModal'
import BasicModal from '@/components/BasicModal.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
defineOptions({ name: 'RequestDetailsModal' })
const { getModal } = useModal()
const request = computed(() => getModal('RequestDetailsModal')?.data ?? {})
</script>
<style lang="scss" scoped>
.request-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-family: var(--font-family-fa);
&__row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
&--summary {
flex-direction: row;
justify-content: flex-start;
:deep(.line-info) {
flex: 1 1 auto;
min-width: 8rem;
}
}
&--body :deep(.line-info__desc) {
line-height: 1.7;
}
}
}
</style>
@@ -0,0 +1,203 @@
<template>
<div class="student-missionary">
<BoxedIconTitleBlock
class="student-missionary__heading"
title="سامانه اعزام"
desc="در این قسمت شما میتوانید درخواست های اعزام، تاریخچه اعزام و... را مشاهده کنید."
>
<template #icon>
<SvgIcon name="paper-plane-right" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="profileLoading && !profile" :rows="1" :cols-per-row="1" />
<MissionaryUserCard
v-else
:profile="profile || {}"
@complete-info="onCompleteInfo"
@settings="onProfileSettings"
/>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #my-requests>
<SkeletonLoaderBlock v-if="requestsLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="requests.length > 0">
<MissionaryRequestItem
v-for="request in requests"
:key="request.id"
:request="request"
@show-details="onRequestDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکرده‌اید." />
<PaginationBlock :pagination="requestsMeta" @update:page="setRequestsPage" />
</template>
<template #history>
<SkeletonLoaderBlock v-if="dispatchesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="dispatches.length > 0">
<MissionaryDispatchItem
v-for="dispatch in dispatches"
:key="dispatch.id"
:dispatch="dispatch"
@show-details="onDispatchDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="تاریخچه اعزامی موجود نیست." />
<PaginationBlock :pagination="dispatchesMeta" @update:page="setDispatchesPage" />
</template>
<template #narratives>
<SkeletonLoaderBlock v-if="narrativesLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="narratives.length > 0">
<MissionaryNarrativeItem
v-for="narrative in narratives"
:key="narrative.id"
:narrative="narrative"
@show-details="onNarrativeDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز روایتی ثبت نکرده‌اید." />
<PaginationBlock :pagination="narrativesMeta" @update:page="setNarrativesPage" />
</template>
</TabsBlock>
<RequestDetailsModal v-if="isModal('RequestDetailsModal')" />
<DispatchDetailsModal v-if="isModal('DispatchDetailsModal')" />
<NarrativeDetailsModal v-if="isModal('NarrativeDetailsModal')" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import useModal from '@/composables/useModal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import MissionaryUserCard from '@/features/student/missionary/components/MissionaryUserCard.vue'
import MissionaryRequestItem from '@/features/student/missionary/components/MissionaryRequestItem.vue'
import MissionaryDispatchItem from '@/features/student/missionary/components/MissionaryDispatchItem.vue'
import RequestDetailsModal from '@/features/student/missionary/components/modals/RequestDetailsModal.vue'
import MissionaryNarrativeItem from '@/features/student/missionary/components/MissionaryNarrativeItem.vue'
import DispatchDetailsModal from '@/features/student/missionary/components/modals/DispatchDetailsModal.vue'
import NarrativeDetailsModal from '@/features/student/missionary/components/modals/NarrativeDetailsModal.vue'
import {
useMissionaryProfileQuery,
useMissionaryDispatchesQuery,
useMissionaryNarrativesQuery,
useMissionaryRequestsQuery,
} from '@/services/query/student-missionary'
const router = useRouter()
const { openModal, isModal } = useModal()
const tabs = [
{ name: 'my-requests', label: 'درخواست های من', icon: 'list-bullets' },
{ name: 'history', label: 'تاریخچه اعزام ها', icon: 'paper-plane-right' },
{ name: 'narratives', label: 'روایت های من', icon: 'pencil' },
]
const activeTab = ref('my-requests')
const { data: profile, isLoading: profileLoading } = useMissionaryProfileQuery()
const requestsFilters = ref({})
const dispatchesFilters = ref({})
const narrativesFilters = ref({})
const { pagination: requestsPagination, setPage: setRequestsPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: dispatchesPagination, setPage: setDispatchesPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: narrativesPagination, setPage: setNarrativesPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: requestsData, isLoading: requestsLoading } = useMissionaryRequestsQuery(
requestsFilters,
requestsPagination,
{
enabled: () => activeTab.value === 'my-requests',
keepPreviousData: true,
}
)
const { data: dispatchesData, isLoading: dispatchesLoading } = useMissionaryDispatchesQuery(
dispatchesFilters,
dispatchesPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const { data: narrativesData, isLoading: narrativesLoading } = useMissionaryNarrativesQuery(
narrativesFilters,
narrativesPagination,
{
enabled: () => activeTab.value === 'narratives',
keepPreviousData: true,
}
)
const requests = computed(() => requestsData.value?.data ?? [])
const dispatches = computed(() => dispatchesData.value?.data ?? [])
const narratives = computed(() => narrativesData.value?.data ?? [])
const requestsMeta = computed(() => ({
page: requestsPagination.value.page,
perPage: requestsPagination.value.perPage,
...requestsData.value?.meta,
}))
const dispatchesMeta = computed(() => ({
page: dispatchesPagination.value.page,
perPage: dispatchesPagination.value.perPage,
...dispatchesData.value?.meta,
}))
const narrativesMeta = computed(() => ({
page: narrativesPagination.value.page,
perPage: narrativesPagination.value.perPage,
...narrativesData.value?.meta,
}))
const onCompleteInfo = () => {
router.push({ name: 'student-profile' }).catch(() => {})
}
const onProfileSettings = () => {
router.push({ name: 'student-profile' }).catch(() => {})
}
const onRequestDetails = (request) => {
openModal('RequestDetailsModal', request)
}
const onDispatchDetails = (dispatch) => {
openModal('DispatchDetailsModal', dispatch)
}
const onNarrativeDetails = (narrative) => {
openModal('NarrativeDetailsModal', narrative)
}
</script>
<style lang="scss" scoped>
.student-missionary {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
@@ -217,8 +217,8 @@ import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import PasswordField from '@/components/form/PasswordField.vue'
import { studentProfileSchema } from '@/features/student/schema'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { studentProfileSchema } from '@/features/student/profile/schema'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
import {
+63 -3
View File
@@ -8,19 +8,79 @@ export default [
{
path: '/profile',
name: 'student-profile',
component: () => import('@/features/student/pages/EditProfilePage.vue'),
component: () => import('@/features/student/profile/pages/EditProfilePage.vue'),
meta: { layout: 'student', role: 'student', title: 'ویرایش پروفایل' },
},
{
path: '/my-courses',
name: 'student-courses',
component: () => import('@/features/student/pages/StudentCoursesPage.vue'),
component: () => import('@/features/student/courses/pages/StudentCoursesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'دوره‌های من' },
},
{
path: '/my-courses/:id',
name: 'student-course-details',
component: () => import('@/features/student/pages/StudentCourseDetailsPage.vue'),
component: () => import('@/features/student/courses/pages/StudentCourseDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات دوره' },
},
{
path: '/my-terms',
name: 'student-terms',
component: () => import('@/features/student/terms/pages/StudentTermsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'دوره‌های آموزشی' },
},
{
path: '/my-terms/:id',
name: 'student-term-details',
component: () => import('@/features/student/terms/pages/StudentTermDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات دوره' },
},
{
path: '/my-lessons/:id',
name: 'student-lesson-details',
component: () => import('@/features/student/lessons/pages/StudentLessonDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات درس' },
},
{
path: '/my-sessions/:id',
name: 'student-session-details',
component: () => import('@/features/student/sessions/pages/StudentSessionDetailsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'جزئیات جلسه' },
},
{
path: '/my-exams/:id',
name: 'student-exam',
component: () => import('@/features/student/exams/pages/StudentExamPage.vue'),
meta: { layout: 'student', role: 'student', title: 'آزمون' },
},
{
path: '/my-exams/:id/take',
name: 'student-take-exam',
component: () => import('@/features/student/exams/pages/StudentTakeExamPage.vue'),
meta: { layout: 'student', role: 'student', title: 'شروع آزمون' },
},
{
path: '/missionary',
name: 'student-missionary',
component: () => import('@/features/student/missionary/pages/StudentMissionaryPage.vue'),
meta: { layout: 'student', role: 'student', title: 'سامانه اعزام' },
},
{
path: '/user/services',
name: 'student-services',
component: () => import('@/features/student/services/pages/StudentServicesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'خدمات' },
},
{
path: '/user/consultants',
name: 'student-consultants',
component: () => import('@/features/student/consultants/pages/StudentConsultantsPage.vue'),
meta: { layout: 'student', role: 'student', title: 'مشاوره' },
},
{
path: '/user/certificates',
name: 'student-certificates',
component: () => import('@/features/student/certificates/pages/StudentCertificatesPage.vue'),
meta: { layout: 'student', role: 'student', title: 'گواهینامه ها' },
},
]
@@ -0,0 +1,251 @@
<template>
<div class="csf">
<form class="csf__form" @submit.prevent="onSubmit">
<div class="csf__row">
<div class="csf__cell">
<SelectField
v-model="form.typeKey"
name="typeKey"
label="نوع خدمت"
placeholder="انتخاب کنید"
:options="typeOptions"
option-label="label"
option-value="value"
:error="errors.typeKey"
@change="onTypeChange"
/>
</div>
<div class="csf__cell">
<SelectField
v-model="form.title"
name="title"
label="عنوان خدمت"
placeholder="انتخاب کنید"
:options="titleOptions"
option-label="label"
option-value="value"
:disabled="!form.typeKey"
:error="errors.title"
/>
</div>
</div>
<div class="csf__row">
<div class="csf__cell csf__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات خود را وارد نمایید"
placeholder="لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است."
:row="5"
:error="errors.description"
/>
</div>
</div>
<div class="csf__upload">
<FileUploader
v-model="files"
accept="image/*,application/pdf"
:max-files="3"
@error="onFileError"
/>
</div>
<div class="csf__divider" />
<div class="csf__actions">
<BaseButton
type="submit"
text="ثبت درخواست"
custom-class="csf__submit"
:loading="submitting"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</div>
</template>
<script setup>
import * as yup from 'yup'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import {
useStudentServiceTitlesQuery,
useStudentServiceTypesQuery,
} from '@/services/query/student-services'
defineProps({
submitting: { type: Boolean, default: false },
})
const emit = defineEmits(['submit', 'file-error'])
const form = ref({
typeKey: '',
title: '',
description: '',
})
const files = ref([])
const schema = yup.object({
typeKey: yup.string().required('نوع خدمت را انتخاب کنید'),
title: yup.string().required('عنوان خدمت را انتخاب کنید'),
description: yup
.string()
.trim()
.min(5, 'توضیحات حداقل ۵ کاراکتر باشد')
.required('توضیحات را وارد کنید'),
})
const { validate, errors } = useYup(schema)
const { data: types } = useStudentServiceTypesQuery()
const typeOptions = computed(() => types.value ?? [])
const typeKeyRef = computed(() => form.value.typeKey)
const { data: titles } = useStudentServiceTitlesQuery(typeKeyRef, {
enabled: () => !!form.value.typeKey,
})
const titleOptions = computed(() => titles.value ?? [])
const onTypeChange = () => {
form.value.title = ''
}
watch(
() => form.value.typeKey,
() => {
form.value.title = ''
}
)
const onFileError = (msg) => emit('file-error', msg)
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
emit('submit', { ...payload, files: files.value })
}
</script>
<style lang="scss" scoped>
.csf {
display: flex;
flex-direction: column;
gap: 1rem;
border-radius: 0.75rem;
&__intro {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0 1rem;
border-bottom: 0.75px solid #ddd;
}
&__intro-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
background: rgba(255, 255, 255, 44%);
box-shadow: 0 3px 7.35px rgba(245, 93, 109, 100%);
flex-shrink: 0;
}
&__intro-text {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: end;
gap: 0.125rem;
flex: 1;
@media (min-width: 768px) {
align-items: center;
text-align: center;
}
}
&__intro-title {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.05rem;
color: #4c4c4c;
margin: 0;
}
&__intro-desc {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.75rem;
color: #adadad;
margin: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
&__cell {
display: flex;
flex-direction: column;
&--full {
grid-column: 1 / -1;
}
}
&__upload {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__divider {
height: 0.75px;
background: #ddd;
margin-top: 0.25rem;
}
&__actions {
display: flex;
justify-content: center;
@media (min-width: 768px) {
justify-content: flex-end;
}
}
&__submit {
min-width: 14rem;
height: 2.5rem;
padding: 0 2.5rem;
}
}
</style>
@@ -0,0 +1,106 @@
<template>
<div class="svc" @click="emit('show-details', service)">
<div class="svc__heading">
<p class="svc__title">{{ service.title }}</p>
<p class="svc__subtitle">شماره درخواست : {{ service.requestNumber }}</p>
</div>
<div class="svc__meta">
<Badge
variant="neutral"
size="sm"
icon="chat"
:label="`نوع خدمت :`"
:value="service.typeLabel"
/>
<Badge :variant="tone" size="sm" dot :value="service.statusLabel" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Badge from '@/global-components/Badge.vue'
const TONE_MAP = {
approved: 'success',
rejected: 'danger',
pending: 'neutral',
}
const props = defineProps({
service: { type: Object, required: true },
})
const emit = defineEmits(['show-details'])
const tone = computed(() => TONE_MAP[props.service.status] || 'neutral')
</script>
<style lang="scss" scoped>
.svc {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 75%);
}
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
}
&__heading {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: end;
gap: 0.125rem;
@media (min-width: 1024px) {
min-width: 7rem;
}
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #4b4b4b;
margin: 0;
}
&__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
@media (min-width: 1024px) {
flex: 1;
justify-content: center;
}
}
}
</style>
@@ -0,0 +1,116 @@
<template>
<div class="student-services">
<BoxedIconTitleBlock
class="student-services__heading"
title="خدمات"
desc="درخواست خدمت ثبت کنید یا تاریخچه درخواست‌های خود را مشاهده نمایید."
>
<template #icon>
<SvgIcon name="paper-plane" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock v-model="activeTab" :tabs="tabs">
<template #create>
<CreateServiceForm
:submitting="createMutation.isPending.value"
@submit="onSubmit"
@file-error="onFileError"
/>
</template>
<template #history>
<SkeletonLoaderBlock v-if="historyLoading" :rows="3" :cols-per-row="1" />
<div v-else-if="services.length > 0">
<ServiceItem
v-for="service in services"
:key="service.id"
:service="service"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز درخواستی ثبت نکرده‌اید." />
<PaginationBlock :pagination="historyMeta" @update:page="setHistoryPage" />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import ServiceItem from '@/features/student/services/components/ServiceItem.vue'
import CreateServiceForm from '@/features/student/services/components/CreateServiceForm.vue'
import {
studentServicesKeys,
useCreateStudentServiceMutation,
useStudentServicesQuery,
} from '@/services/query/student-services'
const queryClient = useQueryClient()
const tabs = [
{ name: 'create', label: 'درخواست خدمت', icon: 'paper-plane' },
{ name: 'history', label: 'تاریخچه درخواست ها', icon: 'list-bullets' },
]
const activeTab = ref('create')
const historyFilters = ref({})
const { pagination: historyPagination, setPage: setHistoryPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: historyData, isLoading: historyLoading } = useStudentServicesQuery(
historyFilters,
historyPagination,
{
enabled: () => activeTab.value === 'history',
keepPreviousData: true,
}
)
const services = computed(() => historyData.value?.data ?? [])
const historyMeta = computed(() => ({
page: historyPagination.value.page,
perPage: historyPagination.value.perPage,
...historyData.value?.meta,
}))
const createMutation = useCreateStudentServiceMutation()
const onSubmit = async (payload) => {
try {
await createMutation.mutateAsync(payload)
toast.success('درخواست خدمت با موفقیت ثبت شد.')
await queryClient.invalidateQueries({ queryKey: studentServicesKeys.all })
activeTab.value = 'history'
} catch {
toast.error('ثبت درخواست با خطا مواجه شد.')
}
}
const onFileError = (msg) => toast.warning(msg)
const onShowDetails = () => {}
</script>
<style lang="scss" scoped>
.student-services {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
@@ -0,0 +1,162 @@
<template>
<div class="student-session-item">
<div>
<p class="student-session-item__title">{{ session.title || '—' }}</p>
<div class="student-session-item__hours">
<SvgIcon name="calendar" :size="9" color="#007074" />
<span class="student-session-item__hours-label">زمان جلسه :</span>
<span class="student-session-item__hours-value">{{ durationText }}</span>
</div>
</div>
<div class="student-session-item__badges">
<span
v-if="session.isSeen"
class="student-session-item__badge student-session-item__badge--seen"
>
دیده شده
</span>
<span v-if="session.needsAssignment" class="student-session-item__badge">
<SvgIcon name="paper-plane" :size="11" color="#5d5d5d" />
ارسال تکلیف
</span>
<span v-if="session.hasQuiz" class="student-session-item__badge">
<SvgIcon name="file" :size="11" color="#5d5d5d" />
دارای آزمون
</span>
<span v-if="session.isOnline" class="student-session-item__badge">
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
جلسه آنلاین
</span>
</div>
<div class="student-session-item__actions">
<BaseButton
text="شروع یادگیری"
custom-class="student-session-item__btn"
@click="emit('start', session)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
session: { type: Object, required: true },
})
const emit = defineEmits(['start'])
const durationText = computed(() =>
props.session.durationHours == null ? '—' : `${props.session.durationHours} ساعت`
)
</script>
<style lang="scss" scoped>
.student-session-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__title {
flex: 1 1 22%;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
text-align: end;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
order: 4;
@media (min-width: 1024px) {
order: 0;
}
}
&__badges {
flex: 1 1 38%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.7rem;
color: #5d5d5d;
white-space: nowrap;
&--seen {
background: rgba(0, 255, 68, 4%);
color: #009a12;
}
}
&__hours {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
}
&__hours-label {
font-weight: 300;
font-size: 0.65rem;
color: #007074;
}
&__hours-value {
font-weight: 500;
font-size: 0.75rem;
color: #007074;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
&__btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,306 @@
<template>
<div class="ssd">
<BoxedIconTitleBlock
class="ssd__heading"
:title="session?.title || 'جزئیات جلسه'"
:desc="session?.desc || 'اطلاعات کامل این جلسه را مشاهده کنید.'"
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange">
<template #homework>
<SkeletonLoaderBlock v-if="isLoading && !session" :rows="3" :cols-per-row="1" />
<div v-else class="ssd__panel">
<div class="ssd__prompt">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__prompt-icon" />
<p class="ssd__prompt-text">
{{ session?.homeworkPrompt || 'متنی برای این جلسه ثبت نشده است.' }}
</p>
</div>
<div class="ssd__media">
<div class="ssd__content">
<div v-if="contentKind === 'video'" class="ssd__video">
<VideoPlayerBlock
:src="session?.videoUrl || ''"
:poster="session?.poster || ''"
:video-id="sessionId"
/>
</div>
<div v-else-if="contentKind === 'audio'" class="ssd__audio">
<SvgIcon name="paper-plane-right" :size="40" color="rgba(0, 112, 116, 0.31)" />
<audio :src="session.audioUrl" controls class="ssd__audio-player" />
</div>
<div v-else-if="contentKind === 'text'" class="ssd__text">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" />
<p class="ssd__text-body">{{ session.text }}</p>
</div>
<div v-else-if="contentKind === 'link'" class="ssd__link">
<div class="ssd__link__box">
<span>https://link</span>
<SvgIcon name="link" :size="22" class="ssd__link-icon" />
</div>
<BaseButton text="ورود به جلسه " custom-class="ssd__link-btn">
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
<div v-else class="ssd__empty">
<SvgIcon name="warning" :size="32" color="#bcbcbc" />
<p>محتوایی برای این جلسه ثبت نشده است.</p>
</div>
</div>
<div class="ssd__uploads" v-if="contentKind !== 'link'">
<VoiceRecorder
class="ssd__uploads-recorder"
v-model="homeworkAudio"
:error="errors.audio"
/>
<ImageUploader
class="ssd__uploads-uploader"
v-model="homeworkImage"
:error="errors.image"
/>
</div>
</div>
<div class="ssd__hint">
<SvgIcon name="warning" :size="22" color="#b8b8b8" />
<p class="ssd__hint-text">
{{ session?.homeworkHint || '' }}
</p>
</div>
<div class="ssd__footer">
<BaseButton
text="ارسال تکلیف"
:loading="submitMutation.isPending.value"
:disabled="!canSubmit"
custom-class="ssd__submit-btn"
@click="onSubmit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<template #exam>
<NoItems title="در حال انتقال" desc="در حال انتقال به صفحه آزمون..." />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
import ImageUploader from '@/components/form/ImageUploader.vue'
import VideoPlayerBlock from '@/components/blocks/VideoPlayerBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import {
useStudentSessionQuery,
useSubmitStudentSessionHomeworkMutation,
} from '@/services/query/student-sessions'
const route = useRoute()
const router = useRouter()
const sessionId = computed(() => route.params.id)
const { data: session, isLoading } = useStudentSessionQuery(sessionId, {
enabled: () => !!sessionId.value,
})
const contentKind = computed(() => {
const s = session.value
if (!s) return ''
if (s.videoUrl) return 'link'
if (s.audioUrl) return 'audio'
if (s.text) return 'text'
if (s.link) return 'link'
return ''
})
const tabs = [
{ name: 'homework', label: 'ارسال تکلیف', icon: 'list-bullets' },
{ name: 'exam', label: 'آزمـــــون', icon: 'file' },
]
const activeTab = ref('homework')
const homeworkAudio = ref(null)
const homeworkImage = ref(null)
const errors = reactive({ audio: '', image: '' })
const canSubmit = computed(() => !!(homeworkAudio.value || homeworkImage.value))
const submitMutation = useSubmitStudentSessionHomeworkMutation()
const onSubmit = async () => {
if (!canSubmit.value) {
errors.audio = 'حداقل یک فایل صوتی یا تصویر ارسال کنید.'
return
}
errors.audio = ''
errors.image = ''
try {
const payload = { subType: 'homework' }
if (homeworkAudio.value) payload.audio = homeworkAudio.value
if (homeworkImage.value) payload.image = homeworkImage.value
const fd = objectToFormData(payload)
await submitMutation.mutateAsync({ id: sessionId.value, payload: fd })
toast.success('تکلیف با موفقیت ارسال شد.')
homeworkAudio.value = null
homeworkImage.value = null
} catch {
toast.error('ارسال تکلیف با خطا مواجه شد.')
}
}
const onTabChange = (name) => {
if (name !== 'exam') return
const examId = session.value?.examId
if (examId) {
router.push({ name: 'student-exam', params: { id: examId } }).catch(() => {})
} else {
toast.info('آزمونی برای این جلسه ثبت نشده است.')
activeTab.value = 'homework'
}
}
</script>
<style lang="scss" scoped>
.ssd {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__media {
display: flex;
align-items: center;
gap: 16px;
}
&__panel {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
&__prompt {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 1rem;
}
&__prompt-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
&__prompt-text {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
line-height: 1.7;
color: #5d5d5d;
margin: 0;
text-align: justify;
}
&__content {
width: 100%;
}
&__uploads {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.875rem;
height: -webkit-fill-available;
&-uploader {
height: stretch;
}
:global(.voice-recorder) {
height: stretch;
background-color: #eee !important;
}
}
&__link {
display: flex;
align-items: center;
gap: 1rem;
&__box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.625rem;
padding: 0.625rem 1rem;
border: 1px solid #ddd;
border-radius: 50px;
width: 100%;
}
&__icon {
flex-shrink: 0;
}
}
&__hint {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 1rem;
background: #eee;
border-radius: 1rem;
}
&__hint-text {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
line-height: 1.7;
color: #5d5d5d;
margin: 0;
text-align: justify;
flex: 1;
}
&__footer {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
&__submit-btn {
min-width: 18rem;
padding: 0 2rem;
}
}
</style>
@@ -0,0 +1,280 @@
<template>
<div class="student-term-item">
<div class="student-term-item__box">
<div class="student-term-item__accent" />
<div class="student-term-item__info">
<p class="student-term-item__title">{{ term.title || '—' }}</p>
<div class="student-term-item__teacher">
<SvgIcon name="user" :size="10" color="#bcbcbc" />
<span class="student-term-item__teacher-label">استـــــــاد:</span>
<span class="student-term-item__teacher-name">{{ teacherName }}</span>
</div>
</div>
</div>
<div class="student-term-item__meta">
<div class="student-term-item__dates">
<div class="student-term-item__date">
<span class="student-term-item__date-label">تاریخ پایان :</span>
<span class="student-term-item__date-value">{{ endDate }}</span>
</div>
<span class="student-term-item__date-divider" />
<div class="student-term-item__date">
<span class="student-term-item__date-label">تاریخ شروع :</span>
<span class="student-term-item__date-value">{{ startDate }}</span>
</div>
</div>
<div v-if="hasGpa" class="student-term-item__status student-term-item__status--gpa">
<span class="student-term-item__status-label">معدل دوره :</span>
<span class="student-term-item__status-value student-term-item__status-value--en">
{{ gpaText }}
</span>
</div>
<div
v-else
class="student-term-item__status"
:class="`student-term-item__status--${term.status || 'watching'}`"
>
<span class="student-term-item__status-label">وضعیت دوره :</span>
<span class="student-term-item__status-value">{{ statusLabel }}</span>
</div>
</div>
<div v-if="showDetails" class="student-term-item__actions">
<BaseButton
text="جزئیات دوره"
custom-class="student-term-item__details-btn"
@click="emit('show-details', term)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="14" color="#fff" />
</template>
</BaseButton>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { STUDENT_COURSE_STATUS } from '@/enums'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
const props = defineProps({
term: { type: Object, required: true },
showDetails: { type: Boolean, default: true },
})
const emit = defineEmits(['show-details'])
const teacherName = computed(() => {
const t = props.term.teacher
if (t) return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
return props.term.teacherName || '—'
})
const startDate = computed(
() => props.term.faStartDate || formatJalaaliDate(props.term.startDate) || '—'
)
const endDate = computed(() => props.term.faEndDate || formatJalaaliDate(props.term.endDate) || '—')
const statusLabel = computed(
() => props.term.statusLabel || STUDENT_COURSE_STATUS[props.term.status] || '—'
)
const hasGpa = computed(
() => props.term.gpa !== undefined && props.term.gpa !== null && props.term.gpa !== ''
)
const gpaText = computed(() => {
const v = Number(props.term.gpa)
return Number.isFinite(v) ? v.toFixed(3).replace(/0+$/, '').replace(/\.$/, '') : props.term.gpa
})
</script>
<style lang="scss" scoped>
.student-term-item {
display: flex;
flex-direction: column-reverse;
gap: 0.625rem;
padding: 0.875rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.625rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
gap: 1rem;
}
&__box {
display: flex;
align-items: center;
}
&__info {
flex: 1 1 25%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: end;
margin-right: 4px;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.95rem;
color: #4b4b4b;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
&__teacher {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: var(--font-family-fa);
font-size: 0.65rem;
color: #4b4b4b;
}
&__teacher-label {
color: #4b4b4b;
}
&__teacher-name {
color: #4b4b4b;
}
&__meta {
flex: 1 1 40%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 1rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.75rem;
white-space: nowrap;
&--watching {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--waitForExam {
background: rgba(116, 102, 0, 4%);
color: #746a00;
}
&--completed {
background: rgba(0, 154, 18, 6%);
color: #009a12;
}
&--failed {
background: rgba(204, 40, 49, 6%);
color: #cc2831;
}
&--gpa {
background: rgba(0, 112, 116, 6%);
color: #007074;
}
}
&__status-value--en {
font-family: var(--font-family-en);
}
&__status-label {
font-weight: 300;
font-size: 0.7rem;
color: inherit;
opacity: 0.85;
}
&__status-value {
font-weight: 500;
}
&__dates {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 1rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%);
white-space: nowrap;
}
&__date {
display: inline-flex;
align-items: baseline;
gap: 0.375rem;
}
&__date-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.7rem;
color: #535353;
}
&__date-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
color: #5d5d5d;
}
&__date-divider {
width: 1px;
height: 0.625rem;
background: #dadada;
}
&__actions {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.625rem;
}
&__accent {
width: 3.25rem;
height: 3.35rem;
border-radius: 0.75rem;
background: #f4f4f4;
display: none;
@media (min-width: 1024px) {
display: block;
}
}
&__details-btn {
min-width: 6rem;
padding: 0 1rem;
height: 2rem;
}
}
</style>
@@ -0,0 +1,227 @@
<template>
<div class="student-term-summary">
<div class="student-term-summary__cover">
<img v-if="term.image" :src="term.image" :alt="term.title" />
</div>
<div class="student-term-summary__info">
<div
class="student-term-summary__status"
:class="`student-term-summary__status--${term.status || 'watching'}`"
>
<span class="student-term-summary__status-label">وضعیت دوره :</span>
<span class="student-term-summary__status-value">{{ statusLabel }}</span>
</div>
<p class="student-term-summary__title">{{ term.title || '—' }}</p>
<div class="student-term-summary__teacher">
<span class="student-term-summary__teacher-label">استـــــــاد:</span>
<span class="student-term-summary__teacher-name">{{ teacherName }}</span>
<SvgIcon name="user" :size="13" color="#bcbcbc" />
</div>
</div>
<div class="student-term-summary__dates">
<div class="student-term-summary__date">
<span class="student-term-summary__date-label">تاریخ پایان :</span>
<span class="student-term-summary__date-value">{{ endDate }}</span>
</div>
<span class="student-term-summary__date-divider" />
<div class="student-term-summary__date">
<span class="student-term-summary__date-label">تاریخ شروع :</span>
<span class="student-term-summary__date-value">{{ startDate }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { STUDENT_COURSE_STATUS } from '@/enums'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
const props = defineProps({
term: { type: Object, required: true },
})
const teacherName = computed(() => {
const t = props.term.teacher
if (t) return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
return props.term.teacherName || '—'
})
const startDate = computed(
() => props.term.faStartDate || formatJalaaliDate(props.term.startDate) || '—'
)
const endDate = computed(() => props.term.faEndDate || formatJalaaliDate(props.term.endDate) || '—')
const statusLabel = computed(
() => props.term.statusLabel || STUDENT_COURSE_STATUS[props.term.status] || '—'
)
</script>
<style lang="scss" scoped>
.student-term-summary {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: rgba(255, 255, 255, 40%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
@media (min-width: 1024px) {
flex-direction: row;
align-items: center;
min-height: 9.9rem;
padding: 0.875rem 2.25rem;
}
&__cover {
width: 100%;
height: 8rem;
border-radius: 0.75rem;
background: #f4f4f4;
overflow: hidden;
order: 1;
@media (min-width: 1024px) {
width: 19rem;
height: 8rem;
flex-shrink: 0;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.5rem;
text-align: end;
order: 2;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 1rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
&--watching {
background: rgba(0, 112, 116, 4%);
color: #007074;
}
&--waitForExam {
background: rgba(116, 102, 0, 4%);
color: #746a00;
}
&--completed {
background: rgba(0, 154, 18, 6%);
color: #009a12;
}
&--failed {
background: rgba(204, 40, 49, 6%);
color: #cc2831;
}
}
&__status-label {
font-weight: 400;
font-size: 0.65rem;
color: inherit;
}
&__status-value {
font-weight: 500;
font-size: 0.75rem;
}
&__title {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 1.5rem;
color: #4b4b4b;
margin: 0;
line-height: 1.234;
}
&__teacher {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #4b4b4b;
}
&__teacher-label {
font-weight: 400;
}
&__teacher-name {
font-weight: 400;
}
&__dates {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 1.125rem;
border-radius: 1.375rem;
background: rgba(107, 107, 107, 4%);
white-space: nowrap;
align-self: center;
order: 3;
@media (min-width: 1024px) {
align-self: center;
}
}
&__date {
display: inline-flex;
align-items: baseline;
gap: 0.375rem;
}
&__date-label {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.6rem;
color: #535353;
}
&__date-value {
font-family: var(--font-family-en);
font-weight: 400;
font-size: 0.75rem;
color: #5d5d5d;
}
&__date-divider {
width: 1px;
height: 0.625rem;
background: #dadada;
}
}
</style>
@@ -0,0 +1,77 @@
<template>
<div class="student-term-details">
<BoxedIconTitleBlock
class="student-term-details__heading"
title="جزئیات دوره‌ آموزشی"
desc="اطلاعات کامل این دوره را مشاهده کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading && !term" :rows="2" :cols-per-row="1" />
<StudentTermSummary v-else-if="term" :term="term" />
<SimpleTitleIconBlock title="همه درس ها" class="student-term-details__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock v-if="isLoading && lessons.length === 0" :rows="3" :cols-per-row="1" />
<div v-else-if="lessons.length > 0">
<StudentLessonItem
v-for="lesson in lessons"
:key="lesson.id"
:lesson="lesson"
@show-details="onShowLesson"
/>
</div>
<NoItems v-else title="درسی نیست" desc="هنوز درسی برای این دوره ثبت نشده است." />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { useStudentTermQuery } from '@/services/query/student-terms'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import StudentTermSummary from '@/features/student/terms/components/StudentTermSummary.vue'
import StudentLessonItem from '@/features/student/lessons/components/StudentLessonItem.vue'
const route = useRoute()
const router = useRouter()
const termId = computed(() => route.params.id)
const { data: term, isLoading } = useStudentTermQuery(termId, {
enabled: () => !!termId.value,
})
const lessons = computed(() => term.value?.courses ?? term.value?.lessons ?? [])
const onShowLesson = (lesson) => {
router.push({ name: 'student-lesson-details', params: { id: lesson.id } }).catch(() => {})
}
</script>
<style lang="scss" scoped>
.student-term-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-title {
margin-top: 0.5rem;
}
}
</style>
@@ -0,0 +1,128 @@
<template>
<div class="student-terms">
<BoxedIconTitleBlock
class="student-terms__heading"
title="دوره‌های آموزشی"
desc="دوره‌ها و آموزش‌های گذرانده‌شده خود را مشاهده کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<TabsBlock :tabs="tabs" v-model="activeTab">
<template #current>
<SkeletonLoaderBlock v-if="currentPending" :rows="4" :cols-per-row="1" />
<div v-else-if="currentTerms.length > 0">
<StudentTermItem
v-for="term in currentTerms"
:key="term.id"
:term="term"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="دوره‌ای نیست" desc="در حال حاضر دوره‌ای در حال گذراندن ندارید." />
<PaginationBlock :pagination="currentMeta" @update:page="setCurrentPage" />
</template>
<template #ended>
<SkeletonLoaderBlock v-if="endedPending" :rows="4" :cols-per-row="1" />
<div v-else-if="endedTerms.length > 0">
<StudentTermItem
v-for="term in endedTerms"
:key="term.id"
:term="term"
:showDetails="false"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="موردی نیست" desc="هنوز دوره‌ای را تمام نکرده‌اید." />
<PaginationBlock :pagination="endedMeta" @update:page="setEndedPage" />
</template>
</TabsBlock>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import { useStudentTermsListQuery } from '@/services/query/student-terms'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import StudentTermItem from '@/features/student/terms/components/StudentTermItem.vue'
const router = useRouter()
const tabs = [
{ name: 'current', label: 'دوره های در حال گذراندن', icon: 'list-bullets' },
{ name: 'ended', label: 'دوره های تکمیل شده', icon: 'list-bullets' },
]
const activeTab = ref('current')
const currentFilters = ref({ status: 'current' })
const endedFilters = ref({ status: 'ended' })
const { pagination: currentPagination, setPage: setCurrentPage } = usePagination({
page: 1,
perPage: 10,
})
const { pagination: endedPagination, setPage: setEndedPage } = usePagination({
page: 1,
perPage: 10,
})
const { data: currentData, isLoading: currentPending } = useStudentTermsListQuery(
currentFilters,
currentPagination,
{
enabled: () => activeTab.value === 'current',
keepPreviousData: true,
}
)
const { data: endedData, isLoading: endedPending } = useStudentTermsListQuery(
endedFilters,
endedPagination,
{
enabled: () => activeTab.value === 'ended',
keepPreviousData: true,
}
)
const currentTerms = computed(() => currentData.value?.data ?? [])
const endedTerms = computed(() => endedData.value?.data ?? [])
const currentMeta = computed(() => ({
page: currentPagination.value.page,
perPage: currentPagination.value.perPage,
...currentData.value?.meta,
}))
const endedMeta = computed(() => ({
page: endedPagination.value.page,
perPage: endedPagination.value.perPage,
...endedData.value?.meta,
}))
const onShowDetails = (term) => {
router.push({ name: 'student-term-details', params: { id: term.id } }).catch(() => {})
}
</script>
<style lang="scss" scoped>
.student-terms {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>