fix: missionary education
Deploy banu-front / deploy (push) Successful in 1m31s

This commit is contained in:
sajjadtalkhabi
2026-07-21 12:24:58 +03:30
parent 2a1f1b19d4
commit e8b1e4add6
10 changed files with 534 additions and 0 deletions
@@ -0,0 +1,95 @@
<template>
<div class="missionary-lesson-details">
<BoxedIconTitleBlock
class="missionary-lesson-details__heading"
title="جزئیات درس"
:desc="course?.title || 'جلسات این درس را مشاهده کنید.'"
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SimpleTitleIconBlock title="همه جلسه ها" class="missionary-lesson-details__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock
v-if="sessionsLoading && sessions.length === 0"
:rows="3"
:cols-per-row="1"
/>
<div v-else-if="sessions.length > 0">
<StudentSessionItem
v-for="session in sessions"
:key="session.id"
:session="session"
@start="onStartSession"
/>
</div>
<NoItems v-else title="جلسه‌ای نیست" desc="جلسه‌ای برای این درس ثبت نشده است." />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { StudentSessionItem } from '@/features/student/sessions'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import {
useMissionaryPassedCoursesQuery,
useMissionaryPassedSessionsQuery,
} from '@/services/query/missionary-education'
const route = useRoute()
const router = useRouter()
const courseId = computed(() => route.params.id)
const termId = computed(() => route.query.termId || null)
// Course header comes from the passed-courses list cache (same key as the term
// page), so it needs no detail endpoint.
const coursesFilters = computed(() => ({ termId: termId.value }))
const coursesPagination = ref({ page: 1, perPage: 20 })
const { data: coursesData } = useMissionaryPassedCoursesQuery(coursesFilters, coursesPagination, {
enabled: () => !!termId.value,
})
const course = computed(() =>
(coursesData.value?.data ?? []).find((c) => String(c.id) === String(courseId.value))
)
const sessionsFilters = computed(() => ({ courseId: courseId.value }))
const sessionsPagination = ref({ page: 1, perPage: 20 })
const { data: sessionsData, isLoading: sessionsLoading } = useMissionaryPassedSessionsQuery(
sessionsFilters,
sessionsPagination,
{ enabled: () => !!courseId.value, keepPreviousData: true }
)
const sessions = computed(() => sessionsData.value?.data ?? [])
const onStartSession = (session) => {
router.push({ name: 'missionary-education-session', params: { id: session.id } }).catch(() => {})
}
</script>
<style lang="scss" scoped>
.missionary-lesson-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-title {
margin-top: 0.5rem;
}
}
</style>
@@ -0,0 +1,101 @@
<template>
<div class="missionary-term-details">
<BoxedIconTitleBlock
class="missionary-term-details__heading"
title="جزئیات ترم"
desc="اطلاعات کامل این ترم گذرانده‌شده را مشاهده کنید."
>
<template #icon>
<SvgIcon name="book" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="termLoading && !enrollment" :rows="2" :cols-per-row="1" />
<StudentTermSummary v-else-if="enrollment" :enrollment="enrollment" />
<SimpleTitleIconBlock title="همه درس ها" class="missionary-term-details__list-title">
<template #header-icon>
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
</template>
</SimpleTitleIconBlock>
<SkeletonLoaderBlock
v-if="coursesLoading && courses.length === 0"
:rows="3"
:cols-per-row="1"
/>
<div v-else-if="courses.length > 0">
<StudentLessonItem
v-for="lesson in courses"
:key="lesson.id"
:lesson="lesson"
@show-details="onShowLesson"
/>
</div>
<NoItems v-else title="درسی نیست" desc="درسی برای این ترم ثبت نشده است." />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { StudentLessonItem } from '@/features/student/lessons'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
import StudentTermSummary from '@/features/student/terms/components/StudentTermSummary.vue'
import {
useMissionaryPassedTermsQuery,
useMissionaryPassedCoursesQuery,
} from '@/services/query/missionary-education'
const route = useRoute()
const router = useRouter()
const termId = computed(() => route.params.id)
// Term summary is served from the passed-terms list cache (same query key as
// the list page); it only refetches on a cold deep-link. No detail endpoint.
const termsPagination = ref({ page: 1, perPage: 20 })
const { data: termsData, isLoading: termLoading } = useMissionaryPassedTermsQuery(termsPagination)
const enrollment = computed(() =>
(termsData.value?.data ?? []).find((e) => String(e?.term?.id) === String(termId.value))
)
const coursesFilters = computed(() => ({ termId: termId.value }))
const coursesPagination = ref({ page: 1, perPage: 20 })
const { data: coursesData, isLoading: coursesLoading } = useMissionaryPassedCoursesQuery(
coursesFilters,
coursesPagination,
{ enabled: () => !!termId.value, keepPreviousData: true }
)
const courses = computed(() => coursesData.value?.data ?? [])
const onShowLesson = (lesson) => {
router
.push({
name: 'missionary-education-lesson',
params: { id: lesson.id },
query: { termId: termId.value },
})
.catch(() => {})
}
</script>
<style lang="scss" scoped>
.missionary-term-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
&__list-title {
margin-top: 0.5rem;
}
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="missionary-education">
<BoxedIconTitleBlock
class="missionary-education__heading"
title="آموزش من"
desc="ترم‌هایی که با موفقیت گذرانده‌اید را مشاهده کنید."
>
<template #icon>
<SvgIcon name="graduation-cap" :size="24" color="var(--color-primary)" />
</template>
</BoxedIconTitleBlock>
<SkeletonLoaderBlock v-if="isLoading" :rows="4" :cols-per-row="1" />
<div v-else-if="terms.length > 0">
<StudentTermItem
v-for="enrollment in terms"
:key="enrollment.id"
:enrollment="enrollment"
@show-details="onShowDetails"
/>
</div>
<NoItems v-else title="ترمی نیست" desc="هنوز ترمی را نگذرانده‌اید." />
<PaginationBlock :pagination="meta" @update:page="setPage" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { useMissionaryPassedTermsQuery } from '@/services/query/missionary-education'
import StudentTermItem from '@/features/student/terms/components/StudentTermItem.vue'
const router = useRouter()
const { pagination, setPage } = usePagination({ page: 1, perPage: 20 })
const { data, isLoading } = useMissionaryPassedTermsQuery(pagination, { keepPreviousData: true })
const terms = computed(() => data.value?.data ?? [])
const meta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
...data.value?.meta,
}))
const onShowDetails = (enrollment) => {
router
.push({ name: 'missionary-education-term', params: { id: enrollment?.term?.id } })
.catch(() => {})
}
</script>
<style lang="scss" scoped>
.missionary-education {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__heading {
margin-bottom: 0.75rem;
}
}
</style>
+45
View File
@@ -32,4 +32,49 @@ export default [
subtitle: 'اینجا پل ارتباطی شما با پشتیبانی است.',
},
},
{
path: '/missionary-panel/education',
name: 'missionary-education',
component: () => import('@/features/missionary/education/pages/MissionaryTermsPage.vue'),
meta: {
layout: 'missionary',
role: 'missionary',
title: 'آموزش من',
subtitle: 'ترم‌ها و دوره‌های گذرانده‌شده خود را مرور کنید.',
},
},
{
path: '/missionary-panel/education/terms/:id',
name: 'missionary-education-term',
component: () => import('@/features/missionary/education/pages/MissionaryTermDetailsPage.vue'),
meta: {
layout: 'missionary',
role: 'missionary',
title: 'آموزش من',
subtitle: 'ترم‌ها و دوره‌های گذرانده‌شده خود را مرور کنید.',
},
},
{
path: '/missionary-panel/education/lessons/:id',
name: 'missionary-education-lesson',
component: () =>
import('@/features/missionary/education/pages/MissionaryLessonDetailsPage.vue'),
meta: {
layout: 'missionary',
role: 'missionary',
title: 'آموزش من',
subtitle: 'ترم‌ها و دوره‌های گذرانده‌شده خود را مرور کنید.',
},
},
{
path: '/missionary-panel/education/sessions/:id',
name: 'missionary-education-session',
component: () => import('@/features/student/sessions/pages/StudentSessionDetailsPage.vue'),
meta: {
layout: 'missionary',
role: 'missionary',
title: 'آموزش من',
subtitle: 'ترم‌ها و دوره‌های گذرانده‌شده خود را مرور کنید.',
},
},
]
+6
View File
@@ -70,6 +70,12 @@ const menuItems = computed(() => [
to: { name: 'missionary-requests' },
active: route.name === 'missionary-requests',
},
{
title: 'آموزش من',
icon: 'graduation-cap',
to: { name: 'missionary-education' },
active: route.name?.startsWith('missionary-education'),
},
{
title: 'تیکت',
icon: 'envelope-open',
+8
View File
@@ -30,6 +30,14 @@ export const endpoints = {
// dedicated student route (returns the membership row with the nested term).
getStudentDashboard: '/student/dashboard',
getStudentTerms: '/student/my-terms',
// Missionary — passed (completed) education. List-only; same shapes as
// /student/my-terms, /courses, /sessions. Detail views reuse the shared
// /sessions/:id and the already-fetched list data (via query cache).
getMissionaryPassedTerms: '/missionary/passed-terms',
getMissionaryPassedCourses: '/missionary/passed-courses',
getMissionaryPassedSessions: '/missionary/passed-sessions',
showStudentTerm: '/student/my-terms/:id',
getStudentExamResults: '/student/exam-results',
getStudentHomeworks: '/student/homeworks',
+11
View File
@@ -0,0 +1,11 @@
import { http } from '@/services/api/http'
import { endpoints } from '@/services/api/endpoints'
export const apiGetMissionaryPassedTerms = (params) =>
http.get(endpoints.getMissionaryPassedTerms, { params })
export const apiGetMissionaryPassedCourses = (params) =>
http.get(endpoints.getMissionaryPassedCourses, { params })
export const apiGetMissionaryPassedSessions = (params) =>
http.get(endpoints.getMissionaryPassedSessions, { params })
@@ -0,0 +1,122 @@
// Missionary passed (completed) education. Shapes mirror /student/my-terms,
// /courses and /sessions. Session ids reuse existing adminSessions ids so the
// shared GET /sessions/:id mock resolves the detail drill-down.
const makeTerm = (id, title, over) => ({
id,
title,
description: 'ترم تکمیل‌شده',
isActive: false,
startsAt: '2025-09-22T00:00:00+00:00',
endsAt: '2026-01-20T00:00:00+00:00',
score: 20,
minimumScore: 12,
coverUrl: null,
coursesCount: 3,
sessionsCount: 18,
examsCount: 12,
homeworksCount: 15,
studentsCount: 40,
createdAt: '2025-08-01T10:00:00+00:00',
...over,
})
export const passedTerms = [
{
id: 9,
userId: 14,
termId: 3,
status: 'completed',
completedAt: '2026-02-01T10:00:00+00:00',
term: makeTerm(3, 'پاییز ۱۴۰۴'),
},
{
id: 10,
userId: 14,
termId: 4,
status: 'completed',
completedAt: '2025-07-05T10:00:00+00:00',
term: makeTerm(4, 'تابستان ۱۴۰۴', { coursesCount: 1, sessionsCount: 6 }),
},
]
const teacher = { id: 2, name: 'علیرضا حسینی' }
export const passedCourses = [
{
id: 21,
termId: 3,
teacherId: 2,
teacher,
title: 'مبانی اعتقادات',
description: 'درس پایه',
capacity: 50,
isActive: false,
coverUrl: null,
sessionsCount: 2,
examsCount: 6,
homeworksCount: 7,
prerequisiteCourseIds: [],
prerequisiteCourses: [],
},
{
id: 22,
termId: 3,
teacherId: 2,
teacher,
title: 'اخلاق کاربردی',
description: 'درس تکمیلی',
capacity: 40,
isActive: false,
coverUrl: null,
sessionsCount: 1,
examsCount: 2,
homeworksCount: 3,
prerequisiteCourseIds: [],
prerequisiteCourses: [],
},
{
id: 30,
termId: 4,
teacherId: 2,
teacher,
title: 'تاریخ اسلام',
description: 'درس پایه',
capacity: 45,
isActive: false,
coverUrl: null,
sessionsCount: 0,
examsCount: 1,
homeworksCount: 2,
prerequisiteCourseIds: [],
prerequisiteCourses: [],
},
]
const makeSession = (id, courseId, title, over) => ({
id,
courseId,
title,
description: 'جلسه گذرانده‌شده',
type: 'online',
startsAt: '2025-09-25T18:00:00+03:30',
durationMinutes: 90,
location: null,
link: 'https://meet.example.com/abc',
prerequisiteSessionIds: [],
examsCount: 1,
homeworksCount: 2,
prerequisiteSessions: [],
isComplete: true,
isSeen: true,
course: { id: courseId, termId: 3, teacherId: 2, title: 'مبانی اعتقادات' },
...over,
})
export const passedSessions = [
makeSession(101, 21, 'جلسه اول — مقدمه'),
makeSession(102, 21, 'جلسه دوم — مفاهیم پایه'),
makeSession(103, 22, 'جلسه اول — کلیات', {
course: { id: 22, termId: 3, teacherId: 2, title: 'اخلاق کاربردی' },
}),
]
@@ -0,0 +1,27 @@
import { paginate } from '@/services/mock/helpers'
import { register } from '@/services/mock/registry'
import { endpoints } from '@/services/api/endpoints'
import {
passedTerms,
passedCourses,
passedSessions,
} from '@/services/mock/fixtures/missionary-education'
register('GET', endpoints.getMissionaryPassedTerms, ({ query }) => {
const { data: items, meta } = paginate(passedTerms, query)
return { success: true, message: 'OK', data: { items, meta } }
})
register('GET', endpoints.getMissionaryPassedCourses, ({ query }) => {
let list = passedCourses
if (query.termId) list = list.filter((c) => String(c.termId) === String(query.termId))
const { data: items, meta } = paginate(list, query)
return { success: true, message: 'OK', data: { items, meta } }
})
register('GET', endpoints.getMissionaryPassedSessions, ({ query }) => {
let list = passedSessions
if (query.courseId) list = list.filter((s) => String(s.courseId) === String(query.courseId))
const { data: items, meta } = paginate(list, query)
return { success: true, message: 'OK', data: { items, meta } }
})
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/vue-query'
import { cleanFilters } from '@/utils/clean-filters'
import {
apiGetMissionaryPassedTerms,
apiGetMissionaryPassedCourses,
apiGetMissionaryPassedSessions,
} from '@/services/api/missionary-education'
// All three passed-* endpoints return the paginated `{ items, meta }` envelope,
// mirroring /student/my-terms, /courses and /sessions.
const selectList = (response) => {
const payload = response?.data ?? response ?? {}
return {
data: Array.isArray(payload.items) ? payload.items : payload ?? [],
meta: payload.meta,
}
}
export const useMissionaryPassedTermsQuery = (paginationRef, options = {}) =>
useQuery({
queryKey: ['missionary', 'passed-terms', paginationRef],
queryFn: () => apiGetMissionaryPassedTerms({ ...paginationRef.value }),
select: selectList,
...options,
})
export const useMissionaryPassedCoursesQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['missionary', 'passed-courses', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryPassedCourses({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
select: selectList,
...options,
})
export const useMissionaryPassedSessionsQuery = (filtersRef, paginationRef, options = {}) =>
useQuery({
queryKey: ['missionary', 'passed-sessions', filtersRef, paginationRef],
queryFn: () =>
apiGetMissionaryPassedSessions({
...cleanFilters(filtersRef.value),
...paginationRef.value,
}),
select: selectList,
...options,
})