feat: course progress summary report

This commit is contained in:
Jannat Patel
2025-07-03 13:02:57 +05:30
parent 5f065db991
commit 85da4f6d85
8 changed files with 481 additions and 204 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="border-2 rounded-md min-w-80">
<div class="border-2 rounded-md min-w-80 max-w-sm">
<iframe
v-if="course.data.video_link"
:src="video_link"
@@ -26,6 +26,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Continue Learning') }}
</span>
@@ -44,6 +47,9 @@
}"
>
<Button variant="solid" size="md" class="w-full">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Buy this course') }}
</span>
@@ -57,12 +63,15 @@
{{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button
v-else
v-else-if="!user.data?.is_moderator && !is_instructor()"
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<template #prefix>
<BookText class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Start Learning') }}
</span>
@@ -74,8 +83,17 @@
class="w-full mt-2"
size="md"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Get Certificate') }}
</Button>
<Button v-if="user.data?.is_moderator || is_instructor()" class="w-full mt-2" size="md" @click="showProgressSummary">
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
{{ __("Progress Summary") }}
</template>
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
@@ -86,6 +104,9 @@
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
@@ -142,18 +163,25 @@
</div>
</div>
</div>
<CourseProgressSummary
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template>
<script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue'
import { BookOpen, BookText, CreditCard, GraduationCap, Pencil, Star, TrendingUp, Users } from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
const router = useRouter()
const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
@@ -246,4 +274,8 @@ const fetchCertificate = () => {
member: user.data?.name,
})
}
const showProgressSummary = () => {
showProgressModal.value = true
}
</script>

View File

@@ -0,0 +1,182 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Course Progress Summary'),
size: '5xl',
}"
>
<template #body-content>
<div class="flex justify-between space-x-10 text-base">
<div class="w-full ">
<div class="flex items-center justify-between space-x-5 mb-4">
<div class="text-xl font-semibold text-ink-gray-6">
{{ __("{0} Members").format(memberCount) }}
</div>
<FormControl
v-model="searchFilter"
:label="__('Search by Member Name')"
type="text"
class="w-1/2"
/>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<ListView
v-if="progressList.loading || progressList.data?.length"
: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-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in progressColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
</template>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data">
<router-link :to="{
name: 'Profile',
params: { username: row.member_username },
}">
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="mb-4 self-start w-full space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Progress %'),
value: chartDetails.data?.average_progress || 0,
}"
/>
<DonutChart
:config="{
data: chartDetails.data?.progress_distribution || [],
title: __('Progress Distribution'),
categoryColumn: 'category',
valueColumn: 'count',
colors: [theme.colors.red['400'], theme.colors.amber['400'], theme.colors.pink['400'], theme.colors.blue['400'], theme.colors.green['400']],
}"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Avatar, Button, createListResource, createResource, Dialog, DonutChart, FeatherIcon, FormControl, ListView, ListHeader, ListHeaderItem, ListRows, ListRow, ListRowItem, NumberChart } from 'frappe-ui';
import { computed, ref, watch } from 'vue';
import { theme } from '@/utils/theme'
const show = defineModel<boolean | undefined>()
const searchFilter = ref<string | null>(null);
const props = defineProps<{
courseName?: string;
enrollments?: number;
}>();
const memberCount = ref<number>(props.enrollments || 0);
const chartDetails = createResource({
url: "lms.lms.api.get_course_progress_distribution",
params: {
course: props.courseName,
},
auto: true,
})
const progressList = createListResource({
doctype: "LMS Enrollment",
filters: {
course: props.courseName,
},
fields: ["name", "member", "member_name", "member_image", "member_username", "progress"],
pageLength: 50,
auto: true,
})
watch([searchFilter], () => {
let filterApplied = false;
type Filters = {
course: string | undefined;
member_name?: string[];
}
let filters: Filters = {
course: props.courseName,
}
if (searchFilter.value) {
filters.member_name = ["like", `%${searchFilter.value}%`];
filterApplied = true;
}
progressList.update({
filters: filters,
})
progressList.reload({}, {
onSuccess(data: any[]) {
memberCount.value = filterApplied ? data.length : props.enrollments || 0;
}
});
});
const progressColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: '50%',
icon: 'user',
},
{
label: __('Progress'),
key: 'progress',
width: '30%',
align: 'right',
icon: 'trending-up',
}
]
})
</script>

View File

@@ -145,7 +145,6 @@ const submissions = createListResource({
},
})
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
watch([assignmentID, member, status], () => {
router.push({
query: {

View File

@@ -6,7 +6,7 @@
<Breadcrumbs class="h-7" :items="breadcrumbs" />
</header>
<div class="m-5">
<div class="flex justify-between w-full">
<div class="flex justify-between w-full space-x-5">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ course.data.title }}
@@ -66,7 +66,9 @@
{{ tag }}
</Badge>
</div>
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div class="md:hidden mb-4">
<CourseCardOverlay :course="course" />
</div>
<div
v-html="course.data.description"
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"