@@ -7,7 +7,6 @@
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-if="showTermFilter"
|
||||
v-model="form.termId"
|
||||
name="termId"
|
||||
label="ترم"
|
||||
@@ -68,7 +67,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({ title: '', termId: '', status: '', fromDate: '', toDate: '' }),
|
||||
},
|
||||
showTermFilter: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
||||
@@ -100,9 +98,7 @@ const statusOptions = [
|
||||
|
||||
const termFilters = ref({})
|
||||
const termPagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination, {
|
||||
enabled: () => props.showTermFilter,
|
||||
})
|
||||
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
||||
const termOptions = computed(() => termsResponse.value?.data ?? [])
|
||||
|
||||
const hasFilters = computed(() =>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="course-details__grid">
|
||||
<LineInfoBlock title="عنوان دوره" :desc="course.title || '—'" />
|
||||
<LineInfoBlock title="تعداد جلسات" :numeric-desc="course.sessionsCount ?? 0" />
|
||||
<LineInfoBlock title="پیشنیاز دوره" :desc="teacherName" />
|
||||
<LineInfoBlock title="پیشنیاز دوره" :desc="prerequisiteCourses" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,11 +99,9 @@ const { data: course, isLoading } = useAdminCourseQuery(courseId, {
|
||||
enabled: () => !!courseId.value,
|
||||
})
|
||||
|
||||
const teacherName = computed(() => {
|
||||
const t = course.value?.teacher
|
||||
if (!t) return '—'
|
||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||
})
|
||||
const prerequisiteCourses = computed(() =>
|
||||
course.value.prerequisiteCourses.map((course_) => course_.title).join(' ,')
|
||||
)
|
||||
|
||||
const sessionsFilters = computed(() => ({ courseId: courseId.value }))
|
||||
const { pagination: sessionsPagination, setPage: setSessionsPage } = usePagination({
|
||||
|
||||
@@ -65,28 +65,30 @@
|
||||
</div>
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.prerequisites"
|
||||
name="prerequisites"
|
||||
v-model="form.termId"
|
||||
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="دورههای پیشنیاز"
|
||||
:options="prerequisiteOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
:multiple="true"
|
||||
:searchable="true"
|
||||
:on-search="searchPrerequisites"
|
||||
/>
|
||||
</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"
|
||||
:disabled="!form.termId"
|
||||
:on-search="searchPrerequisiteCourses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="course-form__cell course-form__cell--full">
|
||||
<TextareaField
|
||||
v-model="form.description"
|
||||
@@ -132,7 +134,6 @@
|
||||
import { toast } from 'vue3-toastify'
|
||||
import useYup from '@/composables/useYup'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { COURSE_CONTENT_TYPE } from '@/enums'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import useDebounce from '@/composables/useDebounce'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -146,6 +147,7 @@ import { useUploadMediaMutation } from '@/services/query/auth'
|
||||
import { courseSchema } from '@/features/admin/courses/schema'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
||||
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import {
|
||||
adminCoursesKeys,
|
||||
@@ -167,8 +169,7 @@ const form = ref({
|
||||
title: '',
|
||||
teacherId: '',
|
||||
capacity: '',
|
||||
prerequisites: [],
|
||||
contentType: '',
|
||||
prerequisiteCourseIds: [],
|
||||
description: '',
|
||||
coverMediaId: null,
|
||||
termId: termId.value,
|
||||
@@ -176,11 +177,6 @@ const form = ref({
|
||||
|
||||
const image = ref(null)
|
||||
|
||||
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
|
||||
const { validate, validateAt, errors } = useYup(courseSchema)
|
||||
|
||||
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 prereqFilters = computed(() => ({ title: prereqSearch.value }))
|
||||
const prereqPagination = ref({ page: 1, perPage: 10 })
|
||||
@@ -211,13 +212,14 @@ const selectedPrereqs = ref([])
|
||||
const prerequisiteOptions = computed(() => {
|
||||
const base = prereqsResponse.value?.data ?? []
|
||||
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) => {
|
||||
teacherSearch.value = q || ''
|
||||
}, 400)
|
||||
const searchPrerequisites = useDebounce((q) => {
|
||||
const searchPrerequisiteCourses = useDebounce((q) => {
|
||||
prereqSearch.value = q || ''
|
||||
}, 400)
|
||||
|
||||
@@ -225,25 +227,29 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
|
||||
enabled: () => !!courseId.value,
|
||||
})
|
||||
|
||||
watch(existingCourse, (course) => {
|
||||
if (!course) return
|
||||
const teacher = course.teacher
|
||||
if (teacher) selectedTeacher.value = teacher
|
||||
const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : []
|
||||
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
|
||||
watch(
|
||||
existingCourse,
|
||||
(course) => {
|
||||
if (!course) return
|
||||
const teacher = course.teacher
|
||||
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 = {
|
||||
title: course.title || '',
|
||||
teacherId: teacher?.id || course.teacherId || '',
|
||||
capacity: course.capacity ?? '',
|
||||
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
|
||||
contentType: course.contentType || '',
|
||||
description: course.description || '',
|
||||
coverMediaId: course.coverMediaId || null,
|
||||
termId: course.termId ?? termId.value,
|
||||
}
|
||||
if (course.coverUrl) image.value = { url: course.coverUrl }
|
||||
})
|
||||
form.value = {
|
||||
title: course.title || '',
|
||||
teacherId: teacher?.id || course.teacherId || '',
|
||||
capacity: course.capacity ?? '',
|
||||
prerequisiteCourseIds: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
|
||||
contentType: course.contentType || '',
|
||||
description: course.description || '',
|
||||
coverMediaId: course.coverMediaId || null,
|
||||
termId: course.termId ?? termId.value,
|
||||
}
|
||||
if (course.coverUrl) image.value = { url: course.coverUrl }
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const uploadMutation = useUploadMediaMutation()
|
||||
|
||||
|
||||
@@ -10,19 +10,14 @@
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
<CoursesFilters
|
||||
v-model="currentFilters"
|
||||
:show-term-filter="activeTab === 'offered'"
|
||||
@apply="onFilterApply"
|
||||
@reset="onFilterReset"
|
||||
/>
|
||||
<CoursesFilters v-model="offeredFilters" @apply="onFilterApply" @reset="onFilterReset" />
|
||||
|
||||
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange">
|
||||
<template #templates>
|
||||
<SkeletonLoaderBlock v-if="templatesPending" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="templates.length > 0">
|
||||
<TabsBlock :tabs="tabs">
|
||||
<template #offered>
|
||||
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
|
||||
<div v-else-if="offered.length > 0">
|
||||
<CourseItem
|
||||
v-for="course in templates"
|
||||
v-for="course in offered"
|
||||
:key="course.id"
|
||||
:course="course"
|
||||
@edit="onEditCourse"
|
||||
@@ -31,36 +26,11 @@
|
||||
@show-details="onShowDetails"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="دوره الگویی برای نمایش وجود ندارد." />
|
||||
<PaginationBlock :pagination="templatesPaginationMeta" @update:page="setTemplatesPage" />
|
||||
</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>
|
||||
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
||||
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
|
||||
</template>
|
||||
</TabsBlock>
|
||||
|
||||
<AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" />
|
||||
<CourseDetailsModal v-if="isModal('CourseDetailsModal')" />
|
||||
<AddSessionToCourseModal v-if="isModal('AddSessionToCourseModal')" />
|
||||
</div>
|
||||
@@ -81,7 +51,6 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.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 {
|
||||
adminCoursesKeys,
|
||||
@@ -98,25 +67,10 @@ const { openModal, isModal } = useModal()
|
||||
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
|
||||
|
||||
const onAdd = () => {
|
||||
if (activeTab.value === 'templates') {
|
||||
router.push({ name: 'admin-add-course' }).catch(() => {})
|
||||
} else {
|
||||
openModal('AddOfferedCourseModal', {
|
||||
mode: 'add',
|
||||
termId: routeTermId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
router.push({ name: 'admin-add-course' }).catch(() => {})
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'templates',
|
||||
label: 'دورههای الگو',
|
||||
icon: 'list-bullets',
|
||||
hasButton: true,
|
||||
textButton: 'افزودن دوره الگوی جدید',
|
||||
buttonAction: onAdd,
|
||||
},
|
||||
{
|
||||
name: 'offered',
|
||||
label: 'دورههای ارائه شده',
|
||||
@@ -126,58 +80,21 @@ const tabs = [
|
||||
buttonAction: onAdd,
|
||||
},
|
||||
]
|
||||
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
|
||||
|
||||
const templateFilters = 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 {
|
||||
pagination: offeredPagination,
|
||||
setPage: setOfferedPage,
|
||||
reset: resetOfferedPagination,
|
||||
} = 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(
|
||||
offeredFilters,
|
||||
offeredPagination,
|
||||
{
|
||||
enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
{ 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 offeredPaginationMeta = computed(() => ({
|
||||
page: offeredPagination.value.page,
|
||||
@@ -185,14 +102,7 @@ const offeredPaginationMeta = computed(() => ({
|
||||
...offeredData.value?.meta,
|
||||
}))
|
||||
|
||||
const onTabChange = () => {
|
||||
/* tab managed via v-model; no further action required */
|
||||
}
|
||||
|
||||
const onFilterApply = () => {
|
||||
if (activeTab.value === 'templates') resetTemplatesPagination()
|
||||
else resetOfferedPagination()
|
||||
}
|
||||
const onFilterApply = () => resetOfferedPagination()
|
||||
const onFilterReset = onFilterApply
|
||||
|
||||
const onEditCourse = (course) => {
|
||||
@@ -200,16 +110,12 @@ const onEditCourse = (course) => {
|
||||
}
|
||||
|
||||
const onShowDetails = (course) => {
|
||||
openModal('CourseDetailsModal', {
|
||||
id: course.id,
|
||||
type: activeTab.value === 'templates' ? 'template' : 'offered',
|
||||
})
|
||||
openModal('CourseDetailsModal', { id: course.id })
|
||||
}
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
|
||||
const deleteMutation = useDeleteAdminCourseMutation()
|
||||
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
|
||||
const updateMutation = useUpdateAdminCourseMutation()
|
||||
|
||||
const onAskDelete = (course) => {
|
||||
@@ -226,7 +132,6 @@ const onChangeStatus = ({ id, isActive }) => {
|
||||
|
||||
const syncRouteTermId = (termId) => {
|
||||
if (!termId) return
|
||||
activeTab.value = 'offered'
|
||||
offeredFilters.value = { ...offeredFilters.value, termId }
|
||||
resetOfferedPagination()
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ import { array, boolean, mixed, number, object, string } from 'yup'
|
||||
export const courseSchema = object().shape({
|
||||
title: string().required().min(3).max(255),
|
||||
teacherId: mixed().required(),
|
||||
capacity: number().required().min(1),
|
||||
prerequisites: array().nullable().default([]),
|
||||
contentType: string().oneOf(['video', 'voice', 'text']).required(),
|
||||
capacity: number().typeError('ظرفیت باید عدد باشد').required().min(1),
|
||||
prerequisiteCourseIds: array().nullable().default([]),
|
||||
contentMediaId: number().nullable().notRequired(),
|
||||
description: string().nullable().notRequired(),
|
||||
termId: mixed().nullable(),
|
||||
termId: mixed().required(),
|
||||
isActive: boolean().nullable().notRequired(),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user