fix
This commit is contained in:
@@ -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>
|
||||
+1
-1
@@ -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>
|
||||
+1
-1
@@ -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 {
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user