417 lines
10 KiB
Vue
417 lines
10 KiB
Vue
<template>
|
|
<div class="p-5">
|
|
<div class="grid grid-cols-4 gap-5 mb-5">
|
|
<NumberChartGraph
|
|
:title="__('Enrolled')"
|
|
:value="formatAmount(course.data?.enrollments)"
|
|
/>
|
|
<NumberChartGraph
|
|
:title="__('Average Completion Rate')"
|
|
:value="averageCompletionRate"
|
|
/>
|
|
<NumberChartGraph
|
|
:title="__('Average Rating')"
|
|
:value="course.data?.rating || 0"
|
|
>
|
|
<template #prefix>
|
|
<Star class="size-5 text-transparent fill-amber-500" />
|
|
</template>
|
|
</NumberChartGraph>
|
|
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
|
|
</div>
|
|
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
|
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
|
{{ __('Students') }}
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<FormControl
|
|
v-model="searchFilter"
|
|
:placeholder="__('Search by name')"
|
|
type="text"
|
|
/>
|
|
<Button @click="showEnrollmentModal = true">
|
|
<template #prefix>
|
|
<Plus class="size-4 stroke-1.5" />
|
|
</template>
|
|
{{ __('Enroll') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="progressList.loading || progressList.data?.length"
|
|
class="max-h-[63vh] overflow-y-auto"
|
|
>
|
|
<ListView
|
|
:columns="progressColumns"
|
|
:rows="progressList.data"
|
|
rowKey="name"
|
|
:options="{
|
|
selectable: false,
|
|
showTooltip: false,
|
|
}"
|
|
>
|
|
<ListHeader
|
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
|
>
|
|
<ListHeaderItem
|
|
:item="item"
|
|
v-for="item in progressColumns"
|
|
:key="item.key"
|
|
>
|
|
</ListHeaderItem>
|
|
</ListHeader>
|
|
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
|
|
<ListRow
|
|
:row="row"
|
|
@click="
|
|
() => {
|
|
showProgressModal = true
|
|
currentStudent = row
|
|
}
|
|
"
|
|
class="cursor-pointer"
|
|
>
|
|
<template #default="{ column, item }">
|
|
<ListRowItem
|
|
:item="row[column.key]"
|
|
:align="column.align"
|
|
class="w-full"
|
|
>
|
|
<template #prefix>
|
|
<div v-if="column.key == 'member_name'">
|
|
<Avatar
|
|
class="flex items-center"
|
|
:image="row['member_image']"
|
|
:label="item"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
<ProgressBar
|
|
v-else-if="column.key == 'progress'"
|
|
:progress="Math.ceil(row[column.key])"
|
|
class="!mx-0 !mr-4"
|
|
/>
|
|
</template>
|
|
<div v-if="column.key == 'creation'">
|
|
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
|
</div>
|
|
<div
|
|
v-else-if="column.key == 'progress'"
|
|
class="text-xs !mx-0 w-5"
|
|
>
|
|
{{ Math.ceil(row[column.key]) }}%
|
|
</div>
|
|
<div v-else>
|
|
{{ row[column.key].toString() }}
|
|
</div>
|
|
</ListRowItem>
|
|
</template>
|
|
</ListRow>
|
|
</ListRows>
|
|
</ListView>
|
|
<div
|
|
v-if="progressList.data && progressList.hasNextPage"
|
|
class="flex justify-center my-3"
|
|
>
|
|
<Button @click="progressList.next()">
|
|
{{ __('Load More') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-5">
|
|
<div
|
|
v-if="chartDetails.data?.average_progress > 0"
|
|
class="border rounded-lg p-4"
|
|
>
|
|
<div class="text-ink-gray-5 mb-4">
|
|
{{ __('Progress Summary') }}
|
|
</div>
|
|
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
|
|
<div class="flex flex-col space-y-4 flex-1 text-sm">
|
|
<div
|
|
class="flex items-center text-ink-gray-7"
|
|
v-for="row in chartDetails.data?.progress_distribution"
|
|
>
|
|
<div
|
|
class="size-2 rounded"
|
|
:style="{
|
|
backgroundColor:
|
|
colors[theme][
|
|
row.name.startsWith('Just')
|
|
? 'red'
|
|
: row.name.startsWith('In')
|
|
? 'amber'
|
|
: row.name.startsWith('Adv')
|
|
? 'blue'
|
|
: 'green'
|
|
][400],
|
|
}"
|
|
></div>
|
|
<Tooltip :text="row.name.split('(')[1].replace(')', '')">
|
|
<div class="ml-2">
|
|
{{ row.name.split('(')[0] }}
|
|
</div>
|
|
</Tooltip>
|
|
<Tooltip :text="row.value">
|
|
<div class="ml-auto">
|
|
{{
|
|
Math.round((row.value / course.data?.enrollments) * 100)
|
|
}}%
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<ECharts
|
|
class="w-40 h-20"
|
|
:options="{
|
|
color: progressColors,
|
|
series: [
|
|
{
|
|
type: 'pie',
|
|
radius: ['50%', '70%'],
|
|
center: ['50%', '50%'],
|
|
label: {
|
|
show: false,
|
|
},
|
|
labelLine: {
|
|
show: false,
|
|
},
|
|
emphasis: {
|
|
label: {
|
|
show: false,
|
|
},
|
|
scale: false,
|
|
},
|
|
legend: {
|
|
show: false,
|
|
},
|
|
data: chartDetails.data?.progress_distribution || [],
|
|
},
|
|
],
|
|
showInlineLabels: false,
|
|
}"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="lessonProgress.data?.length"
|
|
class="border rounded-lg pt-4 px-4"
|
|
>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="text-ink-gray-5">
|
|
{{ __('Lesson Completion') }}
|
|
</div>
|
|
<Select
|
|
:options="lessonProgressSortingOptions"
|
|
@update:modelValue="(value: string) => updateLessonProgress(value)"
|
|
:placeholder="__('Sort by')"
|
|
class="!w-32"
|
|
/>
|
|
</div>
|
|
<div class="divide-y max-h-[43vh] text-ink-gray-7 overflow-y-auto">
|
|
<div
|
|
v-for="progress in lessonProgress.data"
|
|
class="flex justify-between text-sm py-2 my-1"
|
|
>
|
|
<div class="">
|
|
<span class="mr-3 text-xs">
|
|
{{ progress.chapter_idx }}.{{ progress.idx }}
|
|
</span>
|
|
<span>
|
|
{{ progress.title }}
|
|
</span>
|
|
</div>
|
|
<Tooltip :text="progress.completion_count">
|
|
<div>
|
|
{{
|
|
Math.ceil(
|
|
(progress.completion_count / course.data?.enrollments) *
|
|
100
|
|
)
|
|
}}%
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<CourseEnrollmentModal
|
|
v-if="showEnrollmentModal"
|
|
v-model="showEnrollmentModal"
|
|
:course="course"
|
|
/>
|
|
<StudentCourseProgress
|
|
v-if="showProgressModal"
|
|
v-model="showProgressModal"
|
|
:course="course"
|
|
:student="currentStudent"
|
|
:lessons="lessonProgress"
|
|
/>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import {
|
|
Avatar,
|
|
Button,
|
|
createListResource,
|
|
createResource,
|
|
dayjs,
|
|
Dropdown,
|
|
ECharts,
|
|
FormControl,
|
|
ListView,
|
|
ListHeader,
|
|
ListHeaderItem,
|
|
ListRows,
|
|
ListRow,
|
|
ListRowItem,
|
|
Select,
|
|
Tooltip,
|
|
} from 'frappe-ui'
|
|
import { computed, ref, watch } from 'vue'
|
|
import { Plus, Star } from 'lucide-vue-next'
|
|
import { formatAmount } from '@/utils'
|
|
import colors from '@/utils/frappe-ui-colors.json'
|
|
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
|
|
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
|
import ProgressBar from '@/components/ProgressBar.vue'
|
|
import StudentCourseProgress from '@/pages/Courses/StudentCourseProgress.vue'
|
|
|
|
const props = defineProps<{
|
|
course: any
|
|
}>()
|
|
|
|
const showEnrollmentModal = ref(false)
|
|
const searchFilter = ref<string | null>(null)
|
|
const showProgressModal = ref(false)
|
|
const currentStudent = ref<any>(null)
|
|
const theme = ref<'darkMode' | 'lightMode'>(
|
|
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
|
)
|
|
type Filters = {
|
|
course: string | undefined
|
|
member_name?: string[]
|
|
}
|
|
|
|
const chartDetails = createResource({
|
|
url: 'lms.lms.api.get_course_progress_distribution',
|
|
makeParams() {
|
|
return {
|
|
course: props.course.data?.name,
|
|
}
|
|
},
|
|
auto: true,
|
|
})
|
|
|
|
const progressList = createListResource({
|
|
doctype: 'LMS Enrollment',
|
|
filters: {
|
|
course: props.course.data?.name,
|
|
},
|
|
fields: [
|
|
'name',
|
|
'member',
|
|
'member_name',
|
|
'member_image',
|
|
'member_username',
|
|
'progress',
|
|
'creation',
|
|
],
|
|
pageLength: 100,
|
|
auto: true,
|
|
cache: ['courseProgress', props.course.data?.name],
|
|
})
|
|
|
|
const lessonProgress = createResource({
|
|
url: 'lms.lms.api.get_lesson_completion_stats',
|
|
params: {
|
|
course: props.course.data?.name,
|
|
},
|
|
auto: true,
|
|
})
|
|
|
|
const updateLessonProgress = (value: string) => {
|
|
if (value == 'completion_rate') {
|
|
lessonProgress.data?.sort((a: any, b: any) => {
|
|
const rateA = a.completion_count / (props.course.data?.enrollments || 1)
|
|
const rateB = b.completion_count / (props.course.data?.enrollments || 1)
|
|
return rateB - rateA
|
|
})
|
|
} else if (value == 'index') {
|
|
lessonProgress.data?.sort((a: any, b: any) => {
|
|
return a.chapter_idx - b.chapter_idx || a.idx - b.idx
|
|
})
|
|
}
|
|
}
|
|
|
|
watch([searchFilter], () => {
|
|
let filters: Filters = {
|
|
course: props.course.data?.name,
|
|
}
|
|
|
|
if (searchFilter.value) {
|
|
filters.member_name = ['like', `%${searchFilter.value}%`]
|
|
}
|
|
|
|
progressList.update({
|
|
filters: filters,
|
|
})
|
|
progressList.reload()
|
|
})
|
|
|
|
const averageCompletionRate = computed(() => {
|
|
let value = Math.ceil(chartDetails.data?.average_progress) || 0
|
|
return value + '%'
|
|
})
|
|
|
|
const progressColors = computed(() => {
|
|
let colorList = []
|
|
colorList.push(colors[theme.value]['red'][400])
|
|
colorList.push(colors[theme.value]['amber'][400])
|
|
colorList.push(colors[theme.value]['blue'][400])
|
|
colorList.push(colors[theme.value]['green'][400])
|
|
return colorList
|
|
})
|
|
|
|
const progressColumns = computed(() => {
|
|
return [
|
|
{
|
|
label: __('Name'),
|
|
key: 'member_name',
|
|
width: '40%',
|
|
},
|
|
{
|
|
label: __('Progress'),
|
|
key: 'progress',
|
|
width: '30%',
|
|
},
|
|
{
|
|
label: __('Enrolled On'),
|
|
key: 'creation',
|
|
align: 'right',
|
|
},
|
|
]
|
|
})
|
|
|
|
const lessonProgressSortingOptions = [
|
|
{
|
|
label: __('Lesson Index'),
|
|
value: 'index',
|
|
onClick() {
|
|
updateLessonProgress('index')
|
|
},
|
|
},
|
|
{
|
|
label: __('Completion Rate'),
|
|
value: 'completion_rate',
|
|
onClick() {
|
|
updateLessonProgress('completion_rate')
|
|
},
|
|
},
|
|
]
|
|
</script>
|