fix
Deploy banu-front / deploy (push) Successful in 1m19s

This commit is contained in:
sajjadtalkhabi
2026-06-05 14:41:38 +03:30
parent d42b7aaa7b
commit 1c665830b0
27 changed files with 565 additions and 617 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
export const fields = { export const fields = {
mobile: 'شماره موبایل', mobile: 'شماره موبایل',
phoneNumber: 'شماره موبایل', phoneNumber: 'شماره موبایل',
phone: 'تلفن ثابت', phone: 'شماره موبایل',
password: 'رمز عبور', password: 'رمز عبور',
passwordConfirmation: 'تکرار رمز عبور', passwordConfirmation: 'تکرار رمز عبور',
newPassword: 'رمز عبور جدید', newPassword: 'رمز عبور جدید',
+7 -10
View File
@@ -5,8 +5,11 @@ export const getEnumObject = (enumArray, key = 'value', value = 'label') =>
}, {}) }, {})
export const ROLES = Object.freeze([ export const ROLES = Object.freeze([
{ value: 'admin', label: 'مدیر' }, { value: 'admin', label: 'مدیر', changeable: false },
{ value: 'student', label: 'دانش‌آموز' }, { value: 'student', label: 'دانشجو', changeable: true },
{ value: 'teacher', label: 'استاد', changeable: true },
{ value: 'counselor', label: 'مشاور', changeable: true },
{ value: 'missionary', label: 'مبلغ', changeable: true },
]) ])
export const LOGIN_STEPS = Object.freeze({ export const LOGIN_STEPS = Object.freeze({
@@ -54,15 +57,9 @@ export const GENDER = Object.freeze({
female: 'خانم', female: 'خانم',
}) })
export const ROLE_LABELS = Object.freeze({ export const ROLE_LABELS = Object.freeze(Object.fromEntries(ROLES.map((r) => [r.value, r.label])))
admin: 'مدیر',
counselor: 'مشاور',
missionary: 'مبلغ',
student: 'دانشجو',
teacher: 'استاد',
})
export const CHANGEABLE_ROLES = Object.freeze(['student', 'teacher', 'missionary', 'counselor']) export const CHANGEABLE_ROLES = Object.freeze(ROLES.filter((r) => r.changeable).map((r) => r.value))
export const EDUCATION_STATUS = Object.freeze({ export const EDUCATION_STATUS = Object.freeze({
seminary_student: 'طلبه', seminary_student: 'طلبه',
@@ -7,7 +7,6 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-if="showTermFilter"
v-model="form.termId" v-model="form.termId"
name="termId" name="termId"
label="ترم" label="ترم"
@@ -68,7 +67,6 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({ title: '', termId: '', status: '', fromDate: '', toDate: '' }), default: () => ({ title: '', termId: '', status: '', fromDate: '', toDate: '' }),
}, },
showTermFilter: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:modelValue', 'apply', 'reset']) const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
@@ -100,9 +98,7 @@ const statusOptions = [
const termFilters = ref({}) const termFilters = ref({})
const termPagination = ref({ page: 1, perPage: 10 }) const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination, { const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
enabled: () => props.showTermFilter,
})
const termOptions = computed(() => termsResponse.value?.data ?? []) const termOptions = computed(() => termsResponse.value?.data ?? [])
const hasFilters = computed(() => const hasFilters = computed(() =>
@@ -20,7 +20,7 @@
<div class="course-details__grid"> <div class="course-details__grid">
<LineInfoBlock title="عنوان دوره" :desc="course.title || '—'" /> <LineInfoBlock title="عنوان دوره" :desc="course.title || '—'" />
<LineInfoBlock title="تعداد جلسات" :numeric-desc="course.sessionsCount ?? 0" /> <LineInfoBlock title="تعداد جلسات" :numeric-desc="course.sessionsCount ?? 0" />
<LineInfoBlock title="پیش‌نیاز دوره" :desc="teacherName" /> <LineInfoBlock title="پیش‌نیاز دوره" :desc="prerequisiteCourses" />
</div> </div>
</div> </div>
@@ -99,11 +99,9 @@ const { data: course, isLoading } = useAdminCourseQuery(courseId, {
enabled: () => !!courseId.value, enabled: () => !!courseId.value,
}) })
const teacherName = computed(() => { const prerequisiteCourses = computed(() =>
const t = course.value?.teacher course.value.prerequisiteCourses.map((course_) => course_.title).join(' ,')
if (!t) return '—' )
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
})
const sessionsFilters = computed(() => ({ courseId: courseId.value })) const sessionsFilters = computed(() => ({ courseId: courseId.value }))
const { pagination: sessionsPagination, setPage: setSessionsPage } = usePagination({ const { pagination: sessionsPagination, setPage: setSessionsPage } = usePagination({
@@ -65,28 +65,30 @@
</div> </div>
<div class="course-form__cell course-form__cell--third"> <div class="course-form__cell course-form__cell--third">
<SelectField <SelectField
v-model="form.prerequisites" v-model="form.termId"
name="prerequisites" name="termId"
label="ترم"
:options="termOptions"
option-label="title"
option-value="id"
:error="errors.termId"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.prerequisiteCourseIds"
name="prerequisiteCourseIds"
label="دوره‌های پیش‌نیاز" label="دوره‌های پیش‌نیاز"
:options="prerequisiteOptions" :options="prerequisiteOptions"
option-label="title" option-label="title"
option-value="id" option-value="id"
:multiple="true" :multiple="true"
:searchable="true" :searchable="true"
:on-search="searchPrerequisites" :disabled="!form.termId"
/> :on-search="searchPrerequisiteCourses"
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.contentType"
name="contentType"
label="نوع فایل دوره"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
/> />
</div> </div>
<div class="course-form__cell course-form__cell--full"> <div class="course-form__cell course-form__cell--full">
<TextareaField <TextareaField
v-model="form.description" v-model="form.description"
@@ -132,7 +134,6 @@
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup' import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { COURSE_CONTENT_TYPE } from '@/enums'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import useDebounce from '@/composables/useDebounce' import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
@@ -146,6 +147,7 @@ import { useUploadMediaMutation } from '@/services/query/auth'
import { courseSchema } from '@/features/admin/courses/schema' import { courseSchema } from '@/features/admin/courses/schema'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users' import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { import {
adminCoursesKeys, adminCoursesKeys,
@@ -167,8 +169,7 @@ const form = ref({
title: '', title: '',
teacherId: '', teacherId: '',
capacity: '', capacity: '',
prerequisites: [], prerequisiteCourseIds: [],
contentType: '',
description: '', description: '',
coverMediaId: null, coverMediaId: null,
termId: termId.value, termId: termId.value,
@@ -176,11 +177,6 @@ const form = ref({
const image = ref(null) const image = ref(null)
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const { validate, validateAt, errors } = useYup(courseSchema) const { validate, validateAt, errors } = useYup(courseSchema)
const teacherSearch = ref('') const teacherSearch = ref('')
@@ -203,6 +199,11 @@ const teacherOptions = computed(() => {
})) }))
}) })
const termFilters = ref({})
const termPagination = ref({ page: 1, perPage: 100 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const prereqSearch = ref('') const prereqSearch = ref('')
const prereqFilters = computed(() => ({ title: prereqSearch.value })) const prereqFilters = computed(() => ({ title: prereqSearch.value }))
const prereqPagination = ref({ page: 1, perPage: 10 }) const prereqPagination = ref({ page: 1, perPage: 10 })
@@ -211,13 +212,14 @@ const selectedPrereqs = ref([])
const prerequisiteOptions = computed(() => { const prerequisiteOptions = computed(() => {
const base = prereqsResponse.value?.data ?? [] const base = prereqsResponse.value?.data ?? []
const extras = selectedPrereqs.value.filter((p) => !base.some((b) => b.id === p.id)) const extras = selectedPrereqs.value.filter((p) => !base.some((b) => b.id === p.id))
return [...base, ...extras] const merged = [...base, ...extras]
return courseId.value ? merged.filter((c) => c.id !== courseId.value) : merged
}) })
const searchTeachers = useDebounce((q) => { const searchTeachers = useDebounce((q) => {
teacherSearch.value = q || '' teacherSearch.value = q || ''
}, 400) }, 400)
const searchPrerequisites = useDebounce((q) => { const searchPrerequisiteCourses = useDebounce((q) => {
prereqSearch.value = q || '' prereqSearch.value = q || ''
}, 400) }, 400)
@@ -225,25 +227,29 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
enabled: () => !!courseId.value, enabled: () => !!courseId.value,
}) })
watch(existingCourse, (course) => { watch(
if (!course) return existingCourse,
const teacher = course.teacher (course) => {
if (teacher) selectedTeacher.value = teacher if (!course) return
const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : [] const teacher = course.teacher
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id) if (teacher) selectedTeacher.value = teacher
const prereqs = Array.isArray(course.prerequisiteCourseIds) ? course.prerequisiteCourseIds : []
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
form.value = { form.value = {
title: course.title || '', title: course.title || '',
teacherId: teacher?.id || course.teacherId || '', teacherId: teacher?.id || course.teacherId || '',
capacity: course.capacity ?? '', capacity: course.capacity ?? '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean), prerequisiteCourseIds: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
contentType: course.contentType || '', contentType: course.contentType || '',
description: course.description || '', description: course.description || '',
coverMediaId: course.coverMediaId || null, coverMediaId: course.coverMediaId || null,
termId: course.termId ?? termId.value, termId: course.termId ?? termId.value,
} }
if (course.coverUrl) image.value = { url: course.coverUrl } if (course.coverUrl) image.value = { url: course.coverUrl }
}) },
{ immediate: true }
)
const uploadMutation = useUploadMediaMutation() const uploadMutation = useUploadMediaMutation()
@@ -10,19 +10,14 @@
</template> </template>
</BoxedIconTitleBlock> </BoxedIconTitleBlock>
<CoursesFilters <CoursesFilters v-model="offeredFilters" @apply="onFilterApply" @reset="onFilterReset" />
v-model="currentFilters"
:show-term-filter="activeTab === 'offered'"
@apply="onFilterApply"
@reset="onFilterReset"
/>
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange"> <TabsBlock :tabs="tabs">
<template #templates> <template #offered>
<SkeletonLoaderBlock v-if="templatesPending" :rows="6" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
<div v-else-if="templates.length > 0"> <div v-else-if="offered.length > 0">
<CourseItem <CourseItem
v-for="course in templates" v-for="course in offered"
:key="course.id" :key="course.id"
:course="course" :course="course"
@edit="onEditCourse" @edit="onEditCourse"
@@ -31,36 +26,11 @@
@show-details="onShowDetails" @show-details="onShowDetails"
/> />
</div> </div>
<NoItems v-else title="متاسفیم" desc="دوره الگویی برای نمایش وجود ندارد." /> <NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="templatesPaginationMeta" @update:page="setTemplatesPage" /> <PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
</template>
<template #offered>
<NoItems
v-if="!hasOfferedTerm"
title="ترم را انتخاب کنید"
desc="برای نمایش دوره‌های ارائه شده، ابتدا ترم را از فیلترها انتخاب کنید."
/>
<template v-else>
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
<div v-else-if="offered.length > 0">
<CourseItem
v-for="course in offered"
:key="course.id"
:course="course"
@edit="onEditCourse"
@delete="onAskDelete"
@change-status="onChangeStatus"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
</template>
</template> </template>
</TabsBlock> </TabsBlock>
<AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" />
<CourseDetailsModal v-if="isModal('CourseDetailsModal')" /> <CourseDetailsModal v-if="isModal('CourseDetailsModal')" />
<AddSessionToCourseModal v-if="isModal('AddSessionToCourseModal')" /> <AddSessionToCourseModal v-if="isModal('AddSessionToCourseModal')" />
</div> </div>
@@ -81,7 +51,6 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.vue' import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.vue'
import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue' import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue'
import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue'
import AddSessionToCourseModal from '@/features/admin/courses/components/modals/AddSessionToCourseModal.vue' import AddSessionToCourseModal from '@/features/admin/courses/components/modals/AddSessionToCourseModal.vue'
import { import {
adminCoursesKeys, adminCoursesKeys,
@@ -98,25 +67,10 @@ const { openModal, isModal } = useModal()
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null)) const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
const onAdd = () => { const onAdd = () => {
if (activeTab.value === 'templates') { router.push({ name: 'admin-add-course' }).catch(() => {})
router.push({ name: 'admin-add-course' }).catch(() => {})
} else {
openModal('AddOfferedCourseModal', {
mode: 'add',
termId: routeTermId.value ?? undefined,
})
}
} }
const tabs = [ const tabs = [
{
name: 'templates',
label: 'دوره‌های الگو',
icon: 'list-bullets',
hasButton: true,
textButton: 'افزودن دوره الگوی جدید',
buttonAction: onAdd,
},
{ {
name: 'offered', name: 'offered',
label: 'دوره‌های ارائه شده', label: 'دوره‌های ارائه شده',
@@ -126,58 +80,21 @@ const tabs = [
buttonAction: onAdd, buttonAction: onAdd,
}, },
] ]
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
const templateFilters = ref({})
const offeredFilters = ref({}) const offeredFilters = ref({})
const currentFilters = computed({
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
set: (val) => {
if (activeTab.value === 'templates') templateFilters.value = val
else offeredFilters.value = val
},
})
const {
pagination: templatesPagination,
setPage: setTemplatesPage,
reset: resetTemplatesPagination,
} = usePagination({ page: 1, perPage: 10 })
const { const {
pagination: offeredPagination, pagination: offeredPagination,
setPage: setOfferedPage, setPage: setOfferedPage,
reset: resetOfferedPagination, reset: resetOfferedPagination,
} = usePagination({ page: 1, perPage: 10 }) } = usePagination({ page: 1, perPage: 10 })
const { data: templatesData, isLoading: templatesPending } = useAdminCoursesListQuery(
templateFilters,
templatesPagination,
{
enabled: () => activeTab.value === 'templates',
keepPreviousData: true,
}
)
const hasOfferedTerm = computed(() => !!offeredFilters.value.termId)
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery( const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
offeredFilters, offeredFilters,
offeredPagination, offeredPagination,
{ { keepPreviousData: true }
enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
keepPreviousData: true,
}
) )
const templates = computed(() => templatesData.value?.data ?? [])
const templatesPaginationMeta = computed(() => ({
page: templatesPagination.value.page,
perPage: templatesPagination.value.perPage,
...templatesData.value?.meta,
}))
const offered = computed(() => offeredData.value?.data ?? []) const offered = computed(() => offeredData.value?.data ?? [])
const offeredPaginationMeta = computed(() => ({ const offeredPaginationMeta = computed(() => ({
page: offeredPagination.value.page, page: offeredPagination.value.page,
@@ -185,14 +102,7 @@ const offeredPaginationMeta = computed(() => ({
...offeredData.value?.meta, ...offeredData.value?.meta,
})) }))
const onTabChange = () => { const onFilterApply = () => resetOfferedPagination()
/* tab managed via v-model; no further action required */
}
const onFilterApply = () => {
if (activeTab.value === 'templates') resetTemplatesPagination()
else resetOfferedPagination()
}
const onFilterReset = onFilterApply const onFilterReset = onFilterApply
const onEditCourse = (course) => { const onEditCourse = (course) => {
@@ -200,16 +110,12 @@ const onEditCourse = (course) => {
} }
const onShowDetails = (course) => { const onShowDetails = (course) => {
openModal('CourseDetailsModal', { openModal('CourseDetailsModal', { id: course.id })
id: course.id,
type: activeTab.value === 'templates' ? 'template' : 'offered',
})
} }
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all }) const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const deleteMutation = useDeleteAdminCourseMutation() const deleteMutation = useDeleteAdminCourseMutation()
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
const updateMutation = useUpdateAdminCourseMutation() const updateMutation = useUpdateAdminCourseMutation()
const onAskDelete = (course) => { const onAskDelete = (course) => {
@@ -226,7 +132,6 @@ const onChangeStatus = ({ id, isActive }) => {
const syncRouteTermId = (termId) => { const syncRouteTermId = (termId) => {
if (!termId) return if (!termId) return
activeTab.value = 'offered'
offeredFilters.value = { ...offeredFilters.value, termId } offeredFilters.value = { ...offeredFilters.value, termId }
resetOfferedPagination() resetOfferedPagination()
} }
+3 -4
View File
@@ -3,12 +3,11 @@ import { array, boolean, mixed, number, object, string } from 'yup'
export const courseSchema = object().shape({ export const courseSchema = object().shape({
title: string().required().min(3).max(255), title: string().required().min(3).max(255),
teacherId: mixed().required(), teacherId: mixed().required(),
capacity: number().required().min(1), capacity: number().typeError('ظرفیت باید عدد باشد').required().min(1),
prerequisites: array().nullable().default([]), prerequisiteCourseIds: array().nullable().default([]),
contentType: string().oneOf(['video', 'voice', 'text']).required(),
contentMediaId: number().nullable().notRequired(), contentMediaId: number().nullable().notRequired(),
description: string().nullable().notRequired(), description: string().nullable().notRequired(),
termId: mixed().nullable(), termId: mixed().required(),
isActive: boolean().nullable().notRequired(), isActive: boolean().nullable().notRequired(),
}) })
@@ -15,10 +15,6 @@
<span class="session-item__pill-label">متعلق به دوره:</span> <span class="session-item__pill-label">متعلق به دوره:</span>
<span class="session-item__pill-value">{{ session.course?.title || '—' }}</span> <span class="session-item__pill-value">{{ session.course?.title || '—' }}</span>
</div> </div>
<div class="session-item__pill">
<span class="session-item__pill-label">مدت جلسه:</span>
<span class="session-item__pill-value">{{ session.durationMinutes ?? '—' }}</span>
</div>
<div class="session-item__pill"> <div class="session-item__pill">
<span class="session-item__pill-label">نوع جلسه:</span> <span class="session-item__pill-label">نوع جلسه:</span>
<span class="session-item__pill-value">{{ sessionTypeLabel }}</span> <span class="session-item__pill-value">{{ sessionTypeLabel }}</span>
@@ -83,9 +79,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'show-details', 'show-attendance']) const emit = defineEmits(['edit', 'delete', 'show-details', 'show-attendance'])
const sessionTypeLabel = computed( const sessionTypeLabel = computed(() => SESSION_TYPE[props.session.type] || '—')
() => props.session.sessionTypeFa || SESSION_TYPE[props.session.sessionType] || '—'
)
const hasUsedTerms = computed(() => (props.session.usedInTerms?.length ?? 0) > 0) const hasUsedTerms = computed(() => (props.session.usedInTerms?.length ?? 0) > 0)
</script> </script>
@@ -112,7 +112,7 @@ const isOnline = computed(() => session.value?.type === 'online')
const collectionToContentType = (collectionName) => { const collectionToContentType = (collectionName) => {
if (collectionName === 'videos') return 'video' if (collectionName === 'videos') return 'video'
if (collectionName === 'voices') return 'voice' if (collectionName === 'voice') return 'voice'
if (collectionName === 'pdfs') return 'text' if (collectionName === 'pdfs') return 'text'
return '' return ''
} }
@@ -235,6 +235,13 @@ const { data: existingSession } = useAdminSessionQuery(sessionId, {
enabled: () => !!sessionId.value, enabled: () => !!sessionId.value,
}) })
const collectionToContentType = (collectionName) => {
if (collectionName === 'video') return 'video'
if (collectionName === 'voice') return 'voice'
if (collectionName === 'pdf') return 'text'
return ''
}
watch( watch(
existingSession, existingSession,
(session) => { (session) => {
@@ -277,13 +284,6 @@ const purposeForContentType = (contentType) => {
return 'pdf' return 'pdf'
} }
const collectionToContentType = (collectionName) => {
if (collectionName === 'videos') return 'video'
if (collectionName === 'voices') return 'voice'
if (collectionName === 'pdf') return 'text'
return ''
}
const onContentSelect = async (files) => { const onContentSelect = async (files) => {
const file = files?.[0] const file = files?.[0]
if (!file) return if (!file) return
@@ -7,12 +7,12 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.roleId" v-model="form.role"
name="roleId" name="role"
label="نقش کاربر" label="نقش کاربر"
:options="roleOptions" :options="ROLES"
option-label="label" option-label="label"
option-value="id" option-value="value"
/> />
<TextField <TextField
v-model="form.nationalCode" v-model="form.nationalCode"
@@ -66,18 +66,17 @@
</template> </template>
<script setup> <script setup>
import { ROLE_LABELS } from '@/enums' import { ROLES } from '@/enums'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue' import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import { useAdminRolesListQuery } from '@/services/query/admin-users'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ name: '', nationalCode: '', phoneNumber: '', roleId: '' }), default: () => ({ name: '', nationalCode: '', phoneNumber: '', role: '' }),
}, },
}) })
@@ -92,11 +91,6 @@ watch(
{ deep: true } { deep: true }
) )
const { data: roles = ref([]) } = useAdminRolesListQuery()
const roleOptions = computed(() =>
(roles.value ?? []).map((r) => ({ ...r, label: ROLE_LABELS[r.name] || r.name }))
)
const hasFilters = computed(() => const hasFilters = computed(() =>
Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined) Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined)
) )
@@ -107,7 +101,7 @@ const onSubmit = () => {
} }
const onReset = () => { const onReset = () => {
form.value = { name: '', nationalCode: '', phoneNumber: '', roleId: '' } form.value = { name: '', nationalCode: '', phoneNumber: '', role: '' }
emit('update:modelValue', { ...form.value }) emit('update:modelValue', { ...form.value })
emit('reset') emit('reset')
} }
@@ -66,11 +66,11 @@
</template> </template>
<script setup> <script setup>
import { ROLES } from '@/enums'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import { CHANGEABLE_ROLES, ROLE_LABELS } from '@/enums'
import DropdownMenu from '@/components/DropdownMenu.vue' import DropdownMenu from '@/components/DropdownMenu.vue'
import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users' import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users'
@@ -99,7 +99,8 @@ const roleName = (user) => {
const roleLabel = (user) => { const roleLabel = (user) => {
const name = roleName(user) const name = roleName(user)
return name ? ROLE_LABELS[name] || name : '' if (!name) return ''
return ROLES.find((r) => r.value === name)?.label || name
} }
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -146,11 +147,11 @@ const onChangeRole = (targetRoleName) => {
const roleMenuItems = computed(() => { const roleMenuItems = computed(() => {
const current = activeUser.value ? roleName(activeUser.value) : null const current = activeUser.value ? roleName(activeUser.value) : null
return CHANGEABLE_ROLES.filter((r) => r !== current).map((r) => ({ return ROLES.filter((r) => r.changeable && r.value !== current).map((r) => ({
key: `role-${r}`, key: `role-${r.value}`,
label: `تغییر به ${ROLE_LABELS[r]}`, label: `تغییر به ${r.label}`,
icon: 'arrows-clockwise', icon: 'arrows-clockwise',
onClick: () => onChangeRole(r), onClick: () => onChangeRole(r.value),
})) }))
}) })
@@ -73,7 +73,7 @@ const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, isModal } = useModal() const { openModal, isModal } = useModal()
const filters = ref({ name: '', nationalCode: '', phoneNumber: '', roleId: '' }) const filters = ref({ name: '', nationalCode: '', phoneNumber: '', role: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 }) const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminUsersListQuery(filters, pagination, { const { data, isLoading } = useAdminUsersListQuery(filters, pagination, {
@@ -4,19 +4,19 @@
<div class="student-exam-item__stats"> <div class="student-exam-item__stats">
<div class="student-exam-item__stat student-exam-item__stat--duration"> <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" /> <SvgIcon name="calendar" :size="11" color="#5d5d5d" />
<span class="student-exam-item__stat-label">مدت آزمون :</span>
<span class="student-exam-item__stat-value">{{ durationText }}</span>
</div> </div>
<div class="student-exam-item__stat"> <div class="student-exam-item__stat">
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
<span class="student-exam-item__stat-label">نمره قبولی :</span> <span class="student-exam-item__stat-label">نمره قبولی :</span>
<span class="student-exam-item__stat-value">{{ passingScoreText }}</span> <span class="student-exam-item__stat-value">{{ passingScoreText }}</span>
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
</div> </div>
<div class="student-exam-item__stat"> <div class="student-exam-item__stat">
<SvgIcon name="file" :size="11" color="#5d5d5d" />
<span class="student-exam-item__stat-label">تعداد سوال :</span> <span class="student-exam-item__stat-label">تعداد سوال :</span>
<span class="student-exam-item__stat-value">{{ questionsCountText }}</span> <span class="student-exam-item__stat-value">{{ questionsCountText }}</span>
<SvgIcon name="file" :size="11" color="#5d5d5d" />
</div> </div>
</div> </div>
@@ -49,7 +49,7 @@ const durationText = computed(() =>
props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه` props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه`
) )
const passingScoreText = computed(() => const passingScoreText = computed(() =>
props.exam.passingScore == null ? '—' : `${props.exam.passingScore}` props.exam.minimumScore == null ? '—' : `${props.exam.minimumScore}`
) )
const questionsCountText = computed(() => const questionsCountText = computed(() =>
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}` props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
@@ -80,7 +80,7 @@ const questionsCountText = computed(() =>
font-size: 0.95rem; font-size: 0.95rem;
color: #4b4b4b; color: #4b4b4b;
margin: 0; margin: 0;
text-align: end; text-align: start;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -96,7 +96,7 @@ const questionsCountText = computed(() =>
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -2,34 +2,51 @@
<div class="student-lesson-item"> <div class="student-lesson-item">
<div class="student-lesson-item__head"> <div class="student-lesson-item__head">
<span class="student-lesson-item__decor" aria-hidden="true"> <span class="student-lesson-item__decor" aria-hidden="true">
<SvgIcon name="users-three" :size="20" color="rgba(0, 112, 116, 0.31)" /> <img
v-if="lesson.coverUrl"
:src="lesson.coverUrl"
:alt="lesson.title || ''"
class="student-lesson-item__cover"
/>
<SvgIcon v-else name="users-three" :size="20" color="rgba(0, 112, 116, 0.31)" />
</span> </span>
<div> <div>
<p class="student-lesson-item__title">{{ lesson.title || '—' }}</p> <p class="student-lesson-item__title">{{ lesson.title || '—' }}</p>
<div class="student-lesson-item__hours"> <Badge
<SvgIcon name="calendar" :size="11" color="#007074" /> variant="primary"
<span class="student-lesson-item__hours-label">زمان یادگیری :</span> label="زمان یادگیری :"
<span class="student-lesson-item__hours-value">{{ hoursText }}</span> :value="hoursText"
</div> class="student-lesson-item__hours"
>
<template #prepend>
<SvgIcon name="calendar" :size="11" color="#007074" />
</template>
</Badge>
</div> </div>
</div> </div>
<div class="student-lesson-item__stats"> <div class="student-lesson-item__stats">
<div class="student-lesson-item__stat"> <Badge
<SvgIcon name="users-three" :size="12" color="#5d5d5d" /> variant="neutral"
<span class="student-lesson-item__stat-label">تعداد دانشجویان :</span> label="استاد :"
<span class="student-lesson-item__stat-value">{{ studentsText }}</span> :value="teacherText"
</div> class="student-lesson-item__stat"
<div class="student-lesson-item__stat"> >
<SvgIcon name="file" :size="12" color="#5d5d5d" /> <template #prepend>
<span class="student-lesson-item__stat-label">تعداد آزمون :</span> <SvgIcon name="user" :size="12" color="#5d5d5d" />
<span class="student-lesson-item__stat-value">{{ quizzesText }}</span> </template>
</div> </Badge>
<div class="student-lesson-item__stat">
<SvgIcon name="list-bullets" :size="12" color="#5d5d5d" /> <Badge
<span class="student-lesson-item__stat-label">تعداد جلسات :</span> variant="neutral"
<span class="student-lesson-item__stat-value">{{ sessionsText }}</span> label="تعداد جلسات :"
</div> :value="sessionsText"
class="student-lesson-item__stat"
>
<template #prepend>
<SvgIcon name="list-bullets" :size="12" color="#5d5d5d" />
</template>
</Badge>
</div> </div>
<div class="student-lesson-item__actions"> <div class="student-lesson-item__actions">
@@ -48,6 +65,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
@@ -63,12 +81,8 @@ const hoursText = computed(() =>
const sessionsText = computed(() => const sessionsText = computed(() =>
props.lesson.sessionsCount == null ? '—' : `${props.lesson.sessionsCount} جلسه` props.lesson.sessionsCount == null ? '—' : `${props.lesson.sessionsCount} جلسه`
) )
const quizzesText = computed(() =>
props.lesson.quizzesCount == null ? '—' : `${props.lesson.quizzesCount}` const teacherText = computed(() => props.lesson.teacher?.name || '—')
)
const studentsText = computed(() =>
props.lesson.studentsCount == null ? '—' : `${props.lesson.studentsCount} دانشجو`
)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -106,6 +120,13 @@ const studentsText = computed(() =>
border-radius: 0.75rem; border-radius: 0.75rem;
background: rgba(0, 112, 116, 4%); background: rgba(0, 112, 116, 4%);
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
}
&__cover {
width: 100%;
height: 100%;
object-fit: cover;
} }
&__title { &__title {
@@ -125,54 +146,28 @@ const studentsText = computed(() =>
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
&__stat { &__hours.badge,
display: inline-flex; &__stat.badge {
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem; padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%); :deep(.badge__label) {
font-family: var(--font-family-fa); font-size: 0.7rem;
white-space: nowrap; opacity: 1;
}
} }
&__stat-label { &__hours.badge {
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%); 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; color: #007074;
} }
&__hours-value { &__stat.badge {
font-weight: 500; background: rgba(107, 107, 107, 4%);
font-size: 0.75rem; color: #5d5d5d;
color: #007074;
} }
&__actions { &__actions {
@@ -13,37 +13,47 @@
<SkeletonLoaderBlock v-if="isLoading && !lesson" :rows="2" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading && !lesson" :rows="2" :cols-per-row="1" />
<div v-else-if="lesson" class="student-lesson-details__summary"> <div v-else-if="lesson" class="student-lesson-details__summary">
<span class="student-lesson-details__decor" aria-hidden="true"> <span class="student-lesson-details__decor" aria-hidden="true">
<SvgIcon name="users-three" :size="40" color="rgba(0, 112, 116, 0.31)" /> <img
v-if="lesson.coverUrl"
:src="lesson.coverUrl"
:alt="lesson.title || ''"
class="student-lesson-details__cover"
/>
<SvgIcon v-else name="users-three" :size="40" color="rgba(0, 112, 116, 0.31)" />
</span> </span>
<div class="student-lesson-details__summary-content"> <div class="student-lesson-details__summary-content">
<p class="student-lesson-details__summary-title">{{ lesson.title || '—' }}</p> <p class="student-lesson-details__summary-title">{{ lesson.title || '—' }}</p>
<div class="student-lesson-details__summary-stats"> <div class="student-lesson-details__summary-stats">
<div class="student-lesson-details__summary-stat"> <Badge
<SvgIcon name="users-three" :size="12" color="#5d5d5d" /> variant="neutral"
<span class="student-lesson-details__summary-stat-label">تعداد دانشجویان :</span> label="استاد :"
<span class="student-lesson-details__summary-stat-value"> :value="teacherText"
{{ studentsText }} class="student-lesson-details__summary-stat"
</span> >
</div> <template #prepend>
<div class="student-lesson-details__summary-stat"> <SvgIcon name="user" :size="12" color="#5d5d5d" />
<SvgIcon name="file" :size="12" color="#5d5d5d" /> </template>
<span class="student-lesson-details__summary-stat-label">تعداد آزمون :</span> </Badge>
<span class="student-lesson-details__summary-stat-value"> <Badge
{{ quizzesText }} variant="neutral"
</span> label="تعداد جلسات :"
</div> :value="sessionsText"
<div class="student-lesson-details__summary-stat"> class="student-lesson-details__summary-stat"
<SvgIcon name="list-bullets" :size="12" color="#5d5d5d" /> >
<span class="student-lesson-details__summary-stat-label">تعداد جلسات :</span> <template #prepend>
<span class="student-lesson-details__summary-stat-value"> <SvgIcon name="list-bullets" :size="12" color="#5d5d5d" />
{{ sessionsText }} </template>
</span> </Badge>
</div> <Badge
<div class="student-lesson-details__summary-stat"> variant="neutral"
<SvgIcon name="calendar" :size="12" color="#535353" /> label="ظرفیت :"
<span class="student-lesson-details__summary-stat-label">تاریخ پایان :</span> :value="capacityText"
<span class="student-lesson-details__summary-stat-value">{{ endDateText }}</span> class="student-lesson-details__summary-stat"
</div> >
<template #prepend>
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
</template>
</Badge>
</div> </div>
</div> </div>
</div> </div>
@@ -51,7 +61,7 @@
<TabsBlock :tabs="tabs" v-model="activeTab"> <TabsBlock :tabs="tabs" v-model="activeTab">
<template #sessions> <template #sessions>
<SkeletonLoaderBlock <SkeletonLoaderBlock
v-if="isLoading && sessions.length === 0" v-if="sessionsLoading && sessions.length === 0"
:rows="3" :rows="3"
:cols-per-row="1" :cols-per-row="1"
/> />
@@ -67,7 +77,11 @@
</template> </template>
<template #exams> <template #exams>
<SkeletonLoaderBlock v-if="isLoading && exams.length === 0" :rows="3" :cols-per-row="1" /> <SkeletonLoaderBlock
v-if="examsLoading && exams.length === 0"
:rows="3"
:cols-per-row="1"
/>
<div v-else-if="exams.length > 0"> <div v-else-if="exams.length > 0">
<StudentExamItem v-for="exam in exams" :key="exam.id" :exam="exam" @start="onStartExam" /> <StudentExamItem v-for="exam in exams" :key="exam.id" :exam="exam" @start="onStartExam" />
</div> </div>
@@ -79,14 +93,16 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import Badge from '@/components/Badge.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue' import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import TabsBlock from '@/components/blocks/TabsBlock.vue' import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { useStudentCourseQuery } from '@/services/query/student-courses' import { useStudentCourseQuery } from '@/services/query/student-courses'
import { useStudentExamsListQuery } from '@/services/query/student-exams'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useStudentSessionsListQuery } from '@/services/query/student-sessions'
import StudentExamItem from '@/features/student/exams/components/StudentExamItem.vue' import StudentExamItem from '@/features/student/exams/components/StudentExamItem.vue'
import StudentSessionItem from '@/features/student/sessions/components/StudentSessionItem.vue' import StudentSessionItem from '@/features/student/sessions/components/StudentSessionItem.vue'
@@ -99,20 +115,31 @@ const { data: lesson, isLoading } = useStudentCourseQuery(lessonId, {
enabled: () => !!lessonId.value, enabled: () => !!lessonId.value,
}) })
const sessions = computed(() => lesson.value?.sessions ?? []) const sessionsFilters = computed(() => ({ courseId: lessonId.value }))
const exams = computed(() => lesson.value?.exams ?? []) const sessionsPagination = ref({ page: 1, perPage: 50 })
const { data: sessionsData, isLoading: sessionsLoading } = useStudentSessionsListQuery(
sessionsFilters,
sessionsPagination,
{ enabled: () => !!lessonId.value, keepPreviousData: true }
)
const sessions = computed(() => sessionsData.value?.data ?? [])
const examsFilters = computed(() => ({ courseId: lessonId.value }))
const examsPagination = ref({ page: 1, perPage: 50 })
const { data: examsData, isLoading: examsLoading } = useStudentExamsListQuery(
examsFilters,
examsPagination,
{ enabled: () => !!lessonId.value, keepPreviousData: true }
)
const exams = computed(() => examsData.value?.data ?? [])
const sessionsText = computed(() => const sessionsText = computed(() =>
lesson.value?.sessionsCount == null ? '—' : `${lesson.value.sessionsCount} جلسه` lesson.value?.sessionsCount == null ? '—' : `${lesson.value.sessionsCount} جلسه`
) )
const quizzesText = computed(() => const teacherText = computed(() => lesson.value?.teacher?.name || '—')
lesson.value?.quizzesCount == null ? '—' : `${lesson.value.quizzesCount}` const capacityText = computed(() =>
) lesson.value?.capacity == null ? '—' : `${lesson.value.capacity} نفر`
const studentsText = computed(() =>
lesson.value?.studentsCount == null ? '—' : `${lesson.value.studentsCount} دانشجو`
)
const endDateText = computed(
() => lesson.value?.faEndDate || formatJalaaliDate(lesson.value?.endDate) || '—'
) )
const tabs = [ const tabs = [
@@ -171,12 +198,19 @@ const onStartExam = (exam) => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
@media (min-width: 1024px) { @media (min-width: 1024px) {
display: inline-flex; display: inline-flex;
} }
} }
&__cover {
width: 100%;
height: 100%;
object-fit: cover;
}
&__summary-title { &__summary-title {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 400; font-weight: 400;
@@ -200,27 +234,15 @@ const onStartExam = (exam) => {
gap: 0.5rem; gap: 0.5rem;
} }
&__summary-stat { &__summary-stat.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem; padding: 0.375rem 0.875rem;
border-radius: 0.75rem;
background: rgba(107, 107, 107, 4%); 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; color: #5d5d5d;
}
&__summary-stat-value { :deep(.badge__label) {
font-weight: 500; font-size: 0.7rem;
font-size: 0.75rem; opacity: 1;
color: #5d5d5d; }
} }
} }
</style> </style>
@@ -27,30 +27,18 @@
<div class="edit-profile__row"> <div class="edit-profile__row">
<div class="edit-profile__cell edit-profile__cell--third"> <div class="edit-profile__cell edit-profile__cell--third">
<TextField <TextField
v-model="form.firstName" v-model="form.name"
name="firstName" name="name"
label="نام" label="نام و نام خانوادگی"
:error="errors.firstName" :error="errors.name"
@blur="validateAt('firstName', form.firstName)" @blur="validateAt('name', form.name)"
>
<template #appendIcon>
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.lastName"
name="lastName"
label="نام خانوادگی"
:error="errors.lastName"
@blur="validateAt('lastName', form.lastName)"
> >
<template #appendIcon> <template #appendIcon>
<SvgIcon name="user" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="user" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
</div> </div>
<div class="edit-profile__cell edit-profile__cell--third"> <div class="edit-profile__cell edit-profile__cell--third">
<DatePickerField <DatePickerField
v-model="form.birthDate" v-model="form.birthDate"
@@ -60,6 +48,22 @@
:error="errors.birthDate" :error="errors.birthDate"
/> />
</div> </div>
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.phone"
name="phone"
label="شماره تلفن همراه"
inputmode="numeric"
:convert-digits="true"
:disabled="!!initialPhoneNumber"
:error="errors.phone"
@blur="validateAt('phone', form.phone)"
>
<template #appendIcon>
<SvgIcon name="phone" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third"> <div class="edit-profile__cell edit-profile__cell--third">
<TextField <TextField
v-model="form.nationalCode" v-model="form.nationalCode"
@@ -78,8 +82,8 @@
</div> </div>
<div class="edit-profile__cell edit-profile__cell--third"> <div class="edit-profile__cell edit-profile__cell--third">
<SelectField <SelectField
v-model="form.maritalStatus" v-model="form.marriageStatus"
name="maritalStatus" name="marriageStatus"
label="وضعیت تاهل" label="وضعیت تاهل"
:options="maritalStatusOptions" :options="maritalStatusOptions"
option-label="label" option-label="label"
@@ -96,29 +100,10 @@
option-value="value" option-value="value"
/> />
</div> </div>
<div class="edit-profile__cell edit-profile__cell--full">
<TextareaField v-model="form.bio" name="bio" label="زندگی نامه" :row="3" />
</div>
</div> </div>
<LineTitleBlock title="اطلاعات تماس" title-en="Contact Details" /> <LineTitleBlock title="مشخصات محل سکونت" title-en="Address Details" />
<div class="edit-profile__row"> <div class="edit-profile__row">
<div class="edit-profile__cell edit-profile__cell--third">
<TextField
v-model="form.phoneNumber"
name="phoneNumber"
label="شماره تلفن همراه"
inputmode="numeric"
:convert-digits="true"
:disabled="!!initialPhoneNumber"
:error="errors.phoneNumber"
@blur="validateAt('phoneNumber', form.phoneNumber)"
>
<template #appendIcon>
<SvgIcon name="phone" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
</div>
<div class="edit-profile__cell edit-profile__cell--third"> <div class="edit-profile__cell edit-profile__cell--third">
<SelectField <SelectField
v-model="form.provinceId" v-model="form.provinceId"
@@ -155,24 +140,40 @@
<div class="edit-profile__row"> <div class="edit-profile__row">
<div class="edit-profile__cell edit-profile__cell--half"> <div class="edit-profile__cell edit-profile__cell--half">
<PasswordField <PasswordField
v-model="form.password" v-model="passwordForm.password"
name="password" name="password"
label="رمز عبور" label="رمز عبور"
:error="errors.password" :error="passwordErrors.password"
@blur="validateAt('password', form.password)" @blur="validateAtPassword('password', passwordForm.password)"
/> />
</div> </div>
<div class="edit-profile__cell edit-profile__cell--half"> <div class="edit-profile__cell edit-profile__cell--half">
<PasswordField <PasswordField
v-model="form.passwordConfirmation" v-model="passwordForm.passwordConfirmation"
name="passwordConfirmation" name="passwordConfirmation"
label="تکرار رمز عبور" label="تکرار رمز عبور"
:error="errors.passwordConfirmation" :error="passwordErrors.passwordConfirmation"
@blur="validateAt('passwordConfirmation', form.passwordConfirmation)" @blur="
validateAtPassword('passwordConfirmation', passwordForm.passwordConfirmation)
"
/> />
</div> </div>
</div> </div>
<div class="edit-profile__password-actions">
<BaseButton
type="button"
text="تغییر رمز عبور"
:loading="resetPasswordMutation.isPending.value"
custom-class="edit-profile__btn-reset"
@click="onResetPassword"
>
<template #appendIcon>
<SvgIcon name="arrows-clockwise" :size="20" color="#fff" />
</template>
</BaseButton>
</div>
<div class="edit-profile__actions"> <div class="edit-profile__actions">
<BaseButton <BaseButton
variant="transparent" variant="transparent"
@@ -212,18 +213,20 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ImageCropper from '@/components/form/ImageCropper.vue' import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata' import { objectToFormData } from '@/utils/object-to-formdata'
import { applyValidationErrors } from '@/utils/error-handler'
import TextareaField from '@/components/form/TextareaField.vue' import TextareaField from '@/components/form/TextareaField.vue'
import PasswordField from '@/components/form/PasswordField.vue' import PasswordField from '@/components/form/PasswordField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import { studentProfileSchema } from '@/features/student/profile/schema' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common' import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
import { studentProfileSchema, studentResetPasswordSchema } from '@/features/student/profile/schema'
import { import {
authKeys, authKeys,
useGetMeQuery, useGetMeQuery,
useResetPasswordMutation,
useUpdateProfileMutation, useUpdateProfileMutation,
useUploadMediaMutation, useUploadMediaMutation,
} from '@/services/query/auth' } from '@/services/query/auth'
@@ -246,29 +249,23 @@ const maritalStatusOptions = Object.entries(MARITAL_STATUS).map(([value, label])
})) }))
const genderOptions = Object.entries(GENDER).map(([value, label]) => ({ value, label })) const genderOptions = Object.entries(GENDER).map(([value, label]) => ({ value, label }))
const { data: provinces = ref([]) } = useGetProvincesQuery()
const provinceIdRef = ref(null)
const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
enabled: () => !!provinceIdRef.value,
})
const initialNationalCode = ref('') const initialNationalCode = ref('')
const initialPhoneNumber = ref('') const initialPhoneNumber = ref('')
const form = ref({ const form = ref({
firstName: '', name: '',
lastName: '',
birthDate: '', birthDate: '',
nationalCode: '', nationalCode: '',
maritalStatus: '', marriageStatus: '',
gender: '', gender: '',
bio: '', phone: '',
phoneNumber: '',
provinceId: '', provinceId: '',
cityId: '', cityId: '',
address: '', address: '',
avatarId: null, avatarMediaId: null,
})
const passwordForm = ref({
password: '', password: '',
passwordConfirmation: '', passwordConfirmation: '',
}) })
@@ -278,44 +275,47 @@ const avatar = ref(null)
const schema = studentProfileSchema const schema = studentProfileSchema
const { validate, validateAt, errors } = useYup(schema) const { validate, validateAt, errors } = useYup(schema)
const {
validate: validatePassword,
validateAt: validateAtPassword,
errors: passwordErrors,
resetErrors: resetPasswordErrors,
setError: setPasswordError,
} = useYup(studentResetPasswordSchema, () => ({
...passwordForm.value,
phone: form.value.phone,
}))
const { data: provinces = ref([]) } = useGetProvincesQuery()
const provinceIdRef = computed(() => form.value.provinceId)
const { data: cities = ref([]) } = useGetCitiesOfProvinceQuery(provinceIdRef, {
enabled: () => !!provinceIdRef.value,
})
const onProvinceChange = () => { const onProvinceChange = () => {
provinceIdRef.value = form.value.provinceId
form.value.cityId = '' form.value.cityId = ''
} }
watch(
() => form.value.provinceId,
(val) => {
provinceIdRef.value = val
}
)
const { data: profile } = useGetMeQuery() const { data: profile } = useGetMeQuery()
watch(profile, (user) => { watch(profile, (user) => {
if (!user) return if (!user) return
const nameParts = String(user.name || '') const fullName =
.trim() user.name || [user.firstName, user.lastName].filter(Boolean).join(' ').trim() || ''
.split(/\s+/)
.filter(Boolean)
const firstName = user.firstName || nameParts[0] || ''
const lastName = user.lastName || nameParts.slice(1).join(' ') || ''
const phone = user.phone || user.phoneNumber || '' const phone = user.phone || user.phoneNumber || ''
form.value = { form.value = {
...form.value, ...form.value,
firstName, name: fullName,
lastName,
birthDate: user.profile?.birthDate || '', birthDate: user.profile?.birthDate || '',
nationalCode: user.nationalCode || '', nationalCode: user.nationalCode || '',
maritalStatus: user.profile?.maritalStatus || '', marriageStatus: user.profile?.marriageStatus || '',
gender: user.profile?.gender || '', gender: user.profile?.gender || '',
bio: user.profile?.bio || '', phone,
phoneNumber: phone,
provinceId: user?.province?.id || '', provinceId: user?.province?.id || '',
cityId: user?.city?.id || '', cityId: user?.city?.id || '',
address: user?.address || '', address: user?.address || '',
avatarId: user.profile?.avatarId || null, avatarMediaId: user.profile?.avatarMediaId || null,
} }
initialNationalCode.value = user.nationalCode || '' initialNationalCode.value = user.nationalCode || ''
initialPhoneNumber.value = phone initialPhoneNumber.value = phone
@@ -330,7 +330,7 @@ const onAvatarCropped = async (file) => {
const response = await uploadMutation.mutateAsync(formData) const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload } avatar.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
form.value.avatarId = payload?.uploadId || payload?.id form.value.avatarMediaId = payload?.uploadId || payload?.id
} catch { } catch {
/* handled globally */ /* handled globally */
} }
@@ -339,23 +339,20 @@ const onAvatarCropped = async (file) => {
const onAvatarError = (msg) => toast.error(msg) const onAvatarError = (msg) => toast.error(msg)
const updateMutation = useUpdateProfileMutation() const updateMutation = useUpdateProfileMutation()
const resetPasswordMutation = useResetPasswordMutation()
const onSubmit = async () => { const onSubmit = async () => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
// Backend PATCH /me accepts: name, email, phone, password (+confirmation), // Backend PATCH /me accepts: name, email, phone, avatar_media_id.
// avatar_media_id. Other UI-only fields (province/city/address/birthDate/…) // Other UI-only fields (province/city/address/birthDate/…) are not in
// are not in the backend Postman doc and are dropped here. // the backend Postman doc and are dropped here. Password is handled
const name = [payload.firstName, payload.lastName].filter(Boolean).join(' ').trim() // separately via POST /reset-password.
const body = { const body = {
...(name ? { name } : {}), ...(payload.name ? { name: payload.name } : {}),
...(payload.phoneNumber ? { phone: payload.phoneNumber } : {}), ...(payload.phone ? { phone: payload.phone } : {}),
...(payload.email ? { email: payload.email } : {}), ...(payload.email ? { email: payload.email } : {}),
...(payload.avatarId ? { avatarMediaId: payload.avatarId } : {}), ...(payload.avatarMediaId ? { avatarMediaId: payload.avatarMediaId } : {}),
}
if (payload.password) {
body.password = payload.password
body.passwordConfirmation = payload.passwordConfirmation
} }
await updateMutation.mutateAsync(body) await updateMutation.mutateAsync(body)
await queryClient.invalidateQueries({ queryKey: authKeys.me() }) await queryClient.invalidateQueries({ queryKey: authKeys.me() })
@@ -363,6 +360,25 @@ const onSubmit = async () => {
router.push({ name: 'student-dashboard' }) router.push({ name: 'student-dashboard' })
} }
const onResetPassword = async () => {
resetPasswordErrors()
const { isValid, payload } = await validatePassword({
phone: form.value.phone,
password: passwordForm.value.password,
passwordConfirmation: passwordForm.value.passwordConfirmation,
})
if (!isValid) return
try {
await resetPasswordMutation.mutateAsync(payload)
passwordForm.value.password = ''
passwordForm.value.passwordConfirmation = ''
resetPasswordErrors()
toast.success('رمز عبور با موفقیت تغییر یافت')
} catch (error) {
applyValidationErrors(error, setPasswordError)
}
}
const onCancel = () => router.push({ name: 'student-dashboard' }) const onCancel = () => router.push({ name: 'student-dashboard' })
</script> </script>
@@ -457,6 +473,13 @@ const onCancel = () => router.push({ name: 'student-dashboard' })
margin-top: 1rem; margin-top: 1rem;
} }
&__password-actions {
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
&__btn-cancel { &__btn-cancel {
min-width: 9rem; min-width: 9rem;
} }
@@ -464,5 +487,9 @@ const onCancel = () => router.push({ name: 'student-dashboard' })
&__btn-submit { &__btn-submit {
min-width: 12rem; min-width: 12rem;
} }
&__btn-reset {
min-width: 12rem;
}
} }
</style> </style>
+11 -9
View File
@@ -1,22 +1,24 @@
import { object, string } from 'yup' import { object, string } from 'yup'
import { passwordRule } from '@/constants/rules/passwordRule'
import { phoneNumberRule } from '@/constants/rules/phoneNumberRule' import { phoneNumberRule } from '@/constants/rules/phoneNumberRule'
import { nationalCodeRule } from '@/constants/rules/nationalCodeRule' import { nationalCodeRule } from '@/constants/rules/nationalCodeRule'
import { optionalPasswordRule } from '@/constants/rules/passwordRule' import { passwordConfirmationRule } from '@/constants/rules/passwordConfirmationRule'
import { optionalPasswordConfirmationRule } from '@/constants/rules/passwordConfirmationRule'
export const studentProfileSchema = object().shape({ export const studentProfileSchema = object().shape({
firstName: string().required().min(2), name: string().required().min(2),
lastName: string().required().min(2), phone: phoneNumberRule,
phoneNumber: phoneNumberRule,
nationalCode: nationalCodeRule, nationalCode: nationalCodeRule,
birthDate: string().nullable().notRequired(), birthDate: string().nullable().notRequired(),
maritalStatus: string().nullable().notRequired(), marriageStatus: string().nullable().notRequired(),
gender: string().nullable().notRequired(), gender: string().nullable().notRequired(),
bio: string().nullable().notRequired(),
provinceId: string().nullable().notRequired(), provinceId: string().nullable().notRequired(),
cityId: string().nullable().notRequired(), cityId: string().nullable().notRequired(),
address: string().nullable().notRequired(), address: string().nullable().notRequired(),
password: optionalPasswordRule, })
passwordConfirmation: optionalPasswordConfirmationRule,
export const studentResetPasswordSchema = object().shape({
phone: phoneNumberRule,
password: passwordRule,
passwordConfirmation: passwordConfirmationRule,
}) })
@@ -10,24 +10,12 @@
</div> </div>
<div class="student-session-item__badges"> <div class="student-session-item__badges">
<span <Badge v-if="isComplete" variant="success">تکمیل شده</Badge>
v-if="session.isSeen" <Badge v-if="session.isSeen" variant="success">دیده شده</Badge>
class="student-session-item__badge student-session-item__badge--seen" <Badge v-if="session.grade != null" variant="primary" label="نمره" :value="session.grade" />
> <Badge v-if="session.needsAssignment" icon="paper-plane">دارای تکلیف</Badge>
دیده شده <Badge v-if="session.hasQuiz" icon="file">دارای آزمون</Badge>
</span> <Badge v-if="isOnline" icon="users-three">جلسه آنلاین</Badge>
<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>
<div class="student-session-item__actions"> <div class="student-session-item__actions">
@@ -46,6 +34,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
@@ -58,6 +47,9 @@ const emit = defineEmits(['start'])
const durationText = computed(() => const durationText = computed(() =>
props.session.durationHours == null ? '—' : `${props.session.durationHours} ساعت` props.session.durationHours == null ? '—' : `${props.session.durationHours} ساعت`
) )
const isOnline = computed(() => props.session.type === 'online')
const isComplete = computed(() => props.session.isComplete === true)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -84,7 +76,7 @@ const durationText = computed(() =>
font-size: 0.95rem; font-size: 0.95rem;
color: #4b4b4b; color: #4b4b4b;
margin: 0; margin: 0;
text-align: end; text-align: start;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -104,25 +96,6 @@ const durationText = computed(() =>
gap: 0.5rem; 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 { &__hours {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -10,103 +10,92 @@
</template> </template>
</BoxedIconTitleBlock> </BoxedIconTitleBlock>
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange"> <SkeletonLoaderBlock v-if="isLoading && !session" :rows="3" :cols-per-row="1" />
<template #homework> <div v-else class="ssd__panel">
<SkeletonLoaderBlock v-if="isLoading && !session" :rows="3" :cols-per-row="1" /> <div class="ssd__prompt">
<div v-else class="ssd__panel"> <SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__prompt-icon" />
<div class="ssd__prompt"> <p class="ssd__prompt-text">
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__prompt-icon" /> {{ session?.homeworkPrompt || 'متنی برای این جلسه ثبت نشده است.' }}
<p class="ssd__prompt-text"> </p>
{{ session?.homeworkPrompt || 'متنی برای این جلسه ثبت نشده است.' }} </div>
</p>
</div>
<div class="ssd__media"> <div class="ssd__media">
<div class="ssd__content"> <div class="ssd__content">
<div v-if="contentKind === 'video'" class="ssd__video"> <div v-if="contentKind === 'video'" class="ssd__video">
<VideoPlayerBlock <VideoPlayerBlock
:src="session?.videoUrl || ''" :src="session?.videoUrl || ''"
:poster="session?.poster || ''" :poster="session?.poster || ''"
:video-id="sessionId" :video-id="sessionId"
/> />
</div> </div>
<div v-else-if="contentKind === 'audio'" class="ssd__audio"> <div v-else-if="contentKind === 'audio'" class="ssd__audio">
<SvgIcon name="paper-plane-right" :size="40" color="rgba(0, 112, 116, 0.31)" /> <VoiceRecorder :model-value="session.audioUrl" disabled />
<audio :src="session.audioUrl" controls class="ssd__audio-player" /> </div>
</div> <div v-else-if="contentKind === 'text'" class="ssd__text">
<div v-else-if="contentKind === 'text'" class="ssd__text"> <SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" />
<SvgIcon name="warning" :size="22" color="#b8b8b8" class="ssd__text-icon" /> <p class="ssd__text-body">{{ session.text }}</p>
<p class="ssd__text-body">{{ session.text }}</p> </div>
</div> <div v-else-if="contentKind === 'link'" class="ssd__link">
<div v-else-if="contentKind === 'link'" class="ssd__link"> <div class="ssd__link__box">
<div class="ssd__link__box"> <span>https://link</span>
<span>https://link</span> <SvgIcon name="link" :size="22" class="ssd__link-icon" />
<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>
<BaseButton text="ورود به جلسه " custom-class="ssd__link-btn">
<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> <template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" /> <SvgIcon name="arrow-left" :size="18" color="#fff" />
</template> </template>
</BaseButton> </BaseButton>
</div> </div>
<div v-else class="ssd__empty">
<SvgIcon name="warning" :size="32" color="#bcbcbc" />
<p>محتوایی برای این جلسه ثبت نشده است.</p>
</div>
</div> </div>
</template>
<template #exam> <div class="ssd__uploads" v-if="contentKind !== 'link'">
<NoItems title="در حال انتقال" desc="در حال انتقال به صفحه آزمون..." /> <VoiceRecorder
</template> class="ssd__uploads-recorder"
</TabsBlock> 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>
</div> </div>
</template> </template>
<script setup> <script setup>
import { toast } from 'vue3-toastify' import { toast } from 'vue3-toastify'
import { useRoute } from 'vue-router'
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.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 { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth' import { useUploadMediaMutation } from '@/services/query/auth'
import VoiceRecorder from '@/components/form/VoiceRecorder.vue' import VoiceRecorder from '@/components/form/VoiceRecorder.vue'
@@ -120,7 +109,6 @@ import {
} from '@/services/query/student-sessions' } from '@/services/query/student-sessions'
const route = useRoute() const route = useRoute()
const router = useRouter()
const sessionId = computed(() => route.params.id) const sessionId = computed(() => route.params.id)
@@ -131,19 +119,13 @@ const { data: session, isLoading } = useStudentSessionQuery(sessionId, {
const contentKind = computed(() => { const contentKind = computed(() => {
const s = session.value const s = session.value
if (!s) return '' if (!s) return ''
if (s.type === 'online') return 'link'
if (s.videoUrl) return 'link' if (s.videoUrl) return 'link'
if (s.audioUrl) return 'audio' if (s.audioUrl) return 'audio'
if (s.text) return 'text' if (s.text) return 'text'
if (s.link) return 'link'
return '' return ''
}) })
const tabs = [
{ name: 'homework', label: 'ارسال تکلیف', icon: 'list-bullets' },
{ name: 'exam', label: 'آزمـــــون', icon: 'file' },
]
const activeTab = ref('homework')
const homeworkAudio = ref(null) const homeworkAudio = ref(null)
const homeworkImage = ref(null) const homeworkImage = ref(null)
const errors = reactive({ audio: '', image: '' }) const errors = reactive({ audio: '', image: '' })
@@ -190,17 +172,6 @@ const onSubmit = async () => {
toast.error('ارسال تکلیف با خطا مواجه شد.') 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -313,7 +284,7 @@ const onTabChange = (name) => {
&__footer { &__footer {
display: flex; display: flex;
justify-content: center; justify-content: flex-end;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
+1
View File
@@ -85,6 +85,7 @@ export const endpoints = {
// ─── Backend-aligned: Exams ──────────────────────────────────────────────── // ─── Backend-aligned: Exams ────────────────────────────────────────────────
getExamsList: '/admin/exams', getExamsList: '/admin/exams',
getStudentExamsList: '/student/exams',
showExam: '/exams/:id', showExam: '/exams/:id',
addNewExam: '/exams', addNewExam: '/exams',
updateExam: '/exams/:id', updateExam: '/exams/:id',
+2
View File
@@ -1,6 +1,8 @@
import { http } from '@/services/api/http' import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints' import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetStudentExams = (params) => http.get(endpoints.getStudentExamsList, { params })
export const apiShowStudentExam = (id) => http.get(buildUrl(endpoints.showExam, { id })) export const apiShowStudentExam = (id) => http.get(buildUrl(endpoints.showExam, { id }))
export const apiSubmitStudentExamAttempt = (id, payload) => export const apiSubmitStudentExamAttempt = (id, payload) =>
+2
View File
@@ -1,6 +1,8 @@
import { http } from '@/services/api/http' import { http } from '@/services/api/http'
import { buildUrl, endpoints } from '@/services/api/endpoints' import { buildUrl, endpoints } from '@/services/api/endpoints'
export const apiGetStudentSessions = (params) => http.get(endpoints.getSessionsList, { params })
export const apiShowStudentSession = (id) => http.get(buildUrl(endpoints.showSession, { id })) export const apiShowStudentSession = (id) => http.get(buildUrl(endpoints.showSession, { id }))
export const apiSubmitStudentHomework = (homeworkId, payload) => export const apiSubmitStudentHomework = (homeworkId, payload) =>
+33 -33
View File
@@ -10,45 +10,45 @@ export const commonKeys = {
// Sample data so the register form is usable while the backend geo endpoints // Sample data so the register form is usable while the backend geo endpoints
// are still being built. Once /provinces and /provinces/:id/cities are live, // are still being built. Once /provinces and /provinces/:id/cities are live,
// the real response overwrites these and the keys stay the same. // the real response overwrites these and the keys stay the same.
const SAMPLE_PROVINCES = [ // const SAMPLE_PROVINCES = [
{ id: 1, name: 'تهران' }, // { id: 1, name: 'تهران' },
{ id: 2, name: 'اصفهان' }, // { id: 2, name: 'اصفهان' },
{ id: 3, name: 'فارس' }, // { id: 3, name: 'فارس' },
{ id: 4, name: 'خراسان رضوی' }, // { id: 4, name: 'خراسان رضوی' },
{ id: 5, name: 'قم' }, // { id: 5, name: 'قم' },
] // ]
const SAMPLE_CITIES_BY_PROVINCE = { // const SAMPLE_CITIES_BY_PROVINCE = {
1: [ // 1: [
{ id: 101, name: 'تهران' }, // { id: 101, name: 'تهران' },
{ id: 102, name: 'ری' }, // { id: 102, name: 'ری' },
{ id: 103, name: 'شمیرانات' }, // { id: 103, name: 'شمیرانات' },
{ id: 104, name: 'اسلامشهر' }, // { id: 104, name: 'اسلامشهر' },
], // ],
2: [ // 2: [
{ id: 201, name: 'اصفهان' }, // { id: 201, name: 'اصفهان' },
{ id: 202, name: 'کاشان' }, // { id: 202, name: 'کاشان' },
{ id: 203, name: 'نجف‌آباد' }, // { id: 203, name: 'نجف‌آباد' },
], // ],
3: [ // 3: [
{ id: 301, name: 'شیراز' }, // { id: 301, name: 'شیراز' },
{ id: 302, name: 'مرودشت' }, // { id: 302, name: 'مرودشت' },
{ id: 303, name: 'کازرون' }, // { id: 303, name: 'کازرون' },
], // ],
4: [ // 4: [
{ id: 401, name: 'مشهد' }, // { id: 401, name: 'مشهد' },
{ id: 402, name: 'نیشابور' }, // { id: 402, name: 'نیشابور' },
{ id: 403, name: 'سبزوار' }, // { id: 403, name: 'سبزوار' },
], // ],
5: [{ id: 501, name: 'قم' }], // 5: [{ id: 501, name: 'قم' }],
} // }
export const useGetProvincesQuery = (options = {}) => export const useGetProvincesQuery = (options = {}) =>
useQuery({ useQuery({
queryKey: commonKeys.provinces(), queryKey: commonKeys.provinces(),
queryFn: () => apiGetProvinces(), queryFn: () => apiGetProvinces(),
select: (response) => response?.data ?? response, select: (response) => response?.data ?? response,
initialData: SAMPLE_PROVINCES, // initialData: SAMPLE_PROVINCES,
...options, ...options,
}) })
@@ -57,6 +57,6 @@ export const useGetCitiesOfProvinceQuery = (provinceIdRef, options = {}) =>
queryKey: ['common', 'cities', provinceIdRef], queryKey: ['common', 'cities', provinceIdRef],
queryFn: () => apiGetCitiesOfProvince(provinceIdRef.value), queryFn: () => apiGetCitiesOfProvince(provinceIdRef.value),
select: (response) => response?.data ?? response, select: (response) => response?.data ?? response,
initialData: () => SAMPLE_CITIES_BY_PROVINCE[provinceIdRef.value] || [], // initialData: () => SAMPLE_CITIES_BY_PROVINCE[provinceIdRef.value] || [],
...options, ...options,
}) })
+22 -1
View File
@@ -1,10 +1,31 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query' import { useMutation, useQuery } from '@tanstack/vue-query'
import { apiShowStudentExam, apiSubmitStudentExamAttempt } from '@/services/api/student-exams' import {
apiGetStudentExams,
apiShowStudentExam,
apiSubmitStudentExamAttempt,
} from '@/services/api/student-exams'
export const studentExamsKeys = { export const studentExamsKeys = {
list: (filters, pagination) => ['student', 'exams', 'list', filters, pagination],
detail: (id) => ['student', 'exams', 'detail', id], detail: (id) => ['student', 'exams', 'detail', id],
} }
export const useStudentExamsListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'exams', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetStudentExams({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
export const useStudentExamQuery = (idRef, options = {}) => export const useStudentExamQuery = (idRef, options = {}) =>
useQuery({ useQuery({
queryKey: ['student', 'exams', 'detail', idRef], queryKey: ['student', 'exams', 'detail', idRef],
+25 -4
View File
@@ -1,19 +1,40 @@
import { cleanFilters } from '@/utils/clean-filters'
import { useMutation, useQuery } from '@tanstack/vue-query' import { useMutation, useQuery } from '@tanstack/vue-query'
import { apiShowStudentSession, apiSubmitStudentHomework } from '@/services/api/student-sessions' import {
apiGetStudentSessions,
apiShowStudentSession,
apiSubmitStudentHomework,
} from '@/services/api/student-sessions'
export const studentSessionsKeys = { export const studentSessionsKeys = {
list: (filters, pagination) => ['student', 'sessions', 'list', filters, pagination],
detail: (id) => ['student', 'sessions', 'detail', id], detail: (id) => ['student', 'sessions', 'detail', id],
} }
export const useStudentSessionsListQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['student', 'sessions', 'list', filtersRef, paginationRef],
queryFn: () =>
apiGetStudentSessions({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
select: (response) => ({
data: response?.data?.items ?? response?.data ?? [],
meta: response?.data?.meta ?? response?.meta,
}),
...options,
})
const adaptSessionResponse = (response) => { const adaptSessionResponse = (response) => {
const session = response?.data ?? response ?? {} const session = response?.data ?? response ?? {}
const media = Array.isArray(session.media) ? session.media : [] const media = Array.isArray(session.media) ? session.media : []
const byCollection = (name) => media.find((m) => m.collectionName === name) const byCollection = (name) => media.find((m) => m.collectionName === name)
return { return {
...session, ...session,
videoUrl: byCollection('videos')?.url ?? null, videoUrl: byCollection('video')?.url ?? null,
audioUrl: byCollection('voices')?.url ?? null, audioUrl: byCollection('voice')?.downloadUrl ?? null,
pdfUrl: byCollection('pdfs')?.url ?? null, pdfUrl: byCollection('pdf')?.url ?? null,
poster: byCollection('cover')?.url ?? null, poster: byCollection('cover')?.url ?? null,
} }
} }
+21
View File
@@ -40,6 +40,27 @@ export function handleApiError(error_) {
toast.error('عملیات با خطا مواجه شد') toast.error('عملیات با خطا مواجه شد')
} }
// Walks a typed validation error response (e.g. Laravel 422 with `errors`
// keyed by field) and pushes the first message for each field into the
// caller's form via setFieldError. Falls back to a toast when the error
// isn't a field-level validation failure.
export function applyValidationErrors(error, setFieldError) {
const data = error?.response?.data
const fieldErrors = data?.errors
if (fieldErrors && typeof fieldErrors === 'object' && !Array.isArray(fieldErrors)) {
Object.entries(fieldErrors).forEach(([field, messages]) => {
const message = Array.isArray(messages) ? messages[0] : messages
if (typeof setFieldError === 'function' && field && message) {
setFieldError(field, message)
}
})
return
}
toast.error(data?.message || 'عملیات با خطا مواجه شد')
}
export function handleUnknownError(options) { export function handleUnknownError(options) {
const { showToast } = { showToast: true, ...options } const { showToast } = { showToast: true, ...options }
if (showToast) { if (showToast) {