fix: design

This commit is contained in:
sajjadtalkhabi
2026-05-19 22:53:05 +03:30
parent c82701888a
commit 8990ceaddd
44 changed files with 2678 additions and 435 deletions
@@ -119,7 +119,7 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { offeredCourseSchema } from '@/features/admin/courses/schema'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
@@ -213,11 +213,11 @@ watch(existingCourse, (course) => {
if (course.image) image.value = { url: course.image }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
@@ -48,13 +48,35 @@
name="defaultTeacherId"
label="استاد"
:options="teacherOptions"
option-label="label"
option-label="name"
option-value="id"
:searchable="true"
:on-search="searchTeachers"
:error="errors.defaultTeacherId"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.defaultCapacity"
name="defaultCapacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.defaultCapacity"
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.sessionsCount"
name="sessionsCount"
label="تعداد جلسه"
inputmode="numeric"
:convert-digits="true"
:error="errors.sessionsCount"
@blur="validateAt('sessionsCount', form.sessionsCount)"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.prerequisites"
@@ -69,25 +91,36 @@
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.defaultCapacity"
name="defaultCapacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.defaultCapacity"
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
<SelectField
v-model="form.contentType"
name="contentType"
label="نوع فایل دوره"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
@change="onContentTypeChange"
/>
</div>
<div class="course-form__cell course-form__cell--third course-form__toggle-cell">
<ToggleSwitch v-model="form.isActiveByDefault" label="دوره فعال باشد" />
</div>
<div class="course-form__cell course-form__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات"
label="توضیحات دوره"
:row="5"
: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>
@@ -134,13 +167,14 @@ 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 ToggleSwitch from '@/components/form/ToggleSwitch.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 { useUploadTemporaryMutation } from '@/services/query/auth'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { courseTemplateSchema } from '@/features/admin/courses/schema'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT } from '@/enums'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminCourseTemplatesKeys,
@@ -155,23 +189,33 @@ const router = useRouter()
const queryClient = useQueryClient()
const courseId = computed(() => (route.params.id ? Number(route.params.id) : null))
const termId = computed(() => (route.query.termId ? Number(route.query.termId) : null))
const isEditMode = computed(() => !!courseId.value)
const form = ref({
title: '',
defaultTeacherId: '',
prerequisites: [],
defaultCapacity: '',
sessionsCount: '',
prerequisites: [],
contentType: '',
contentMediaId: null,
description: '',
imageId: null,
isActiveByDefault: false,
coverMediaId: null,
termId: termId.value,
})
const image = ref(null)
const contentFiles = ref([])
const schema = courseTemplateSchema
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const { validate, validateAt, errors } = useYup(schema)
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const { validate, validateAt, errors } = useYup(courseTemplateSchema)
const teacherSearch = ref('')
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
@@ -222,24 +266,37 @@ watch(existingCourse, (course) => {
form.value = {
title: course.title || '',
defaultTeacherId: teacher?.id || course.defaultTeacherId || '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
defaultCapacity: course.defaultCapacity ?? course.capacity ?? '',
sessionsCount: course.sessionsCount ?? '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
contentType: course.contentType || '',
contentMediaId: course.contentMediaId || null,
description: course.description || '',
imageId: course.imageId || null,
isActiveByDefault: course.isActiveByDefault ?? course.isActive ?? false,
coverMediaId: course.coverMediaId || null,
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,
},
]
}
if (course.image) image.value = { url: course.image }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
form.value.imageId = payload?.uploadId || payload?.id
image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id
} catch {
/* handled globally */
}
@@ -247,6 +304,38 @@ 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: 'content',
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 = useAddAdminCourseTemplateMutation()
const updateMutation = useUpdateAdminCourseTemplateMutation()
@@ -345,9 +434,14 @@ const onCancel = () => router.push({ name: 'admin-courses' })
}
}
&__toggle-cell {
display: flex;
align-items: center;
&__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);
}
&__divider {
@@ -164,7 +164,7 @@ const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuer
}
)
const templates = computed(() => templatesData.value?.data ?? [])
const templates = computed(() => templatesData.value?.data?.items ?? [])
const templatesPaginationMeta = computed(() => ({
page: templatesPagination.value.page,
perPage: templatesPagination.value.perPage,
+7 -3
View File
@@ -1,11 +1,15 @@
import { array, boolean, object, string } from 'yup'
import { array, boolean, mixed, number, object, string } from 'yup'
export const courseTemplateSchema = object().shape({
title: string().required().min(3).max(255),
defaultTeacherId: string().required(),
defaultCapacity: string().required(),
defaultTeacherId: mixed().required(),
defaultCapacity: number().required().min(1),
sessionsCount: number().required().min(1),
prerequisites: array().nullable().default([]),
contentType: string().oneOf(['video', 'voice', 'text']).required(),
contentMediaId: number().nullable().notRequired(),
description: string().nullable().notRequired(),
termId: string().nullable(),
})
export const offeredCourseSchema = object().shape({
@@ -236,7 +236,7 @@ import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import { sessionSchema } from '@/features/admin/sessions/schema'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
@@ -341,7 +341,7 @@ watch(existingSession, (session) => {
}
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
@@ -1,8 +1,8 @@
<template>
<div class="term-item">
<div class="term-item__main">
<div v-if="term.image" class="term-item__image">
<img :src="term.image" :alt="term.title" />
<div v-if="term.coverUrl" class="term-item__image">
<img :src="term.coverUrl" :alt="term.title" />
</div>
<div v-else class="term-item__image term-item__image--placeholder">
<SvgIcon name="book" :size="24" color="#bcbcbc" />
+22 -22
View File
@@ -42,20 +42,20 @@
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.startDate"
name="startDate"
v-model="form.startsAt"
name="startsAt"
label="تاریخ شروع"
:min="todayIso"
:error="errors.startDate"
:error="errors.startsAt"
/>
</div>
<div class="term-form__cell term-form__cell--third">
<DatePickerField
v-model="form.endDate"
name="endDate"
v-model="form.endsAt"
name="endsAt"
label="تاریخ پایان"
:min="todayIso"
:error="errors.endDate"
:error="errors.endsAt"
/>
</div>
<div class="term-form__cell term-form__cell--full">
@@ -112,8 +112,8 @@ import { termSchema } from '@/features/admin/terms/schema'
import LineTitleBlock from '@/components/LineTitleBlock.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 { useUploadTemporaryMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
@@ -134,17 +134,16 @@ const todayIso = new Date().toISOString()
const form = ref({
title: '',
startDate: '',
endDate: '',
description: '',
imageId: null,
isActive: true,
startsAt: '',
endsAt: '',
coverMediaId: null,
})
const image = ref(null)
const schema = termSchema
const { validate, validateAt, errors } = useYup(schema)
const { validate, validateAt, errors } = useYup(termSchema)
const { data: existingTerm } = useAdminTermQuery(termId, {
enabled: () => !!termId.value,
@@ -154,23 +153,24 @@ watch(existingTerm, (term) => {
if (!term) return
form.value = {
title: term.title || '',
startDate: term.startDate || '',
endDate: term.endDate || '',
description: term.description || '',
imageId: term.imageId || null,
isActive: term.isActive ?? true,
startsAt: term.startsAt || '',
endsAt: term.endsAt || '',
coverMediaId: term.coverMediaId || null,
}
if (term.image) image.value = { url: term.image }
if (term.coverUrl) image.value = { url: term.coverUrl }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'term' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'term' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
form.value.imageId = payload?.uploadId || payload?.id
image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id
} catch {
/* handled globally */
}
@@ -191,7 +191,7 @@ const onSubmit = async () => {
} else {
await addMutation.mutateAsync(payload)
}
await queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
await queryClient.resetQueries({ queryKey: adminTermsKeys.all })
router.push({ name: 'admin-terms' })
}
+5 -3
View File
@@ -1,10 +1,12 @@
import { array, mixed, object, string } from 'yup'
import { array, boolean, mixed, number, object, string } from 'yup'
export const termSchema = object().shape({
title: string().required().min(3),
startDate: string().required(),
endDate: string().required(),
description: string().nullable().notRequired(),
isActive: boolean().default(true),
startsAt: string().required(),
endsAt: string().required(),
coverMediaId: number().nullable().notRequired(),
})
export const addTermStudentSchema = object().shape({
@@ -72,11 +72,7 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import BaseButton from '@/components/BaseButton.vue'
import { CHANGEABLE_ROLES, ROLE_LABELS } from '@/enums'
import DropdownMenu from '@/components/DropdownMenu.vue'
import {
adminUsersKeys,
useAdminRolesListQuery,
useUpdateAdminUserRoleMutation,
} from '@/services/query/admin-users'
import { adminUsersKeys, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users'
defineProps({
users: { type: Array, required: true },
@@ -124,15 +120,6 @@ const onMoreClick = (event, user) => {
const roleMenuOpen = ref(false)
const roleMenuTrigger = ref(null)
const { data: rolesList = ref([]) } = useAdminRolesListQuery()
const roleIdByName = computed(() => {
const map = {}
;(rolesList.value ?? []).forEach((r) => {
if (r?.name) map[r.name] = r.id
})
return map
})
const updateRoleMutation = useUpdateAdminUserRoleMutation()
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: adminUsersKeys.all })
@@ -150,10 +137,11 @@ const onRoleClick = (event, user) => {
const onChangeRole = (targetRoleName) => {
const user = activeUser.value
if (!user) return
const roleId = roleIdByName.value[targetRoleName]
const payload = roleId ? { roleId } : { role: targetRoleName }
updateRoleMutation.mutate({ id: user.id, payload }, { onSuccess: invalidateUsers })
if (!user || !targetRoleName) return
updateRoleMutation.mutate(
{ id: user.id, payload: { roles: [targetRoleName] } },
{ onSuccess: invalidateUsers }
)
}
const roleMenuItems = computed(() => {
@@ -233,7 +233,7 @@ import { GENDER, MARITAL_STATUS, ROLE_LABELS } from '@/enums'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import PasswordField from '@/components/form/PasswordField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
@@ -348,7 +348,7 @@ watch(existingUser, (user) => {
}
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => {
try {
@@ -79,7 +79,7 @@ import useModal from '@/composables/useModal'
import { computed, onBeforeUnmount, ref } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.vue'
const props = defineProps({
@@ -123,7 +123,7 @@ const formattedTime = computed(() => {
return `${mm}:${ss}`
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const stopTracks = () => {
if (mediaStream) {
@@ -167,7 +167,7 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import TextareaField from '@/components/form/TextareaField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { GENDER, MARITAL_STATUS, STUDENT_REGISTRATION } from '@/enums'
@@ -242,7 +242,7 @@ watch(
}
)
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => {
try {
@@ -223,9 +223,9 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { useGetCitiesOfProvinceQuery, useGetProvincesQuery } from '@/services/query/common'
import {
authKeys,
useGetProfileQuery,
useGetMeQuery,
useUpdateProfileMutation,
useUploadTemporaryMutation,
useUploadMediaMutation,
} from '@/services/query/auth'
const router = useRouter()
@@ -291,7 +291,7 @@ watch(
}
)
const { data: profile } = useGetProfileQuery()
const { data: profile } = useGetMeQuery()
watch(profile, (user) => {
if (!user) return
@@ -315,7 +315,7 @@ watch(profile, (user) => {
if (user.avatarUrl) avatar.value = { url: user.avatarUrl }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onAvatarCropped = async (file) => {
try {
@@ -341,7 +341,7 @@ const onSubmit = async () => {
delete payload.passwordConfirmation
}
await updateMutation.mutateAsync(payload)
await queryClient.invalidateQueries({ queryKey: authKeys.profile() })
await queryClient.invalidateQueries({ queryKey: authKeys.me() })
toast.success('پروفایل با موفقیت به‌روز شد')
router.push({ name: 'student-dashboard' })
}