fix
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
<div class="assignment-item__sub">
|
||||
<span class="assignment-item__sub-label">دوره:</span>
|
||||
<span class="assignment-item__sub-value">
|
||||
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }}
|
||||
{{ assignment.course?.title || assignment.courseTitle || '—' }}
|
||||
</span>
|
||||
<span class="assignment-item__dot">|</span>
|
||||
<span class="assignment-item__sub-label">جلسه:</span>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-model="form.courseTemplateId"
|
||||
name="courseTemplateId"
|
||||
label="دوره الگو"
|
||||
v-model="form.courseId"
|
||||
name="courseId"
|
||||
label="دوره"
|
||||
:options="templateOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
@@ -65,15 +65,15 @@ import TextField from '@/components/form/TextField.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
title: '',
|
||||
courseTemplateId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
@@ -85,7 +85,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
||||
|
||||
const emptyForm = () => ({
|
||||
title: '',
|
||||
courseTemplateId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
@@ -105,16 +105,13 @@ const todayIso = new Date().toISOString()
|
||||
const templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
||||
templateFilters,
|
||||
templatePagination
|
||||
)
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
||||
|
||||
const sessionSearch = ref('')
|
||||
const sessionListFilters = computed(() => ({
|
||||
title: sessionSearch.value,
|
||||
courseTemplateId: form.value.courseTemplateId || undefined,
|
||||
courseId: form.value.courseId || undefined,
|
||||
}))
|
||||
const sessionPagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination)
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-model="form.courseTemplateId"
|
||||
name="courseTemplateId"
|
||||
v-model="form.courseId"
|
||||
name="courseId"
|
||||
label="دوره مرتبط"
|
||||
:options="templateOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchTemplates"
|
||||
:error="errors.courseTemplateId"
|
||||
:error="errors.courseId"
|
||||
@update:model-value="onCourseChange"
|
||||
/>
|
||||
<SelectField
|
||||
@@ -40,7 +40,7 @@
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchSessions"
|
||||
:disabled="!form.courseTemplateId"
|
||||
:disabled="!form.courseId"
|
||||
:error="errors.sessionId"
|
||||
/>
|
||||
<DatePickerField
|
||||
@@ -131,8 +131,8 @@ import FileUploader from '@/components/form/FileUploader.vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { assignmentSchema } from '@/features/admin/assignments/schema'
|
||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
||||
import {
|
||||
adminAssignmentsKeys,
|
||||
useAddAdminAssignmentMutation,
|
||||
@@ -156,7 +156,7 @@ const priorityOptions = Object.entries(ASSIGNMENT_PRIORITY).map(([value, label])
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
courseTemplateId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
@@ -173,10 +173,7 @@ const { validate, validateAt, errors, resetErrors } = useYup(schema)
|
||||
const templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
||||
templateFilters,
|
||||
templatePagination
|
||||
)
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||
const selectedTemplate = ref(null)
|
||||
const templateOptions = computed(() => {
|
||||
const base = templatesResponse.value?.data ?? []
|
||||
@@ -189,11 +186,11 @@ const templateOptions = computed(() => {
|
||||
const sessionSearch = ref('')
|
||||
const sessionFilters = computed(() => ({
|
||||
title: sessionSearch.value,
|
||||
courseTemplateId: form.value.courseTemplateId,
|
||||
courseId: form.value.courseId,
|
||||
}))
|
||||
const sessionPagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination, {
|
||||
enabled: () => !!form.value.courseTemplateId,
|
||||
enabled: () => !!form.value.courseId,
|
||||
})
|
||||
const selectedSession = ref(null)
|
||||
const sessionOptions = computed(() => {
|
||||
@@ -212,7 +209,7 @@ const searchSessions = useDebounce((q) => {
|
||||
}, 400)
|
||||
|
||||
const onCourseChange = (value) => {
|
||||
form.value.courseTemplateId = value
|
||||
form.value.courseId = value
|
||||
form.value.sessionId = ''
|
||||
selectedSession.value = null
|
||||
}
|
||||
@@ -223,13 +220,13 @@ const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, {
|
||||
|
||||
watch(existingAssignment, (assignment) => {
|
||||
if (!assignment) return
|
||||
const tpl = assignment.courseTemplate
|
||||
const tpl = assignment.course
|
||||
const sessionEntity = assignment.session
|
||||
if (tpl) selectedTemplate.value = tpl
|
||||
if (sessionEntity) selectedSession.value = sessionEntity
|
||||
form.value = {
|
||||
title: assignment.title || '',
|
||||
courseTemplateId: tpl?.id || assignment.courseTemplateId || '',
|
||||
courseId: tpl?.id || assignment.courseId || '',
|
||||
sessionId: sessionEntity?.id || assignment.sessionId || '',
|
||||
startDate: assignment.startDate || '',
|
||||
endDate: assignment.endDate || '',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<p class="assignment-details__sub">
|
||||
<span>
|
||||
دوره:
|
||||
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }}
|
||||
{{ assignment.course?.title || assignment.courseTitle || '—' }}
|
||||
</span>
|
||||
<span class="assignment-details__sep">|</span>
|
||||
<span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
<div class="submission-details__assignment-meta">
|
||||
<span>ترم: {{ submission.termTitle || '—' }}</span>
|
||||
<span class="submission-details__sep">|</span>
|
||||
<span>دوره: {{ submission.courseTemplateTitle || '—' }}</span>
|
||||
<span>دوره: {{ submission.courseTitle || '—' }}</span>
|
||||
<span class="submission-details__sep">|</span>
|
||||
<span>جلسه: {{ submission.sessionTitle || '—' }}</span>
|
||||
<span class="submission-details__sep">|</span>
|
||||
|
||||
@@ -77,7 +77,7 @@ const { openModal, isModal } = useModal()
|
||||
|
||||
const filters = ref({
|
||||
title: '',
|
||||
courseTemplateId: '',
|
||||
courseId: '',
|
||||
sessionId: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { number, object, string } from 'yup'
|
||||
|
||||
export const assignmentSchema = object().shape({
|
||||
title: string().required().min(3),
|
||||
courseTemplateId: string().required(),
|
||||
courseId: string().required(),
|
||||
sessionId: string().required(),
|
||||
startDate: string().required(),
|
||||
endDate: string().required(),
|
||||
|
||||
@@ -21,18 +21,13 @@
|
||||
</div>
|
||||
|
||||
<div class="course-item__meta">
|
||||
<div v-if="course.term?.title" class="course-item__pill">
|
||||
<span class="course-item__pill-label">مختص به:</span>
|
||||
<span class="course-item__pill-value">{{ course.term.title }}</span>
|
||||
</div>
|
||||
<div class="course-item__pill">
|
||||
<span class="course-item__pill-label">ظرفیت:</span>
|
||||
<span class="course-item__pill-value">{{ capacity || '—' }}</span>
|
||||
</div>
|
||||
<div v-if="course.prerequisitesCount" class="course-item__pill">
|
||||
<span class="course-item__pill-label">پیشنیاز:</span>
|
||||
<span class="course-item__pill-value">{{ course.prerequisitesCount }}</span>
|
||||
</div>
|
||||
<Badge v-if="course.term?.title" label="مختص به:" :value="course.term.title" />
|
||||
<Badge label="ظرفیت:" :value="capacity || '—'" />
|
||||
<Badge
|
||||
v-if="course.prerequisitesCount"
|
||||
label="پیشنیاز:"
|
||||
:value="course.prerequisitesCount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!isActive" class="course-item__status">
|
||||
@@ -82,6 +77,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
@@ -94,14 +90,14 @@ const props = defineProps({
|
||||
const emit = defineEmits(['edit', 'delete', 'change-status', 'show-details'])
|
||||
|
||||
const teacherName = computed(() => {
|
||||
const t = props.course.teacher || props.course.defaultTeacher
|
||||
const t = props.course.teacher
|
||||
if (!t) return '—'
|
||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||
return t.name
|
||||
})
|
||||
|
||||
const capacity = computed(() => props.course.capacity ?? props.course.defaultCapacity ?? '')
|
||||
const capacity = computed(() => props.course.capacity ?? '')
|
||||
|
||||
const isActive = computed(() => props.course.isActive ?? props.course.isActiveByDefault ?? false)
|
||||
const isActive = computed(() => props.course.isActive ?? false)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -193,28 +189,7 @@ const isActive = computed(() => props.course.isActive ?? props.course.isActiveBy
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
flex: 1 1 33%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
background: rgba(107, 107, 107, 5%);
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.75rem;
|
||||
color: #848484;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__status {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<BasicModal width="95%" max-width="42rem" min-width="auto" :show-close-button="true">
|
||||
<BasicModal
|
||||
title="افزودن دانشجو"
|
||||
title-en="Add Student"
|
||||
width="95%"
|
||||
max-width="42rem"
|
||||
min-width="auto"
|
||||
:show-close-button="true"
|
||||
>
|
||||
<template #default="{ close }">
|
||||
<div class="add-course-student">
|
||||
<LineTitleBlock title="افزودن دانشجو" title-en="Add Student" />
|
||||
|
||||
<div class="add-course-student__search">
|
||||
<SvgIcon name="user" :size="18" color="var(--color-thd-gray)" />
|
||||
<input
|
||||
@@ -21,7 +26,7 @@
|
||||
<div v-for="user in users" :key="user.id" class="add-course-student__row">
|
||||
<div class="add-course-student__main">
|
||||
<div v-if="user.avatarUrl" class="add-course-student__avatar">
|
||||
<img :src="user.avatarUrl" :alt="userLabel(user)" />
|
||||
<img :src="user.avatarUrl" :alt="user?.name" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -30,7 +35,7 @@
|
||||
<SvgIcon name="user" :size="20" color="#bcbcbc" />
|
||||
</div>
|
||||
<div class="add-course-student__text">
|
||||
<p class="add-course-student__name">{{ userLabel(user) }}</p>
|
||||
<p class="add-course-student__name">{{ user?.name }}</p>
|
||||
<p class="add-course-student__meta">
|
||||
<span>{{ user.address?.province?.name || '—' }}</span>
|
||||
<span class="add-course-student__sep">،</span>
|
||||
@@ -98,11 +103,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { useAdminUsersListQuery } from '@/services/query/admin-users'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
adminCourseTemplatesKeys,
|
||||
useAddAdminTemplateStudentMutation,
|
||||
useAdminTemplateStudentsQuery,
|
||||
useRemoveAdminTemplateStudentMutation,
|
||||
} from '@/services/query/admin-course-templates'
|
||||
adminCoursesKeys,
|
||||
useAddAdminCourseStudentMutation,
|
||||
useAdminCourseStudentsQuery,
|
||||
useRemoveAdminCourseStudentMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
|
||||
defineOptions({ name: 'AddCourseStudentModal' })
|
||||
|
||||
@@ -110,7 +115,7 @@ const queryClient = useQueryClient()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('AddCourseStudentModal')?.data ?? {})
|
||||
const templateId = computed(() => modalData.value.templateId ?? null)
|
||||
const courseId = computed(() => modalData.value.courseId ?? null)
|
||||
|
||||
const searchInput = ref('')
|
||||
const searchQuery = ref('')
|
||||
@@ -122,22 +127,16 @@ const users = computed(() => usersResponse.value?.data ?? [])
|
||||
|
||||
const attachedFilters = computed(() => ({}))
|
||||
const attachedPagination = ref({ page: 1, perPage: 200 })
|
||||
const { data: attachedResponse } = useAdminTemplateStudentsQuery(
|
||||
templateId,
|
||||
const { data: attachedResponse } = useAdminCourseStudentsQuery(
|
||||
courseId,
|
||||
attachedFilters,
|
||||
attachedPagination,
|
||||
{ enabled: () => !!templateId.value }
|
||||
{ enabled: () => !!courseId.value }
|
||||
)
|
||||
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((u) => u.id)))
|
||||
|
||||
const isAttached = (id) => attachedIds.value.has(id)
|
||||
|
||||
const userLabel = (user) =>
|
||||
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
|
||||
user.fullName ||
|
||||
user.phoneNumber ||
|
||||
'—'
|
||||
|
||||
const onSearchInput = useDebounce(() => {
|
||||
searchQuery.value = searchInput.value || ''
|
||||
userPagination.value = { ...userPagination.value, page: 1 }
|
||||
@@ -145,17 +144,17 @@ const onSearchInput = useDebounce(() => {
|
||||
|
||||
const pendingId = ref(null)
|
||||
|
||||
const addMutation = useAddAdminTemplateStudentMutation()
|
||||
const removeMutation = useRemoveAdminTemplateStudentMutation()
|
||||
const addMutation = useAddAdminCourseStudentMutation()
|
||||
const removeMutation = useRemoveAdminCourseStudentMutation()
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
|
||||
const onAttach = async (user) => {
|
||||
if (!templateId.value) return
|
||||
if (!courseId.value) return
|
||||
pendingId.value = user.id
|
||||
try {
|
||||
await addMutation.mutateAsync({
|
||||
templateId: templateId.value,
|
||||
courseId: courseId.value,
|
||||
payload: { userIds: [user.id] },
|
||||
})
|
||||
invalidate()
|
||||
@@ -165,17 +164,17 @@ const onAttach = async (user) => {
|
||||
}
|
||||
|
||||
const onDetach = async (user) => {
|
||||
if (!templateId.value) return
|
||||
if (!courseId.value) return
|
||||
pendingId.value = user.id
|
||||
try {
|
||||
await removeMutation.mutateAsync({ templateId: templateId.value, userId: user.id })
|
||||
await removeMutation.mutateAsync({ courseId: courseId.value, userId: user.id })
|
||||
invalidate()
|
||||
} finally {
|
||||
pendingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(templateId, () => {
|
||||
watch(courseId, () => {
|
||||
searchInput.value = ''
|
||||
searchQuery.value = ''
|
||||
pendingId.value = null
|
||||
|
||||
@@ -122,11 +122,11 @@ 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 { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
||||
import {
|
||||
adminCoursesKeys,
|
||||
useAddAdminCourseMutation,
|
||||
useAdminCourseQuery,
|
||||
useAdminCoursesListQuery,
|
||||
useUpdateAdminCourseMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
|
||||
@@ -139,19 +139,24 @@ 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: '',
|
||||
termId: presetTermId.value,
|
||||
templateId: '',
|
||||
title: '',
|
||||
capacity: '',
|
||||
imageId: null,
|
||||
isActive: false,
|
||||
})
|
||||
|
||||
watch(presetTermId, (val) => {
|
||||
if (val && !form.value.termId) form.value.termId = val
|
||||
})
|
||||
const image = ref(null)
|
||||
|
||||
const schema = offeredCourseSchema
|
||||
@@ -172,12 +177,9 @@ const termOptions = computed(() => {
|
||||
})
|
||||
|
||||
const templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value, termId: 'null' }))
|
||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
||||
templateFilters,
|
||||
templatePagination
|
||||
)
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||
const selectedTemplate = ref(null)
|
||||
const templateOptions = computed(() => {
|
||||
const base = templatesResponse.value?.data ?? []
|
||||
@@ -201,10 +203,9 @@ const { data: existingCourse } = useAdminCourseQuery(courseId, {
|
||||
watch(existingCourse, (course) => {
|
||||
if (!course) return
|
||||
if (course.term) selectedTerm.value = course.term
|
||||
if (course.template) selectedTemplate.value = course.template
|
||||
form.value = {
|
||||
termId: course.term?.id || course.termId || '',
|
||||
templateId: course.template?.id || course.templateId || '',
|
||||
templateId: '',
|
||||
title: course.title || '',
|
||||
capacity: course.capacity ?? '',
|
||||
imageId: course.imageId || null,
|
||||
@@ -237,10 +238,11 @@ const submitting = computed(() => addMutation.isPending.value || updateMutation.
|
||||
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 })
|
||||
await updateMutation.mutateAsync({ id: courseId.value, payload: submitPayload })
|
||||
} else {
|
||||
await addMutation.mutateAsync(payload)
|
||||
await addMutation.mutateAsync(submitPayload)
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
resetErrors()
|
||||
|
||||
@@ -95,11 +95,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
adminCourseTemplatesKeys,
|
||||
useAdminTemplateSessionsQuery,
|
||||
useAttachAdminTemplateSessionMutation,
|
||||
useDetachAdminTemplateSessionMutation,
|
||||
} from '@/services/query/admin-course-templates'
|
||||
adminCoursesKeys,
|
||||
useAdminCourseSessionsQuery,
|
||||
useAttachAdminCourseSessionMutation,
|
||||
useDetachAdminCourseSessionMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
|
||||
defineOptions({ name: 'AddSessionToCourseModal' })
|
||||
|
||||
@@ -107,7 +107,7 @@ const queryClient = useQueryClient()
|
||||
const { getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('AddSessionToCourseModal')?.data ?? {})
|
||||
const templateId = computed(() => modalData.value.templateId ?? null)
|
||||
const courseId = computed(() => modalData.value.courseId ?? null)
|
||||
|
||||
const searchInput = ref('')
|
||||
const searchQuery = ref('')
|
||||
@@ -122,18 +122,18 @@ const sessions = computed(() => sessionsResponse.value?.data ?? [])
|
||||
|
||||
const attachedFilters = computed(() => ({}))
|
||||
const attachedPagination = ref({ page: 1, perPage: 200 })
|
||||
const { data: attachedResponse } = useAdminTemplateSessionsQuery(
|
||||
templateId,
|
||||
const { data: attachedResponse } = useAdminCourseSessionsQuery(
|
||||
courseId,
|
||||
attachedFilters,
|
||||
attachedPagination,
|
||||
{ enabled: () => !!templateId.value }
|
||||
{ enabled: () => !!courseId.value }
|
||||
)
|
||||
const attachedIds = computed(() => new Set((attachedResponse.value?.data ?? []).map((s) => s.id)))
|
||||
|
||||
const isAttached = (id) => attachedIds.value.has(id)
|
||||
|
||||
const teacherName = (session) => {
|
||||
const t = session.teacher || session.defaultTeacher
|
||||
const t = session.teacher
|
||||
if (!t) return '—'
|
||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||
}
|
||||
@@ -145,17 +145,17 @@ const onSearchInput = useDebounce(() => {
|
||||
|
||||
const pendingId = ref(null)
|
||||
|
||||
const attachMutation = useAttachAdminTemplateSessionMutation()
|
||||
const detachMutation = useDetachAdminTemplateSessionMutation()
|
||||
const attachMutation = useAttachAdminCourseSessionMutation()
|
||||
const detachMutation = useDetachAdminCourseSessionMutation()
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
|
||||
const onAttach = async (session) => {
|
||||
if (!templateId.value) return
|
||||
if (!courseId.value) return
|
||||
pendingId.value = session.id
|
||||
try {
|
||||
await attachMutation.mutateAsync({
|
||||
templateId: templateId.value,
|
||||
courseId: courseId.value,
|
||||
payload: { sessionIds: [session.id] },
|
||||
})
|
||||
invalidate()
|
||||
@@ -165,17 +165,17 @@ const onAttach = async (session) => {
|
||||
}
|
||||
|
||||
const onDetach = async (session) => {
|
||||
if (!templateId.value) return
|
||||
if (!courseId.value) return
|
||||
pendingId.value = session.id
|
||||
try {
|
||||
await detachMutation.mutateAsync({ templateId: templateId.value, sessionId: session.id })
|
||||
await detachMutation.mutateAsync({ courseId: courseId.value, sessionId: session.id })
|
||||
invalidate()
|
||||
} finally {
|
||||
pendingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(templateId, () => {
|
||||
watch(courseId, () => {
|
||||
searchInput.value = ''
|
||||
searchQuery.value = ''
|
||||
pendingId.value = null
|
||||
|
||||
@@ -166,12 +166,12 @@ import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import CourseSessionItem from '@/features/admin/courses/components/CourseSessionItem.vue'
|
||||
import {
|
||||
adminCourseTemplatesKeys,
|
||||
useAdminCourseTemplateQuery,
|
||||
useAdminTemplateSessionsQuery,
|
||||
useAdminTemplateStudentsQuery,
|
||||
useRemoveAdminTemplateStudentMutation,
|
||||
} from '@/services/query/admin-course-templates'
|
||||
adminCoursesKeys,
|
||||
useAdminCourseQuery,
|
||||
useAdminCourseSessionsQuery,
|
||||
useAdminCourseStudentsQuery,
|
||||
useRemoveAdminCourseStudentMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
|
||||
defineOptions({ name: 'CourseDetailsModal' })
|
||||
|
||||
@@ -179,14 +179,14 @@ const queryClient = useQueryClient()
|
||||
const { openModal, getModal } = useModal()
|
||||
|
||||
const modalData = computed(() => getModal('CourseDetailsModal')?.data ?? {})
|
||||
const templateId = computed(() => modalData.value.id ?? null)
|
||||
const courseId = computed(() => modalData.value.id ?? null)
|
||||
|
||||
const { data: course, isLoading } = useAdminCourseTemplateQuery(templateId, {
|
||||
enabled: () => !!templateId.value,
|
||||
const { data: course, isLoading } = useAdminCourseQuery(courseId, {
|
||||
enabled: () => !!courseId.value,
|
||||
})
|
||||
|
||||
const teacherName = computed(() => {
|
||||
const t = course.value?.defaultTeacher || course.value?.teacher
|
||||
const t = course.value?.teacher
|
||||
if (!t) return '—'
|
||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||
})
|
||||
@@ -203,12 +203,12 @@ const { pagination: sessionsPagination, setPage: setSessionsPage } = usePaginati
|
||||
perPage: 10,
|
||||
})
|
||||
|
||||
const { data: sessionsData, isLoading: sessionsPending } = useAdminTemplateSessionsQuery(
|
||||
templateId,
|
||||
const { data: sessionsData, isLoading: sessionsPending } = useAdminCourseSessionsQuery(
|
||||
courseId,
|
||||
sessionsFilters,
|
||||
sessionsPagination,
|
||||
{
|
||||
enabled: () => !!templateId.value && activeTab.value === 'sessions',
|
||||
enabled: () => !!courseId.value && activeTab.value === 'sessions',
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
@@ -226,12 +226,12 @@ const { pagination: studentsPagination, setPage: setStudentsPage } = usePaginati
|
||||
perPage: 10,
|
||||
})
|
||||
|
||||
const { data: studentsData, isLoading: studentsPending } = useAdminTemplateStudentsQuery(
|
||||
templateId,
|
||||
const { data: studentsData, isLoading: studentsPending } = useAdminCourseStudentsQuery(
|
||||
courseId,
|
||||
studentsFilters,
|
||||
studentsPagination,
|
||||
{
|
||||
enabled: () => !!templateId.value && activeTab.value === 'students',
|
||||
enabled: () => !!courseId.value && activeTab.value === 'students',
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
@@ -249,16 +249,16 @@ const studentName = (student) =>
|
||||
student.phoneNumber ||
|
||||
'—'
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
|
||||
const removeStudentMutation = useRemoveAdminTemplateStudentMutation()
|
||||
const removeStudentMutation = useRemoveAdminCourseStudentMutation()
|
||||
|
||||
const onOpenAddSession = () => {
|
||||
openModal('AddSessionToCourseModal', { templateId: templateId.value })
|
||||
openModal('AddSessionToCourseModal', { courseId: courseId.value })
|
||||
}
|
||||
|
||||
const onOpenAddStudent = () => {
|
||||
openModal('AddCourseStudentModal', { templateId: templateId.value })
|
||||
openModal('AddCourseStudentModal', { courseId: courseId.value })
|
||||
}
|
||||
|
||||
const onAskRemoveStudent = (student) => {
|
||||
@@ -267,7 +267,7 @@ const onAskRemoveStudent = (student) => {
|
||||
message: `آیا از حذف <strong>${studentName(student)}</strong> از این دوره اطمینان دارید؟`,
|
||||
onConfirm: () =>
|
||||
removeStudentMutation.mutate(
|
||||
{ templateId: templateId.value, userId: student.id },
|
||||
{ courseId: courseId.value, userId: student.id },
|
||||
{ onSuccess: invalidate }
|
||||
),
|
||||
})
|
||||
|
||||
+30
-31
@@ -2,11 +2,9 @@
|
||||
<div class="course-form">
|
||||
<BoxedIconTitleBlock
|
||||
class="course-form__heading"
|
||||
:title="isEditMode ? 'ویرایش دوره الگو' : 'افزودن دوره الگوی جدید'"
|
||||
:title="isEditMode ? 'ویرایش دوره' : 'افزودن دوره جدید'"
|
||||
:desc="
|
||||
isEditMode
|
||||
? 'اطلاعات دوره الگو را بهروز کنید'
|
||||
: 'در این قسمت میتوانید دوره الگوی جدید اضافه کنید'
|
||||
isEditMode ? 'اطلاعات دوره را بهروز کنید' : 'در این قسمت میتوانید دوره جدید اضافه کنید'
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -44,26 +42,26 @@
|
||||
</div>
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.defaultTeacherId"
|
||||
name="defaultTeacherId"
|
||||
v-model="form.teacherId"
|
||||
name="teacherId"
|
||||
label="استاد"
|
||||
:options="teacherOptions"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchTeachers"
|
||||
:error="errors.defaultTeacherId"
|
||||
:error="errors.teacherId"
|
||||
/>
|
||||
</div>
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
<TextField
|
||||
v-model="form.defaultCapacity"
|
||||
name="defaultCapacity"
|
||||
v-model="form.capacity"
|
||||
name="capacity"
|
||||
label="ظرفیت (نفر)"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.defaultCapacity"
|
||||
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
|
||||
:error="errors.capacity"
|
||||
@blur="validateAt('capacity', form.capacity)"
|
||||
/>
|
||||
</div>
|
||||
<div class="course-form__cell course-form__cell--third">
|
||||
@@ -171,18 +169,18 @@ 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 { courseSchema } from '@/features/admin/courses/schema'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
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,
|
||||
useAddAdminCourseTemplateMutation,
|
||||
useAdminCourseTemplateQuery,
|
||||
useAdminCourseTemplatesListQuery,
|
||||
useUpdateAdminCourseTemplateMutation,
|
||||
} from '@/services/query/admin-course-templates'
|
||||
adminCoursesKeys,
|
||||
useAddAdminCourseMutation,
|
||||
useAdminCourseQuery,
|
||||
useAdminCoursesListQuery,
|
||||
useUpdateAdminCourseMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -194,8 +192,8 @@ const isEditMode = computed(() => !!courseId.value)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
defaultTeacherId: '',
|
||||
defaultCapacity: '',
|
||||
teacherId: '',
|
||||
capacity: '',
|
||||
sessionsCount: '',
|
||||
prerequisites: [],
|
||||
contentType: '',
|
||||
@@ -215,7 +213,7 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
|
||||
|
||||
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
||||
|
||||
const { validate, validateAt, errors } = useYup(courseTemplateSchema)
|
||||
const { validate, validateAt, errors } = useYup(courseSchema)
|
||||
|
||||
const teacherSearch = ref('')
|
||||
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
|
||||
@@ -237,7 +235,7 @@ const teacherOptions = computed(() => {
|
||||
const prereqSearch = ref('')
|
||||
const prereqFilters = computed(() => ({ title: prereqSearch.value }))
|
||||
const prereqPagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: prereqsResponse } = useAdminCourseTemplatesListQuery(prereqFilters, prereqPagination)
|
||||
const { data: prereqsResponse } = useAdminCoursesListQuery(prereqFilters, prereqPagination)
|
||||
const selectedPrereqs = ref([])
|
||||
const prerequisiteOptions = computed(() => {
|
||||
const base = prereqsResponse.value?.data ?? []
|
||||
@@ -252,28 +250,28 @@ const searchPrerequisites = useDebounce((q) => {
|
||||
prereqSearch.value = q || ''
|
||||
}, 400)
|
||||
|
||||
const { data: existingCourse } = useAdminCourseTemplateQuery(courseId, {
|
||||
const { data: existingCourse } = useAdminCourseQuery(courseId, {
|
||||
enabled: () => !!courseId.value,
|
||||
})
|
||||
|
||||
watch(existingCourse, (course) => {
|
||||
if (!course) return
|
||||
const teacher = course.defaultTeacher || course.teacher
|
||||
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)
|
||||
|
||||
form.value = {
|
||||
title: course.title || '',
|
||||
defaultTeacherId: teacher?.id || course.defaultTeacherId || '',
|
||||
defaultCapacity: course.defaultCapacity ?? course.capacity ?? '',
|
||||
teacherId: teacher?.id || course.teacherId || '',
|
||||
capacity: 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 || '',
|
||||
coverMediaId: course.coverMediaId || null,
|
||||
termId: termId.value,
|
||||
termId: course.termId ?? termId.value,
|
||||
}
|
||||
if (course.coverUrl) image.value = { url: course.coverUrl }
|
||||
if (course.contentMedia) {
|
||||
@@ -315,7 +313,7 @@ const onContentSelect = async (files) => {
|
||||
try {
|
||||
const formData = objectToFormData({
|
||||
file,
|
||||
purpose: 'content',
|
||||
purpose: 'voice',
|
||||
context: 'course',
|
||||
type: form.value.contentType,
|
||||
})
|
||||
@@ -336,8 +334,8 @@ const onContentRemove = () => {
|
||||
|
||||
const onContentError = (msg) => toast.error(msg)
|
||||
|
||||
const addMutation = useAddAdminCourseTemplateMutation()
|
||||
const updateMutation = useUpdateAdminCourseTemplateMutation()
|
||||
const addMutation = useAddAdminCourseMutation()
|
||||
const updateMutation = useUpdateAdminCourseMutation()
|
||||
|
||||
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
||||
|
||||
@@ -349,7 +347,7 @@ const onSubmit = async () => {
|
||||
} else {
|
||||
await addMutation.mutateAsync(payload)
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
||||
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
router.push({ name: 'admin-courses' })
|
||||
}
|
||||
|
||||
@@ -452,6 +450,7 @@ const onCancel = () => router.push({ name: 'admin-courses' })
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
v-for="course in templates"
|
||||
:key="course.id"
|
||||
:course="course"
|
||||
@edit="onEditTemplate"
|
||||
@delete="onAskDeleteTemplate"
|
||||
@change-status="onChangeTemplateStatus"
|
||||
@edit="onEditCourse"
|
||||
@delete="onAskDelete"
|
||||
@change-status="onChangeStatus"
|
||||
@show-details="onShowDetails"
|
||||
/>
|
||||
</div>
|
||||
@@ -36,20 +36,27 @@
|
||||
</template>
|
||||
|
||||
<template #offered>
|
||||
<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="onEditOffered"
|
||||
@delete="onAskDeleteOffered"
|
||||
@change-status="onChangeOfferedStatus"
|
||||
@show-details="onShowDetails"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
||||
<PaginationBlock :pagination="offeredPaginationMeta" @update:page="setOfferedPage" />
|
||||
<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>
|
||||
</template>
|
||||
</TabsBlock>
|
||||
|
||||
@@ -61,11 +68,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import TabsBlock from '@/components/blocks/TabsBlock.vue'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
@@ -81,25 +88,25 @@ import AddSessionToCourseModal from '@/features/admin/courses/components/modals/
|
||||
import {
|
||||
adminCoursesKeys,
|
||||
useAdminCoursesListQuery,
|
||||
useChangeAdminCourseStatusMutation,
|
||||
useDeleteAdminCourseMutation,
|
||||
useUpdateAdminCourseMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
import {
|
||||
adminCourseTemplatesKeys,
|
||||
useAdminCourseTemplatesListQuery,
|
||||
useChangeAdminCourseTemplateStatusMutation,
|
||||
useDeleteAdminCourseTemplateMutation,
|
||||
} from '@/services/query/admin-course-templates'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
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-template' }).catch(() => {})
|
||||
router.push({ name: 'admin-add-course' }).catch(() => {})
|
||||
} else {
|
||||
openModal('AddOfferedCourseModal', { mode: 'add' })
|
||||
openModal('AddOfferedCourseModal', {
|
||||
mode: 'add',
|
||||
termId: routeTermId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,10 +128,16 @@ const tabs = [
|
||||
buttonAction: onAdd,
|
||||
},
|
||||
]
|
||||
const activeTab = ref('templates')
|
||||
const activeTab = ref(routeTermId.value ? 'offered' : 'templates')
|
||||
|
||||
const templateFilters = ref({ title: '', status: '', fromDate: '', toDate: '' })
|
||||
const offeredFilters = ref({ title: '', termId: '', status: '', fromDate: '', toDate: '' })
|
||||
const offeredFilters = ref({
|
||||
title: '',
|
||||
termId: routeTermId.value ?? '',
|
||||
status: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
})
|
||||
|
||||
const currentFilters = computed({
|
||||
get: () => (activeTab.value === 'templates' ? templateFilters.value : offeredFilters.value),
|
||||
@@ -146,7 +159,7 @@ const {
|
||||
reset: resetOfferedPagination,
|
||||
} = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data: templatesData, isLoading: templatesPending } = useAdminCourseTemplatesListQuery(
|
||||
const { data: templatesData, isLoading: templatesPending } = useAdminCoursesListQuery(
|
||||
templateFilters,
|
||||
templatesPagination,
|
||||
{
|
||||
@@ -155,16 +168,18 @@ const { data: templatesData, isLoading: templatesPending } = useAdminCourseTempl
|
||||
}
|
||||
)
|
||||
|
||||
const hasOfferedTerm = computed(() => !!offeredFilters.value.termId)
|
||||
|
||||
const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuery(
|
||||
offeredFilters,
|
||||
offeredPagination,
|
||||
{
|
||||
enabled: () => activeTab.value === 'offered',
|
||||
enabled: () => activeTab.value === 'offered' && hasOfferedTerm.value,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const templates = computed(() => templatesData.value?.data?.items ?? [])
|
||||
const templates = computed(() => templatesData.value?.data ?? [])
|
||||
const templatesPaginationMeta = computed(() => ({
|
||||
page: templatesPagination.value.page,
|
||||
perPage: templatesPagination.value.perPage,
|
||||
@@ -188,12 +203,8 @@ const onFilterApply = () => {
|
||||
}
|
||||
const onFilterReset = onFilterApply
|
||||
|
||||
const onEditTemplate = (course) => {
|
||||
router.push({ name: 'admin-edit-course-template', params: { id: course.id } }).catch(() => {})
|
||||
}
|
||||
|
||||
const onEditOffered = (course) => {
|
||||
openModal('AddOfferedCourseModal', { mode: 'edit', courseId: course.id })
|
||||
const onEditCourse = (course) => {
|
||||
router.push({ name: 'admin-edit-course', params: { id: course.id } }).catch(() => {})
|
||||
}
|
||||
|
||||
const onShowDetails = (course) => {
|
||||
@@ -203,45 +214,33 @@ const onShowDetails = (course) => {
|
||||
})
|
||||
}
|
||||
|
||||
const invalidateTemplates = () =>
|
||||
queryClient.invalidateQueries({ queryKey: adminCourseTemplatesKeys.all })
|
||||
const invalidateOffered = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
|
||||
const deleteTemplateMutation = useDeleteAdminCourseTemplateMutation()
|
||||
const changeTemplateStatusMutation = useChangeAdminCourseTemplateStatusMutation()
|
||||
const deleteMutation = useDeleteAdminCourseMutation()
|
||||
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
|
||||
const updateMutation = useUpdateAdminCourseMutation()
|
||||
|
||||
const deleteOfferedMutation = useDeleteAdminCourseMutation()
|
||||
const changeOfferedStatusMutation = useChangeAdminCourseStatusMutation()
|
||||
|
||||
const onAskDeleteTemplate = (course) => {
|
||||
const onAskDelete = (course) => {
|
||||
openModal('ConfirmModal', {
|
||||
title: `حذف ${course.title}`,
|
||||
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که میخواهید <strong>${course.title}</strong> را حذف کنید؟`,
|
||||
onConfirm: () => deleteTemplateMutation.mutate(course.id, { onSuccess: invalidateTemplates }),
|
||||
onConfirm: () => deleteMutation.mutate(course.id, { onSuccess: invalidate }),
|
||||
})
|
||||
}
|
||||
|
||||
const onAskDeleteOffered = (course) => {
|
||||
openModal('ConfirmModal', {
|
||||
title: `حذف ${course.title}`,
|
||||
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا مطمئن هستید که میخواهید <strong>${course.title}</strong> را حذف کنید؟`,
|
||||
onConfirm: () => deleteOfferedMutation.mutate(course.id, { onSuccess: invalidateOffered }),
|
||||
})
|
||||
const onChangeStatus = ({ id, isActive }) => {
|
||||
updateMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
|
||||
}
|
||||
|
||||
const onChangeTemplateStatus = ({ id, isActive }) => {
|
||||
changeTemplateStatusMutation.mutate(
|
||||
{ id, payload: { isActiveByDefault: isActive } },
|
||||
{ onSuccess: invalidateTemplates }
|
||||
)
|
||||
const syncRouteTermId = (termId) => {
|
||||
if (!termId) return
|
||||
activeTab.value = 'offered'
|
||||
offeredFilters.value = { ...offeredFilters.value, termId }
|
||||
resetOfferedPagination()
|
||||
}
|
||||
|
||||
const onChangeOfferedStatus = ({ id, isActive }) => {
|
||||
changeOfferedStatusMutation.mutate(
|
||||
{ id, payload: { isActive } },
|
||||
{ onSuccess: invalidateOffered }
|
||||
)
|
||||
}
|
||||
onMounted(() => syncRouteTermId(routeTermId.value))
|
||||
watch(routeTermId, (val) => syncRouteTermId(val))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { array, boolean, mixed, number, object, string } from 'yup'
|
||||
|
||||
export const courseTemplateSchema = object().shape({
|
||||
export const courseSchema = object().shape({
|
||||
title: string().required().min(3).max(255),
|
||||
defaultTeacherId: mixed().required(),
|
||||
defaultCapacity: number().required().min(1),
|
||||
teacherId: mixed().required(),
|
||||
capacity: 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(),
|
||||
termId: mixed().nullable(),
|
||||
isActive: boolean().nullable().notRequired(),
|
||||
})
|
||||
|
||||
export const offeredCourseSchema = object().shape({
|
||||
termId: string().required(),
|
||||
templateId: string().required(),
|
||||
title: string().required(),
|
||||
capacity: string().required(),
|
||||
imageId: string().nullable().notRequired(),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="exam-item__sub">
|
||||
<span class="exam-item__sub-label">دوره:</span>
|
||||
<span class="exam-item__sub-value">
|
||||
{{ exam.courseTemplate?.title || exam.courseTemplateTitle || '—' }}
|
||||
{{ exam.course?.title || exam.courseTitle || '—' }}
|
||||
</span>
|
||||
<span class="exam-item__dot">|</span>
|
||||
<span class="exam-item__sub-label">جلسه:</span>
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
v-for="(question, questionIndex) in questions"
|
||||
:key="question.id"
|
||||
class="exam-question-builder__card"
|
||||
:class="{ 'exam-question-builder__card--readonly': isLocked(question) }"
|
||||
>
|
||||
<div class="exam-question-builder__head">
|
||||
<p class="exam-question-builder__title">سوال شماره {{ questionIndex + 1 }}</p>
|
||||
<p class="exam-question-builder__title">
|
||||
سوال شماره {{ questionIndex + 1 }}
|
||||
<span v-if="isLocked(question)" class="exam-question-builder__lock">(ذخیره شده)</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || isLocked(question)"
|
||||
class="exam-question-builder__remove"
|
||||
aria-label="حذف سوال"
|
||||
@click="removeQuestion(question.id)"
|
||||
@@ -22,24 +26,24 @@
|
||||
<div class="exam-question-builder__col exam-question-builder__col--main">
|
||||
<label class="exam-question-builder__label">متن سوال</label>
|
||||
<textarea
|
||||
:value="question.title"
|
||||
:disabled="disabled"
|
||||
:value="question.questionText"
|
||||
:disabled="disabled || isLocked(question)"
|
||||
rows="1"
|
||||
placeholder="لطفا سوال خود را وارد کنید"
|
||||
class="exam-question-builder__textarea"
|
||||
@input="updateQuestion(question.id, { title: $event.target.value })"
|
||||
@input="updateQuestion(question.id, { questionText: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
<div class="exam-question-builder__col exam-question-builder__col--score">
|
||||
<label class="exam-question-builder__label">بارم نمره</label>
|
||||
<label class="exam-question-builder__label">ترتیب</label>
|
||||
<input
|
||||
:value="question.score"
|
||||
:disabled="disabled"
|
||||
:value="question.position"
|
||||
:disabled="disabled || isLocked(question)"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="15"
|
||||
placeholder="1"
|
||||
class="exam-question-builder__input"
|
||||
@input="updateQuestion(question.id, { score: $event.target.value })"
|
||||
@input="updateQuestion(question.id, { position: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,31 +53,31 @@
|
||||
<p class="exam-question-builder__label">گزینهها</p>
|
||||
<div class="exam-question-builder__answers-list">
|
||||
<div
|
||||
v-for="answer in question.answers"
|
||||
:key="answer.id"
|
||||
v-for="option in question.options"
|
||||
:key="option.id"
|
||||
class="exam-question-builder__answer"
|
||||
>
|
||||
<input
|
||||
:value="answer.title"
|
||||
:disabled="disabled"
|
||||
:value="option.optionText"
|
||||
:disabled="disabled || isLocked(question)"
|
||||
type="text"
|
||||
placeholder="متن گزینه"
|
||||
class="exam-question-builder__answer-input"
|
||||
@input="updateOption(question.id, answer.id, $event.target.value)"
|
||||
@input="updateOption(question.id, option.id, $event.target.value)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || isLocked(question)"
|
||||
class="exam-question-builder__answer-remove"
|
||||
aria-label="حذف گزینه"
|
||||
@click="removeOption(question.id, answer.id)"
|
||||
@click="removeOption(question.id, option.id)"
|
||||
>
|
||||
<SvgIcon name="close" :size="14" color="#b1b1b1" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || isLocked(question)"
|
||||
class="exam-question-builder__answer-add"
|
||||
aria-label="افزودن گزینه"
|
||||
@click="addOption(question.id)"
|
||||
@@ -85,16 +89,14 @@
|
||||
|
||||
<div class="exam-question-builder__correct">
|
||||
<SelectField
|
||||
:model-value="question.correctAnswerId"
|
||||
:model-value="correctOptionId(question)"
|
||||
:name="`correctAnswer-${question.id}`"
|
||||
:options="correctAnswerOptions(question)"
|
||||
:options="correctOptions(question)"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
label="گزینه صحیح"
|
||||
:disabled="disabled || question.answers.length === 0"
|
||||
@update:model-value="
|
||||
(value) => updateQuestion(question.id, { correctAnswerId: value || null })
|
||||
"
|
||||
:disabled="disabled || isLocked(question) || question.options.length === 0"
|
||||
@update:model-value="(value) => setCorrectOption(question.id, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,22 +130,26 @@ const defaultLabels = ['گزینه اول', 'گزینه دوم', 'گزینه س
|
||||
|
||||
const questions = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []))
|
||||
|
||||
// Backend has no PATCH/DELETE for questions/options — once a question came
|
||||
// from the server (numeric id, no `__local`), the form locks editing it.
|
||||
const isLocked = (question) => question?.__local !== true
|
||||
|
||||
const cloneQuestions = () =>
|
||||
questions.value.map((q) => ({
|
||||
...q,
|
||||
answers: Array.isArray(q.answers) ? q.answers.map((a) => ({ ...a })) : [],
|
||||
options: Array.isArray(q.options) ? q.options.map((o) => ({ ...o })) : [],
|
||||
}))
|
||||
|
||||
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
|
||||
const createOption = () => ({ id: createId('answer'), title: '' })
|
||||
const createOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
|
||||
|
||||
const createQuestion = () => ({
|
||||
id: createId('question'),
|
||||
title: '',
|
||||
score: '',
|
||||
correctAnswerId: null,
|
||||
answers: [createOption(), createOption()],
|
||||
questionText: '',
|
||||
position: questions.value.length + 1,
|
||||
options: [createOption(), createOption()],
|
||||
__local: true,
|
||||
})
|
||||
|
||||
const emitQuestions = (next) => emit('update:modelValue', next)
|
||||
@@ -163,39 +169,48 @@ const updateQuestion = (questionId, patch) => {
|
||||
const addOption = (questionId) => {
|
||||
const next = cloneQuestions().map((q) => {
|
||||
if (q.id !== questionId) return q
|
||||
return { ...q, answers: [...q.answers, createOption()] }
|
||||
return { ...q, options: [...q.options, createOption()] }
|
||||
})
|
||||
emitQuestions(next)
|
||||
}
|
||||
|
||||
const removeOption = (questionId, answerId) => {
|
||||
const removeOption = (questionId, optionId) => {
|
||||
const next = cloneQuestions().map((q) => {
|
||||
if (q.id !== questionId) return q
|
||||
const options = q.options.filter((o) => o.id !== optionId)
|
||||
return { ...q, options }
|
||||
})
|
||||
emitQuestions(next)
|
||||
}
|
||||
|
||||
const updateOption = (questionId, optionId, optionText) => {
|
||||
const next = cloneQuestions().map((q) => {
|
||||
if (q.id !== questionId) return q
|
||||
const answers = q.answers.filter((a) => a.id !== answerId)
|
||||
return {
|
||||
...q,
|
||||
answers,
|
||||
correctAnswerId: String(q.correctAnswerId) === String(answerId) ? null : q.correctAnswerId,
|
||||
options: q.options.map((o) => (o.id === optionId ? { ...o, optionText } : o)),
|
||||
}
|
||||
})
|
||||
emitQuestions(next)
|
||||
}
|
||||
|
||||
const updateOption = (questionId, answerId, title) => {
|
||||
const setCorrectOption = (questionId, optionId) => {
|
||||
const next = cloneQuestions().map((q) => {
|
||||
if (q.id !== questionId) return q
|
||||
return {
|
||||
...q,
|
||||
answers: q.answers.map((a) => (a.id === answerId ? { ...a, title } : a)),
|
||||
options: q.options.map((o) => ({ ...o, isCorrect: String(o.id) === String(optionId) })),
|
||||
}
|
||||
})
|
||||
emitQuestions(next)
|
||||
}
|
||||
|
||||
const correctOptionId = (question) => question.options.find((o) => o.isCorrect)?.id ?? null
|
||||
|
||||
const optionLabel = (index) => defaultLabels[index] || `گزینه ${index + 1}`
|
||||
|
||||
const correctAnswerOptions = (question) =>
|
||||
question.answers.map((a, idx) => ({ value: a.id, label: optionLabel(idx) }))
|
||||
const correctOptions = (question) =>
|
||||
question.options.map((o, idx) => ({ value: o.id, label: optionLabel(idx) }))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -209,6 +224,10 @@ const correctAnswerOptions = (question) =>
|
||||
border-radius: 1.5rem;
|
||||
padding: 0.875rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
|
||||
|
||||
&--readonly {
|
||||
background: rgba(0, 0, 0, 2%);
|
||||
}
|
||||
}
|
||||
|
||||
&__head {
|
||||
@@ -227,6 +246,12 @@ const correctAnswerOptions = (question) =>
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__lock {
|
||||
font-size: 0.7rem;
|
||||
color: #9c9c9c;
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-model="form.courseTemplateId"
|
||||
name="courseTemplateId"
|
||||
label="دوره الگو"
|
||||
v-model="form.courseId"
|
||||
name="courseId"
|
||||
label="دوره"
|
||||
:options="templateOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
@@ -30,7 +30,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
@@ -55,18 +55,18 @@ import TextField from '@/components/form/TextField.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({ title: '', courseTemplateId: '', fromDate: '', toDate: '' }),
|
||||
default: () => ({ title: '', courseId: '', fromDate: '', toDate: '' }),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
||||
|
||||
const emptyForm = () => ({ title: '', courseTemplateId: '', fromDate: '', toDate: '' })
|
||||
const emptyForm = () => ({ title: '', courseId: '', fromDate: '', toDate: '' })
|
||||
const form = ref({ ...emptyForm(), ...props.modelValue })
|
||||
|
||||
watch(
|
||||
@@ -82,10 +82,7 @@ const todayIso = new Date().toISOString()
|
||||
const templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
||||
templateFilters,
|
||||
templatePagination
|
||||
)
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
||||
|
||||
const searchTemplates = useDebounce((q) => {
|
||||
|
||||
@@ -102,13 +102,13 @@ const summaryItems = computed(() => {
|
||||
},
|
||||
{
|
||||
title: 'دوره مرتبط',
|
||||
value: e.courseTemplate?.title || e.courseTemplateTitle || '—',
|
||||
value: e.course?.title || e.courseTitle || '—',
|
||||
numeric: false,
|
||||
},
|
||||
{ title: 'وضعیت', value: e.statusLabel || e.faStatus || e.status || '—', numeric: false },
|
||||
{
|
||||
title: 'مدت زمان',
|
||||
value: e.durationMinutes == null ? '—' : `${e.durationMinutes} دقیقه`,
|
||||
title: 'حد نصاب قبولی',
|
||||
value: e.passScore == null ? '—' : `${e.passScore} نمره`,
|
||||
numeric: true,
|
||||
},
|
||||
{
|
||||
@@ -127,25 +127,17 @@ const summaryItems = computed(() => {
|
||||
const displayQuestions = computed(() => {
|
||||
const raw = exam.value?.questions || []
|
||||
return raw.map((question, index) => {
|
||||
const answersSource =
|
||||
(Array.isArray(question.answers) && question.answers) ||
|
||||
(Array.isArray(question.options) && question.options) ||
|
||||
[]
|
||||
const answers = answersSource.map((answer, ai) => {
|
||||
const id = answer?.id ?? `${question?.id || index + 1}-${ai + 1}`
|
||||
const title =
|
||||
(typeof answer === 'string' && answer) ||
|
||||
answer?.title ||
|
||||
answer?.text ||
|
||||
answer?.label ||
|
||||
'—'
|
||||
return { id, title, isCorrect: String(id) === String(question?.correctAnswerId) }
|
||||
})
|
||||
const optionsSource = Array.isArray(question.options) ? question.options : []
|
||||
const answers = optionsSource.map((option, ai) => ({
|
||||
id: option?.id ?? `${question?.id || index + 1}-${ai + 1}`,
|
||||
title: option?.optionText || '—',
|
||||
isCorrect: !!option?.isCorrect,
|
||||
}))
|
||||
return {
|
||||
id: question?.id ?? `question-${index + 1}`,
|
||||
order: index + 1,
|
||||
title: question?.title || question?.question || '—',
|
||||
score: question?.score ?? '',
|
||||
order: question?.position ?? index + 1,
|
||||
title: question?.questionText || '—',
|
||||
score: '',
|
||||
answers,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -31,32 +31,17 @@
|
||||
:error="errors.title"
|
||||
@blur="validateAt('title', form.title)"
|
||||
/>
|
||||
<TextField
|
||||
v-model="form.durationMinutes"
|
||||
name="durationMinutes"
|
||||
label="مدت آزمون"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.durationMinutes"
|
||||
@blur="validateAt('durationMinutes', form.durationMinutes)"
|
||||
/>
|
||||
<TextField
|
||||
v-model="form.passingScore"
|
||||
name="passingScore"
|
||||
label="حداقل نمره قبولی"
|
||||
label="حد نصاب قبولی"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.passingScore"
|
||||
@blur="validateAt('passingScore', form.passingScore)"
|
||||
/>
|
||||
<DatePickerField
|
||||
v-model="form.endDate"
|
||||
name="endDate"
|
||||
label="تاریخ اعتبار"
|
||||
:error="errors.endDate"
|
||||
/>
|
||||
<div class="exam-form__toggle-cell">
|
||||
<ToggleSwitch v-model="form.randomize" label="به صورت رندوم باشد" />
|
||||
<ToggleSwitch v-model="form.isActive" label="آزمون فعال باشد" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,13 +101,13 @@ import { examSchema } from '@/features/admin/exams/schema'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||
import TextareaField from '@/components/form/TextareaField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import ExamQuestionBuilder from '@/features/admin/exams/components/ExamQuestionBuilder.vue'
|
||||
import {
|
||||
adminExamsKeys,
|
||||
useAddAdminExamMutation,
|
||||
useAddAdminExamQuestionMutation,
|
||||
useAdminExamQuery,
|
||||
useUpdateAdminExamMutation,
|
||||
} from '@/services/query/admin-exams'
|
||||
@@ -137,32 +122,27 @@ const isEditMode = computed(() => !!examId.value)
|
||||
const form = ref({
|
||||
title: '',
|
||||
sessionId: '',
|
||||
endDate: '',
|
||||
durationMinutes: '',
|
||||
passingScore: '',
|
||||
randomize: true,
|
||||
isActive: true,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
|
||||
const blankOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
|
||||
|
||||
const blankQuestion = () => ({
|
||||
id: createId('question'),
|
||||
title: '',
|
||||
score: '',
|
||||
correctAnswerId: null,
|
||||
answers: [
|
||||
{ id: createId('answer'), title: '' },
|
||||
{ id: createId('answer'), title: '' },
|
||||
],
|
||||
questionText: '',
|
||||
position: 1,
|
||||
options: [blankOption(), { ...blankOption(), id: createId('option') }],
|
||||
__local: true,
|
||||
})
|
||||
|
||||
const questions = ref([blankQuestion()])
|
||||
const questionError = ref('')
|
||||
|
||||
const schema = examSchema
|
||||
|
||||
const { validate, validateAt, errors } = useYup(schema)
|
||||
const { validate, validateAt, errors } = useYup(examSchema)
|
||||
|
||||
const sessionSearch = ref('')
|
||||
const sessionFilters = computed(() => ({ title: sessionSearch.value }))
|
||||
@@ -185,20 +165,20 @@ const { data: existingExam } = useAdminExamQuery(examId, {
|
||||
enabled: () => !!examId.value,
|
||||
})
|
||||
|
||||
const normalizeQuestions = (raw = []) => {
|
||||
const normalizeExistingQuestions = (raw = []) => {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return [blankQuestion()]
|
||||
return raw.map((q, qIdx) => {
|
||||
const answersSrc = q.answers || q.options || q.choices || []
|
||||
const answers = (Array.isArray(answersSrc) ? answersSrc : []).map((a, aIdx) => ({
|
||||
id: a?.id || createId(`answer-${qIdx}-${aIdx}`),
|
||||
title: typeof a === 'string' ? a : a?.title || a?.text || a?.label || '',
|
||||
}))
|
||||
const options = Array.isArray(q.options) ? q.options : []
|
||||
return {
|
||||
id: q.id || createId(`question-${qIdx}`),
|
||||
title: q.title || q.question || q.text || '',
|
||||
score: q.score ?? q.barom ?? '',
|
||||
correctAnswerId: q.correctAnswerId || q.correctOptionId || q.correctAnswer?.id || null,
|
||||
answers: answers.length > 0 ? answers : blankQuestion().answers,
|
||||
id: q.id ?? createId(`question-${qIdx}`),
|
||||
questionText: q.questionText || '',
|
||||
position: q.position ?? qIdx + 1,
|
||||
options: options.map((o, oIdx) => ({
|
||||
id: o.id ?? createId(`option-${qIdx}-${oIdx}`),
|
||||
optionText: o.optionText || '',
|
||||
isCorrect: !!o.isCorrect,
|
||||
})),
|
||||
// No `__local` flag — these came from the server, so the builder will lock them.
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -209,62 +189,89 @@ watch(existingExam, (exam) => {
|
||||
form.value = {
|
||||
title: exam.title || '',
|
||||
sessionId: exam.session?.id || exam.sessionId || '',
|
||||
endDate: exam.endDate || '',
|
||||
durationMinutes: exam.durationMinutes ?? '',
|
||||
passingScore: exam.passingScore ?? '',
|
||||
randomize: exam.randomize ?? true,
|
||||
passingScore: exam.passScore ?? '',
|
||||
isActive: exam.isActive ?? true,
|
||||
description: exam.description || '',
|
||||
}
|
||||
questions.value = normalizeQuestions(exam.questions)
|
||||
questions.value = normalizeExistingQuestions(exam.questions)
|
||||
})
|
||||
|
||||
const validateQuestionList = () => {
|
||||
const list = questions.value
|
||||
if (list.some((q) => !String(q.title || '').trim() || !String(q.score || '').trim())) {
|
||||
questionError.value = 'لطفا متن سوال و بارم هر سوال را وارد کنید.'
|
||||
const validateLocalQuestions = () => {
|
||||
const localOnes = questions.value.filter((q) => q.__local === true)
|
||||
if (!isEditMode.value && localOnes.length === 0) {
|
||||
questionError.value = 'حداقل یک سوال اضافه کنید.'
|
||||
return null
|
||||
}
|
||||
if (list.some((q) => q.answers.filter((a) => a.title?.trim()).length < 2)) {
|
||||
questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.'
|
||||
return null
|
||||
}
|
||||
if (
|
||||
list.some((q) => {
|
||||
if (!q.correctAnswerId) return true
|
||||
return !q.answers.some((a) => String(a.id) === String(q.correctAnswerId) && a.title?.trim())
|
||||
})
|
||||
) {
|
||||
questionError.value = 'گزینه صحیح هر سوال را از گزینههای موجود انتخاب کنید.'
|
||||
return null
|
||||
for (const q of localOnes) {
|
||||
if (!String(q.questionText || '').trim()) {
|
||||
questionError.value = 'متن همه سوالات را وارد کنید.'
|
||||
return null
|
||||
}
|
||||
const validOptions = q.options.filter((o) => String(o.optionText || '').trim())
|
||||
if (validOptions.length < 2) {
|
||||
questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.'
|
||||
return null
|
||||
}
|
||||
if (!validOptions.some((o) => o.isCorrect)) {
|
||||
questionError.value = 'گزینه صحیح هر سوال را انتخاب کنید.'
|
||||
return null
|
||||
}
|
||||
}
|
||||
questionError.value = ''
|
||||
return list.map((q) => ({
|
||||
id: q.id,
|
||||
title: q.title.trim(),
|
||||
score: q.score,
|
||||
correctAnswerId: q.correctAnswerId,
|
||||
answers: q.answers.filter((a) => a.title?.trim()).map((a) => ({ id: a.id, title: a.title })),
|
||||
return localOnes.map((q, idx) => ({
|
||||
questionText: q.questionText.trim(),
|
||||
position: Number(q.position) || idx + 1,
|
||||
options: q.options
|
||||
.filter((o) => String(o.optionText || '').trim())
|
||||
.map((o) => ({
|
||||
optionText: o.optionText.trim(),
|
||||
isCorrect: !!o.isCorrect,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
const addMutation = useAddAdminExamMutation()
|
||||
const updateMutation = useUpdateAdminExamMutation()
|
||||
const buildExamPayload = (values) => ({
|
||||
sessionId: values.sessionId,
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
passingScore: Number(values.passingScore) || 0,
|
||||
isActive: values.isActive,
|
||||
})
|
||||
|
||||
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
||||
const addExamMutation = useAddAdminExamMutation()
|
||||
const updateExamMutation = useUpdateAdminExamMutation()
|
||||
const addQuestionMutation = useAddAdminExamQuestionMutation()
|
||||
|
||||
const submitting = computed(
|
||||
() =>
|
||||
addExamMutation.isPending.value ||
|
||||
updateExamMutation.isPending.value ||
|
||||
addQuestionMutation.isPending.value
|
||||
)
|
||||
|
||||
const postQuestionsSequentially = async (id, list) => {
|
||||
for (const payload of list) {
|
||||
// Sequential so question position ordering is preserved on the backend.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await addQuestionMutation.mutateAsync({ examId: id, payload })
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { isValid, payload } = await validate(form.value)
|
||||
const cleanQuestions = validateQuestionList()
|
||||
if (!isValid || !cleanQuestions) return
|
||||
const finalPayload = {
|
||||
...payload,
|
||||
questions: cleanQuestions,
|
||||
questionsCount: cleanQuestions.length,
|
||||
}
|
||||
const { isValid } = await validate(form.value)
|
||||
const newQuestions = validateLocalQuestions()
|
||||
if (!isValid || !newQuestions) return
|
||||
|
||||
const examPayload = buildExamPayload(form.value)
|
||||
let targetExamId = examId.value
|
||||
if (isEditMode.value) {
|
||||
await updateMutation.mutateAsync({ id: examId.value, payload: finalPayload })
|
||||
await updateExamMutation.mutateAsync({ id: targetExamId, payload: examPayload })
|
||||
} else {
|
||||
await addMutation.mutateAsync(finalPayload)
|
||||
const created = await addExamMutation.mutateAsync(examPayload)
|
||||
targetExamId = created?.data?.id ?? created?.id ?? targetExamId
|
||||
}
|
||||
if (targetExamId && newQuestions.length > 0) {
|
||||
await postQuestionsSequentially(targetExamId, newQuestions)
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all })
|
||||
router.push({ name: 'admin-exams' })
|
||||
@@ -306,7 +313,7 @@ const onCancel = () => router.push({ name: 'admin-exams' })
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const filters = ref({ title: '', courseTemplateId: '', fromDate: '', toDate: '' })
|
||||
const filters = ref({ title: '', courseId: '', fromDate: '', toDate: '' })
|
||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const { data, isLoading } = useAdminExamsListQuery(filters, pagination, {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { number, object, string } from 'yup'
|
||||
import { boolean, number, object, string } from 'yup'
|
||||
|
||||
export const examSchema = object().shape({
|
||||
title: string().required().min(3),
|
||||
sessionId: string().required(),
|
||||
endDate: string().required(),
|
||||
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(),
|
||||
passingScore: number().typeError('حد نصاب قبولی باید عدد باشد').required(),
|
||||
description: string().nullable().notRequired(),
|
||||
isActive: boolean().nullable().notRequired(),
|
||||
})
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
<template>
|
||||
<div class="ticket-item">
|
||||
<div class="ticket-item__user">
|
||||
<div v-if="ticket.user?.avatarUrl" class="ticket-item__avatar">
|
||||
<img :src="ticket.user.avatarUrl" :alt="userName" />
|
||||
<div v-if="ticket.student?.avatarUrl" class="ticket-item__avatar">
|
||||
<img :src="ticket.student.avatarUrl" :alt="userName" />
|
||||
</div>
|
||||
<div v-else class="ticket-item__avatar ticket-item__avatar--placeholder">
|
||||
<SvgIcon name="user" :size="24" color="#bcbcbc" />
|
||||
</div>
|
||||
<div class="ticket-item__info">
|
||||
<p class="ticket-item__name">{{ userName }}</p>
|
||||
<p class="ticket-item__title">{{ ticket.title || '—' }}</p>
|
||||
<p class="ticket-item__title">{{ ticket.subject || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ticket-item__meta">
|
||||
<span
|
||||
class="ticket-item__status"
|
||||
:class="`ticket-item__status--${ticket.status || 'pending'}`"
|
||||
>
|
||||
<span class="ticket-item__status" :class="`ticket-item__status--${ticket.status || 'open'}`">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<div class="ticket-item__pill">
|
||||
@@ -58,20 +55,22 @@ const props = defineProps({
|
||||
const emit = defineEmits(['show-details'])
|
||||
|
||||
const userName = computed(() => {
|
||||
const u = props.ticket.user
|
||||
if (!u) return '—'
|
||||
return `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.fullName || '—'
|
||||
const s = props.ticket.student
|
||||
if (!s) return '—'
|
||||
return s.name || `${s.firstName || ''} ${s.lastName || ''}`.trim() || s.fullName || '—'
|
||||
})
|
||||
|
||||
const statusLabel = computed(
|
||||
() => props.ticket.statusLabel || TICKET_STATUS[props.ticket.status] || '—'
|
||||
)
|
||||
const statusLabel = computed(() => TICKET_STATUS[props.ticket.status] || '—')
|
||||
|
||||
const createdAt = computed(
|
||||
() => props.ticket.faCreatedAt || formatJalaaliDate(props.ticket.createdAt) || '—'
|
||||
)
|
||||
const createdAt = computed(() => formatJalaaliDate(props.ticket.createdAt) || '—')
|
||||
|
||||
const createdTime = computed(() => props.ticket.faCreatedTime || '')
|
||||
const createdTime = computed(() => {
|
||||
const iso = props.ticket.createdAt
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -160,7 +159,7 @@ const createdTime = computed(() => props.ticket.faCreatedTime || '')
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&--pending {
|
||||
&--open {
|
||||
background: rgba(204, 154, 40, 8%);
|
||||
color: #cc6f00;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
:class="`ticket-details__row--${senderClass(message)}`"
|
||||
>
|
||||
<div class="ticket-details__bubble">
|
||||
<p class="ticket-details__text">{{ message.text }}</p>
|
||||
<p class="ticket-details__text">{{ message.message }}</p>
|
||||
<span class="ticket-details__time">{{ messageTime(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,19 +26,6 @@
|
||||
|
||||
<div class="ticket-details__divider" />
|
||||
|
||||
<div v-if="attachment" class="ticket-details__attachment">
|
||||
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" />
|
||||
<span class="ticket-details__attachment-name">{{ attachment.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ticket-details__attachment-remove"
|
||||
aria-label="حذف فایل"
|
||||
@click="removeAttachment"
|
||||
>
|
||||
<SvgIcon name="close" :size="14" color="var(--color-error)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="ticket-details__compose" @submit.prevent="onSend">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -65,20 +52,6 @@
|
||||
>
|
||||
<SvgIcon name="mood" :size="30" color="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ticket-details__compose-btn"
|
||||
aria-label="پیوست فایل"
|
||||
@click="triggerFilePicker"
|
||||
>
|
||||
<SvgIcon name="attach-file" :size="30" color="currentColor" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="ticket-details__file-input"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -110,7 +83,6 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import { objectToFormData } from '@/utils/object-to-formdata'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
@@ -167,28 +139,21 @@ const { data: ticket, isLoading } = useAdminTicketQuery(ticketId, {
|
||||
|
||||
const messages = computed(() => ticket.value?.messages ?? [])
|
||||
|
||||
const ticketDate = computed(
|
||||
() => ticket.value?.faCreatedAt || formatJalaaliDate(ticket.value?.createdAt) || '—'
|
||||
)
|
||||
const ticketDate = computed(() => formatJalaaliDate(ticket.value?.createdAt) || '—')
|
||||
|
||||
const senderClass = (message) => (message.sender === 'admin' ? 'admin' : 'user')
|
||||
// Anyone whose id matches the ticket's student is "the student"; everyone else
|
||||
// (admin, counselor) renders on the opposite side of the thread.
|
||||
const senderClass = (message) => (message.senderId === ticket.value?.studentId ? 'user' : 'admin')
|
||||
|
||||
const messageTime = (message) => message.time || message.faSentAt || message.sentAt || '—'
|
||||
const messageTime = (message) => {
|
||||
if (!message.createdAt) return '—'
|
||||
const d = new Date(message.createdAt)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const text = ref('')
|
||||
const attachment = ref(null)
|
||||
const canSend = computed(() => text.value.trim().length > 0 || !!attachment.value)
|
||||
|
||||
const fileInput = ref(null)
|
||||
const triggerFilePicker = () => fileInput.value?.click()
|
||||
const onFileSelected = (event) => {
|
||||
const file = event.target?.files?.[0]
|
||||
if (file) attachment.value = file
|
||||
if (event.target) event.target.value = ''
|
||||
}
|
||||
const removeAttachment = () => {
|
||||
attachment.value = null
|
||||
}
|
||||
const canSend = computed(() => text.value.trim().length > 0)
|
||||
|
||||
const emojiOpen = ref(false)
|
||||
const emojiButton = ref(null)
|
||||
@@ -244,12 +209,10 @@ const sendMutation = useSendAdminTicketMessageMutation()
|
||||
const onSend = async () => {
|
||||
if (!canSend.value || !ticketId.value) return
|
||||
const value = text.value.trim()
|
||||
const file = attachment.value
|
||||
text.value = ''
|
||||
attachment.value = null
|
||||
emojiOpen.value = false
|
||||
const payload = file ? objectToFormData({ text: value, attachment: file }) : { text: value }
|
||||
await sendMutation.mutateAsync({ id: ticketId.value, payload })
|
||||
// Backend POST /admin/tickets/:id/messages — body is { message: string }.
|
||||
await sendMutation.mutateAsync({ id: ticketId.value, payload: { message: value } })
|
||||
await queryClient.invalidateQueries({ queryKey: adminTicketsKeys.all })
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,28 @@ export default [
|
||||
meta: { layout: 'admin', role: 'admin', title: 'مدیریت دوره' },
|
||||
},
|
||||
{
|
||||
path: '/add-course-template',
|
||||
name: 'admin-add-course-template',
|
||||
component: () => import('@/features/admin/courses/pages/CourseTemplateFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'افزودن دوره الگو' },
|
||||
path: '/add-course',
|
||||
name: 'admin-add-course',
|
||||
component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'افزودن دوره' },
|
||||
},
|
||||
{
|
||||
path: '/edit-course-template/:id',
|
||||
name: 'admin-edit-course-template',
|
||||
component: () => import('@/features/admin/courses/pages/CourseTemplateFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'ویرایش دوره الگو' },
|
||||
path: '/edit-course/:id',
|
||||
name: 'admin-edit-course',
|
||||
component: () => import('@/features/admin/courses/pages/CourseFormPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'ویرایش دوره' },
|
||||
},
|
||||
{
|
||||
path: '/terms/:termId/courses',
|
||||
name: 'admin-term-courses',
|
||||
component: () => import('@/features/admin/courses/pages/CoursesListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'دورههای ترم' },
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseId/sessions',
|
||||
name: 'admin-course-sessions',
|
||||
component: () => import('@/features/admin/sessions/pages/SessionsListPage.vue'),
|
||||
meta: { layout: 'admin', role: 'admin', title: 'جلسات دوره' },
|
||||
},
|
||||
{
|
||||
path: '/sessions',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="session-item__meta">
|
||||
<div class="session-item__pill">
|
||||
<span class="session-item__pill-label">متعلق به دوره:</span>
|
||||
<span class="session-item__pill-value">{{ session.courseTemplate?.title || '—' }}</span>
|
||||
<span class="session-item__pill-value">{{ session.course?.title || '—' }}</span>
|
||||
</div>
|
||||
<div class="session-item__pill">
|
||||
<span class="session-item__pill-label">مدت جلسه:</span>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
</template>
|
||||
</TextField>
|
||||
<SelectField
|
||||
v-model="form.courseTemplateId"
|
||||
name="courseTemplateId"
|
||||
label="دوره الگو"
|
||||
v-model="form.courseId"
|
||||
name="courseId"
|
||||
label="دوره"
|
||||
:options="templateOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
@@ -64,14 +64,14 @@ import TextField from '@/components/form/TextField.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
title: '',
|
||||
courseTemplateId: '',
|
||||
courseId: '',
|
||||
sessionType: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
@@ -83,7 +83,7 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
|
||||
|
||||
const emptyForm = () => ({
|
||||
title: '',
|
||||
courseTemplateId: '',
|
||||
courseId: '',
|
||||
sessionType: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
@@ -109,10 +109,7 @@ const sessionTypeOptions = Object.entries(SESSION_TYPE).map(([value, label]) =>
|
||||
const templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
||||
templateFilters,
|
||||
templatePagination
|
||||
)
|
||||
const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination)
|
||||
const templateOptions = computed(() => templatesResponse.value?.data ?? [])
|
||||
|
||||
const searchTemplates = useDebounce((q) => {
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
</div>
|
||||
<div class="session-details__hero-info">
|
||||
<p class="session-details__title">{{ session.title || '—' }}</p>
|
||||
<p v-if="session.courseTemplate?.title" class="session-details__sub">
|
||||
دوره: {{ session.courseTemplate.title }}
|
||||
<p v-if="session.course?.title" class="session-details__sub">
|
||||
دوره: {{ session.course.title }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,182 +11,143 @@
|
||||
</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>
|
||||
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
|
||||
|
||||
<div class="session-form__main-col">
|
||||
<LineTitleBlock title="اطلاعات جلسه" title-en="Session Details" />
|
||||
<div class="session-form__row">
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<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="session-form__cell session-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.courseTemplateId"
|
||||
name="courseTemplateId"
|
||||
label="دوره الگو"
|
||||
:options="templateOptions"
|
||||
option-label="title"
|
||||
option-value="id"
|
||||
:searchable="true"
|
||||
:on-search="searchTemplates"
|
||||
:error="errors.courseTemplateId"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.sessionType"
|
||||
name="sessionType"
|
||||
label="نوع جلسه"
|
||||
:options="sessionTypeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:error="errors.sessionType"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<TextField
|
||||
v-model="form.durationMinutes"
|
||||
name="durationMinutes"
|
||||
label="مدت زمان (دقیقه)"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.durationMinutes"
|
||||
@blur="validateAt('durationMinutes', form.durationMinutes)"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<TextField
|
||||
v-model="form.order"
|
||||
name="order"
|
||||
label="ترتیب"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
:error="errors.order"
|
||||
/>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<template v-if="form.sessionType === 'online'">
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<TextField
|
||||
v-model="form.sessionConfig.meetingLink"
|
||||
name="meetingLink"
|
||||
label="لینک جلسه"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<SelectField
|
||||
v-model="form.sessionConfig.platform"
|
||||
name="platform"
|
||||
label="پلتفرم"
|
||||
:options="platformOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<DatePickerField
|
||||
v-model="form.sessionConfig.startTime"
|
||||
name="startTime"
|
||||
label="تاریخ و ساعت شروع"
|
||||
type="datetime"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 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>
|
||||
|
||||
<template v-else-if="form.sessionType === 'in_person'">
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<DatePickerField
|
||||
v-model="form.sessionConfig.startTime"
|
||||
name="startTime"
|
||||
label="تاریخ و ساعت شروع"
|
||||
type="datetime"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--full">
|
||||
<TextField
|
||||
v-model="form.sessionConfig.location"
|
||||
name="location"
|
||||
label="مکان جلسه"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 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>
|
||||
|
||||
<template v-else-if="['video', 'audio'].includes(form.sessionType)">
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<TextField
|
||||
v-model="form.sessionConfig.minWatchedPercent"
|
||||
name="minWatchedPercent"
|
||||
label="حداقل درصد مشاهده (%)"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--third session-form__toggle-cell">
|
||||
<ToggleSwitch
|
||||
v-model="form.sessionConfig.mustCompleteBeforeNext"
|
||||
label="الزام تکمیل قبل از جلسه بعدی"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- row 4 — description -->
|
||||
<div class="session-form__row">
|
||||
<TextareaField
|
||||
v-model="form.description"
|
||||
name="description"
|
||||
label="توضیحات جلسه"
|
||||
:row="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else-if="['text', 'slide', 'pdf'].includes(form.sessionType)">
|
||||
<div class="session-form__cell session-form__cell--third">
|
||||
<TextField
|
||||
v-model="form.sessionConfig.minReadPercent"
|
||||
name="minReadPercent"
|
||||
label="حداقل درصد مطالعه (%)"
|
||||
inputmode="numeric"
|
||||
:convert-digits="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="session-form__cell session-form__cell--third session-form__toggle-cell">
|
||||
<ToggleSwitch
|
||||
v-model="form.sessionConfig.mustCompleteBeforeNext"
|
||||
label="الزام تکمیل قبل از جلسه بعدی"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 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 class="session-form__cell session-form__cell--full">
|
||||
<TextareaField
|
||||
v-model="form.description"
|
||||
name="description"
|
||||
label="توضیحات"
|
||||
:row="5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-form__materials">
|
||||
<LineTitleBlock title="فایلهای جلسه" title-en="Session Materials" />
|
||||
<FileUploader
|
||||
v-model="materials"
|
||||
accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx"
|
||||
:multiple="true"
|
||||
:max-files="10"
|
||||
context="session"
|
||||
/>
|
||||
</div>
|
||||
</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" />
|
||||
@@ -227,19 +188,17 @@ 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 { SESSION_PLATFORM, SESSION_TYPE } from '@/enums'
|
||||
import SelectField from '@/components/form/SelectField.vue'
|
||||
import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||
import ImageCropper from '@/components/form/ImageCropper.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 { useUploadMediaMutation } from '@/services/query/auth'
|
||||
import DatePickerField from '@/components/form/DatePickerField.vue'
|
||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
||||
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
|
||||
import {
|
||||
adminSessionsKeys,
|
||||
useAddAdminSessionMutation,
|
||||
@@ -254,149 +213,146 @@ const queryClient = useQueryClient()
|
||||
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
|
||||
const isEditMode = computed(() => !!sessionId.value)
|
||||
|
||||
const sessionTypeOptions = Object.entries(SESSION_TYPE).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
const platformOptions = Object.entries(SESSION_PLATFORM).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))
|
||||
// نوع جلسه: 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 },
|
||||
]
|
||||
|
||||
const emptySessionConfig = () => ({
|
||||
meetingLink: '',
|
||||
platform: '',
|
||||
startTime: '',
|
||||
location: '',
|
||||
minWatchedPercent: '',
|
||||
minReadPercent: '',
|
||||
mustCompleteBeforeNext: false,
|
||||
})
|
||||
// محتوای جلسه: 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: '',
|
||||
courseTemplateId: '',
|
||||
sessionType: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
durationMinutes: '',
|
||||
order: '',
|
||||
courseId: '',
|
||||
contentType: '',
|
||||
sessionType: '',
|
||||
meetingLink: '',
|
||||
description: '',
|
||||
imageId: null,
|
||||
sessionConfig: emptySessionConfig(),
|
||||
contentMediaId: null,
|
||||
})
|
||||
|
||||
const image = ref(null)
|
||||
const materials = ref([])
|
||||
const contentFiles = ref([])
|
||||
|
||||
const schema = sessionSchema
|
||||
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
||||
const contentPreviewUrl = computed(() => contentFiles.value[0]?.url || '')
|
||||
|
||||
const { validate, validateAt, errors } = useYup(schema)
|
||||
const { validate, validateAt, errors } = useYup(sessionSchema)
|
||||
|
||||
const templateSearch = ref('')
|
||||
const templateFilters = computed(() => ({ title: templateSearch.value }))
|
||||
const templatePagination = ref({ page: 1, perPage: 30 })
|
||||
const { data: templatesResponse } = useAdminCourseTemplatesListQuery(
|
||||
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]
|
||||
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 searchTemplates = useDebounce((q) => {
|
||||
templateSearch.value = q || ''
|
||||
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.courseTemplate) {
|
||||
selectedTemplate.value = session.courseTemplate
|
||||
}
|
||||
if (session.course) selectedCourse.value = session.course
|
||||
form.value = {
|
||||
title: session.title || '',
|
||||
courseTemplateId: session.courseTemplate?.id || session.courseTemplateId || '',
|
||||
sessionType: session.sessionType || '',
|
||||
startTime: session.startsAt || session.sessionConfig?.startTime || '',
|
||||
endTime: session.endsAt || session.sessionConfig?.endTime || '',
|
||||
durationMinutes: session.durationMinutes ?? '',
|
||||
order: session.order ?? '',
|
||||
courseId: session.course?.id || session.courseId || '',
|
||||
contentType: session.contentType || '',
|
||||
sessionType: session.sessionType || '',
|
||||
meetingLink: session.link || session.sessionConfig?.meetingLink || '',
|
||||
description: session.description || '',
|
||||
imageId: session.imageId || null,
|
||||
sessionConfig: { ...emptySessionConfig(), ...session.sessionConfig },
|
||||
contentMediaId: session.contentMediaId || null,
|
||||
}
|
||||
if (session.image) image.value = { url: session.image }
|
||||
if (Array.isArray(session.materials)) {
|
||||
materials.value = session.materials.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.title || `فایل ${m.id}`,
|
||||
url: m.filePath || '',
|
||||
type: m.type,
|
||||
}))
|
||||
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 onImageCropped = async (file) => {
|
||||
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, subType: 'avatar', context: 'session' })
|
||||
const formData = objectToFormData({
|
||||
file,
|
||||
purpose: purposeForContentType(form.value.contentType),
|
||||
context: 'session',
|
||||
})
|
||||
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
|
||||
const id = payload?.id ?? payload?.uploadId
|
||||
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
|
||||
form.value.contentMediaId = id
|
||||
} catch {
|
||||
/* handled globally */
|
||||
contentFiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const onImageError = (msg) => toast.error(msg)
|
||||
|
||||
const cleanSessionConfig = (config, type) => {
|
||||
const result = {}
|
||||
const allow = (key) => {
|
||||
if (type === 'online') return ['meetingLink', 'platform', 'startTime'].includes(key)
|
||||
if (type === 'in_person') return ['startTime', 'location'].includes(key)
|
||||
if (['video', 'audio'].includes(type))
|
||||
return ['minWatchedPercent', 'mustCompleteBeforeNext'].includes(key)
|
||||
if (['text', 'slide', 'pdf'].includes(type))
|
||||
return ['minReadPercent', 'mustCompleteBeforeNext'].includes(key)
|
||||
return false
|
||||
}
|
||||
Object.entries(config || {}).forEach(([k, v]) => {
|
||||
if (!allow(k)) return
|
||||
if (v === '' || v === null || v === undefined) return
|
||||
result[k] = v
|
||||
})
|
||||
return result
|
||||
const onContentRemove = () => {
|
||||
contentFiles.value = []
|
||||
form.value.contentMediaId = null
|
||||
}
|
||||
|
||||
const onContentError = (msg) => toast.error(msg)
|
||||
|
||||
const buildPayload = (values) => {
|
||||
const sessionConfig = cleanSessionConfig(values.sessionConfig, values.sessionType)
|
||||
const payload = {
|
||||
title: values.title,
|
||||
courseTemplateId: values.courseTemplateId,
|
||||
sessionType: values.sessionType,
|
||||
startTime: values.startTime,
|
||||
endTime: values.endTime,
|
||||
durationMinutes: values.durationMinutes,
|
||||
order: values.order,
|
||||
courseId: values.courseId,
|
||||
contentType: values.contentType,
|
||||
sessionType: values.sessionType,
|
||||
meetingLink: values.sessionType === 'online' ? values.meetingLink : undefined,
|
||||
description: values.description,
|
||||
imageId: values.imageId,
|
||||
contentMediaId: values.contentMediaId,
|
||||
}
|
||||
if (Object.keys(sessionConfig).length > 0) payload.sessionConfig = sessionConfig
|
||||
payload.materials = materials.value.map((m, index) => ({
|
||||
fileId: m.id,
|
||||
isRequired: false,
|
||||
type: m.type,
|
||||
title: m.name,
|
||||
order: index + 1,
|
||||
}))
|
||||
Object.keys(payload).forEach((key) => {
|
||||
if (payload[key] === undefined || payload[key] === '') delete payload[key]
|
||||
if (payload[key] === undefined || payload[key] === '' || payload[key] === null) {
|
||||
delete payload[key]
|
||||
}
|
||||
})
|
||||
return payload
|
||||
}
|
||||
@@ -436,82 +392,72 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
|
||||
background: rgba(255, 255, 255, 60%);
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&__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;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 2;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
&__cell {
|
||||
width: 100%;
|
||||
|
||||
&--third {
|
||||
&--two {
|
||||
@media (min-width: 768px) {
|
||||
width: 49%;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 32.3%;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&--full {
|
||||
width: 100%;
|
||||
&--three {
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle-cell {
|
||||
display: flex;
|
||||
&__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 {
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
&__materials {
|
||||
margin-top: 1.5rem;
|
||||
&__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: 1.5rem;
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import { usePagination } from '@/composables/usePagination'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
@@ -69,19 +69,31 @@ import {
|
||||
useDeleteAdminSessionMutation,
|
||||
} from '@/services/query/admin-sessions'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { openModal, isModal } = useModal()
|
||||
|
||||
const routeCourseId = computed(() => (route.params.courseId ? Number(route.params.courseId) : null))
|
||||
|
||||
const filters = ref({
|
||||
title: '',
|
||||
courseTemplateId: '',
|
||||
courseId: routeCourseId.value ?? '',
|
||||
sessionType: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
})
|
||||
const { pagination, setPage, reset: resetPagination } = usePagination({ page: 1, perPage: 10 })
|
||||
|
||||
const syncRouteCourseId = (courseId) => {
|
||||
if (!courseId) return
|
||||
filters.value = { ...filters.value, courseId }
|
||||
resetPagination()
|
||||
}
|
||||
|
||||
onMounted(() => syncRouteCourseId(routeCourseId.value))
|
||||
watch(routeCourseId, (val) => syncRouteCourseId(val))
|
||||
|
||||
const { data, isLoading } = useAdminSessionsListQuery(filters, pagination, {
|
||||
keepPreviousData: true,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { number, object, string } from 'yup'
|
||||
|
||||
export const sessionSchema = object().shape({
|
||||
courseTemplateId: string().required(),
|
||||
title: string().required().min(3),
|
||||
sessionType: string().required(),
|
||||
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required(),
|
||||
order: number().typeError('ترتیب باید عدد باشد').nullable().notRequired(),
|
||||
startTime: string().required(),
|
||||
endTime: string().nullable().notRequired(),
|
||||
durationMinutes: number().typeError('مدت زمان باید عدد باشد').required().min(1),
|
||||
courseId: string().required(),
|
||||
contentType: string().oneOf(['voice', 'video', 'text']).required(),
|
||||
sessionType: string().oneOf(['in_person', 'online']).required(),
|
||||
meetingLink: string().when('sessionType', {
|
||||
is: 'online',
|
||||
then: (schema) => schema.required(),
|
||||
otherwise: (schema) => schema.nullable().notRequired(),
|
||||
}),
|
||||
description: string().nullable().notRequired(),
|
||||
contentMediaId: number().nullable().notRequired(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="course-item">
|
||||
<div class="course-item__main">
|
||||
<div v-if="course.image" class="course-item__image">
|
||||
<img :src="course.image" :alt="course.title" />
|
||||
</div>
|
||||
<div v-else class="course-item__image course-item__image--placeholder">
|
||||
<SvgIcon name="book" :size="22" color="#bcbcbc" />
|
||||
</div>
|
||||
<div class="course-item__title-block">
|
||||
<p class="course-item__title">
|
||||
<span>دوره</span>
|
||||
<strong>{{ course.title }}</strong>
|
||||
</p>
|
||||
<div class="course-item__teacher">
|
||||
<SvgIcon name="user" :size="11" color="#bcbcbc" />
|
||||
<span class="course-item__teacher-label">استاد:</span>
|
||||
<span class="course-item__teacher-name">{{ teacherName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-item__meta">
|
||||
<Badge
|
||||
variant="cyan"
|
||||
size="sm"
|
||||
icon="calendar"
|
||||
:label="`وضعیت دوره :`"
|
||||
:value="`در حال گذراندن`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!isActive" class="course-item__status">
|
||||
<span class="course-item__status-badge">
|
||||
<span class="course-item__status-dot" />
|
||||
غیرفعال
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="course-item__actions">
|
||||
<CircleButton
|
||||
tooltip="حذف"
|
||||
bg-color="rgba(104, 104, 104, 0.05)"
|
||||
size="2.5rem"
|
||||
@click="emit('delete', course)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
course: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['delete'])
|
||||
|
||||
const teacherName = computed(() => {
|
||||
const t = props.course.teacher
|
||||
if (!t) return '—'
|
||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||
})
|
||||
|
||||
const isActive = computed(() => props.course.isActive ?? false)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.course-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 50%);
|
||||
box-shadow: 0 4px 10px -6px rgba(241, 241, 241, 90%);
|
||||
border-radius: 0.875rem;
|
||||
margin-bottom: 0.625rem;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
flex: 1 1 33%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&--placeholder {
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.95rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__teacher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
&__teacher-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.65rem;
|
||||
color: #838383;
|
||||
}
|
||||
|
||||
&__teacher-name {
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.75rem;
|
||||
color: #4b4b4b;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
flex: 1 1 33%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
background: rgba(107, 107, 107, 5%);
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__pill-label {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.75rem;
|
||||
color: #848484;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
&__pill-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__status {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
background: rgba(204, 40, 49, 6%);
|
||||
color: var(--color-error);
|
||||
font-family: var(--font-family-fa);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
&__status-dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1 1 100%;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__details-btn {
|
||||
min-width: 8rem;
|
||||
padding: 0 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -43,16 +43,6 @@
|
||||
<SvgIcon name="trash" :size="18" color="var(--color-error)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
tooltip="کپی"
|
||||
bg-color="rgba(104, 104, 104, 0.05)"
|
||||
size="2.5rem"
|
||||
@click="emit('clone', term)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="copy" :size="18" color="var(--color-sec-gray)" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
tooltip="ویرایش"
|
||||
bg-color="rgba(104, 104, 104, 0.05)"
|
||||
@@ -90,10 +80,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['edit', 'delete', 'clone', 'change-status', 'show-details'])
|
||||
|
||||
const startDate = computed(
|
||||
() => props.term.faStartDate || formatJalaaliDate(props.term.startDate) || ''
|
||||
)
|
||||
const endDate = computed(() => props.term.faEndDate || formatJalaaliDate(props.term.endDate) || '')
|
||||
const startDate = computed(() => formatJalaaliDate(props.term.startsAt) || '')
|
||||
const endDate = computed(() => formatJalaaliDate(props.term.endsAt) || '')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="close" :size="20" />
|
||||
<SvgIcon name="close" color="black" :size="20" />
|
||||
</template>
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
|
||||
@@ -96,14 +96,16 @@ import NoItems from '@/components/blocks/NoItems.vue'
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import { adminTermsKeys } from '@/services/query/admin-terms'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
|
||||
import {
|
||||
adminCoursesKeys,
|
||||
useAddAdminCourseMutation,
|
||||
useAdminCoursesListQuery,
|
||||
useDeleteAdminCourseMutation,
|
||||
useUpdateAdminCourseMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
|
||||
// TODO: repurpose this modal as "copy course from another term" — the original
|
||||
// flow (link a stand-alone template to a term) no longer matches the unified
|
||||
// course model. Until then, this stays a stand-alone-course → new-offered-course copy.
|
||||
|
||||
defineOptions({ name: 'AttachCourseToTermModal' })
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
@@ -116,7 +118,7 @@ const searchQuery = ref('')
|
||||
const templateFilters = computed(() => ({ title: searchQuery.value }))
|
||||
const templatePagination = ref({ page: 1, perPage: 20 })
|
||||
|
||||
const { data: templatesResponse, isLoading } = useAdminCourseTemplatesListQuery(
|
||||
const { data: templatesResponse, isLoading } = useAdminCoursesListQuery(
|
||||
templateFilters,
|
||||
templatePagination
|
||||
)
|
||||
@@ -124,18 +126,24 @@ const { data: templatesResponse, isLoading } = useAdminCourseTemplatesListQuery(
|
||||
const templates = computed(() => templatesResponse.value?.data ?? [])
|
||||
|
||||
const courseFilters = computed(() => ({ termId: termId.value }))
|
||||
const coursePagination = ref({ page: 1, perPage: 100 })
|
||||
const coursePagination = ref({ page: 1, perPage: 10 })
|
||||
const { data: coursesResponse } = useAdminCoursesListQuery(courseFilters, coursePagination, {
|
||||
enabled: () => !!termId.value,
|
||||
})
|
||||
|
||||
const attachedCourses = computed(() => coursesResponse.value?.data ?? [])
|
||||
|
||||
const attachedCourseByTemplate = (templateId) =>
|
||||
attachedCourses.value.find((c) => c.template?.id === templateId || c.templateId === templateId)
|
||||
// Tracks which stand-alone course (termId=null) has already been copied
|
||||
// into this term. We match by title since the unified model no longer
|
||||
// keeps a back-reference to the source course.
|
||||
const attachedCourseByTemplate = (sourceId) => {
|
||||
const source = templates.value.find((t) => t.id === sourceId)
|
||||
if (!source) return null
|
||||
return attachedCourses.value.find((c) => c.title === source.title)
|
||||
}
|
||||
|
||||
const teacherName = (template) => {
|
||||
const t = template.defaultTeacher || template.teacher
|
||||
const teacherName = (course) => {
|
||||
const t = course.teacher
|
||||
if (!t) return '—'
|
||||
return `${t.firstName || ''} ${t.lastName || ''}`.trim() || t.fullName || '—'
|
||||
}
|
||||
@@ -147,8 +155,7 @@ const onSearch = useDebounce((event) => {
|
||||
|
||||
const pendingId = ref(null)
|
||||
|
||||
const addMutation = useAddAdminCourseMutation()
|
||||
const deleteMutation = useDeleteAdminCourseMutation()
|
||||
const updateMutation = useUpdateAdminCourseMutation()
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
@@ -156,16 +163,12 @@ const invalidate = () => {
|
||||
}
|
||||
|
||||
const onAttach = async (template) => {
|
||||
console.log(template)
|
||||
|
||||
if (!termId.value) return
|
||||
pendingId.value = template.id
|
||||
try {
|
||||
await addMutation.mutateAsync({
|
||||
termId: termId.value,
|
||||
templateId: template.id,
|
||||
title: template.title,
|
||||
capacity: template.defaultCapacity ?? null,
|
||||
isActive: template.isActiveByDefault ?? true,
|
||||
})
|
||||
await updateMutation.mutateAsync({ id: template.id, payload: { termId: termId.value } })
|
||||
invalidate()
|
||||
} finally {
|
||||
pendingId.value = null
|
||||
@@ -177,7 +180,7 @@ const onDetach = async (template) => {
|
||||
if (!offered) return
|
||||
pendingId.value = template.id
|
||||
try {
|
||||
await deleteMutation.mutateAsync(offered.id)
|
||||
await updateMutation.mutateAsync({ id: template.id, payload: { termId: null } })
|
||||
invalidate()
|
||||
} finally {
|
||||
pendingId.value = null
|
||||
@@ -303,7 +306,7 @@ watch(termId, () => {
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__close-btn {
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
<LineInfoBlock title="عنوان ترم" :desc="term.title || '-'" />
|
||||
<LineInfoBlock
|
||||
title="تاریخ شروع"
|
||||
:numeric-desc="term.faStartDate || formatJalaaliDate(term.startDate) || '-'"
|
||||
:numeric-desc="formatJalaaliDate(term.startsAt) || '-'"
|
||||
/>
|
||||
<LineInfoBlock
|
||||
title="تاریخ پایان"
|
||||
:numeric-desc="term.faEndDate || formatJalaaliDate(term.endDate) || '-'"
|
||||
:numeric-desc="formatJalaaliDate(term.endsAt) || '-'"
|
||||
/>
|
||||
<LineInfoBlock title="تعداد دانشجویان" :numeric-desc="term.studentsCount ?? 0" />
|
||||
<LineInfoBlock title="تعداد دورهها" :numeric-desc="term.coursesCount ?? 0" />
|
||||
@@ -109,10 +109,7 @@
|
||||
v-for="course in termCourses"
|
||||
:key="course.id"
|
||||
:course="course"
|
||||
@edit="onEditCourse"
|
||||
@delete="onAskDeleteCourse"
|
||||
@change-status="onChangeCourseStatus"
|
||||
@show-details="onShowCourseDetails"
|
||||
@delete="(course) => onAskDeleteCourse(course)"
|
||||
/>
|
||||
</div>
|
||||
<NoItems v-else title="متاسفیم" desc="دورهای برای نمایش وجود ندارد." />
|
||||
@@ -138,6 +135,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import CourseItem from '../CourseItem.vue'
|
||||
import useModal from '@/composables/useModal'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import BasicModal from '@/components/BasicModal.vue'
|
||||
@@ -152,13 +150,11 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
|
||||
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
|
||||
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
|
||||
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
|
||||
import CourseItem from '@/features/admin/courses/components/CourseItem.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import {
|
||||
adminCoursesKeys,
|
||||
useAdminCoursesListQuery,
|
||||
useChangeAdminCourseStatusMutation,
|
||||
useDeleteAdminCourseMutation,
|
||||
useUpdateAdminCourseMutation,
|
||||
} from '@/services/query/admin-courses'
|
||||
import {
|
||||
adminTermsKeys,
|
||||
@@ -181,10 +177,10 @@ const { data: term } = useAdminTermQuery(termId, {
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ name: 'students', label: 'دانشجویان', icon: 'users' },
|
||||
{ name: 'courses', label: 'دورهها', icon: 'list-bullets' },
|
||||
{ name: 'students', label: 'دانشجویان', icon: 'users' },
|
||||
]
|
||||
const activeTab = ref('students')
|
||||
const activeTab = ref('courses')
|
||||
|
||||
const studentFilters = ref({})
|
||||
const {
|
||||
@@ -230,15 +226,10 @@ const onToggleLeave = (student, isOnLeave) => {
|
||||
}
|
||||
|
||||
const onAskRemoveStudent = (student) => {
|
||||
openModal('ConfirmModal', {
|
||||
title: `حذف ${studentName(student)}`,
|
||||
message: `آیا از حذف <strong>${studentName(student)}</strong> از این ترم اطمینان دارید؟`,
|
||||
onConfirm: () =>
|
||||
removeStudentMutation.mutate(
|
||||
{ termId: termId.value, userId: student.id },
|
||||
{ onSuccess: invalidate }
|
||||
),
|
||||
})
|
||||
removeStudentMutation.mutate(
|
||||
{ termId: termId.value, userId: student.id },
|
||||
{ onSuccess: invalidate }
|
||||
)
|
||||
}
|
||||
|
||||
const courseFilters = computed(() => ({ termId: termId.value }))
|
||||
@@ -265,31 +256,18 @@ const coursePaginationMeta = computed(() => ({
|
||||
|
||||
const invalidateCourses = () => queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||
|
||||
const deleteCourseMutation = useDeleteAdminCourseMutation()
|
||||
const changeCourseStatusMutation = useChangeAdminCourseStatusMutation()
|
||||
// Backend has no dedicated status endpoint — PATCH /courses/:id with { isActive }.
|
||||
const updateCourseMutation = useUpdateAdminCourseMutation()
|
||||
|
||||
const onOpenAddCourse = () => {
|
||||
openModal('AttachCourseToTermModal', { termId: termId.value })
|
||||
}
|
||||
|
||||
const onEditCourse = (course) => {
|
||||
openModal('AddOfferedCourseModal', { mode: 'edit', courseId: course.id })
|
||||
}
|
||||
|
||||
const onShowCourseDetails = (course) => {
|
||||
openModal('CourseDetailsModal', { id: course.id })
|
||||
}
|
||||
|
||||
const onAskDeleteCourse = (course) => {
|
||||
openModal('ConfirmModal', {
|
||||
title: `حذف ${course.title}`,
|
||||
message: `بعد از حذف امکان بازگشت وجود ندارد، آیا از حذف <strong>${course.title}</strong> اطمینان دارید؟`,
|
||||
onConfirm: () => deleteCourseMutation.mutate(course.id, { onSuccess: invalidateCourses }),
|
||||
})
|
||||
}
|
||||
|
||||
const onChangeCourseStatus = ({ id, isActive }) => {
|
||||
changeCourseStatusMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidateCourses })
|
||||
updateCourseMutation.mutate(
|
||||
{ id: course.id, payload: { termId: null } },
|
||||
{ onSuccess: invalidateCourses }
|
||||
)
|
||||
}
|
||||
|
||||
const onOpenAddStudent = () => {
|
||||
@@ -352,7 +330,7 @@ const onOpenAddStudent = () => {
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,18 +149,22 @@ const { data: existingTerm } = useAdminTermQuery(termId, {
|
||||
enabled: () => !!termId.value,
|
||||
})
|
||||
|
||||
watch(existingTerm, (term) => {
|
||||
if (!term) return
|
||||
form.value = {
|
||||
title: term.title || '',
|
||||
description: term.description || '',
|
||||
isActive: term.isActive ?? true,
|
||||
startsAt: term.startsAt || '',
|
||||
endsAt: term.endsAt || '',
|
||||
coverMediaId: term.coverMediaId || null,
|
||||
}
|
||||
if (term.coverUrl) image.value = { url: term.coverUrl }
|
||||
})
|
||||
watch(
|
||||
existingTerm,
|
||||
(term) => {
|
||||
console.log(term)
|
||||
if (!term) return
|
||||
form.value = {
|
||||
title: term.title || '',
|
||||
description: term.description || '',
|
||||
isActive: term.isActive ?? true,
|
||||
startsAt: term.startsAt || '',
|
||||
endsAt: term.endsAt || '',
|
||||
}
|
||||
if (term.coverUrl) image.value = { url: term.coverUrl }
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const uploadMutation = useUploadMediaMutation()
|
||||
|
||||
|
||||
@@ -73,9 +73,8 @@ import AttachCourseToTermModal from '@/features/admin/terms/components/modals/At
|
||||
import {
|
||||
adminTermsKeys,
|
||||
useAdminTermsListQuery,
|
||||
useChangeAdminTermStatusMutation,
|
||||
useCloneAdminTermMutation,
|
||||
useDeleteAdminTermMutation,
|
||||
useUpdateAdminTermMutation,
|
||||
} from '@/services/query/admin-terms'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -110,8 +109,8 @@ const onEdit = (term) => {
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: adminTermsKeys.all })
|
||||
|
||||
const deleteMutation = useDeleteAdminTermMutation()
|
||||
const cloneMutation = useCloneAdminTermMutation()
|
||||
const changeStatusMutation = useChangeAdminTermStatusMutation()
|
||||
// Backend has no dedicated status endpoint — PATCH /terms/:id with { isActive }.
|
||||
const updateMutation = useUpdateAdminTermMutation()
|
||||
|
||||
const onAskDelete = (term) => {
|
||||
openModal('ConfirmModal', {
|
||||
@@ -121,12 +120,8 @@ const onAskDelete = (term) => {
|
||||
})
|
||||
}
|
||||
|
||||
const onClone = (term) => {
|
||||
cloneMutation.mutate(term.id, { onSuccess: invalidate })
|
||||
}
|
||||
|
||||
const onChangeStatus = ({ id, isActive }) => {
|
||||
changeStatusMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
|
||||
updateMutation.mutate({ id, payload: { isActive } }, { onSuccess: invalidate })
|
||||
}
|
||||
|
||||
const onShowDetails = (term) => {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/global-components/Badge.vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
|
||||
const TONE_MAP = {
|
||||
approved: 'success',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/global-components/Badge.vue'
|
||||
import Badge from '@/components/Badge.vue'
|
||||
|
||||
const TONE_MAP = {
|
||||
approved: 'success',
|
||||
|
||||
Reference in New Issue
Block a user