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
@@ -11,143 +11,131 @@
</BoxedIconTitleBlock>
<form class="session-form__form" @submit.prevent="onSubmit">
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
<div class="session-form__grid">
<div class="session-form__image-col">
<ImageCropper
v-model="image"
name="image"
bg-color="#eeeeee"
@crop="onImageCropped"
@error="onImageError"
/>
</div>
<!-- row 1 title / startTime / endTime -->
<div class="session-form__row session-form__row--three">
<TextField
v-model="form.title"
name="title"
label="عنوان جلسه"
:error="errors.title"
@blur="validateAt('title', form.title)"
>
<template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<DatePickerField
v-model="form.startTime"
name="startTime"
label="زمان شروع"
type="datetime"
:error="errors.startTime"
@blur="validateAt('startTime', form.startTime)"
/>
<DatePickerField
v-model="form.endTime"
name="endTime"
label="زمان پایان"
type="datetime"
:error="errors.endTime"
/>
</div>
<div class="session-form__main-col">
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
<!-- row 2 durationMinutes / courseId / contentType -->
<div class="session-form__row session-form__row--three">
<TextField
v-model="form.durationMinutes"
name="durationMinutes"
label="مدت زمان جلسه (دقیقه)"
inputmode="numeric"
:convert-digits="true"
:error="errors.durationMinutes"
@blur="validateAt('durationMinutes', form.durationMinutes)"
/>
<SelectField
v-model="form.courseId"
name="courseId"
label="دوره"
:options="courseOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchCourses"
:error="errors.courseId"
/>
<SelectField
v-model="form.contentType"
name="contentType"
label="محتوای جلسه"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
@change="onContentTypeChange"
/>
</div>
<!-- row 1 title / startsAt / endsAt -->
<div class="session-form__row session-form__row--three">
<TextField
v-model="form.title"
name="title"
label="عنوان جلسه"
:error="errors.title"
@blur="validateAt('title', form.title)"
>
<template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" />
</template>
</TextField>
<DatePickerField
v-model="form.startsAt"
name="startsAt"
label="زمان شروع"
type="datetime"
:error="errors.startsAt"
@blur="validateAt('startsAt', form.startsAt)"
/>
<DatePickerField
v-model="form.endsAt"
name="endsAt"
label="زمان پایان"
type="datetime"
:error="errors.endsAt"
/>
</div>
<!-- row 3 sessionType / meetingLink -->
<div class="session-form__row session-form__row--two">
<SelectField
v-model="form.sessionType"
name="sessionType"
label="نوع جلسه"
:options="sessionTypeOptions"
option-label="label"
option-value="value"
:error="errors.sessionType"
@change="onSessionTypeChange"
/>
<TextField
v-model="form.meetingLink"
name="meetingLink"
label="لینک جلسه"
:disabled="form.sessionType !== 'online'"
:error="errors.meetingLink"
@blur="validateAt('meetingLink', form.meetingLink)"
/>
</div>
<!-- row 2 durationMinutes / courseId / contentType -->
<div class="session-form__row session-form__row--three">
<TextField
v-model="form.durationMinutes"
name="durationMinutes"
label="مدت زمان جلسه (دقیقه)"
inputmode="numeric"
:convert-digits="true"
:error="errors.durationMinutes"
@blur="validateAt('durationMinutes', form.durationMinutes)"
/>
<SelectField
v-model="form.courseId"
name="courseId"
label="دوره"
:options="courseOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchCourses"
:error="errors.courseId"
/>
<SelectField
v-model="form.contentType"
name="contentType"
label="محتوای جلسه"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
@change="onContentTypeChange"
/>
</div>
<!-- row 4 description -->
<div class="session-form__row">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات جلسه"
:row="5"
/>
</div>
<!-- row 3 type / link -->
<div class="session-form__row session-form__row--two">
<SelectField
v-model="form.type"
name="type"
label="نوع جلسه"
:options="sessionTypeOptions"
option-label="label"
option-value="value"
:error="errors.type"
@change="onSessionTypeChange"
/>
<TextField
v-model="form.link"
name="link"
label="لینک جلسه"
:disabled="form.type !== 'online'"
:error="errors.link"
@blur="validateAt('link', form.link)"
/>
</div>
<!-- row 5 content uploader -->
<div class="session-form__row">
<label class="session-form__uploader-label">محتوای جلسه</label>
<FileUploader
v-model="contentFiles"
:accept="contentAccept"
:multiple="false"
:max-files="1"
:disabled="!form.contentType"
@select="onContentSelect"
@remove="onContentRemove"
@error="onContentError"
/>
</div>
<!-- row 4 description -->
<div class="session-form__row">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات جلسه"
:row="5"
/>
</div>
<!-- row 6 preview of uploaded content -->
<div v-if="contentPreviewUrl" class="session-form__row session-form__preview">
<video
v-if="form.contentType === 'video'"
:src="contentPreviewUrl"
controls
class="session-form__media"
/>
<audio
v-else-if="form.contentType === 'voice'"
:src="contentPreviewUrl"
controls
class="session-form__media"
/>
<a
v-else
:href="contentPreviewUrl"
target="_blank"
rel="noopener noreferrer"
class="session-form__file-link"
>
<SvgIcon name="file" :size="18" color="var(--color-primary)" />
<span>{{ contentFiles[0]?.name || 'فایل پیوست' }}</span>
</a>
<!-- row 5 content uploader -->
<div class="session-form__row">
<label class="session-form__uploader-label">محتوای جلسه</label>
<FileUploader
v-model="contentFiles"
:accept="contentAccept"
:multiple="false"
:max-files="1"
:disabled="!form.contentType"
@select="onContentSelect"
@remove="onContentRemove"
@error="onContentError"
/>
</div>
</div>
</div>
<div class="session-form__divider" />
@@ -191,6 +179,7 @@ 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 TextareaField from '@/components/form/TextareaField.vue'
@@ -213,13 +202,11 @@ const queryClient = useQueryClient()
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEditMode = computed(() => !!sessionId.value)
// نوع جلسه: only the two delivery modes — backend `type` collapses to online/offline.
const sessionTypeOptions = [
{ value: 'in_person', label: SESSION_TYPE.in_person },
{ value: 'offline', label: SESSION_TYPE.offline },
{ value: 'online', label: SESSION_TYPE.online },
]
// محتوای جلسه: voice / video / text — reuses the course content-type enum.
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
@@ -227,27 +214,28 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
const form = ref({
title: '',
startTime: '',
endTime: '',
startsAt: '',
endsAt: '',
durationMinutes: '',
courseId: '',
contentType: '',
sessionType: '',
meetingLink: '',
type: '',
link: '',
description: '',
contentMediaId: null,
})
const image = ref(null)
const contentFiles = ref([])
const coverMediaId = ref(null)
const contentMediaId = ref(null)
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const contentPreviewUrl = computed(() => contentFiles.value[0]?.url || '')
const { validate, validateAt, errors } = useYup(sessionSchema)
const courseSearch = ref('')
const courseFilters = computed(() => ({ title: courseSearch.value }))
const coursePagination = ref({ page: 1, perPage: 30 })
const coursePagination = ref({ page: 1, perPage: 10 })
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
const selectedCourse = ref(null)
const courseOptions = computed(() => {
@@ -264,50 +252,67 @@ const searchCourses = useDebounce((q) => {
const onContentTypeChange = () => {
contentFiles.value = []
form.value.contentMediaId = null
contentMediaId.value = null
}
const onSessionTypeChange = () => {
if (form.value.sessionType !== 'online') form.value.meetingLink = ''
if (form.value.type !== 'online') form.value.link = ''
}
const { data: existingSession } = useAdminSessionQuery(sessionId, {
enabled: () => !!sessionId.value,
})
watch(existingSession, (session) => {
if (!session) return
if (session.course) selectedCourse.value = session.course
form.value = {
title: session.title || '',
startTime: session.startsAt || session.sessionConfig?.startTime || '',
endTime: session.endsAt || session.sessionConfig?.endTime || '',
durationMinutes: session.durationMinutes ?? '',
courseId: session.course?.id || session.courseId || '',
contentType: session.contentType || '',
sessionType: session.sessionType || '',
meetingLink: session.link || session.sessionConfig?.meetingLink || '',
description: session.description || '',
contentMediaId: session.contentMediaId || null,
}
if (session.contentMedia) {
contentFiles.value = [
{
id: session.contentMedia.id,
name: session.contentMedia.fileName || session.contentMedia.name || 'file',
size: session.contentMedia.fileSize ?? 0,
url: session.contentMedia.url,
},
]
}
})
watch(
existingSession,
(session) => {
if (!session) return
if (session.course) selectedCourse.value = session.course
const media = Array.isArray(session.media) ? session.media : []
const coverMedia = media.find((m) => m.collectionName === 'cover')
const contentMedia = media.find((m) => m.collectionName !== 'cover')
const contentType = collectionToContentType(contentMedia?.collectionName)
form.value = {
title: session.title ?? '',
startsAt: session.startsAt ?? '',
endsAt: session.endsAt ?? '',
durationMinutes: session.durationMinutes ?? '',
courseId: session.course?.id ?? '',
contentType,
type: session.type ?? '',
link: session.link ?? '',
description: session.description ?? '',
}
coverMediaId.value = coverMedia?.id ?? null
contentMediaId.value = contentMedia?.id ?? null
if (contentMedia) {
contentFiles.value = [
{
id: contentMedia.id,
name: contentMedia.fileName ?? 'file',
size: contentMedia.fileSize ?? 0,
url: contentMedia.url,
},
]
}
if (coverMedia?.url) image.value = { url: coverMedia.url }
},
{ immediate: true }
)
const uploadMutation = useUploadMediaMutation()
const purposeForContentType = (contentType) => {
if (contentType === 'video') return 'video'
if (contentType === 'voice') return 'voice'
return 'attachment'
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) => {
@@ -323,7 +328,7 @@ const onContentSelect = async (files) => {
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
contentMediaId.value = id
} catch {
contentFiles.value = []
}
@@ -331,26 +336,41 @@ const onContentSelect = async (files) => {
const onContentRemove = () => {
contentFiles.value = []
form.value.contentMediaId = null
contentMediaId.value = null
}
const onContentError = (msg) => toast.error(msg)
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, purpose: 'cover', context: 'session' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, ...payload }
coverMediaId.value = payload?.id ?? null
} catch {
/* handled globally */
}
}
const onImageError = (msg) => toast.error(msg)
const buildPayload = (values) => {
const mediaIds = [coverMediaId.value, contentMediaId.value].filter((id) => id != null)
const payload = {
title: values.title,
startTime: values.startTime,
endTime: values.endTime,
startsAt: values.startsAt,
endsAt: values.endsAt,
durationMinutes: values.durationMinutes,
courseId: values.courseId,
contentType: values.contentType,
sessionType: values.sessionType,
meetingLink: values.sessionType === 'online' ? values.meetingLink : undefined,
type: values.type,
link: values.type === 'online' ? values.link : undefined,
description: values.description,
contentMediaId: values.contentMediaId,
mediaIds,
}
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined || payload[key] === '' || payload[key] === null) {
const value = payload[key]
if (value === undefined || value === '' || value === null) {
delete payload[key]
}
})
@@ -397,6 +417,41 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
gap: 0.75rem;
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
@media (min-width: 1024px) {
grid-template-columns: 4fr 8fr;
}
@media (min-width: 1280px) {
grid-template-columns: 3fr 9fr;
}
}
&__image-col {
order: 2;
@media (min-width: 1024px) {
order: 1;
padding: 0.5rem;
}
}
&__main-col {
order: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
@media (min-width: 1024px) {
order: 2;
padding-inline-start: 1.25rem;
}
}
&__row {
display: grid;
grid-template-columns: 1fr;
@@ -461,8 +516,8 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
&__actions {
display: flex;
justify-content: flex-end;
gap: 0.625rem;
justify-content: space-between;
align-items: center;
}
&__btn-cancel {