feat: program self enrollment
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
135
frontend/src/pages/Programs/ProgramDetail.vue
Normal file
135
frontend/src/pages/Programs/ProgramDetail.vue
Normal 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>
|
||||
157
frontend/src/pages/Programs/ProgramEnrollment.vue
Normal file
157
frontend/src/pages/Programs/ProgramEnrollment.vue
Normal 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>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user