This commit is contained in:
sajjadtalkhabi
2026-05-21 15:51:32 +03:30
parent 8990ceaddd
commit 74227d5021
71 changed files with 2014 additions and 1666 deletions
@@ -21,18 +21,13 @@
</div>
<div class="course-item__meta">
<div v-if="course.term?.title" class="course-item__pill">
<span class="course-item__pill-label">مختص به:</span>
<span class="course-item__pill-value">{{ course.term.title }}</span>
</div>
<div class="course-item__pill">
<span class="course-item__pill-label">ظرفیت:</span>
<span class="course-item__pill-value">{{ capacity || '—' }}</span>
</div>
<div v-if="course.prerequisitesCount" class="course-item__pill">
<span class="course-item__pill-label">پیش‌نیاز:</span>
<span class="course-item__pill-value">{{ course.prerequisitesCount }}</span>
</div>
<Badge v-if="course.term?.title" label="مختص به:" :value="course.term.title" />
<Badge label="ظرفیت:" :value="capacity || '—'" />
<Badge
v-if="course.prerequisitesCount"
label="پیش‌نیاز:"
:value="course.prerequisitesCount"
/>
</div>
<div v-if="!isActive" class="course-item__status">
@@ -82,6 +77,7 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/Badge.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
@@ -94,14 +90,14 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'change-status', 'show-details'])
const teacherName = computed(() => {
const t = props.course.teacher || props.course.defaultTeacher
const t = props.course.teacher
if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
return t.name
})
const capacity = computed(() => props.course.capacity ?? props.course.defaultCapacity ?? '')
const capacity = computed(() => props.course.capacity ?? '')
const isActive = computed(() => props.course.isActive ?? props.course.isActiveByDefault ?? false)
const isActive = computed(() => props.course.isActive ?? false)
</script>
<style lang="scss" scoped>
@@ -193,28 +189,7 @@ const isActive = computed(() => props.course.isActive ?? props.course.isActiveBy
flex-wrap: wrap;
gap: 0.375rem;
flex: 1 1 33%;
justify-content: flex-end;
}
&__pill {
background: rgba(107, 107, 107, 5%);
padding: 0.25rem 1rem;
border-radius: 0.875rem;
line-height: 1.5;
white-space: nowrap;
}
&__pill-label {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #848484;
margin-inline-end: 0.25rem;
}
&__pill-value {
font-family: var(--font-family-en);
font-size: 0.75rem;
justify-content: flex-start;
}
&__status {
@@ -1,9 +1,14 @@
<template>
<BasicModal width="95%" max-width="42rem" min-width="auto" :show-close-button="true">
<BasicModal
title="افزودن دانشجو"
title-en="Add Student"
width="95%"
max-width="42rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<div class="add-course-student">
<LineTitleBlock title="افزودن دانشجو" title-en="Add Student" />
<div class="add-course-student__search">
<SvgIcon name="user" :size="18" color="var(--color-thd-gray)" />
<input
@@ -21,7 +26,7 @@
<div v-for="user in users" :key="user.id" class="add-course-student__row">
<div class="add-course-student__main">
<div v-if="user.avatarUrl" class="add-course-student__avatar">
<img :src="user.avatarUrl" :alt="userLabel(user)" />
<img :src="user.avatarUrl" :alt="user?.name" />
</div>
<div
v-else
@@ -30,7 +35,7 @@
<SvgIcon name="user" :size="20" color="#bcbcbc" />
</div>
<div class="add-course-student__text">
<p class="add-course-student__name">{{ userLabel(user) }}</p>
<p class="add-course-student__name">{{ user?.name }}</p>
<p class="add-course-student__meta">
<span>{{ user.address?.province?.name || '—' }}</span>
<span class="add-course-student__sep">،</span>
@@ -98,11 +103,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import {
adminCourseTemplatesKeys,
useAddAdminTemplateStudentMutation,
useAdminTemplateStudentsQuery,
useRemoveAdminTemplateStudentMutation,
} from '@/services/query/admin-course-templates'
adminCoursesKeys,
useAddAdminCourseStudentMutation,
useAdminCourseStudentsQuery,
useRemoveAdminCourseStudentMutation,
} from '@/services/query/admin-courses'
defineOptions({ name: 'AddCourseStudentModal' })
@@ -110,7 +115,7 @@ const queryClient = useQueryClient()
const { getModal } = useModal()
const modalData = computed(() => getModal('AddCourseStudentModal')?.data ?? {})
const templateId = computed(() => modalData.value.templateId ?? null)
const courseId = computed(() => modalData.value.courseId ?? null)
const searchInput = ref('')
const searchQuery = ref('')
@@ -122,22 +127,16 @@ const users = computed(() => usersResponse.value?.data ?? [])
const attachedFilters = computed(() => ({}))
const attachedPagination = ref({ page: 1, perPage: 200 })
const { data: attachedResponse } = useAdminTemplateStudentsQuery(
templateId,
const { data: attachedResponse } = useAdminCourseStudentsQuery(
courseId,
attachedFilters,
attachedPagination,
{ enabled: () => !!templateId.value }
{ enabled: () => !!courseId.value }
)
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((u) => u.id)))
const isAttached = (id) => attachedIds.value.has(id)
const userLabel = (user) =>
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.fullName ||
user.phoneNumber ||
'—'
const onSearchInput = useDebounce(() => {
searchQuery.value = searchInput.value || ''
userPagination.value = { ...userPagination.value, page: 1 }
@@ -145,17 +144,17 @@ const onSearchInput = useDebounce(() => {
const pendingId = ref(null)
const addMutation = useAddAdminTemplateStudentMutation()
const removeMutation = useRemoveAdminTemplateStudentMutation()
const addMutation = useAddAdminCourseStudentMutation()
const removeMutation = useRemoveAdminCourseStudentMutation()
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const onAttach = async (user) => {
if (!templateId.value) return
if (!courseId.value) return
pendingId.value = user.id
try {
await addMutation.mutateAsync({
templateId: templateId.value,
courseId: courseId.value,
payload: { userIds: [user.id] },
})
invalidate()
@@ -165,17 +164,17 @@ const onAttach = async (user) => {
}
const onDetach = async (user) => {
if (!templateId.value) return
if (!courseId.value) return
pendingId.value = user.id
try {
await removeMutation.mutateAsync({ templateId: templateId.value, userId: user.id })
await removeMutation.mutateAsync({ courseId: courseId.value, userId: user.id })
invalidate()
} finally {
pendingId.value = null
}
}
watch(templateId, () => {
watch(courseId, () => {
searchInput.value = ''
searchQuery.value = ''
pendingId.value = null
@@ -122,11 +122,11 @@ import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { offeredCourseSchema } from '@/features/admin/courses/schema'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import {
adminCoursesKeys,
useAddAdminCourseMutation,
useAdminCourseQuery,
useAdminCoursesListQuery,
useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses'
@@ -139,19 +139,24 @@ const modalData = computed(() => getModal('AddOfferedCourseModal')?.data ?? {})
const mode = computed(() => modalData.value.mode || 'add')
const courseId = computed(() => modalData.value.courseId ?? null)
const isEditMode = computed(() => mode.value === 'edit')
const presetTermId = computed(() => modalData.value.termId ?? '')
const modeTitle = computed(() =>
isEditMode.value ? 'ویرایش دوره ارائه شده' : 'افزودن دوره ارائه شده'
)
const form = ref({
termId: '',
termId: presetTermId.value,
templateId: '',
title: '',
capacity: '',
imageId: null,
isActive: false,
})
watch(presetTermId, (val) => {
if (val && !form.value.termId) form.value.termId = val
})
const image = ref(null)
const schema = offeredCourseSchema
@@ -172,12 +177,9 @@ const termOptions = computed(() => {
})
const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value }))
const templateFilters = computed(() => ({ title: templateSearch.value, termId: 'null' }))
const templatePagination = ref({ page: 1, perPage: 30 })
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
templateFilters,
templatePagination
)
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
const selectedTemplate = ref(null)
const templateOptions = computed(() => {
const base = templatesResponse.value?.data ?? []
@@ -201,10 +203,9 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
watch(existingCourse, (course) => {
if (!course) return
if (course.term) selectedTerm.value = course.term
if (course.template) selectedTemplate.value = course.template
form.value = {
termId: course.term?.id || course.termId || '',
templateId: course.template?.id || course.templateId || '',
templateId: '',
title: course.title || '',
capacity: course.capacity ?? '',
imageId: course.imageId || null,
@@ -237,10 +238,11 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
const onSubmit = async (close) => {
const { isValid, payload } = await validate(form.value)
if (!isValid) return
const { templateId: _ignored, ...submitPayload } = payload
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: courseId.value, payload })
await updateMutation.mutateAsync({ id: courseId.value, payload: submitPayload })
} else {
await addMutation.mutateAsync(payload)
await addMutation.mutateAsync(submitPayload)
}
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
resetErrors()
@@ -95,11 +95,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import {
adminCourseTemplatesKeys,
useAdminTemplateSessionsQuery,
useAttachAdminTemplateSessionMutation,
useDetachAdminTemplateSessionMutation,
} from '@/services/query/admin-course-templates'
adminCoursesKeys,
useAdminCourseSessionsQuery,
useAttachAdminCourseSessionMutation,
useDetachAdminCourseSessionMutation,
} from '@/services/query/admin-courses'
defineOptions({ name: 'AddSessionToCourseModal' })
@@ -107,7 +107,7 @@ const queryClient = useQueryClient()
const { getModal } = useModal()
const modalData = computed(() => getModal('AddSessionToCourseModal')?.data ?? {})
const templateId = computed(() => modalData.value.templateId ?? null)
const courseId = computed(() => modalData.value.courseId ?? null)
const searchInput = ref('')
const searchQuery = ref('')
@@ -122,18 +122,18 @@ const sessions = computed(() => sessionsResponse.value?.data ?? [])
const attachedFilters = computed(() => ({}))
const attachedPagination = ref({ page: 1, perPage: 200 })
const { data: attachedResponse } = useAdminTemplateSessionsQuery(
templateId,
const { data: attachedResponse } = useAdminCourseSessionsQuery(
courseId,
attachedFilters,
attachedPagination,
{ enabled: () => !!templateId.value }
{ enabled: () => !!courseId.value }
)
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((s) => s.id)))
const isAttached = (id) => attachedIds.value.has(id)
const teacherName = (session) => {
const t = session.teacher || session.defaultTeacher
const t = session.teacher
if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
}
@@ -145,17 +145,17 @@ const onSearchInput = useDebounce(() => {
const pendingId = ref(null)
const attachMutation = useAttachAdminTemplateSessionMutation()
const detachMutation = useDetachAdminTemplateSessionMutation()
const attachMutation = useAttachAdminCourseSessionMutation()
const detachMutation = useDetachAdminCourseSessionMutation()
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const onAttach = async (session) => {
if (!templateId.value) return
if (!courseId.value) return
pendingId.value = session.id
try {
await attachMutation.mutateAsync({
templateId: templateId.value,
courseId: courseId.value,
payload: { sessionIds: [session.id] },
})
invalidate()
@@ -165,17 +165,17 @@ const onAttach = async (session) => {
}
const onDetach = async (session) => {
if (!templateId.value) return
if (!courseId.value) return
pendingId.value = session.id
try {
await detachMutation.mutateAsync({ templateId: templateId.value, sessionId: session.id })
await detachMutation.mutateAsync({ courseId: courseId.value, sessionId: session.id })
invalidate()
} finally {
pendingId.value = null
}
}
watch(templateId, () => {
watch(courseId, () => {
searchInput.value = ''
searchQuery.value = ''
pendingId.value = null
@@ -166,12 +166,12 @@ import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import CourseSessionItem from '@/features/admin/courses/components/CourseSessionItem.vue'
import {
adminCourseTemplatesKeys,
useAdminCourseTemplateQuery,
useAdminTemplateSessionsQuery,
useAdminTemplateStudentsQuery,
useRemoveAdminTemplateStudentMutation,
} from '@/services/query/admin-course-templates'
adminCoursesKeys,
useAdminCourseQuery,
useAdminCourseSessionsQuery,
useAdminCourseStudentsQuery,
useRemoveAdminCourseStudentMutation,
} from '@/services/query/admin-courses'
defineOptions({ name: 'CourseDetailsModal' })
@@ -179,14 +179,14 @@ const queryClient = useQueryClient()
const { openModal, getModal } = useModal()
const modalData = computed(() => getModal('CourseDetailsModal')?.data ?? {})
const templateId = computed(() => modalData.value.id ?? null)
const courseId = computed(() => modalData.value.id ?? null)
const { data: course, isLoading } = useAdminCourseTemplateQuery(templateId, {
enabled: () => !!templateId.value,
const { data: course, isLoading } = useAdminCourseQuery(courseId, {
enabled: () => !!courseId.value,
})
const teacherName = computed(() => {
const t = course.value?.defaultTeacher || course.value?.teacher
const t = course.value?.teacher
if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
})
@@ -203,12 +203,12 @@ const { pagination: sessionsPagination, setPage: setSessionsPage } = usePaginati
perPage: 10,
})
const { data: sessionsData, isLoading: sessionsPending } = useAdminTemplateSessionsQuery(
templateId,
const { data: sessionsData, isLoading: sessionsPending } = useAdminCourseSessionsQuery(
courseId,
sessionsFilters,
sessionsPagination,
{
enabled: () => !!templateId.value && activeTab.value === 'sessions',
enabled: () => !!courseId.value && activeTab.value === 'sessions',
keepPreviousData: true,
}
)
@@ -226,12 +226,12 @@ const { pagination: studentsPagination, setPage: setStudentsPage } = usePaginati
perPage: 10,
})
const { data: studentsData, isLoading: studentsPending } = useAdminTemplateStudentsQuery(
templateId,
const { data: studentsData, isLoading: studentsPending } = useAdminCourseStudentsQuery(
courseId,
studentsFilters,
studentsPagination,
{
enabled: () => !!templateId.value && activeTab.value === 'students',
enabled: () => !!courseId.value && activeTab.value === 'students',
keepPreviousData: true,
}
)
@@ -249,16 +249,16 @@ const studentName = (student) =>
student.phoneNumber ||
'—'
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const removeStudentMutation = useRemoveAdminTemplateStudentMutation()
const removeStudentMutation = useRemoveAdminCourseStudentMutation()
const onOpenAddSession = () => {
openModal('AddSessionToCourseModal', { templateId: templateId.value })
openModal('AddSessionToCourseModal', { courseId: courseId.value })
}
const onOpenAddStudent = () => {
openModal('AddCourseStudentModal', { templateId: templateId.value })
openModal('AddCourseStudentModal', { courseId: courseId.value })
}
const onAskRemoveStudent = (student) => {
@@ -267,7 +267,7 @@ const onAskRemoveStudent = (student) => {
message: `آیا از حذف <strong>${studentName(student)}</strong> از این دوره اطمینان دارید؟`,
onConfirm: () =>
removeStudentMutation.mutate(
{ templateId: templateId.value, userId: student.id },
{ courseId: courseId.value, userId: student.id },
{ onSuccess: invalidate }
),
})
@@ -2,11 +2,9 @@
<div class="course-form">
<BoxedIconTitleBlock
class="course-form__heading"
:title="isEditMode ? 'ویرایش دوره الگو' : 'افزودن دوره الگوی جدید'"
:title="isEditMode ? 'ویرایش دوره' : 'افزودن دوره جدید'"
:desc="
isEditMode
? 'اطلاعات دوره الگو را به‌روز کنید'
: 'در این قسمت می‌توانید دوره الگوی جدید اضافه کنید'
isEditMode ? 'اطلاعات دوره را به‌روز کنید' : 'در این قسمت می‌توانید دوره جدید اضافه کنید'
"
>
<template #icon>
@@ -44,26 +42,26 @@
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.defaultTeacherId"
name="defaultTeacherId"
v-model="form.teacherId"
name="teacherId"
label="استاد"
:options="teacherOptions"
option-label="name"
option-value="id"
:searchable="true"
:on-search="searchTeachers"
:error="errors.defaultTeacherId"
:error="errors.teacherId"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.defaultCapacity"
name="defaultCapacity"
v-model="form.capacity"
name="capacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.defaultCapacity"
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
:error="errors.capacity"
@blur="validateAt('capacity', form.capacity)"
/>
</div>
<div class="course-form__cell course-form__cell--third">
@@ -171,18 +169,18 @@ import FileUploader from '@/components/form/FileUploader.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
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 { courseTemplateSchema } from '@/features/admin/courses/schema'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT } from '@/enums'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminCourseTemplatesKeys,
useAddAdminCourseTemplateMutation,
useAdminCourseTemplateQuery,
useAdminCourseTemplatesListQuery,
useUpdateAdminCourseTemplateMutation,
} from '@/services/query/admin-course-templates'
adminCoursesKeys,
useAddAdminCourseMutation,
useAdminCourseQuery,
useAdminCoursesListQuery,
useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses'
const route = useRoute()
const router = useRouter()
@@ -194,8 +192,8 @@ const isEditMode = computed(() => !!courseId.value)
const form = ref({
title: '',
defaultTeacherId: '',
defaultCapacity: '',
teacherId: '',
capacity: '',
sessionsCount: '',
prerequisites: [],
contentType: '',
@@ -215,7 +213,7 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const { validate, validateAt, errors } = useYup(courseTemplateSchema)
const { validate, validateAt, errors } = useYup(courseSchema)
const teacherSearch = ref('')
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
@@ -237,7 +235,7 @@ const teacherOptions = computed(() => {
const prereqSearch = ref('')
const prereqFilters = computed(() => ({ title: prereqSearch.value }))
const prereqPagination = ref({ page: 1, perPage: 30 })
const { data: prereqsResponse } = useAdminCourseTemplatesListQuery(prereqFilters, prereqPagination)
const { data: prereqsResponse } = useAdminCoursesListQuery(prereqFilters, prereqPagination)
const selectedPrereqs = ref([])
const prerequisiteOptions = computed(() => {
const base = prereqsResponse.value?.data ?? []
@@ -252,28 +250,28 @@ const searchPrerequisites = useDebounce((q) => {
prereqSearch.value = q || ''
}, 400)
const { data: existingCourse } = useAdminCourseTemplateQuery(courseId, {
const { data: existingCourse } = useAdminCourseQuery(courseId, {
enabled: () => !!courseId.value,
})
watch(existingCourse, (course) => {
if (!course) return
const teacher = course.defaultTeacher || course.teacher
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)
form.value = {
title: course.title || '',
defaultTeacherId: teacher?.id || course.defaultTeacherId || '',
defaultCapacity: course.defaultCapacity ?? course.capacity ?? '',
teacherId: teacher?.id || course.teacherId || '',
capacity: course.capacity ?? '',
sessionsCount: course.sessionsCount ?? '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
contentType: course.contentType || '',
contentMediaId: course.contentMediaId || null,
description: course.description || '',
coverMediaId: course.coverMediaId || null,
termId: termId.value,
termId: course.termId ?? termId.value,
}
if (course.coverUrl) image.value = { url: course.coverUrl }
if (course.contentMedia) {
@@ -315,7 +313,7 @@ const onContentSelect = async (files) => {
try {
const formData = objectToFormData({
file,
purpose: 'content',
purpose: 'voice',
context: 'course',
type: form.value.contentType,
})
@@ -336,8 +334,8 @@ const onContentRemove = () => {
const onContentError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseTemplateMutation()
const updateMutation = useUpdateAdminCourseTemplateMutation()
const addMutation = useAddAdminCourseMutation()
const updateMutation = useUpdateAdminCourseMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
@@ -349,7 +347,7 @@ const onSubmit = async () => {
} else {
await addMutation.mutateAsync(payload)
}
await queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
router.push({ name: 'admin-courses' })
}
@@ -452,6 +450,7 @@ const onCancel = () => router.push({ name: 'admin-courses' })
&__actions {
display: flex;
justify-content: flex-end;
justify-content: space-between;
gap: 0.625rem;
}
@@ -25,9 +25,9 @@
v-for="course in templates"
:key="course.id"
:course="course"
@edit="onEditTemplate"
@delete="onAskDeleteTemplate"
@change-status="onChangeTemplateStatus"
@edit="onEditCourse"
@delete="onAskDelete"
@change-status="onChangeStatus"
@show-details="onShowDetails"
/>
</div>
@@ -36,20 +36,27 @@
</template>
<template #offered>
<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="onEditOffered"
@delete="onAskDeleteOffered"
@change-status="onChangeOfferedStatus"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
<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>
</TabsBlock>
@@ -61,11 +68,11 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import useModal from '@/composables/useModal'
import { useRoute, useRouter } from 'vue-router'
import { useQueryClient } from '@tanstack/vue-query'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { computed, onMounted, ref, watch } from 'vue'
import NoItems from '@/components/blocks/NoItems.vue'
import TabsBlock from '@/components/blocks/TabsBlock.vue'
import { usePagination } from '@/composables/usePagination'
@@ -81,25 +88,25 @@ import AddSessionToCourseModal from '@/features/admin/courses/components/modals/
import {
adminCoursesKeys,
useAdminCoursesListQuery,
useChangeAdminCourseStatusMutation,
useDeleteAdminCourseMutation,
useUpdateAdminCourseMutation,
} from '@/services/query/admin-courses'
import {
adminCourseTemplatesKeys,
useAdminCourseTemplatesListQuery,
useChangeAdminCourseTemplateStatusMutation,
useDeleteAdminCourseTemplateMutation,
} from '@/services/query/admin-course-templates'
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
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-template' }).catch(() => {})
router.push({ name: 'admin-add-course' }).catch(() => {})
} else {
openModal('AddOfferedCourseModal', { mode: 'add' })
openModal('AddOfferedCourseModal', {
mode: 'add',
termId: routeTermId.value ?? undefined,
})
}
}
@@ -121,10 +128,16 @@ const tabs = [
buttonAction: onAdd,
},
]
const activeTab = ref('templates')
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
const templateFilters = ref({ title: '', status: '', fromDate: '', toDate: '' })
const offeredFilters = ref({ title: '', termId: '', status: '', fromDate: '', toDate: '' })
const offeredFilters = ref({
title: '',
termId: routeTermId.value ?? '',
status: '',
fromDate: '',
toDate: '',
})
const currentFilters = computed({
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
@@ -146,7 +159,7 @@ const {
reset: resetOfferedPagination,
} = usePagination({ page: 1, perPage: 10 })
const { data: templatesData, isLoading: templatesPending } = useAdminCourseTemplatesListQuery(
const { data: templatesData, isLoading: templatesPending } = useAdminCoursesListQuery(
templateFilters,
templatesPagination,
{
@@ -155,16 +168,18 @@ const { data: templatesData, isLoading: templatesPending } = useAdminCourseTempl
}
)
const hasOfferedTerm = computed(() => !!offeredFilters.value.termId)
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
offeredFilters,
offeredPagination,
{
enabled: () => activeTab.value === 'offered',
enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
keepPreviousData: true,
}
)
const templates = computed(() => templatesData.value?.data?.items ?? [])
const templates = computed(() => templatesData.value?.data ?? [])
const templatesPaginationMeta = computed(() => ({
page: templatesPagination.value.page,
perPage: templatesPagination.value.perPage,
@@ -188,12 +203,8 @@ const onFilterApply = () => {
}
const onFilterReset = onFilterApply
const onEditTemplate = (course) => {
router.push({ name: 'admin-edit-course-template', params: { id: course.id } }).catch(() => {})
}
const onEditOffered = (course) => {
openModal('AddOfferedCourseModal', { mode: 'edit', courseId: course.id })
const onEditCourse = (course) => {
router.push({ name: 'admin-edit-course', params: { id: course.id } }).catch(() => {})
}
const onShowDetails = (course) => {
@@ -203,45 +214,33 @@ const onShowDetails = (course) => {
})
}
const invalidateTemplates = () =>
queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
const invalidateOffered = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const deleteTemplateMutation = useDeleteAdminCourseTemplateMutation()
const changeTemplateStatusMutation = useChangeAdminCourseTemplateStatusMutation()
const deleteMutation = useDeleteAdminCourseMutation()
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
const updateMutation = useUpdateAdminCourseMutation()
const deleteOfferedMutation = useDeleteAdminCourseMutation()
const changeOfferedStatusMutation = useChangeAdminCourseStatusMutation()
const onAskDeleteTemplate = (course) => {
const onAskDelete = (course) => {
openModal('ConfirmModal', {
title: `حذف ${course.title}`,
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید <strong>${course.title}</strong> را حذف کنید؟`,
onConfirm: () => deleteTemplateMutation.mutate(course.id, { onSuccess: invalidateTemplates }),
onConfirm: () => deleteMutation.mutate(course.id, { onSuccess: invalidate }),
})
}
const onAskDeleteOffered = (course) => {
openModal('ConfirmModal', {
title: `حذف ${course.title}`,
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که می‌خواهید <strong>${course.title}</strong> را حذف کنید؟`,
onConfirm: () => deleteOfferedMutation.mutate(course.id, { onSuccess: invalidateOffered }),
})
const onChangeStatus = ({ id, isActive }) => {
updateMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
}
const onChangeTemplateStatus = ({ id, isActive }) => {
changeTemplateStatusMutation.mutate(
{ id, payload: { isActiveByDefault: isActive } },
{ onSuccess: invalidateTemplates }
)
const syncRouteTermId = (termId) => {
if (!termId) return
activeTab.value = 'offered'
offeredFilters.value = { ...offeredFilters.value, termId }
resetOfferedPagination()
}
const onChangeOfferedStatus = ({ id, isActive }) => {
changeOfferedStatusMutation.mutate(
{ id, payload: { isActive } },
{ onSuccess: invalidateOffered }
)
}
onMounted(() => syncRouteTermId(routeTermId.value))
watch(routeTermId, (val) => syncRouteTermId(val))
</script>
<style lang="scss" scoped>
+5 -5
View File
@@ -1,20 +1,20 @@
import { array, boolean, mixed, number, object, string } from 'yup'
export const courseTemplateSchema = object().shape({
export const courseSchema = object().shape({
title: string().required().min(3).max(255),
defaultTeacherId: mixed().required(),
defaultCapacity: number().required().min(1),
teacherId: mixed().required(),
capacity: number().required().min(1),
sessionsCount: number().required().min(1),
prerequisites: array().nullable().default([]),
contentType: string().oneOf(['video', 'voice', 'text']).required(),
contentMediaId: number().nullable().notRequired(),
description: string().nullable().notRequired(),
termId: string().nullable(),
termId: mixed().nullable(),
isActive: boolean().nullable().notRequired(),
})
export const offeredCourseSchema = object().shape({
termId: string().required(),
templateId: string().required(),
title: string().required(),
capacity: string().required(),
imageId: string().nullable().notRequired(),