This commit is contained in:
@@ -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>
|
||||
@@ -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: 'ترمها و دورههای گذراندهشده خود را مرور کنید.',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user