fix
Deploy banu-front / deploy (push) Successful in 1m19s

This commit is contained in:
sajjadtalkhabi
2026-06-05 14:41:38 +03:30
parent d42b7aaa7b
commit 1c665830b0
27 changed files with 565 additions and 617 deletions
@@ -7,7 +7,6 @@
</template>
</TextField>
<SelectField
v-if="showTermFilter"
v-model="form.termId"
name="termId"
label="ترم"
@@ -68,7 +67,6 @@ const props = defineProps({
type: Object,
default: () => ({ title: '', termId: '', status: '', fromDate: '', toDate: '' }),
},
showTermFilter: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
@@ -100,9 +98,7 @@ const statusOptions = [
const termFilters = ref({})
const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination, {
enabled: () => props.showTermFilter,
})
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const hasFilters = computed(() =>
@@ -20,7 +20,7 @@
<div class="course-details__grid">
<LineInfoBlock title="عنوان دوره" :desc="course.title || '—'" />
<LineInfoBlock title="تعداد جلسات" :numeric-desc="course.sessionsCount ?? 0" />
<LineInfoBlock title="پیش‌نیاز دوره" :desc="teacherName" />
<LineInfoBlock title="پیش‌نیاز دوره" :desc="prerequisiteCourses" />
</div>
</div>
@@ -99,11 +99,9 @@ const { data: course, isLoading } = useAdminCourseQuery(courseId, {
enabled: () => !!courseId.value,
})
const teacherName = computed(() => {
const t = course.value?.teacher
if (!t) return '—'
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
})
const prerequisiteCourses = computed(() =>
course.value.prerequisiteCourses.map((course_) => course_.title).join(' ,')
)
const sessionsFilters = computed(() => ({ courseId: courseId.value }))
const { pagination: sessionsPagination, setPage: setSessionsPage } = usePagination({
@@ -65,28 +65,30 @@
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.prerequisites"
name="prerequisites"
v-model="form.termId"
name="termId"
label="ترم"
:options="termOptions"
option-label="title"
option-value="id"
:error="errors.termId"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.prerequisiteCourseIds"
name="prerequisiteCourseIds"
label="دوره‌های پیش‌نیاز"
:options="prerequisiteOptions"
option-label="title"
option-value="id"
:multiple="true"
:searchable="true"
:on-search="searchPrerequisites"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.contentType"
name="contentType"
label="نوع فایل دوره"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
:disabled="!form.termId"
:on-search="searchPrerequisiteCourses"
/>
</div>
<div class="course-form__cell course-form__cell--full">
<TextareaField
v-model="form.description"
@@ -132,7 +134,6 @@
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue'
import { COURSE_CONTENT_TYPE } from '@/enums'
import { useRoute, useRouter } from 'vue-router'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query'
@@ -146,6 +147,7 @@ import { useUploadMediaMutation } from '@/services/query/auth'
import { courseSchema } from '@/features/admin/courses/schema'
import TextareaField from '@/components/form/TextareaField.vue'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminCoursesKeys,
@@ -167,8 +169,7 @@ const form = ref({
title: '',
teacherId: '',
capacity: '',
prerequisites: [],
contentType: '',
prerequisiteCourseIds: [],
description: '',
coverMediaId: null,
termId: termId.value,
@@ -176,11 +177,6 @@ const form = ref({
const image = ref(null)
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const { validate, validateAt, errors } = useYup(courseSchema)
const teacherSearch = ref('')
@@ -203,6 +199,11 @@ const teacherOptions = computed(() => {
}))
})
const termFilters = ref({})
const termPagination = ref({ page: 1, perPage: 100 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const prereqSearch = ref('')
const prereqFilters = computed(() => ({ title: prereqSearch.value }))
const prereqPagination = ref({ page: 1, perPage: 10 })
@@ -211,13 +212,14 @@ const selectedPrereqs = ref([])
const prerequisiteOptions = computed(() => {
const base = prereqsResponse.value?.data ?? []
const extras = selectedPrereqs.value.filter((p) => !base.some((b) => b.id === p.id))
return [...base, ...extras]
const merged = [...base, ...extras]
return courseId.value ? merged.filter((c) => c.id !== courseId.value) : merged
})
const searchTeachers = useDebounce((q) => {
teacherSearch.value = q || ''
}, 400)
const searchPrerequisites = useDebounce((q) => {
const searchPrerequisiteCourses = useDebounce((q) => {
prereqSearch.value = q || ''
}, 400)
@@ -225,25 +227,29 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
enabled: () => !!courseId.value,
})
watch(existingCourse, (course) => {
if (!course) return
const teacher = course.teacher
if (teacher) selectedTeacher.value = teacher
const prereqs = Array.isArray(course.prerequisites) ? course.prerequisites : []
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
watch(
existingCourse,
(course) => {
if (!course) return
const teacher = course.teacher
if (teacher) selectedTeacher.value = teacher
const prereqs = Array.isArray(course.prerequisiteCourseIds) ? course.prerequisiteCourseIds : []
selectedPrereqs.value = prereqs.map((p) => p.course || p).filter((c) => c?.id)
form.value = {
title: course.title || '',
teacherId: teacher?.id || course.teacherId || '',
capacity: course.capacity ?? '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
contentType: course.contentType || '',
description: course.description || '',
coverMediaId: course.coverMediaId || null,
termId: course.termId ?? termId.value,
}
if (course.coverUrl) image.value = { url: course.coverUrl }
})
form.value = {
title: course.title || '',
teacherId: teacher?.id || course.teacherId || '',
capacity: course.capacity ?? '',
prerequisiteCourseIds: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
contentType: course.contentType || '',
description: course.description || '',
coverMediaId: course.coverMediaId || null,
termId: course.termId ?? termId.value,
}
if (course.coverUrl) image.value = { url: course.coverUrl }
},
{ immediate: true }
)
const uploadMutation = useUploadMediaMutation()
@@ -10,19 +10,14 @@
</template>
</BoxedIconTitleBlock>
<CoursesFilters
v-model="currentFilters"
:show-term-filter="activeTab === 'offered'"
@apply="onFilterApply"
@reset="onFilterReset"
/>
<CoursesFilters v-model="offeredFilters" @apply="onFilterApply" @reset="onFilterReset" />
<TabsBlock :tabs="tabs" v-model="activeTab" @change-tab="onTabChange">
<template #templates>
<SkeletonLoaderBlock v-if="templatesPending" :rows="6" :cols-per-row="1" />
<div v-else-if="templates.length > 0">
<TabsBlock :tabs="tabs">
<template #offered>
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
<div v-else-if="offered.length > 0">
<CourseItem
v-for="course in templates"
v-for="course in offered"
:key="course.id"
:course="course"
@edit="onEditCourse"
@@ -31,36 +26,11 @@
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="متاسفیم" desc="دوره الگویی برای نمایش وجود ندارد." />
<PaginationBlock :pagination="templatesPaginationMeta" @update:page="setTemplatesPage" />
</template>
<template #offered>
<NoItems
v-if="!hasOfferedTerm"
title="ترم را انتخاب کنید"
desc="برای نمایش دوره‌های ارائه شده، ابتدا ترم را از فیلترها انتخاب کنید."
/>
<template v-else>
<SkeletonLoaderBlock v-if="offeredPending" :rows="6" :cols-per-row="1" />
<div v-else-if="offered.length > 0">
<CourseItem
v-for="course in offered"
:key="course.id"
:course="course"
@edit="onEditCourse"
@delete="onAskDelete"
@change-status="onChangeStatus"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
</template>
<NoItems v-else title="متاسفیم" desc="دوره‌ای برای نمایش وجود ندارد." />
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
</template>
</TabsBlock>
<AddOfferedCourseModal v-if="isModal('AddOfferedCourseModal')" />
<CourseDetailsModal v-if="isModal('CourseDetailsModal')" />
<AddSessionToCourseModal v-if="isModal('AddSessionToCourseModal')" />
</div>
@@ -81,7 +51,6 @@ import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import CoursesFilters from '@/features/admin/courses/components/CoursesFilters.vue'
import CourseDetailsModal from '@/features/admin/courses/components/modals/CourseDetailsModal.vue'
import AddOfferedCourseModal from '@/features/admin/courses/components/modals/AddOfferedCourseModal.vue'
import AddSessionToCourseModal from '@/features/admin/courses/components/modals/AddSessionToCourseModal.vue'
import {
adminCoursesKeys,
@@ -98,25 +67,10 @@ const { openModal, isModal } = useModal()
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
const onAdd = () => {
if (activeTab.value === 'templates') {
router.push({ name: 'admin-add-course' }).catch(() => {})
} else {
openModal('AddOfferedCourseModal', {
mode: 'add',
termId: routeTermId.value ?? undefined,
})
}
router.push({ name: 'admin-add-course' }).catch(() => {})
}
const tabs = [
{
name: 'templates',
label: 'دوره‌های الگو',
icon: 'list-bullets',
hasButton: true,
textButton: 'افزودن دوره الگوی جدید',
buttonAction: onAdd,
},
{
name: 'offered',
label: 'دوره‌های ارائه شده',
@@ -126,58 +80,21 @@ const tabs = [
buttonAction: onAdd,
},
]
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
const templateFilters = ref({})
const offeredFilters = ref({})
const currentFilters = computed({
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
set: (val) => {
if (activeTab.value === 'templates') templateFilters.value = val
else offeredFilters.value = val
},
})
const {
pagination: templatesPagination,
setPage: setTemplatesPage,
reset: resetTemplatesPagination,
} = usePagination({ page: 1, perPage: 10 })
const {
pagination: offeredPagination,
setPage: setOfferedPage,
reset: resetOfferedPagination,
} = usePagination({ page: 1, perPage: 10 })
const { data: templatesData, isLoading: templatesPending } = useAdminCoursesListQuery(
templateFilters,
templatesPagination,
{
enabled: () => activeTab.value === 'templates',
keepPreviousData: true,
}
)
const hasOfferedTerm = computed(() => !!offeredFilters.value.termId)
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
offeredFilters,
offeredPagination,
{
enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
keepPreviousData: true,
}
{ keepPreviousData: true }
)
const templates = computed(() => templatesData.value?.data ?? [])
const templatesPaginationMeta = computed(() => ({
page: templatesPagination.value.page,
perPage: templatesPagination.value.perPage,
...templatesData.value?.meta,
}))
const offered = computed(() => offeredData.value?.data ?? [])
const offeredPaginationMeta = computed(() => ({
page: offeredPagination.value.page,
@@ -185,14 +102,7 @@ const offeredPaginationMeta = computed(() => ({
...offeredData.value?.meta,
}))
const onTabChange = () => {
/* tab managed via v-model; no further action required */
}
const onFilterApply = () => {
if (activeTab.value === 'templates') resetTemplatesPagination()
else resetOfferedPagination()
}
const onFilterApply = () => resetOfferedPagination()
const onFilterReset = onFilterApply
const onEditCourse = (course) => {
@@ -200,16 +110,12 @@ const onEditCourse = (course) => {
}
const onShowDetails = (course) => {
openModal('CourseDetailsModal', {
id: course.id,
type: activeTab.value === 'templates' ? 'template' : 'offered',
})
openModal('CourseDetailsModal', { id: course.id })
}
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
const deleteMutation = useDeleteAdminCourseMutation()
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
const updateMutation = useUpdateAdminCourseMutation()
const onAskDelete = (course) => {
@@ -226,7 +132,6 @@ const onChangeStatus = ({ id, isActive }) => {
const syncRouteTermId = (termId) => {
if (!termId) return
activeTab.value = 'offered'
offeredFilters.value = { ...offeredFilters.value, termId }
resetOfferedPagination()
}
+3 -4
View File
@@ -3,12 +3,11 @@ import { array, boolean, mixed, number, object, string } from 'yup'
export const courseSchema = object().shape({
title: string().required().min(3).max(255),
teacherId: mixed().required(),
capacity: number().required().min(1),
prerequisites: array().nullable().default([]),
contentType: string().oneOf(['video', 'voice', 'text']).required(),
capacity: number().typeError('ظرفیت باید عدد باشد').required().min(1),
prerequisiteCourseIds: array().nullable().default([]),
contentMediaId: number().nullable().notRequired(),
description: string().nullable().notRequired(),
termId: mixed().nullable(),
termId: mixed().required(),
isActive: boolean().nullable().notRequired(),
})
@@ -15,10 +15,6 @@
<span class="session-item__pill-label">متعلق به دوره:</span>
<span class="session-item__pill-value">{{ session.course?.title || '—' }}</span>
</div>
<div class="session-item__pill">
<span class="session-item__pill-label">مدت جلسه:</span>
<span class="session-item__pill-value">{{ session.durationMinutes ?? '—' }}</span>
</div>
<div class="session-item__pill">
<span class="session-item__pill-label">نوع جلسه:</span>
<span class="session-item__pill-value">{{ sessionTypeLabel }}</span>
@@ -83,9 +79,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'show-details', 'show-attendance'])
const sessionTypeLabel = computed(
() => props.session.sessionTypeFa || SESSION_TYPE[props.session.sessionType] || '—'
)
const sessionTypeLabel = computed(() => SESSION_TYPE[props.session.type] || '—')
const hasUsedTerms = computed(() => (props.session.usedInTerms?.length ?? 0) > 0)
</script>
@@ -112,7 +112,7 @@ const isOnline = computed(() => session.value?.type === 'online')
const collectionToContentType = (collectionName) => {
if (collectionName === 'videos') return 'video'
if (collectionName === 'voices') return 'voice'
if (collectionName === 'voice') return 'voice'
if (collectionName === 'pdfs') return 'text'
return ''
}
@@ -235,6 +235,13 @@ const { data: existingSession } = useAdminSessionQuery(sessionId, {
enabled: () => !!sessionId.value,
})
const collectionToContentType = (collectionName) => {
if (collectionName === 'video') return 'video'
if (collectionName === 'voice') return 'voice'
if (collectionName === 'pdf') return 'text'
return ''
}
watch(
existingSession,
(session) => {
@@ -277,13 +284,6 @@ const purposeForContentType = (contentType) => {
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
@@ -7,12 +7,12 @@
</template>
</TextField>
<SelectField
v-model="form.roleId"
name="roleId"
v-model="form.role"
name="role"
label="نقش کاربر"
:options="roleOptions"
:options="ROLES"
option-label="label"
option-value="id"
option-value="value"
/>
<TextField
v-model="form.nationalCode"
@@ -66,18 +66,17 @@
</template>
<script setup>
import { ROLE_LABELS } from '@/enums'
import { ROLES } from '@/enums'
import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue'
import { useAdminRolesListQuery } from '@/services/query/admin-users'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({ name: '', nationalCode: '', phoneNumber: '', roleId: '' }),
default: () => ({ name: '', nationalCode: '', phoneNumber: '', role: '' }),
},
})
@@ -92,11 +91,6 @@ watch(
{ deep: true }
)
const { data: roles = ref([]) } = useAdminRolesListQuery()
const roleOptions = computed(() =>
(roles.value ?? []).map((r) => ({ ...r, label: ROLE_LABELS[r.name] || r.name }))
)
const hasFilters = computed(() =>
Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined)
)
@@ -107,7 +101,7 @@ const onSubmit = () => {
}
const onReset = () => {
form.value = { name: '', nationalCode: '', phoneNumber: '', roleId: '' }
form.value = { name: '', nationalCode: '', phoneNumber: '', role: '' }
emit('update:modelValue', { ...form.value })
emit('reset')
}
@@ -66,11 +66,11 @@
</template>
<script setup>
import { ROLES } from '@/enums'
import { computed, ref } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
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, useUpdateAdminUserRoleMutation } from '@/services/query/admin-users'
@@ -99,7 +99,8 @@ const roleName = (user) => {
const roleLabel = (user) => {
const name = roleName(user)
return name ? ROLE_LABELS[name] || name : ''
if (!name) return ''
return ROLES.find((r) => r.value === name)?.label || name
}
const menuOpen = ref(false)
@@ -146,11 +147,11 @@ const onChangeRole = (targetRoleName) => {
const roleMenuItems = computed(() => {
const current = activeUser.value ? roleName(activeUser.value) : null
return CHANGEABLE_ROLES.filter((r) => r !== current).map((r) => ({
key: `role-${r}`,
label: `تغییر به ${ROLE_LABELS[r]}`,
return ROLES.filter((r) => r.changeable && r.value !== current).map((r) => ({
key: `role-${r.value}`,
label: `تغییر به ${r.label}`,
icon: 'arrows-clockwise',
onClick: () => onChangeRole(r),
onClick: () => onChangeRole(r.value),
}))
})
@@ -73,7 +73,7 @@ const router = useRouter()
const queryClient = useQueryClient()
const { openModal, isModal } = useModal()
const filters = ref({ name: '', nationalCode: '', phoneNumber: '', roleId: '' })
const filters = ref({ name: '', nationalCode: '', phoneNumber: '', role: '' })
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
const { data, isLoading } = useAdminUsersListQuery(filters, pagination, {