2eb7c68f9f
The admin course form sent a stray upload id (imageId/uploadId) that didn't match any field the API accepts; the API expects cover_media_id with the media id returned from the upload endpoint. Also switch course displays to the coverUrl field the API actually returns.
325 lines
9.5 KiB
Vue
325 lines
9.5 KiB
Vue
<template>
|
|
<BasicModal
|
|
:title="modeTitle"
|
|
title-en="Offered Course"
|
|
width="95%"
|
|
max-width="60rem"
|
|
min-width="auto"
|
|
:show-close-button="true"
|
|
>
|
|
<template #default="{ close }">
|
|
<form class="offered-course" @submit.prevent="onSubmit(close)">
|
|
<div class="offered-course__grid">
|
|
<div class="offered-course__image-col">
|
|
<ImageCropper
|
|
v-model="image"
|
|
name="image"
|
|
bg-color="#eeeeee"
|
|
@crop="onImageCropped"
|
|
@error="onImageError"
|
|
/>
|
|
</div>
|
|
|
|
<div class="offered-course__main-col">
|
|
<div class="offered-course__row">
|
|
<div class="offered-course__cell">
|
|
<SelectField
|
|
v-model="form.termId"
|
|
name="termId"
|
|
label="ترم"
|
|
:options="termOptions"
|
|
option-label="title"
|
|
option-value="id"
|
|
:searchable="true"
|
|
:on-search="searchTerms"
|
|
:error="errors.termId"
|
|
/>
|
|
</div>
|
|
<div class="offered-course__cell">
|
|
<SelectField
|
|
v-model="form.templateId"
|
|
name="templateId"
|
|
label="دوره الگو"
|
|
:options="templateOptions"
|
|
option-label="title"
|
|
option-value="id"
|
|
:searchable="true"
|
|
:on-search="searchTemplates"
|
|
:error="errors.templateId"
|
|
/>
|
|
</div>
|
|
<div class="offered-course__cell">
|
|
<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>
|
|
</div>
|
|
<div class="offered-course__cell">
|
|
<TextField
|
|
v-model="form.capacity"
|
|
name="capacity"
|
|
label="ظرفیت (نفر)"
|
|
inputmode="numeric"
|
|
:convert-digits="true"
|
|
:error="errors.capacity"
|
|
@blur="validateAt('capacity', form.capacity)"
|
|
/>
|
|
</div>
|
|
<div class="offered-course__cell offered-course__toggle-cell">
|
|
<ToggleSwitch v-model="form.isActive" label="دوره فعال باشد" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="offered-course__divider" />
|
|
|
|
<div class="offered-course__actions">
|
|
<BaseButton
|
|
variant="transparent"
|
|
text="منصرف شدم"
|
|
custom-class="offered-course__btn-cancel"
|
|
@click="close"
|
|
>
|
|
<template #prependIcon>
|
|
<SvgIcon name="caret-right" :size="14" color="var(--color-sec-gray)" />
|
|
</template>
|
|
</BaseButton>
|
|
<BaseButton
|
|
type="submit"
|
|
text="تایید اطلاعات"
|
|
:loading="submitting"
|
|
custom-class="offered-course__btn-submit"
|
|
>
|
|
<template #appendIcon>
|
|
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
|
</template>
|
|
</BaseButton>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
</BasicModal>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { toast } from 'vue3-toastify'
|
|
import useYup from '@/composables/useYup'
|
|
import { computed, ref, watch } from 'vue'
|
|
import useModal from '@/composables/useModal'
|
|
import useDebounce from '@/composables/useDebounce'
|
|
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'
|
|
import TextField from '@/components/form/TextField.vue'
|
|
import SelectField from '@/components/form/SelectField.vue'
|
|
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
|
import ImageCropper from '@/components/form/ImageCropper.vue'
|
|
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 {
|
|
adminCoursesKeys,
|
|
useAddAdminCourseMutation,
|
|
useAdminCourseQuery,
|
|
useAdminCoursesListQuery,
|
|
useUpdateAdminCourseMutation,
|
|
} from '@/services/query/admin-courses'
|
|
|
|
defineOptions({ name: 'AddOfferedCourseModal' })
|
|
|
|
const { getModal } = useModal()
|
|
const queryClient = useQueryClient()
|
|
|
|
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: presetTermId.value,
|
|
templateId: '',
|
|
title: '',
|
|
capacity: '',
|
|
coverMediaId: null,
|
|
isActive: false,
|
|
})
|
|
|
|
watch(presetTermId, (val) => {
|
|
if (val && !form.value.termId) form.value.termId = val
|
|
})
|
|
const image = ref(null)
|
|
|
|
const schema = offeredCourseSchema
|
|
|
|
const { validate, validateAt, errors, resetErrors } = useYup(schema)
|
|
|
|
const termSearch = ref('')
|
|
const termFilters = computed(() => ({ title: termSearch.value }))
|
|
const termPagination = ref({ page: 1, perPage: 10 })
|
|
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
|
|
const selectedTerm = ref(null)
|
|
const termOptions = computed(() => {
|
|
const base = termsResponse.value?.data ?? []
|
|
if (selectedTerm.value && !base.some((t) => t.id === selectedTerm.value.id)) {
|
|
return [...base, selectedTerm.value]
|
|
}
|
|
return base
|
|
})
|
|
|
|
const templateSearch = ref('')
|
|
const templateFilters = computed(() => ({ title: templateSearch.value, termId: 'null' }))
|
|
const templatePagination = ref({ page: 1, perPage: 10 })
|
|
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
|
const selectedTemplate = ref(null)
|
|
const templateOptions = computed(() => {
|
|
const base = templatesResponse.value?.data ?? []
|
|
if (selectedTemplate.value && !base.some((t) => t.id === selectedTemplate.value.id)) {
|
|
return [...base, selectedTemplate.value]
|
|
}
|
|
return base
|
|
})
|
|
|
|
const searchTerms = useDebounce((q) => {
|
|
termSearch.value = q || ''
|
|
}, 400)
|
|
const searchTemplates = useDebounce((q) => {
|
|
templateSearch.value = q || ''
|
|
}, 400)
|
|
|
|
const { data: existingCourse } = useAdminCourseQuery(courseId, {
|
|
enabled: () => isEditMode.value && !!courseId.value,
|
|
})
|
|
|
|
watch(existingCourse, (course) => {
|
|
if (!course) return
|
|
if (course.term) selectedTerm.value = course.term
|
|
form.value = {
|
|
termId: course.term?.id || course.termId || '',
|
|
templateId: '',
|
|
title: course.title || '',
|
|
capacity: course.capacity ?? '',
|
|
coverMediaId: course.coverMediaId || null,
|
|
isActive: course.isActive ?? false,
|
|
}
|
|
if (course.coverUrl) image.value = { url: course.coverUrl }
|
|
})
|
|
|
|
const uploadMutation = useUploadMediaMutation()
|
|
|
|
const onImageCropped = async (file) => {
|
|
try {
|
|
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
|
|
const response = await uploadMutation.mutateAsync(formData)
|
|
const payload = response?.data ?? response
|
|
image.value = { url: payload?.url, ...payload }
|
|
form.value.coverMediaId = payload?.id
|
|
} catch {
|
|
/* handled globally */
|
|
}
|
|
}
|
|
|
|
const onImageError = (msg) => toast.error(msg)
|
|
|
|
const addMutation = useAddAdminCourseMutation()
|
|
const updateMutation = useUpdateAdminCourseMutation()
|
|
|
|
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
|
|
|
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: submitPayload })
|
|
} else {
|
|
await addMutation.mutateAsync(submitPayload)
|
|
}
|
|
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
|
resetErrors()
|
|
close()
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.offered-course {
|
|
width: 100%;
|
|
text-align: start;
|
|
|
|
&__grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 1rem;
|
|
margin-block: 0.75rem;
|
|
|
|
@media (min-width: 1024px) {
|
|
grid-template-columns: 4fr 8fr;
|
|
}
|
|
}
|
|
|
|
&__image-col {
|
|
order: 2;
|
|
|
|
@media (min-width: 1024px) {
|
|
order: 1;
|
|
}
|
|
}
|
|
|
|
&__main-col {
|
|
order: 1;
|
|
|
|
@media (min-width: 1024px) {
|
|
order: 2;
|
|
padding-inline-start: 1.25rem;
|
|
}
|
|
}
|
|
|
|
&__row {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 0.5rem;
|
|
|
|
@media (min-width: 640px) {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
&__toggle-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
&__divider {
|
|
border-block-end: 1px solid var(--color-thd-gray);
|
|
margin-block: 1.5rem;
|
|
}
|
|
|
|
&__actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.625rem;
|
|
}
|
|
|
|
&__btn-cancel {
|
|
min-width: 9rem;
|
|
}
|
|
|
|
&__btn-submit {
|
|
min-width: 12rem;
|
|
}
|
|
}
|
|
</style>
|