feat: course admin dashboard
This commit is contained in:
@@ -67,7 +67,7 @@ const emit = defineEmits<{
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
modelValue: string | null
|
||||
label?: string
|
||||
description?: string
|
||||
type?: 'image' | 'video'
|
||||
|
||||
@@ -56,14 +56,14 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
<Badge
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
v-else-if="course.data.disable_self_learning && !isAdmin"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
{{ __('Contact the Administrator to enroll for this course') }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||
v-else-if="!isAdmin"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@@ -88,17 +88,6 @@
|
||||
</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="{
|
||||
@@ -168,12 +157,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseProgressSummary
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
v-model="showProgressModal"
|
||||
:courseName="course.data.name"
|
||||
:enrollments="course.data.enrollments"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -191,12 +174,10 @@ import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils/'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const showProgressModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
@@ -295,7 +276,7 @@ const fetchCertificate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const showProgressSummary = () => {
|
||||
showProgressModal.value = true
|
||||
}
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || is_instructor()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Course Progress Summary'),
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div
|
||||
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</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">
|
||||
<div
|
||||
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
|
||||
>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: memberCount || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: chartDetails.data?.average_progress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: chartDetails.data?.progress_distribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
getColor('red', 400),
|
||||
getColor('amber', 400),
|
||||
getColor('pink', 400),
|
||||
getColor('blue', 400),
|
||||
getColor('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 { getColor } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
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
|
||||
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: '60%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
20
frontend/src/components/NumberChartGraph.vue
Normal file
20
frontend/src/components/NumberChartGraph.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="border rounded-md p-3 space-y-2">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<slot name="prefix" />
|
||||
<div class="font-semibold text-2xl">
|
||||
{{ value }}
|
||||
</div>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
value: number | string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<Tooltip :text="`${props.progress}%`">
|
||||
<div class="w-full bg-surface-gray-3 rounded-full h-1">
|
||||
<div
|
||||
class="w-full bg-surface-gray-3 rounded-full h-1"
|
||||
:class="$attrs.class"
|
||||
>
|
||||
<div
|
||||
class="bg-surface-gray-7 rounded-full"
|
||||
:class="progressBarHeight"
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<template>
|
||||
<div v-if="course.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<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 }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="parseInt(course.data.rating) > 0"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||
<span class="ml-1 text-ink-gray-7">
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Users class="h-4 w-4 text-ink-gray-7" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.enrollment_count" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="course.data.tags" class="flex my-4 w-fit">
|
||||
<Badge
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="mr-2 text-ink-gray-9"
|
||||
v-for="tag in course.data.tags.split(', ')"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="md:hidden my-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"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Badge,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch } from 'vue'
|
||||
import { Users, Star } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
watch(course, () => {
|
||||
if (
|
||||
!isInstructor() &&
|
||||
!user.data?.is_moderator &&
|
||||
!course.data?.published &&
|
||||
!course.data?.upcoming
|
||||
) {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isInstructor = () => {
|
||||
let user_is_instructor = false
|
||||
course.data?.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: course?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const courseTitle = ref(null)
|
||||
313
frontend/src/pages/Courses/CourseDashboard.vue
Normal file
313
frontend/src/pages/Courses/CourseDashboard.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div :class="$attrs.class">
|
||||
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||
<NumberChartGraph :title="__('Enrolled')" :value="memberCount || 0" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
||||
<div class="border rounded-md py-3 px-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
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 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]">
|
||||
<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"
|
||||
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>
|
||||
</router-link>
|
||||
</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="border rounded-md 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-3 flex-1 text-xs">
|
||||
<div
|
||||
class="flex items-center"
|
||||
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'
|
||||
: 'green'
|
||||
][400],
|
||||
}"
|
||||
></div>
|
||||
<div class="ml-2">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
{{ Math.round((row.value / course.data?.enrollments) * 100) }}%
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<CourseEnrollmentModal
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:course="course"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
dayjs,
|
||||
ECharts,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Star } from 'lucide-vue-next'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
|
||||
const showEnrollmentModal = ref(false)
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const memberCount = ref<number>(props.course.data?.enrollments || 0)
|
||||
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,
|
||||
})
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
|
||||
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.course.data?.enrollments || 0
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
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]['green'][400])
|
||||
return colorList
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Name'),
|
||||
key: 'member_name',
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
label: __('Start Date'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
123
frontend/src/pages/Courses/CourseDetail.vue
Normal file
123
frontend/src/pages/Courses/CourseDetail.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div v-if="course.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<CourseOverview v-if="!isAdmin" :course="course" class="p-5" />
|
||||
<div v-else>
|
||||
<Tabs :tabs="tabs" v-model="tabIndex">
|
||||
<template #tab-panel="{ tab }">
|
||||
<component :is="tab.component" :course="course" class="p-5" />
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, Tabs, usePageMeta } from 'frappe-ui'
|
||||
import { computed, inject, markRaw, ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { List, Settings2, TrendingUp } from 'lucide-vue-next'
|
||||
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
|
||||
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
|
||||
import CourseForm from '@/pages/Courses/CourseForm.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const tabIndex = ref(1)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: __('Overview'),
|
||||
component: markRaw(CourseOverview),
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
label: __('Dashboard'),
|
||||
component: markRaw(CourseDashboard),
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
label: __('Settings'),
|
||||
component: markRaw(CourseForm),
|
||||
icon: Settings2,
|
||||
},
|
||||
])
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
watch(course, () => {
|
||||
if (!isAdmin.value && !course.data?.published && !course.data?.upcoming) {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isInstructor = () => {
|
||||
let user_is_instructor = false
|
||||
course.data?.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || isInstructor()
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: course?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/pages/Courses/CourseEnrollmentModal.vue
Normal file
97
frontend/src/pages/Courses/CourseEnrollmentModal.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Enroll a Student'),
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:label="__('Purchased Certificate')"
|
||||
v-model="purchasedCertificate"
|
||||
/>
|
||||
<Link
|
||||
doctype="User"
|
||||
:label="__('Student')"
|
||||
placeholder=" "
|
||||
v-model="student"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-if="purchasedCertificate"
|
||||
doctype="LMS Payment"
|
||||
:label="__('Payment')"
|
||||
placeholder=" "
|
||||
v-model="payment"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
() => {
|
||||
openSettings('Transactions')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="enrollStudent(close)">
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { ref } from 'vue'
|
||||
import { openSettings } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const student = ref<string | null>(null)
|
||||
const payment = ref<string | null>(null)
|
||||
const purchasedCertificate = ref<boolean>(false)
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
|
||||
const enrollStudent = (close: () => void) => {
|
||||
let validationPassed = validateData()
|
||||
if (!validationPassed) return
|
||||
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.course.data?.name,
|
||||
member: student.value,
|
||||
payment: purchasedCertificate.value ? payment.value : null,
|
||||
purchased_certificate: purchasedCertificate.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Student enrolled successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const validateData = (): boolean => {
|
||||
if (!student.value) {
|
||||
toast.error(__('Please select a student to enroll.'))
|
||||
return false
|
||||
}
|
||||
if (purchasedCertificate.value && !payment.value) {
|
||||
toast.error(__('Please select a payment for the purchased certificate.'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
@@ -309,7 +309,7 @@ import {
|
||||
import { Trash2, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
102
frontend/src/pages/Courses/CourseOverview.vue
Normal file
102
frontend/src/pages/Courses/CourseOverview.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div>
|
||||
<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 }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="parseInt(course.data.rating) > 0"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||
<span class="ml-1 text-ink-gray-7">
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Users class="h-4 w-4 text-ink-gray-7" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.enrollment_count" class="mx-3">·</span>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="course.data.tags" class="flex my-4 w-fit">
|
||||
<Badge
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="mr-2 text-ink-gray-9"
|
||||
v-for="tag in course.data.tags.split(', ')"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="md:hidden my-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"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Star, Users } from 'lucide-vue-next'
|
||||
import { Badge, Tooltip } from 'frappe-ui'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
</script>
|
||||
@@ -128,7 +128,7 @@ import { sessionStore } from '@/stores/session'
|
||||
import { canCreateCourse } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import router from '../router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -142,6 +142,7 @@ const filters = ref({})
|
||||
const currentTab = ref('Live')
|
||||
const { brand } = sessionStore()
|
||||
const courseCount = ref(0)
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
@@ -12,12 +12,12 @@ const routes = [
|
||||
{
|
||||
path: '/courses',
|
||||
name: 'Courses',
|
||||
component: () => import('@/pages/Courses.vue'),
|
||||
component: () => import('@/pages/Courses/Courses.vue'),
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName',
|
||||
name: 'CourseDetail',
|
||||
component: () => import('@/pages/CourseDetail.vue'),
|
||||
component: () => import('@/pages/Courses/CourseDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -29,7 +29,7 @@ const routes = [
|
||||
{
|
||||
path: '/courses/:courseName/certification',
|
||||
name: 'CourseCertification',
|
||||
component: () => import('@/pages/CourseCertification.vue'),
|
||||
component: () => import('@/pages/Courses/CourseCertification.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -121,7 +121,7 @@ const routes = [
|
||||
{
|
||||
path: '/courses/:courseName/edit',
|
||||
name: 'CourseForm',
|
||||
component: () => import('@/pages/CourseForm.vue'),
|
||||
component: () => import('@/pages/Courses/CourseForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user