mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
refactor: learning path
This commit is contained in:
Vendored
+1
@@ -88,6 +88,7 @@ declare module 'vue' {
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
||||
ProgramForm: typeof import('./src/components/Modals/ProgramForm.vue')['default']
|
||||
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||
|
||||
@@ -361,34 +361,20 @@ const addProgrammingExercises = () => {
|
||||
}
|
||||
|
||||
const addPrograms = () => {
|
||||
let activeFor = ['Programs', 'ProgramForm']
|
||||
let activeFor = ['Programs']
|
||||
let index = 1
|
||||
let canAddProgram = false
|
||||
|
||||
if (
|
||||
!isInstructor.value &&
|
||||
!isModerator.value &&
|
||||
settingsStore.learningPaths.data
|
||||
) {
|
||||
sidebarLinks.value = sidebarLinks.value.filter(
|
||||
(link) => link.label !== 'Courses'
|
||||
)
|
||||
if (!isInstructor.value && !isModerator.value) {
|
||||
activeFor.push('CourseDetail')
|
||||
activeFor.push('Lesson')
|
||||
index = 0
|
||||
canAddProgram = true
|
||||
} else if (isInstructor.value || isModerator.value) {
|
||||
canAddProgram = true
|
||||
}
|
||||
|
||||
if (canAddProgram) {
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
}
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
}
|
||||
|
||||
const openPageModal = (link) => {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="isModerator || isStudent"
|
||||
v-if="canAccessBatch"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
@@ -66,11 +66,11 @@
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<template #prefix>
|
||||
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
|
||||
<LogIn v-else class="size-4 stroke-1.5" />
|
||||
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||
<Settings v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
@@ -204,4 +204,12 @@ const isStudent = computed(() => {
|
||||
const isModerator = computed(() => {
|
||||
return user.data?.is_moderator
|
||||
})
|
||||
|
||||
const isEvaluator = computed(() => {
|
||||
return user.data?.is_evaluator
|
||||
})
|
||||
|
||||
const canAccessBatch = computed(() => {
|
||||
return isModerator.value || isStudent.value || isEvaluator.value
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md border-2 overflow-auto text-ink-gray-9"
|
||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
|
||||
style="min-height: 350px"
|
||||
>
|
||||
<div
|
||||
@@ -47,7 +47,7 @@
|
||||
{{ course.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex flex-col flex-auto p-4 border-x-2 border-b-2 rounded-b-md">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
|
||||
@@ -117,14 +117,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
@@ -146,7 +139,6 @@ const start = ref(0)
|
||||
const memberList = ref<Member[]>([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
|
||||
|
||||
@@ -109,13 +109,6 @@ const tabsStructure = computed(() => {
|
||||
'If enabled, users can access the course and batch lists without logging in.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Enable Learning Paths',
|
||||
name: 'enable_learning_paths',
|
||||
description:
|
||||
'This will ensure students follow the assigned programs in order.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Prevent Skipping Videos',
|
||||
name: 'prevent_skipping_videos',
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
{{ __(link.label) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="link.count"
|
||||
v-if="link.count && !isCollapsed"
|
||||
class="!ml-auto block text-xs text-ink-gray-5"
|
||||
:class="
|
||||
isCollapsed && link.count > 9
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadbrumbs" />
|
||||
<Button variant="solid" @click="saveProgram()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="program.doc" class="pt-5 px-5 w-3/4 mx-auto space-y-10">
|
||||
<FormControl v-model="program.doc.title" :label="__('Title')" />
|
||||
|
||||
<!-- Courses -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Program Courses') }}
|
||||
</div>
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
currentForm = 'course'
|
||||
showDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
:columns="courseColumns"
|
||||
:rows="program.doc.program_courses"
|
||||
row-key="name"
|
||||
:options="{
|
||||
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 courseColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<Draggable
|
||||
:list="program.doc.program_courses"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOrder"
|
||||
class="cursor-move"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<ListRow :row="row" />
|
||||
</template>
|
||||
</Draggable>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'program_courses')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
|
||||
<!-- Members -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Program Members') }}
|
||||
</div>
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
currentForm = 'member'
|
||||
showDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
:columns="memberColumns"
|
||||
:rows="program.doc.program_members"
|
||||
row-key="name"
|
||||
:options="{
|
||||
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 memberColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in program.doc.program_members" />
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'program_members')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title:
|
||||
currentForm == 'course'
|
||||
? __('New Program Course')
|
||||
: __('New Program Member'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick: () =>
|
||||
currentForm == 'course'
|
||||
? addProgramCourse(close)
|
||||
: addProgramMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link
|
||||
v-if="currentForm == 'course'"
|
||||
v-model="course"
|
||||
doctype="LMS Course"
|
||||
:filters="{
|
||||
disable_self_learning: 1,
|
||||
}"
|
||||
:label="__('Program Course')"
|
||||
:description="
|
||||
__(
|
||||
'Only courses for which self learning is disabled can be added to program.'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-if="currentForm == 'member'"
|
||||
v-model="member"
|
||||
doctype="User"
|
||||
:filters="{
|
||||
ignore_user_type: 1,
|
||||
}"
|
||||
:label="__('Program Member')"
|
||||
:onCreate="(value, close) => openSettings('Members', close)"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createDocumentResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { openSettings } from '@/utils'
|
||||
import Draggable from 'vuedraggable'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const showDialog = ref(false)
|
||||
const currentForm = ref(null)
|
||||
const course = ref(null)
|
||||
const member = ref(null)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
programName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const program = createDocumentResource({
|
||||
doctype: 'LMS Program',
|
||||
name: props.programName,
|
||||
auto: true,
|
||||
cache: ['program', props.programName],
|
||||
})
|
||||
|
||||
const addProgramCourse = () => {
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_courses: [
|
||||
...program.doc.program_courses,
|
||||
{ course: course.value },
|
||||
],
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showDialog.value = false
|
||||
course.value = null
|
||||
toast.success(__('Course added to program'))
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const addProgramMember = () => {
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_members: [
|
||||
...program.doc.program_members,
|
||||
{ member: member.value },
|
||||
],
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showDialog.value = false
|
||||
member.value = null
|
||||
toast.success(__('Member added to program'))
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const remove = (selections, unselectAll, doctype) => {
|
||||
selections = Array.from(selections)
|
||||
program.setValue.submit(
|
||||
{
|
||||
[doctype]: program.doc[doctype].filter(
|
||||
(row) => !selections.includes(row.name)
|
||||
),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
unselectAll()
|
||||
toast.success(__('Items removed successfully'))
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateOrder = (e) => {
|
||||
let sourceIdx = e.from.dataset.idx
|
||||
let targetIdx = e.to.dataset.idx
|
||||
let courses = program.doc.program_courses
|
||||
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
|
||||
|
||||
courses.forEach((course, index) => {
|
||||
course.idx = index + 1
|
||||
})
|
||||
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_courses: courses,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Course moved successfully'))
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const saveProgram = () => {
|
||||
call('frappe.model.rename_doc.update_document_title', {
|
||||
doctype: 'LMS Program',
|
||||
docname: program.doc.name,
|
||||
name: program.doc.title,
|
||||
}).then((data) => {
|
||||
router.push({ name: 'ProgramForm', params: { programName: data } })
|
||||
})
|
||||
}
|
||||
|
||||
const courseColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'course_title',
|
||||
width: 3,
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
key: 'course',
|
||||
width: 3,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const memberColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Member',
|
||||
key: 'member',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Progress (%)',
|
||||
key: 'progress',
|
||||
width: 3,
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadbrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Programs',
|
||||
route: { name: 'Programs' },
|
||||
},
|
||||
{
|
||||
label: props.programName === 'new' ? 'New Program' : props.programName,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: program.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,216 +0,0 @@
|
||||
<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="breadbrumbs" />
|
||||
<Button
|
||||
v-if="canCreateProgram()"
|
||||
@click="showDialog = true"
|
||||
variant="solid"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="programs.data?.length" class="pt-5 px-5">
|
||||
<div v-for="program in programs.data" class="mb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl text-ink-gray-9 font-semibold">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Badge
|
||||
v-if="program.members"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="lg"
|
||||
>
|
||||
{{ program.members }}
|
||||
{{ program.members == 1 ? __('member') : __('members') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="program.progress"
|
||||
variant="subtle"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
>
|
||||
{{ program.progress }}{{ __('% completed') }}
|
||||
</Badge>
|
||||
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
:to="{
|
||||
name: 'ProgramForm',
|
||||
params: { programName: program.name },
|
||||
}"
|
||||
>
|
||||
<Button v-if="!readOnlyMode">
|
||||
<template #prefix>
|
||||
<Edit class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="program.courses?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div v-for="course in program.courses" class="relative group">
|
||||
<CourseCard
|
||||
:course="course"
|
||||
@click="enrollMember(program.name, course.name)"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
<div
|
||||
v-if="lockCourse(course)"
|
||||
class="absolute inset-0 bg-black-overlay-500 opacity-60 rounded-md"
|
||||
></div>
|
||||
<div
|
||||
v-if="lockCourse(course)"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<LockKeyhole class="size-10 text-ink-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5 mt-4">
|
||||
{{ __('No courses in this program') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Programs" />
|
||||
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title: __('New Program'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => createProgram(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl :label="__('Title')" v-model="title" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const showDialog = ref(false)
|
||||
const router = useRouter()
|
||||
const title = ref('')
|
||||
const settings = useSettings()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
!settings.learningPaths.data &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
})
|
||||
|
||||
const programs = createResource({
|
||||
url: 'lms.lms.utils.get_programs',
|
||||
auto: true,
|
||||
cache: 'programs',
|
||||
})
|
||||
|
||||
const createProgram = (close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Program',
|
||||
title: title.value,
|
||||
},
|
||||
}).then((res) => {
|
||||
router.push({ name: 'ProgramForm', params: { programName: res.name } })
|
||||
})
|
||||
}
|
||||
|
||||
const enrollMember = (program, course) => {
|
||||
call('lms.lms.utils.enroll_in_program_course', {
|
||||
program: program,
|
||||
course: course,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.current_lesson) {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course,
|
||||
chapterNumber: data.current_lesson.split('-')[0],
|
||||
lessonNumber: data.current_lesson.split('-')[1],
|
||||
},
|
||||
})
|
||||
} else if (data) {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course,
|
||||
chapterNumber: 1,
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
})
|
||||
}
|
||||
|
||||
const lockCourse = (course) => {
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return false
|
||||
if (course.membership) return false
|
||||
if (course.eligible) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const canCreateProgram = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const breadbrumbs = computed(() => [
|
||||
{
|
||||
label: __('Programs'),
|
||||
},
|
||||
])
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programs'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Program'),
|
||||
size: '2xl',
|
||||
actions: [{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: ({ close }: { close: () => void }) => {
|
||||
saveProgram(close)
|
||||
}
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pb-5">
|
||||
<FormControl
|
||||
v-model="program.name"
|
||||
:label="__('Title')"
|
||||
type="text"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<FormControl
|
||||
v-model="program.published"
|
||||
:label="__('Published')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pb-5">
|
||||
<div class="flex items-center justify-between mt-5 mb-4">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button @click="openForm('course')">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="programCourses.data.length > 0"
|
||||
:columns="courseColumns"
|
||||
:rows="programCourses.data"
|
||||
:options="{
|
||||
selectable: true,
|
||||
resizeColumn: true,
|
||||
showTooltip: false,
|
||||
}"
|
||||
rowKey="name"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in courseColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<Draggable
|
||||
:list="programCourses.data"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOrder"
|
||||
class="cursor-move"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<ListRow :row="row" />
|
||||
</template>
|
||||
</Draggable>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'courses')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No courses added yet.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mt-5 mb-4">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Members') }}
|
||||
</div>
|
||||
<Button @click="openForm('member')">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="programMembers.data.length > 0"
|
||||
:columns="memberColumns"
|
||||
:rows="programMembers.data"
|
||||
:options="{
|
||||
selectable: true,
|
||||
resizeColumn: true,
|
||||
}"
|
||||
rowKey="name"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in memberColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in programMembers.data" />
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'members')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No members added yet.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title:
|
||||
currentForm == 'course'
|
||||
? __('Add Course to Program')
|
||||
: __('Enroll Member to Program'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick: ({ close }: { close: () => void }) =>
|
||||
currentForm == 'course'
|
||||
? addCourse(close)
|
||||
: addMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div @click.stop>
|
||||
<Link
|
||||
v-if="currentForm == 'course'"
|
||||
v-model="course"
|
||||
doctype="LMS Course"
|
||||
:label="__('Course')"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-if="currentForm == 'member'"
|
||||
v-model="member"
|
||||
doctype="User"
|
||||
:filters="{
|
||||
ignore_user_type: 1,
|
||||
}"
|
||||
:label="__('Program Member')"
|
||||
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
ListSelectBanner,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { Programs, Program } from '@/types/programs'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
const show = defineModel<boolean>()
|
||||
const programs = defineModel<Programs>('programs')
|
||||
const showDialog = ref(false)
|
||||
const currentForm = ref<'course' | 'member'>('course')
|
||||
const course = ref<string>('')
|
||||
const member = ref<string>('')
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
programName: string | null
|
||||
}>(),
|
||||
{
|
||||
programName: 'new',
|
||||
}
|
||||
)
|
||||
|
||||
const program = ref<Program>({
|
||||
name: '',
|
||||
title: '',
|
||||
published: false,
|
||||
enforce_course_order: false,
|
||||
allow_self_enrollment: false,
|
||||
program_courses: [],
|
||||
program_members: [],
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.programName,
|
||||
() => {
|
||||
setProgramData()
|
||||
fetchCourses()
|
||||
fetchMembers()
|
||||
}
|
||||
)
|
||||
|
||||
const setProgramData = () => {
|
||||
let isNew = true
|
||||
programs.value?.data.forEach((p: Program) => {
|
||||
if (p.name === props.programName) {
|
||||
isNew = false
|
||||
program.value = { ...p }
|
||||
}
|
||||
})
|
||||
|
||||
if (isNew) {
|
||||
program.value = {
|
||||
name: '',
|
||||
title: '',
|
||||
published: false,
|
||||
enforce_course_order: false,
|
||||
allow_self_enrollment: false,
|
||||
program_courses: [],
|
||||
program_members: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const programCourses = createListResource({
|
||||
doctype: 'LMS Program Course',
|
||||
fields: ['course', 'course_title', 'name', 'idx'],
|
||||
cache: ['programCourses', props.programName],
|
||||
parent: 'LMS Program',
|
||||
orderBy: 'idx',
|
||||
onSuccess(data: ProgramCourse[]) {
|
||||
program.value.program_courses = data
|
||||
},
|
||||
})
|
||||
|
||||
const programMembers = createListResource({
|
||||
doctype: 'LMS Program Member',
|
||||
fields: ['member', 'full_name', 'progress', 'name'],
|
||||
cache: ['programMembers', props.programName],
|
||||
parent: 'LMS Program',
|
||||
orderBy: 'creation desc',
|
||||
onSuccess(data: ProgramMember[]) {
|
||||
program.value.program_members = data
|
||||
},
|
||||
})
|
||||
|
||||
const fetchCourses = () => {
|
||||
programCourses.update({
|
||||
filters: {
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_courses',
|
||||
},
|
||||
})
|
||||
programCourses.reload()
|
||||
}
|
||||
|
||||
const fetchMembers = () => {
|
||||
programMembers.update({
|
||||
filters: {
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_members',
|
||||
},
|
||||
})
|
||||
programMembers.reload()
|
||||
}
|
||||
|
||||
const saveProgram = (close: () => void) => {
|
||||
if (props.programName === 'new') createNewProgram(close)
|
||||
else updateProgram(close)
|
||||
}
|
||||
|
||||
const createNewProgram = (close: () => void) => {
|
||||
programs.value.insert.submit(
|
||||
{
|
||||
...program.value,
|
||||
title: program.value.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
programs.value.reload()
|
||||
toast.success(__('Program created successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateProgram = (close: () => void) => {
|
||||
programs.value.setValue.submit(
|
||||
{
|
||||
name: props.programName,
|
||||
...program.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
programs.value.reload()
|
||||
toast.success(__('Program updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const openForm = (formType: 'course' | 'member') => {
|
||||
currentForm.value = formType
|
||||
showDialog.value = true
|
||||
if (formType === 'course') {
|
||||
course.value = ''
|
||||
} else {
|
||||
member.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const addCourse = (close: () => void) => {
|
||||
if (!course.value) {
|
||||
toast.warning(__('Please select a course'))
|
||||
return
|
||||
}
|
||||
|
||||
programCourses.insert.submit(
|
||||
{
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_courses',
|
||||
course: course.value,
|
||||
idx: programCourses.data.length + 1,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateCounts('course', 'add')
|
||||
close()
|
||||
toast.success(__('Course added to program successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
if (!member.value) {
|
||||
toast.warning(__('Please select a member'))
|
||||
return
|
||||
}
|
||||
|
||||
programMembers.insert.submit(
|
||||
{
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_members',
|
||||
member: member.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateCounts('member', 'add')
|
||||
close()
|
||||
toast.success(__('Member added to program successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateCounts = async (
|
||||
type: 'member' | 'course',
|
||||
action: 'add' | 'remove'
|
||||
) => {
|
||||
console.log('update', props.programName)
|
||||
if (!props.programName) return
|
||||
|
||||
let memberCount = programMembers.data?.length || 0
|
||||
let courseCount = programCourses.data?.length || 0
|
||||
|
||||
if (type === 'member') {
|
||||
memberCount += action === 'add' ? 1 : -1
|
||||
} else {
|
||||
courseCount += action === 'add' ? 1 : -1
|
||||
}
|
||||
|
||||
await programs.value.setValue.submit(
|
||||
{
|
||||
name: props.programName,
|
||||
member_count: memberCount,
|
||||
course_count: courseCount,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
setProgramData()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateOrder = async (e: DragEvent) => {
|
||||
let sourceIdx = e.from.dataset.idx
|
||||
let targetIdx = e.to.dataset.idx
|
||||
let courses = programCourses.data
|
||||
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
|
||||
|
||||
for (const [index, course] of courses.entries()) {
|
||||
programCourses.setValue.submit(
|
||||
{
|
||||
name: course.name,
|
||||
idx: index + 1,
|
||||
},
|
||||
{
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
await wait(100)
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
||||
|
||||
const remove = async (
|
||||
selections: string[],
|
||||
unselectAll: () => void,
|
||||
type: string
|
||||
) => {
|
||||
selections = Array.from(selections)
|
||||
for (const selection of selections) {
|
||||
if (type == 'courses') {
|
||||
await programCourses.delete.submit(selection)
|
||||
await updateCounts('course', 'remove')
|
||||
} else {
|
||||
await programMembers.delete.submit(selection)
|
||||
await updateCounts('member', 'remove')
|
||||
}
|
||||
await programs.value.reload()
|
||||
await wait(100)
|
||||
}
|
||||
unselectAll()
|
||||
}
|
||||
|
||||
const courseColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'course_title',
|
||||
width: 1,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const memberColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Member',
|
||||
key: 'member',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<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" />
|
||||
<Button v-if="canCreateProgram()" @click="openForm('new')" variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div
|
||||
v-if="programs.data?.length"
|
||||
class="grid grid-cols-3 gap-5 py-10 w-3/4 mx-auto"
|
||||
>
|
||||
<div
|
||||
v-for="program in programs.data"
|
||||
@click="openForm(program.name)"
|
||||
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer space-y-2"
|
||||
>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<span>
|
||||
{{ program.course_count }}
|
||||
{{ program.course_count == 1 ? __('Course') : __('Courses') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<User class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<span>
|
||||
{{ program.member_count || 0 }}
|
||||
{{ program.member_count == 1 ? __('member') : __('members') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Programs" />
|
||||
|
||||
<ProgramForm
|
||||
v-model="showForm"
|
||||
:programName="currentProgram"
|
||||
v-model:programs="programs"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Button, usePageMeta, createListResource } from 'frappe-ui'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { BookOpen, Plus, User } from 'lucide-vue-next'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import ProgramForm from '@/pages/Programs/ProgramForm.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const showForm = ref(false)
|
||||
const currentProgram = ref(null)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const programs = createListResource({
|
||||
doctype: 'LMS Program',
|
||||
cache: ['program'],
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'member_count',
|
||||
'course_count',
|
||||
'published',
|
||||
'enforce_course_order',
|
||||
'allow_self_enrollment',
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'creation desc',
|
||||
})
|
||||
|
||||
const canCreateProgram = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const openForm = (programName) => {
|
||||
if (!canCreateProgram()) return
|
||||
currentProgram.value = programName
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Programs'),
|
||||
},
|
||||
])
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programs'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
interface Program {
|
||||
name: string;
|
||||
title: string;
|
||||
published: boolean;
|
||||
enforce_course_order: boolean;
|
||||
allow_self_enrollment: boolean;
|
||||
program_courses: ProgramCourse[];
|
||||
program_batches: ProgramMember[];
|
||||
course_count: number;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
interface ProgramCourse {
|
||||
course: string;
|
||||
course_title: string;
|
||||
idx: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProgramMember {
|
||||
member: string;
|
||||
full_name: string;
|
||||
progress: number;
|
||||
idx: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Programs {
|
||||
data: Program[];
|
||||
reload: () => void;
|
||||
hasNextPage: boolean;
|
||||
next: () => void;
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Program,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void;
|
||||
};
|
||||
insert: {
|
||||
submit: (
|
||||
data: Program,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void;
|
||||
};
|
||||
delete: {
|
||||
submit: (
|
||||
name: string,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
@@ -183,16 +183,10 @@ const routes = [
|
||||
component: () => import('@/pages/QuizSubmission.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/programs/:programName',
|
||||
name: 'ProgramForm',
|
||||
component: () => import('@/pages/ProgramForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/programs',
|
||||
name: 'Programs',
|
||||
component: () => import('@/pages/Programs.vue'),
|
||||
component: () => import('@/pages/Programs/Programs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/assignments',
|
||||
|
||||
@@ -8,13 +8,6 @@ export const useSettings = defineStore('settings', () => {
|
||||
const isSettingsOpen = ref(false)
|
||||
const activeTab = ref(null)
|
||||
|
||||
const learningPaths = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'enable_learning_paths' },
|
||||
auto: true,
|
||||
cache: ['learningPath'],
|
||||
})
|
||||
|
||||
const allowGuestAccess = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'allow_guest_access' },
|
||||
@@ -38,7 +31,6 @@ export const useSettings = defineStore('settings', () => {
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
learningPaths,
|
||||
allowGuestAccess,
|
||||
preventSkippingVideos,
|
||||
sidebarSettings,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref } from 'vue'
|
||||
export const useSidebar = defineStore('sidebar', () => {
|
||||
const isSidebarCollapsed = ref(false)
|
||||
const isWebpagesCollapsed = ref(true)
|
||||
const canAccessPrograms = ref(false)
|
||||
|
||||
if (localStorage.getItem('isSidebarCollapsed')) {
|
||||
isSidebarCollapsed.value = JSON.parse(
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export const usersStore = defineStore('lms-users', () => {
|
||||
let userResource = createResource({
|
||||
url: 'lms.lms.api.get_user_info',
|
||||
onError(error) {
|
||||
if (error && error.exc_type === 'AuthenticationError') {
|
||||
router.push('/login')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
|
||||
@@ -6,9 +6,19 @@
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"published",
|
||||
"title",
|
||||
"column_break_cwjx",
|
||||
"enforce_course_order",
|
||||
"column_break_mikl",
|
||||
"allow_self_enrollment",
|
||||
"section_break_vhhu",
|
||||
"program_courses",
|
||||
"program_members"
|
||||
"program_members",
|
||||
"section_break_pppe",
|
||||
"course_count",
|
||||
"column_break_qwhf",
|
||||
"member_count"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -30,12 +40,61 @@
|
||||
"label": "Title",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "published",
|
||||
"fieldtype": "Check",
|
||||
"label": "Published"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enforce_course_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enforce Course Order"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_vhhu",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_cwjx",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "allow_self_enrollment",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Self Enrollment"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_pppe",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "course_count",
|
||||
"fieldtype": "Int",
|
||||
"label": "Course Count"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qwhf",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "member_count",
|
||||
"fieldtype": "Int",
|
||||
"label": "Member Count"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_mikl",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-28 22:06:16.742867",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-08-13 14:36:59.168945",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Program",
|
||||
"naming_rule": "By fieldname",
|
||||
@@ -78,8 +137,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,18 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-18 12:43:46.800199",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-08-13 17:32:43.554055",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Program Course",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +35,18 @@
|
||||
"label": "Progress"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-11-21 12:51:31.882576",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-08-13 17:33:00.265037",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Program Member",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"persona_captured",
|
||||
"column_break_zdel",
|
||||
"allow_guest_access",
|
||||
"enable_learning_paths",
|
||||
"prevent_skipping_videos",
|
||||
"column_break_bjis",
|
||||
"unsplash_access_key",
|
||||
@@ -339,12 +338,6 @@
|
||||
"fieldtype": "HTML",
|
||||
"label": "Payments app is not installed"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_learning_paths",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Learning Paths"
|
||||
},
|
||||
{
|
||||
"fieldname": "general_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
@@ -429,7 +422,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-01 17:01:58.466698",
|
||||
"modified": "2025-08-12 16:47:49.983018",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Settings",
|
||||
|
||||
@@ -1921,10 +1921,6 @@ def update_certificate_purchase(course, payment_name):
|
||||
def get_programs():
|
||||
if has_course_moderator_role() or has_course_instructor_role() or has_course_evaluator_role():
|
||||
programs = frappe.get_all("LMS Program", fields=["name"])
|
||||
else:
|
||||
programs = frappe.get_all(
|
||||
"LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"]
|
||||
)
|
||||
|
||||
for program in programs:
|
||||
program_courses = frappe.get_all(
|
||||
|
||||
Reference in New Issue
Block a user