Files
banu-front/src/features/admin/sessions/pages/SessionFormPage.vue
T
sajjadtalkhabi 35a0f2c04c fix
2026-05-21 16:08:36 +03:30

477 lines
14 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">
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
<!-- 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>
<!-- 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 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 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>
<!-- 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>
</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 { 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)
// نوع جلسه: only the two delivery modes — backend `type` collapses to online/offline.
const sessionTypeOptions = [
{ value: 'in_person', label: SESSION_TYPE.in_person },
{ 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,
}))
const form = ref({
title: '',
startTime: '',
endTime: '',
durationMinutes: '',
courseId: '',
contentType: '',
sessionType: '',
meetingLink: '',
description: '',
contentMediaId: null,
})
const contentFiles = ref([])
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 { 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 = []
form.value.contentMediaId = null
}
const onSessionTypeChange = () => {
if (form.value.sessionType !== 'online') form.value.meetingLink = ''
}
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,
},
]
}
})
const uploadMutation = useUploadMediaMutation()
const purposeForContentType = (contentType) => {
if (contentType === 'video') return 'video'
if (contentType === 'voice') return 'voice'
return 'attachment'
}
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 }]
form.value.contentMediaId = id
} catch {
contentFiles.value = []
}
}
const onContentRemove = () => {
contentFiles.value = []
form.value.contentMediaId = null
}
const onContentError = (msg) => toast.error(msg)
const buildPayload = (values) => {
const payload = {
title: values.title,
startTime: values.startTime,
endTime: values.endTime,
durationMinutes: values.durationMinutes,
courseId: values.courseId,
contentType: values.contentType,
sessionType: values.sessionType,
meetingLink: values.sessionType === 'online' ? values.meetingLink : undefined,
description: values.description,
contentMediaId: values.contentMediaId,
}
Object.keys(payload).forEach((key) => {
if (payload[key] === undefined || payload[key] === '' || payload[key] === 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;
}
&__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: flex-end;
gap: 0.625rem;
}
&__btn-cancel {
min-width: 9rem;
}
&__btn-submit {
min-width: 12rem;
}
}
</style>