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
@@ -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 -4
View File
@@ -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(),
})