feat: program self enrollment

This commit is contained in:
Jannat Patel
2025-08-19 17:33:20 +05:30
parent acd003814a
commit 9d3b6e0556
12 changed files with 437 additions and 138 deletions

View File

@@ -361,14 +361,9 @@ const addProgrammingExercises = () => {
}
const addPrograms = () => {
let activeFor = ['Programs']
let activeFor = ['Programs', 'ProgramDetail']
let index = 1
if (!isInstructor.value && !isModerator.value) {
activeFor.push('CourseDetail')
activeFor.push('Lesson')
}
sidebarLinks.value.splice(index, 0, {
label: 'Programs',
icon: 'Route',

View File

@@ -209,12 +209,13 @@
v-else
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-8"
>
<LessonContent
content
<!-- <LessonContent
v-if="lesson.data?.body"
:content="lesson.data.body"
:youtube="lesson.data.youtube"
:quizId="lesson.data.quiz_id"
/>
/> -->
</div>
</div>
<div

View File

@@ -0,0 +1,135 @@
<template>
<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 :items="breadcrumbs" />
</header>
<div v-if="program.data" class="pt-5 px-5 pb-10 mx-auto">
<div class="flex items-center mb-5">
<div class="text-lg font-semibold text-ink-gray-9">
{{ program.data.name }}
</div>
<Tooltip
v-if="program.data.enforce_course_order"
:text="
__(
'Courses must be completed in order. You can only start the next course after completing the previous one.'
)
"
>
<Route
class="size-5 ml-2 hover:bg-surface-gray-3 cursor-pointer p-1 rounded-sm"
/>
</Tooltip>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
<div
v-for="course in program.data.courses"
:key="course.name"
class="relative group"
:class="
(course.eligible && program.data.enforce_course_order) ||
!program.data.enforce_course_order
? 'cursor-pointer'
: 'cursor-default'
"
>
<CourseCard
:course="course"
@click="openCourse(course, program.data.enforce_course_order)"
/>
<div
v-if="!course.eligible && program.data.enforce_course_order"
class="absolute inset-0 flex flex-col items-center justify-center space-y-2 text-ink-white rounded-md invisible group-hover:visible"
:style="{
background: 'radial-gradient(circle, darkgray 0%, lightgray 100%)',
}"
>
<LockKeyhole class="size-5" />
<span class="font-medium text-center leading-5 px-10">
{{ __('Please complete the previous course to unlock this one.') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, onMounted } from 'vue'
import {
Breadcrumbs,
call,
createResource,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import { LockKeyhole, Route } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import CourseCard from '@/components/CourseCard.vue'
const { brand } = sessionStore()
const router = useRouter()
const user = inject<any>('$user')
const props = defineProps<{
programName: string
}>()
onMounted(() => {
checkIfEnrolled()
})
const checkIfEnrolled = () => {
call('frappe.client.get_value', {
doctype: 'LMS Program Member',
filters: {
member: user.data.name,
parent: props.programName,
},
parent: 'LMS Program',
fieldname: 'name',
}).then((data: { name: string }) => {
if (data.name) {
program.reload()
} else {
router.push({ name: 'Programs' })
}
})
}
const program = createResource({
url: 'lms.lms.utils.get_program_details',
params: {
program_name: props.programName,
},
})
const openCourse = (course: any, enforceCourseOrder: boolean) => {
if (!course.eligible && enforceCourseOrder) return
router.push({
name: 'CourseDetail',
params: { courseName: course.name },
})
}
const breadcrumbs = computed(() => {
return [
{ label: __('Programs'), route: { name: 'Programs' } },
{
label: props.programName,
route: {
name: 'ProgramDetail',
params: { programName: props.programName },
},
},
]
})
usePageMeta(() => {
return {
title: props.programName,
icon: brand.favicon,
}
})
</script>

View File

@@ -0,0 +1,157 @@
<template>
<Dialog
v-model="show"
:options="{
size: '2xl',
}"
>
<template #body-title>
<div v-if="program.data" class="text-xl font-semibold text-ink-gray-9">
{{ __('Enrollment for Program {0}').format(program.data?.name) }}
</div>
</template>
<template #body-content>
<div v-if="program.data" class="text-base">
<div class="bg-surface-blue-2 text-ink-blue-3 p-2 rounded-md leading-5">
<span>
{{
__('This program consists of {0} courses').format(
program.data.courses.length
)
}}
</span>
<span v-if="program.data.enforce_course_order">
{{
__(
' designed as a structured learning path to guide your progress. Courses in this program must be taken in order, and each course will unlock as you complete the previous one. '
)
}}
</span>
<span v-else>
{{
__(
' designed as a learning path to guide your progress. You may take the courses in any order that suits you. '
)
}}
</span>
<span>
{{ __('Are you sure you want to enroll?') }}
</span>
</div>
<div class="mt-5">
<div class="text-sm font-semibold text-ink-gray-5">
{{ __('Courses in this Program') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div
v-for="course in program.data.courses"
class="flex flex-col border p-2 rounded-md h-full"
>
<div class="font-semibold leading-5 mb-2">
{{ course.title }}
</div>
<!-- <div class="text-sm text-ink-gray-7 mb-8">
{{ course.short_introduction }}
</div> -->
<div
class="flex items-center space-x-5 text-sm text-ink-gray-5 mb-8"
>
<Tooltip :text="__('Lessons')">
<span class="flex items-center space-x-1">
<BookOpen class="size-3 stroke-1.5" />
<span> {{ course.lessons }} {{ __('lessons') }} </span>
</span>
</Tooltip>
<Tooltip :text="__('Enrolled Students')">
<span class="flex items-center space-x-1">
<User class="size-3 stroke-1.5" />
<span> {{ course.enrollments }} {{ __('students') }} </span>
</span>
</Tooltip>
<!-- <Tooltip v-if="course.rating" :text="__('Average Rating')">
<span class="flex items-center space-x-1">
<Star class="size-3 stroke-1.5" />
<span>
{{ course.rating }} {{ __("rating") }}
</span>
</span>
</Tooltip> -->
</div>
<div class="flex items-center space-x-1 mt-auto">
<UserAvatar :user="course.instructors[0]" />
<span>
{{ course.instructors[0].full_name }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="flex justify-end space-x-2 group">
<Button variant="solid" @click="enrollInProgram(close)">
{{ __('Confirm Enrollment') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, createResource, Dialog, toast, Tooltip } from 'frappe-ui'
import { inject, watch } from 'vue'
import { BookOpen, Star, User } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import CourseInstructors from '@/components/CourseInstructors.vue'
const show = defineModel()
const user = inject<any>('$user')
const router = useRouter()
const props = defineProps<{
programName: any
}>()
const program = createResource({
url: 'lms.lms.utils.get_program_details',
makeParams(values: any) {
return {
program_name: props.programName,
}
},
auto: false,
})
watch(
() => props.programName,
() => {
if (props.programName) {
program.reload()
}
}
)
const enrollInProgram = (close: () => void) => {
call('lms.lms.utils.enroll_in_program', {
program: props.programName,
})
.then(() => {
toast.success(__('Successfully enrolled in program'))
router.push({
name: 'ProgramDetail',
params: { programName: props.programName },
})
close()
})
.catch((error: any) => {
toast.error(__('Failed to enroll in program: {0}').format(error.message))
console.error('Enrollment Error:', error)
})
}
</script>

View File

@@ -2,10 +2,21 @@
<Dialog
v-model="show"
:options="{
title: programName === 'new' ? __('Create Program') : __('Edit Program'),
size: '2xl',
}"
>
<template #body-title>
<div class="flex items-center justify-between text-base w-full space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{
programName === 'new' ? __('Create Program') : __('Edit Program')
}}
</div>
<Badge theme="orange" v-if="dirty">
{{ __('Not Saved') }}
</Badge>
</div>
</template>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pb-5">
@@ -14,22 +25,20 @@
:label="__('Title')"
type="text"
:required="true"
@change="dirty = true"
/>
<div class="flex flex-col space-y-3">
<FormControl
v-model="program.published"
:label="__('Published')"
type="checkbox"
@change="dirty = true"
/>
<FormControl
v-model="program.enforce_course_order"
:label="__('Enforce Course Order')"
type="checkbox"
/>
<FormControl
v-model="program.allow_self_enrollment"
:label="__('Allow Self Enrollment')"
type="checkbox"
@change="dirty = true"
/>
</div>
</div>
@@ -208,6 +217,7 @@
</template>
<script setup lang="ts">
import {
Badge,
Button,
createListResource,
Dialog,
@@ -233,6 +243,7 @@ const showDialog = ref(false)
const currentForm = ref<'course' | 'member'>('course')
const course = ref<string>('')
const member = ref<string>('')
const dirty = ref(false)
const props = withDefaults(
defineProps<{
@@ -248,7 +259,6 @@ const program = ref<Program>({
title: '',
published: false,
enforce_course_order: false,
allow_self_enrollment: false,
program_courses: [],
program_members: [],
})
@@ -277,11 +287,11 @@ const setProgramData = () => {
title: '',
published: false,
enforce_course_order: false,
allow_self_enrollment: false,
program_courses: [],
program_members: [],
}
}
dirty.value = false
}
const programCourses = createListResource({
@@ -331,6 +341,7 @@ const fetchMembers = () => {
const saveProgram = (close: () => void) => {
if (props.programName === 'new') createNewProgram(close)
else updateProgram(close)
dirty.value = false
}
const createNewProgram = (close: () => void) => {

View File

@@ -52,7 +52,7 @@
import { Breadcrumbs, Button, usePageMeta, createListResource } from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, Plus, User } from 'lucide-vue-next'
import { sessionStore } from '../../stores/session'
import { sessionStore } from '@/stores/session'
import ProgramForm from '@/pages/Programs/ProgramForm.vue'
import EmptyState from '@/components/EmptyState.vue'
import StudentPrograms from '@/pages/Programs/StudentPrograms.vue'
@@ -82,7 +82,6 @@ const programs = createListResource({
'course_count',
'published',
'enforce_course_order',
'allow_self_enrollment',
],
auto: false,
orderBy: 'creation desc',

View File

@@ -7,71 +7,86 @@
<TabButtons v-model="currentTab" :buttons="tabs" class="w-fit" />
</div>
<div v-for="(data, category) in programs.data">
<div v-if="category == currentTab" class="grid grid-cols-3 gap-5">
<div
v-for="program in data"
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer"
>
<div class="text-lg font-semibold mb-2">
{{ program.name }}
</div>
<!-- <div class="flex items-center space-x-10">
<div class="flex items-center space-x-1">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span>
{{ program.course_count }}
</span>
</div>
<div class="flex items-center space-x-1">
<User class="h-4 w-4 stroke-1.5" />
<span>
{{ program.member_count || 0 }}
</span>
</div>
</div> -->
<div class="flex items-center space-x-5 text-sm text-ink-gray-7">
<div class="flex items-center space-x-1">
<BookOpen class="size-3 stroke-1.5" />
<span>
{{ program.course_count }}
{{ program.course_count == 1 ? __('course') : __('courses') }}
</span>
<div v-if="category == currentTab">
<div v-if="data.length > 0" class="grid grid-cols-3 gap-5">
<div
v-for="program in data"
@click="openDetails(program.name, category)"
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer"
>
<div class="text-lg font-semibold mb-2">
{{ program.name }}
</div>
<div class="flex items-center space-x-1">
<User class="size-4 stroke-1.5" />
<span>
{{ program.member_count || 0 }}
{{ program.member_count == 1 ? __('member') : __('members') }}
</span>
</div>
</div>
<div v-if="Object.keys(program).includes('progress')" class="mt-5">
<ProgressBar :progress="program.progress" />
<div class="text-sm mt-1">
{{ Math.ceil(program.progress) }}% {{ __('completed') }}
<div class="flex items-center space-x-5 text-sm text-ink-gray-7">
<div class="flex items-center space-x-1">
<BookOpen class="size-3 stroke-1.5" />
<span>
{{ program.course_count }}
{{ program.course_count == 1 ? __('course') : __('courses') }}
</span>
</div>
<div class="flex items-center space-x-1">
<User class="size-4 stroke-1.5" />
<span>
{{ program.member_count || 0 }}
{{ program.member_count == 1 ? __('member') : __('members') }}
</span>
</div>
</div>
<div v-if="Object.keys(program).includes('progress')" class="mt-5">
<ProgressBar :progress="program.progress" />
<div class="text-sm mt-1">
{{ Math.ceil(program.progress) }}% {{ __('completed') }}
</div>
</div>
</div>
</div>
<EmptyState v-else :type="convertToTitleCase(category) + ' Programs'" />
<!-- <div v-else class="col-span-3 text-center text-ink-gray-5">
{{ __('No programs found in this category.') }}
</div> -->
</div>
</div>
</div>
<ProgramEnrollment
v-model="showEnrollmentConfirmation"
:programName="enrollmentProgram"
/>
</template>
<script setup lang="ts">
import { createResource, TabButtons } from 'frappe-ui'
import { computed, ref } from 'vue'
import { BookOpen, User } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue'
import ProgramEnrollment from '@/pages/Programs/ProgramEnrollment.vue'
import EmptyState from '@/components/EmptyState.vue'
const currentTab = ref('enrolled')
const router = useRouter()
const showEnrollmentConfirmation = ref(false)
const enrollmentProgram = ref(null)
const programs = createResource({
url: 'lms.lms.utils.get_programs',
auto: true,
})
const openDetails = (programName: any, category: string) => {
if (category === 'enrolled') {
router.push({
name: 'ProgramDetail',
params: { programName: programName },
})
} else {
showEnrollmentConfirmation.value = true
enrollmentProgram.value = programName
}
}
const tabs = computed(() => {
return [
{

View File

@@ -3,7 +3,6 @@ interface Program {
title: string;
published: boolean;
enforce_course_order: boolean;
allow_self_enrollment: boolean;
program_courses: ProgramCourse[];
program_batches: ProgramMember[];
course_count: number;

View File

@@ -188,6 +188,12 @@ const routes = [
name: 'Programs',
component: () => import('@/pages/Programs/Programs.vue'),
},
{
path: '/programs/:programName',
name: 'ProgramDetail',
component: () => import('@/pages/Programs/ProgramDetail.vue'),
props: true,
},
{
path: '/assignments',
name: 'Assignments',