fix: admin courses

This commit is contained in:
sajjadtalkhabi
2026-05-27 14:47:19 +03:30
parent 7eb2984f5e
commit cb86920015
50 changed files with 787 additions and 649 deletions
@@ -37,7 +37,7 @@
@click="onReset"
>
<template #icon>
<SvgIcon name="close" :size="20" />
<SvgIcon name="close" :size="20" color="black" />
</template>
</CircleButton>
<CircleButton
@@ -165,7 +165,7 @@ const { validate, validateAt, errors, resetErrors } = useYup(schema)
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 30 })
const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const selectedTerm = ref(null)
const termOptions = computed(() => {
@@ -178,7 +178,7 @@ const termOptions = computed(() => {
const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value, termId: 'null' }))
const templatePagination = ref({ page: 1, perPage: 30 })
const templatePagination = ref({ page: 1, perPage: 10 })
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
const selectedTemplate = ref(null)
const templateOptions = computed(() => {
@@ -84,24 +84,25 @@
class="course-details__student-row"
>
<div class="course-details__student-main">
<div v-if="student.avatarUrl" class="course-details__avatar">
<img :src="student.avatarUrl" :alt="studentName(student)" />
<div v-if="studentAvatar(student)" class="course-details__avatar">
<img :src="studentAvatar(student)" :alt="student.name" />
</div>
<div v-else class="course-details__avatar course-details__avatar--placeholder">
<SvgIcon name="user" :size="20" color="#bcbcbc" />
</div>
<div>
<p class="course-details__student-name">{{ studentName(student) }}</p>
<p class="course-details__student-name">{{ student.name || '—' }}</p>
<p class="course-details__student-meta">
<span>{{ student.address?.province?.name || '—' }}</span>
<span class="course-details__sep">،</span>
<span>{{ student.address?.city?.name || '—' }}</span>
<span dir="ltr">{{ student.phone || '—' }}</span>
<template v-if="student.email">
<span class="course-details__sep">،</span>
<span dir="ltr">{{ student.email }}</span>
</template>
</p>
</div>
</div>
<div class="course-details__student-actions">
<div v-if="student.phone" class="course-details__student-actions">
<CircleButton
v-if="student.phoneNumber"
tooltip="ارسال پیام"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
@@ -111,16 +112,6 @@
<SvgIcon name="chat" :size="16" color="var(--color-sec-gray)" />
</template>
</CircleButton>
<CircleButton
tooltip="حذف"
bg-color="rgba(104, 104, 104, 0.05)"
size="2.25rem"
@click="onAskRemoveStudent(student)"
>
<template #icon>
<SvgIcon name="trash" :size="16" color="var(--color-error)" />
</template>
</CircleButton>
</div>
</div>
</div>
@@ -151,7 +142,6 @@
<script setup>
import { computed, ref } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
@@ -166,16 +156,13 @@ import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import CourseSessionItem from '@/features/admin/courses/components/CourseSessionItem.vue'
import {
adminCoursesKeys,
useAdminCourseQuery,
useAdminCourseSessionsQuery,
useAdminCourseStudentsQuery,
useRemoveAdminCourseStudentMutation,
useCourseAttendeesQuery,
} from '@/services/query/admin-courses'
defineOptions({ name: 'CourseDetailsModal' })
const queryClient = useQueryClient()
const { openModal, getModal } = useModal()
const modalData = computed(() => getModal('CourseDetailsModal')?.data ?? {})
@@ -226,7 +213,7 @@ const { pagination: studentsPagination, setPage: setStudentsPage } = usePaginati
perPage: 10,
})
const { data: studentsData, isLoading: studentsPending } = useAdminCourseStudentsQuery(
const { data: studentsData, isLoading: studentsPending } = useCourseAttendeesQuery(
courseId,
studentsFilters,
studentsPagination,
@@ -243,15 +230,7 @@ const studentsPaginationMeta = computed(() => ({
...studentsData.value?.meta,
}))
const studentName = (student) =>
`${student.firstName || ''} ${student.lastName || ''}`.trim() ||
student.fullName ||
student.phoneNumber ||
'—'
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const removeStudentMutation = useRemoveAdminCourseStudentMutation()
const studentAvatar = (student) => student.avatarUrl || student.avatarDownloadUrl || ''
const onOpenAddSession = () => {
openModal('AddSessionToCourseModal', { courseId: courseId.value })
@@ -261,21 +240,9 @@ const onOpenAddStudent = () => {
openModal('AddCourseStudentModal', { courseId: courseId.value })
}
const onAskRemoveStudent = (student) => {
openModal('ConfirmModal', {
title: `حذف ${studentName(student)}`,
message: `آیا از حذف <strong>${studentName(student)}</strong> از این دوره اطمینان دارید؟`,
onConfirm: () =>
removeStudentMutation.mutate(
{ courseId: courseId.value, userId: student.id },
{ onSuccess: invalidate }
),
})
}
const onMessageStudent = (student) => {
if (!student?.phoneNumber) return
window.location.href = `sms:${student.phoneNumber}`
if (!student?.phone) return
window.location.href = `sms:${student.phone}`
}
</script>
@@ -97,7 +97,6 @@
option-label="label"
option-value="value"
:error="errors.contentType"
@change="onContentTypeChange"
/>
</div>
<div class="course-form__cell course-form__cell--full">
@@ -109,18 +108,6 @@
:error="errors.description"
/>
</div>
<div class="course-form__cell course-form__cell--full">
<label class="course-form__uploader-label">فایل دوره</label>
<FileUploader
v-model="contentFiles"
:accept="contentAccept"
:multiple="false"
:max-files="1"
@select="onContentSelect"
@remove="onContentRemove"
@error="onContentError"
/>
</div>
</div>
</div>
</div>
@@ -157,6 +144,7 @@
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'
@@ -165,14 +153,12 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
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 { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT } from '@/enums'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminCoursesKeys,
@@ -197,27 +183,23 @@ const form = ref({
sessionsCount: '',
prerequisites: [],
contentType: '',
contentMediaId: null,
description: '',
coverMediaId: null,
termId: termId.value,
})
const image = ref(null)
const contentFiles = ref([])
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const { validate, validateAt, errors } = useYup(courseSchema)
const teacherSearch = ref('')
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
const teacherPagination = ref({ page: 1, perPage: 30 })
const teacherPagination = ref({ page: 1, perPage: 10 })
const { data: teachersResponse } = useAdminUsersListQuery(teacherFilters, teacherPagination)
const selectedTeacher = ref(null)
const teacherOptions = computed(() => {
@@ -234,7 +216,7 @@ const teacherOptions = computed(() => {
const prereqSearch = ref('')
const prereqFilters = computed(() => ({ title: prereqSearch.value }))
const prereqPagination = ref({ page: 1, perPage: 30 })
const prereqPagination = ref({ page: 1, perPage: 10 })
const { data: prereqsResponse } = useAdminCoursesListQuery(prereqFilters, prereqPagination)
const selectedPrereqs = ref([])
const prerequisiteOptions = computed(() => {
@@ -268,22 +250,11 @@ watch(existingCourse, (course) => {
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: course.termId ?? termId.value,
}
if (course.coverUrl) image.value = { url: course.coverUrl }
if (course.contentMedia) {
contentFiles.value = [
{
id: course.contentMedia.id,
name: course.contentMedia.name || course.contentMedia.fileName || 'file',
size: course.contentMedia.size ?? 0,
url: course.contentMedia.url,
},
]
}
})
const uploadMutation = useUploadMediaMutation()
@@ -302,38 +273,6 @@ const onImageCropped = async (file) => {
const onImageError = (msg) => toast.error(msg)
const onContentTypeChange = () => {
contentFiles.value = []
form.value.contentMediaId = null
}
const onContentSelect = async (files) => {
const file = files?.[0]
if (!file) return
try {
const formData = objectToFormData({
file,
purpose: 'voice',
context: 'course',
type: form.value.contentType,
})
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
form.value.contentMediaId = id
} catch {
contentFiles.value = []
}
}
const onContentRemove = () => {
contentFiles.value = []
form.value.contentMediaId = null
}
const onContentError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseMutation()
const updateMutation = useUpdateAdminCourseMutation()
@@ -130,14 +130,8 @@ const tabs = [
]
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
const templateFilters = ref({ title: '', status: '', fromDate: '', toDate: '' })
const offeredFilters = ref({
title: '',
termId: routeTermId.value ?? '',
status: '',
fromDate: '',
toDate: '',
})
const templateFilters = ref({})
const offeredFilters = ref({})
const currentFilters = computed({
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),