Files
banu-front/src/features/admin/sessions/pages/SessionFormPage.vue
T
2026-05-27 14:47:19 +03:30

532 lines
15 KiB
Vue

<template>
<div class="session-form">
<BoxedIconTitleBlock
class="session-form__heading"
:title="isEditMode ? 'ویرایش جلسه' : 'افزودن جلسه جدید'"
desc="در این قسمت می‌توانید جلسه خود را مدیریت کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<form class="session-form__form" @submit.prevent="onSubmit">
<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>
<div class="session-form__main-col">
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
<!-- 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 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 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 4 description -->
<div class="session-form__row">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات جلسه"
:row="5"
/>
</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>
</div>
</div>
<div class="session-form__divider" />
<div class="session-form__actions">
<BaseButton
variant="transparent"
text="منصرف شدم"
custom-class="session-form__btn-cancel"
@click="onCancel"
>
<template #prependIcon>
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
</template>
</BaseButton>
<BaseButton
type="submit"
:text="isEditMode ? 'ذخیره تغییرات' : 'تایید اطلاعات'"
:loading="submitting"
custom-class="session-form__btn-submit"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="20" color="#fff" />
</template>
</BaseButton>
</div>
</form>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query'
import BaseButton from '@/components/BaseButton.vue'
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 TextareaField from '@/components/form/TextareaField.vue'
import { sessionSchema } from '@/features/admin/sessions/schema'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
import {
adminSessionsKeys,
useAddAdminSessionMutation,
useAdminSessionQuery,
useUpdateAdminSessionMutation,
} from '@/services/query/admin-sessions'
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
const isEditMode = computed(() => !!sessionId.value)
const sessionTypeOptions = [
{ value: 'offline', label: SESSION_TYPE.offline },
{ value: 'online', label: SESSION_TYPE.online },
]
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const form = ref({
title: '',
startsAt: '',
endsAt: '',
durationMinutes: '',
courseId: '',
contentType: '',
type: '',
link: '',
description: '',
})
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 { validate, validateAt, errors } = useYup(sessionSchema)
const courseSearch = ref('')
const courseFilters = computed(() => ({ title: courseSearch.value }))
const coursePagination = ref({ page: 1, perPage: 10 })
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination)
const selectedCourse = ref(null)
const courseOptions = computed(() => {
const base = coursesResponse.value?.data ?? []
if (selectedCourse.value && !base.some((c) => c.id === selectedCourse.value.id)) {
return [...base, selectedCourse.value]
}
return base
})
const searchCourses = useDebounce((q) => {
courseSearch.value = q || ''
}, 400)
const onContentTypeChange = () => {
contentFiles.value = []
contentMediaId.value = null
}
const onSessionTypeChange = () => {
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
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 'pdf'
}
const collectionToContentType = (collectionName) => {
if (collectionName === 'videos') return 'video'
if (collectionName === 'voices') return 'voice'
if (collectionName === 'pdf') return 'text'
return ''
}
const onContentSelect = async (files) => {
const file = files?.[0]
if (!file) return
try {
const formData = objectToFormData({
file,
purpose: purposeForContentType(form.value.contentType),
context: 'session',
})
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 }]
contentMediaId.value = id
} catch {
contentFiles.value = []
}
}
const onContentRemove = () => {
contentFiles.value = []
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,
startsAt: values.startsAt,
endsAt: values.endsAt,
durationMinutes: values.durationMinutes,
courseId: values.courseId,
type: values.type,
link: values.type === 'online' ? values.link : undefined,
description: values.description,
mediaIds,
}
Object.keys(payload).forEach((key) => {
const value = payload[key]
if (value === undefined || value === '' || value === null) {
delete payload[key]
}
})
return payload
}
const addMutation = useAddAdminSessionMutation()
const updateMutation = useUpdateAdminSessionMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
const onSubmit = async () => {
const { isValid } = await validate(form.value)
if (!isValid) return
const payload = buildPayload(form.value)
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: sessionId.value, payload })
} else {
await addMutation.mutateAsync(payload)
}
await queryClient.invalidateQueries({ queryKey: adminSessionsKeys.all })
router.push({ name: 'admin-sessions' })
}
const onCancel = () => router.push({ name: 'admin-sessions' })
</script>
<style lang="scss" scoped>
.session-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__form {
background: rgba(255, 255, 255, 60%);
padding: 1rem;
border-radius: 1rem;
display: flex;
flex-direction: column;
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;
gap: 0.5rem;
&--two {
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
&--three {
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
}
}
}
&__uploader-label {
display: block;
margin-bottom: 0.5rem;
font-family: var(--font-family-fa);
font-weight: 300;
line-height: 1.5rem;
font-size: 0.875rem;
color: var(--color-prim-gray);
}
&__preview {
place-items: center center;
}
&__media {
max-width: 100%;
border-radius: 0.75rem;
background: #000;
}
&__file-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-primary);
text-decoration: none;
font-family: var(--font-family-fa);
font-size: 0.875rem;
padding: 0.5rem 0;
&:hover {
text-decoration: underline;
}
}
&__divider {
border-block-end: 1px solid var(--color-thd-gray);
margin-block: 1rem;
}
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
&__btn-cancel {
min-width: 9rem;
}
&__btn-submit {
min-width: 12rem;
}
}
</style>