mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
Merge pull request #1699 from frappe/develop
chore: merge 'develop' into 'main'
This commit is contained in:
@@ -98,7 +98,7 @@ describe("Course Creation", () => {
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.visit("/lms");
|
||||
cy.visit("/lms/courses");
|
||||
cy.closeOnboardingModal();
|
||||
|
||||
cy.url().should("include", "/lms/courses");
|
||||
|
||||
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']
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -196,7 +196,7 @@ import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
||||
import { Button, call, createResource, Tooltip } from 'frappe-ui'
|
||||
import PageModal from '@/components/Modals/PageModal.vue'
|
||||
import { capture } from '@/telemetry'
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
@@ -214,6 +214,7 @@ import {
|
||||
Users,
|
||||
BookText,
|
||||
Zap,
|
||||
Check,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
TrialBanner,
|
||||
@@ -360,35 +361,35 @@ const addProgrammingExercises = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addPrograms = () => {
|
||||
let activeFor = ['Programs', 'ProgramForm']
|
||||
let index = 1
|
||||
let canAddProgram = false
|
||||
const addPrograms = async () => {
|
||||
let canAddProgram = await checkIfCanAddProgram()
|
||||
if (!canAddProgram) return
|
||||
let activeFor = ['Programs', 'ProgramDetail']
|
||||
let index = 2
|
||||
|
||||
if (
|
||||
!isInstructor.value &&
|
||||
!isModerator.value &&
|
||||
settingsStore.learningPaths.data
|
||||
) {
|
||||
sidebarLinks.value = sidebarLinks.value.filter(
|
||||
(link) => link.label !== 'Courses'
|
||||
)
|
||||
activeFor.push('CourseDetail')
|
||||
activeFor.push('Lesson')
|
||||
index = 0
|
||||
canAddProgram = true
|
||||
} else if (isInstructor.value || isModerator.value) {
|
||||
canAddProgram = true
|
||||
}
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
}
|
||||
|
||||
if (canAddProgram) {
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
const checkIfCanAddProgram = async () => {
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
return true
|
||||
}
|
||||
const programs = await call('lms.lms.utils.get_programs')
|
||||
return programs.enrolled.length > 0 || programs.published.length > 0
|
||||
}
|
||||
|
||||
const addHome = () => {
|
||||
sidebarLinks.value.unshift({
|
||||
label: 'Home',
|
||||
icon: 'Home',
|
||||
to: 'Home',
|
||||
activeFor: ['Home'],
|
||||
})
|
||||
}
|
||||
|
||||
const openPageModal = (link) => {
|
||||
@@ -642,6 +643,7 @@ watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addHome()
|
||||
addPrograms()
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
|
||||
@@ -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')">
|
||||
|
||||
@@ -208,6 +208,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
lessonProgress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const outline = createResource({
|
||||
@@ -229,6 +233,13 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lessonProgress,
|
||||
() => {
|
||||
outline.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const deleteLesson = createResource({
|
||||
url: 'lms.lms.api.delete_lesson',
|
||||
makeParams(values) {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { call } from 'frappe-ui'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
@@ -71,6 +72,8 @@ const sidebarLinks = ref(getSidebarLinks())
|
||||
const otherLinks = ref([])
|
||||
const showMenu = ref(false)
|
||||
const menu = ref(null)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
sidebarSettings.reload(
|
||||
@@ -134,12 +137,15 @@ const addOtherLinks = () => {
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (
|
||||
userResource.data &&
|
||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
||||
) {
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addPrograms()
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -159,6 +165,28 @@ const addAssignments = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const addPrograms = async () => {
|
||||
let canAddProgram = await checkIfCanAddProgram()
|
||||
if (!canAddProgram) return
|
||||
let activeFor = ['Programs', 'ProgramDetail']
|
||||
let index = 1
|
||||
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
}
|
||||
|
||||
const checkIfCanAddProgram = async () => {
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
return true
|
||||
}
|
||||
const programs = await call('lms.lms.utils.get_programs')
|
||||
return programs.enrolled.length > 0 || programs.published.length > 0
|
||||
}
|
||||
|
||||
let isActive = (tab) => {
|
||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<!-- <div class="text-xl font-semibold text-ink-gray-6">
|
||||
{{ __('{0} Members').format(memberCount) }}
|
||||
</div> -->
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
@@ -151,7 +148,7 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
@@ -225,7 +222,6 @@ const progressColumns = computed(() => {
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
width: '30%',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -193,6 +185,7 @@ const openProfile = (username: string) => {
|
||||
username: username,
|
||||
},
|
||||
})
|
||||
console.log(show.value)
|
||||
}
|
||||
|
||||
const newMember = createResource({
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
:is="activeTab.template"
|
||||
v-model:show="show"
|
||||
v-bind="{
|
||||
label: activeTab.label,
|
||||
description: activeTab.description,
|
||||
@@ -109,13 +110,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',
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
class="flex items-center w-full duration-300 ease-in-out group"
|
||||
:class="isCollapsed ? 'p-1 relative' : 'px-2 py-1'"
|
||||
>
|
||||
<Tooltip :text="link.label" placement="right">
|
||||
<Tooltip :text="__(link.label)" placement="right">
|
||||
<slot name="icon">
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<component
|
||||
@@ -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,22 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!forHome || (forHome && upcoming_evals.data?.length)">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="upcoming_evals.data?.length != evaluationCourses.length"
|
||||
v-if="
|
||||
upcoming_evals.data?.length != evaluationCourses.length && !forHome
|
||||
"
|
||||
@click="openEvalModal"
|
||||
>
|
||||
{{ __('Schedule Evaluation') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="upcoming_evals.data?.length">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="grid gap-4" :class="forHome ? 'grid-cols-2' : 'grid-cols-3'">
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border text-ink-gray-7 rounded-md p-3">
|
||||
<div class="flex justify-between mb-3">
|
||||
<span class="font-semibold text-ink-gray-9 leading-5">
|
||||
<span class="text-lg font-semibold text-ink-gray-9 leading-5">
|
||||
{{ evl.course_title }}
|
||||
</span>
|
||||
<Menu
|
||||
@@ -94,8 +96,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('Please schedule an evaluation to get certified.') }}
|
||||
<div v-else class="text-ink-gray-5">
|
||||
{{ __('Schedule an evaluation to get certified.') }}
|
||||
</div>
|
||||
</div>
|
||||
<EvaluationModal
|
||||
@@ -122,7 +124,6 @@ import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const showEvalModal = ref(false)
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
@@ -140,12 +141,15 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
forHome: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const upcoming_evals = createResource({
|
||||
url: 'lms.lms.utils.get_upcoming_evals',
|
||||
params: {
|
||||
student: user.data.name,
|
||||
courses: props.courses.map((course) => course.course),
|
||||
batch: props.batch,
|
||||
},
|
||||
|
||||
Vendored
+4
@@ -2,6 +2,10 @@ export {}
|
||||
|
||||
declare global {
|
||||
function __(text: string): string
|
||||
|
||||
interface String {
|
||||
format(...args: any[]): string
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Start Date')"
|
||||
:label="__('Batch Start Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('End Date')"
|
||||
:label="__('Batch End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
@@ -95,14 +95,14 @@
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Start Time')"
|
||||
:label="__('Session Start Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('End Time')"
|
||||
:label="__('Session End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
|
||||
@@ -142,7 +142,10 @@
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="flex flex-col space-y-5">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-5"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.published"
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="createdCourses.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{ __('Courses Created') }}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<router-link
|
||||
v-for="course in createdCourses.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||
>
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="createdBatches.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{ __('Upcoming Batches') }}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<router-link
|
||||
v-for="batch in createdBatches.data"
|
||||
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
|
||||
>
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!createdCourses.data?.length && !createdBatches.data?.length"
|
||||
class="flex flex-col items-center justify-center mt-60"
|
||||
>
|
||||
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
|
||||
<div class="text-lg font-semibold text-ink-gray-7 mb-1.5">
|
||||
{{ __('No courses created') }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'There are no courses currently. Create your first course to get started!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: 'CourseForm', params: { courseName: 'new' } }"
|
||||
class="mt-4"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5 mt-10">
|
||||
<div v-if="evals?.data?.length">
|
||||
<div class="font-semibold text-lg mb-3">
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div
|
||||
v-for="evaluation in evals?.data"
|
||||
class="border rounded-md p-3 flex flex-col h-full cursor-pointer"
|
||||
@click="redirectToProfile()"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ evaluation.course_title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm">
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ formatTime(evaluation.start_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<GraduationCap class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ evaluation.member_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="liveClasses?.data?.length">
|
||||
<div class="font-semibold text-lg mb-3">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div v-for="cls in liveClasses?.data" class="border rounded-md p-3">
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
GraduationCap,
|
||||
Info,
|
||||
Monitor,
|
||||
MoveRight,
|
||||
Plus,
|
||||
Video,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
liveClasses?: { data?: any[] }
|
||||
evals?: { data?: any[] }
|
||||
}>()
|
||||
|
||||
const createdCourses = createResource({
|
||||
url: 'lms.lms.utils.get_created_courses',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const createdBatches = createResource({
|
||||
url: 'lms.lms.utils.get_created_batches',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getClassEnd = (cls: { date: string; time: string; duration: number }) => {
|
||||
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const canAccessClass = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const redirectToProfile = () => {
|
||||
router.push({
|
||||
name: 'ProfileEvaluationSchedule',
|
||||
params: { username: user.data?.username },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<!-- <header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="[{ label: __('Home'), route: { name: 'Home' } }]" />
|
||||
</header> -->
|
||||
<div class="w-full px-5 pt-10 pb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="text-xl font-bold">
|
||||
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
|
||||
</div>
|
||||
<div class="text-lg text-ink-gray-6">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<TabButtons v-if="isAdmin" v-model="currentTab" :buttons="tabs" />
|
||||
<div
|
||||
v-else
|
||||
@click="showStreakModal = true"
|
||||
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
<span> 🔥 </span>
|
||||
<span>
|
||||
{{ streakInfo.data?.current_streak }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminHome
|
||||
v-if="isAdmin && currentTab === 'instructor'"
|
||||
:liveClasses="adminLiveClasses"
|
||||
:evals="adminEvals"
|
||||
/>
|
||||
<StudentHome v-else :myLiveClasses="myLiveClasses" />
|
||||
</div>
|
||||
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import StudentHome from '@/pages/Home/StudentHome.vue'
|
||||
import AdminHome from '@/pages/Home/AdminHome.vue'
|
||||
import Streak from '@/pages/Home/Streak.vue'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const { brand } = sessionStore()
|
||||
const evalCount = ref(0)
|
||||
const currentTab = ref<'student' | 'instructor'>('instructor')
|
||||
const showStreakModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
call('lms.lms.utils.get_upcoming_evals').then((data: any) => {
|
||||
evalCount.value = data.length
|
||||
})
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return (
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
)
|
||||
})
|
||||
|
||||
const myLiveClasses = createResource({
|
||||
url: 'lms.lms.utils.get_my_live_classes',
|
||||
auto: !isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const adminLiveClasses = createResource({
|
||||
url: 'lms.lms.utils.get_admin_live_classes',
|
||||
auto: isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const adminEvals = createResource({
|
||||
url: 'lms.lms.utils.get_admin_evals',
|
||||
auto: isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const streakInfo = createResource({
|
||||
url: 'lms.lms.utils.get_streak_info',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (isAdmin.value) {
|
||||
if (adminLiveClasses.data?.length > 0 && adminEvals.data?.length > 0) {
|
||||
return __(
|
||||
'You have {0} upcoming live classes and {1} evaluations scheduled.'
|
||||
).format(adminLiveClasses.data.length, adminEvals.data.length)
|
||||
} else if (adminLiveClasses.data?.length > 0) {
|
||||
return __('You have {0} upcoming live classes.').format(
|
||||
adminLiveClasses.data.length
|
||||
)
|
||||
} else if (adminEvals.data?.length > 0) {
|
||||
return __('You have {0} evaluations scheduled.').format(
|
||||
adminEvals.data.length
|
||||
)
|
||||
}
|
||||
return __('Manage your courses and batches at a glance')
|
||||
} else {
|
||||
if (myLiveClasses.data?.length > 0 && evalCount.value > 0) {
|
||||
return __(
|
||||
'You have {0} upcoming live classes and {1} evaluations scheduled.'
|
||||
).format(myLiveClasses.data.length, evalCount.value)
|
||||
} else if (myLiveClasses.data?.length > 0) {
|
||||
return __('You have {0} upcoming live classes.').format(
|
||||
myLiveClasses.data.length
|
||||
)
|
||||
} else if (evalCount.value > 0) {
|
||||
return __('You have {0} evaluations scheduled.').format(evalCount.value)
|
||||
}
|
||||
return __('Resume where you left off')
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ label: __('Student'), value: 'student' },
|
||||
{ label: __('Instructor'), value: 'instructor' },
|
||||
]
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Home'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Learning Consistency'),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="text-center">
|
||||
<div class="text-[30px]">🔥</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-ink-gray-5 mb-1">
|
||||
{{
|
||||
streakInfo.data?.current_streak < 1
|
||||
? __('You can do better,')
|
||||
: streakInfo.data?.current_streak < 10
|
||||
? __('Keep going,')
|
||||
: __('You rock,')
|
||||
}}
|
||||
{{ __(' you are on a') }}
|
||||
</div>
|
||||
<div class="font-semibold text-xl">
|
||||
{{ streakInfo.data?.current_streak }} {{ __('day streak') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 bg-surface-gray-1 px-2.5 py-2 rounded-md mt-8"
|
||||
>
|
||||
<div class="space-y-1 border-r border-outline-gray-2 mr-4">
|
||||
<div class="text-ink-gray-6">
|
||||
{{ __('Current Streak') }}
|
||||
</div>
|
||||
<div class="font-semibold text-lg">
|
||||
{{ streakInfo.data?.current_streak }} {{ __('days') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-ink-gray-6">
|
||||
{{ __('Longest Streak') }}
|
||||
</div>
|
||||
<div class="font-semibold text-lg">
|
||||
{{ streakInfo.data?.longest_streak }} {{ __('days') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-ink-gray-7 border border-outline-gray-1 px-2.5 py-2 rounded-md text-xs leading-5 mt-5"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'Your learning streak counts the number of days in a row you’ve kept up your learning, whether it’s a lesson, quiz, or assignment. Don’t worry, weekends don’t break your streak.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog } from 'frappe-ui'
|
||||
|
||||
const show = defineModel<boolean>({
|
||||
default: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
streakInfo: {
|
||||
data: {
|
||||
current_streak: number
|
||||
longest_streak: number
|
||||
}
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="myCourses.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{
|
||||
myCourses.data[0].membership
|
||||
? __('My Courses')
|
||||
: __('Our Popular Courses')
|
||||
}}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<router-link
|
||||
v-for="course in myCourses.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||
>
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="myBatches.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{
|
||||
myBatches.data?.[0].students.includes(user.data?.name)
|
||||
? __('My Batches')
|
||||
: __('Our Upcoming Batches')
|
||||
}}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x- 1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<router-link
|
||||
v-for="batch in myBatches.data"
|
||||
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
|
||||
>
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5 mt-10">
|
||||
<UpcomingEvaluations :forHome="true" />
|
||||
<div v-if="myLiveClasses.data?.length">
|
||||
<div class="font-semibold text-lg mb-3">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div v-for="cls in myLiveClasses.data" class="border rounded-md p-2">
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { createResource, Tooltip } from 'frappe-ui'
|
||||
import { formatTime } from '@/utils'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Info,
|
||||
Monitor,
|
||||
MoveRight,
|
||||
Video,
|
||||
} from 'lucide-vue-next'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
const user = inject<any>('$user')
|
||||
|
||||
const props = defineProps<{
|
||||
myLiveClasses: any
|
||||
}>()
|
||||
|
||||
const myCourses = createResource({
|
||||
url: 'lms.lms.utils.get_my_courses',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const myBatches = createResource({
|
||||
url: 'lms.lms.utils.get_my_batches',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getClassEnd = (cls: { date: string; time: string; duration: number }) => {
|
||||
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const canAccessClass = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
</script>
|
||||
@@ -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
|
||||
@@ -267,6 +268,7 @@
|
||||
:courseName="courseName"
|
||||
:key="chapterNumber"
|
||||
:getProgress="lesson.data.membership ? true : false"
|
||||
:lessonProgress="lessonProgress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,140 @@
|
||||
<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 space-x-2 mb-5">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ program.data.name }}
|
||||
</div>
|
||||
|
||||
<Badge :theme="program.data.progress < 100 ? 'orange' : 'green'">
|
||||
{{ program.data.progress }}% {{ __('completed') }}
|
||||
</Badge>
|
||||
|
||||
<Tooltip
|
||||
v-if="program.data.enforce_course_order"
|
||||
placement="right"
|
||||
:text="
|
||||
__(
|
||||
'Courses must be completed in order. You can only start the next course after completing the previous one.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Info class="size-3 cursor-pointer" />
|
||||
</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 {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { LockKeyhole, Info } 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,586 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '2xl',
|
||||
}"
|
||||
>
|
||||
<template #body-title>
|
||||
<div class="flex items-center justify-between space-x-2 text-base w-full">
|
||||
<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">
|
||||
<FormControl
|
||||
v-model="program.name"
|
||||
: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"
|
||||
@change="dirty = true"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
showProgressDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Progress Summary') }}
|
||||
</Button>
|
||||
<Button @click="openForm('member')">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</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="showFormDialog"
|
||||
: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>
|
||||
|
||||
<ProgramProgressSummary
|
||||
v-model="showProgressDialog"
|
||||
:programName="programName"
|
||||
:programMembers="programMembers.data"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="flex justify-end space-x-2 group">
|
||||
<Button
|
||||
v-if="programName != 'new'"
|
||||
@click="deleteProgram(close)"
|
||||
variant="outline"
|
||||
theme="red"
|
||||
class="invisible group-hover:visible"
|
||||
>
|
||||
<template #prefix>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Delete') }}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveProgram(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
ListSelectBanner,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Trash2, TrendingUp } 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'
|
||||
import ProgramProgressSummary from '@/pages/Programs/ProgramProgressSummary.vue'
|
||||
|
||||
const show = defineModel<boolean>()
|
||||
const programs = defineModel<Programs>('programs')
|
||||
const showFormDialog = ref(false)
|
||||
const currentForm = ref<'course' | 'member'>('course')
|
||||
const course = ref<string>('')
|
||||
const member = ref<string>('')
|
||||
const showProgressDialog = ref(false)
|
||||
const dirty = ref(false)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
programName: string | null
|
||||
}>(),
|
||||
{
|
||||
programName: 'new',
|
||||
}
|
||||
)
|
||||
|
||||
const program = ref<Program>({
|
||||
name: '',
|
||||
title: '',
|
||||
published: false,
|
||||
enforce_course_order: 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,
|
||||
program_courses: [],
|
||||
program_members: [],
|
||||
}
|
||||
}
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
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)
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
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
|
||||
showFormDialog.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'
|
||||
) => {
|
||||
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 deleteProgram = (close: () => void) => {
|
||||
if (props.programName == 'new') return
|
||||
programs.value?.delete.submit(props.programName, {
|
||||
onSuccess() {
|
||||
toast.success(__('Program deleted successfully'))
|
||||
close()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,137 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Progress Summary for {0}').format(programName),
|
||||
size: '2xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center justify-between space-x-4 mb-4">
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: programMembers.length || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: averageProgress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: progressDistribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
theme.colors.red['400'],
|
||||
theme.colors.amber['400'],
|
||||
theme.colors.pink['400'],
|
||||
theme.colors.blue['400'],
|
||||
theme.colors.green['400'],
|
||||
],
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="mt-10">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<ListView
|
||||
v-if="progressList.length"
|
||||
:columns="progressColumns"
|
||||
:rows="progressList"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
/>
|
||||
<div v-else class="text-center text-gray-500">
|
||||
{{ __('No members found.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DonutChart,
|
||||
FormControl,
|
||||
ListView,
|
||||
NumberChart,
|
||||
} from 'frappe-ui'
|
||||
import type { ProgramMember } from '@/types'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
programName: string
|
||||
programMembers: ProgramMember[]
|
||||
}>()
|
||||
|
||||
const progressList = ref<ProgramMember[]>(props.programMembers || [])
|
||||
|
||||
const progressDistribution = computed(() => {
|
||||
const categories = ['0-20%', '20-40%', '40-60%', '60-80%', '80-100%']
|
||||
const distribution = categories.map((category) => {
|
||||
const [min, max] = category.slice(0, -1).split('-').map(Number)
|
||||
return {
|
||||
category,
|
||||
count: props.programMembers.filter((member) => {
|
||||
const progress = member.progress || 0
|
||||
return progress >= min && progress < max
|
||||
}).length,
|
||||
}
|
||||
})
|
||||
return distribution
|
||||
})
|
||||
|
||||
const averageProgress = computed(() => {
|
||||
if (props.programMembers.length === 0) return 0
|
||||
const totalProgress = props.programMembers.reduce(
|
||||
(sum, member) => sum + (member.progress || 0),
|
||||
0
|
||||
)
|
||||
return totalProgress / props.programMembers.length
|
||||
})
|
||||
|
||||
watch(searchFilter, () => {
|
||||
if (searchFilter.value) {
|
||||
progressList.value = props.programMembers.filter((member) =>
|
||||
member.full_name.toLowerCase().includes(searchFilter.value?.toLowerCase())
|
||||
)
|
||||
} else {
|
||||
progressList.value = props.programMembers
|
||||
}
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'full_name',
|
||||
width: '50%',
|
||||
},
|
||||
{
|
||||
label: __('Progress (%)'),
|
||||
key: 'progress',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<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 && !isStudent" class="py-10 w-3/4 mx-auto">
|
||||
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
||||
{{
|
||||
__('{0} {1}').format(
|
||||
programs.data.length,
|
||||
programs.data.length == 1 ? __('Program') : __('Programs')
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<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>
|
||||
</div>
|
||||
<StudentPrograms v-else-if="isStudent" />
|
||||
<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, onMounted, ref } from 'vue'
|
||||
import { BookOpen, Plus, User } from 'lucide-vue-next'
|
||||
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'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const showForm = ref(false)
|
||||
const currentProgram = ref(null)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) {
|
||||
programs.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const programs = createListResource({
|
||||
doctype: 'LMS Program',
|
||||
cache: ['program'],
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'member_count',
|
||||
'course_count',
|
||||
'published',
|
||||
'enforce_course_order',
|
||||
],
|
||||
auto: false,
|
||||
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 isStudent = computed(() => {
|
||||
return user.data?.is_student || false
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Programs'),
|
||||
},
|
||||
])
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programs'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="py-5 px-5 w-full lg:w-3/4 lg:px-0 mx-auto">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('All Programs') }}
|
||||
</div>
|
||||
<TabButtons v-model="currentTab" :buttons="tabs" class="w-fit" />
|
||||
</div>
|
||||
<div v-for="(data, category) in programs.data">
|
||||
<div v-if="category == currentTab">
|
||||
<div
|
||||
v-if="data.length > 0"
|
||||
class="grid grid-cols-1 lg: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-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 [
|
||||
{
|
||||
label: __('Enrolled'),
|
||||
value: 'enrolled',
|
||||
},
|
||||
{
|
||||
label: __('Published'),
|
||||
value: 'published',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
interface Program {
|
||||
name: string;
|
||||
title: string;
|
||||
published: boolean;
|
||||
enforce_course_order: 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;
|
||||
};
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
:config="{
|
||||
data: signupsChart.data,
|
||||
title: 'Signups',
|
||||
subtitle: 'Signups per month',
|
||||
subtitle: 'Signups per day',
|
||||
xAxis: {
|
||||
key: 'date',
|
||||
type: 'time',
|
||||
@@ -74,7 +74,7 @@
|
||||
:config="{
|
||||
data: enrollmentChart.data,
|
||||
title: 'Enrollments',
|
||||
subtitle: 'Enrollments per month',
|
||||
subtitle: 'Enrollments per day',
|
||||
xAxis: {
|
||||
key: 'date',
|
||||
type: 'time',
|
||||
@@ -96,7 +96,7 @@
|
||||
:config="{
|
||||
data: certification.data,
|
||||
title: 'Certifications',
|
||||
subtitle: 'Certifications per month',
|
||||
subtitle: 'Certifications per day',
|
||||
xAxis: {
|
||||
key: 'date',
|
||||
type: 'time',
|
||||
|
||||
+11
-11
@@ -3,13 +3,11 @@ import { usersStore } from './stores/user'
|
||||
import { sessionStore } from './stores/session'
|
||||
import { useSettings } from './stores/settings'
|
||||
|
||||
let defaultRoute = '/courses'
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: {
|
||||
name: 'Courses',
|
||||
},
|
||||
name: 'Home',
|
||||
component: () => import('@/pages/Home/Home.vue'),
|
||||
},
|
||||
{
|
||||
path: '/courses',
|
||||
@@ -183,16 +181,16 @@ 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: '/programs/:programName',
|
||||
name: 'ProgramDetail',
|
||||
component: () => import('@/pages/Programs/ProgramDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/assignments',
|
||||
@@ -260,6 +258,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (to.name == 'Home') router.push({ name: 'Courses' })
|
||||
|
||||
await allowGuestAccess.promise
|
||||
if (!allowGuestAccess.data) {
|
||||
window.location.href = '/login'
|
||||
|
||||
@@ -61,7 +61,7 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
field: 'livecode_url',
|
||||
},
|
||||
cache: 'livecodeURL',
|
||||
auto: true,
|
||||
auto: user.value ? true : false,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,17 +4,9 @@ import { createResource } from 'frappe-ui'
|
||||
import { sessionStore } from './session'
|
||||
|
||||
export const useSettings = defineStore('settings', () => {
|
||||
const { isLoggedIn } = sessionStore()
|
||||
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 +30,6 @@ export const useSettings = defineStore('settings', () => {
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
learningPaths,
|
||||
allowGuestAccess,
|
||||
preventSkippingVideos,
|
||||
sidebarSettings,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,7 +58,9 @@ export class Assignment {
|
||||
}
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Assignment',
|
||||
name: assignment,
|
||||
filters: {
|
||||
name: assignment,
|
||||
},
|
||||
fieldname: ['title'],
|
||||
}).then((data) => {
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||
|
||||
@@ -79,7 +79,9 @@ export class Program {
|
||||
}
|
||||
call("frappe.client.get_value", {
|
||||
doctype: 'LMS Programming Exercise',
|
||||
name: exercise,
|
||||
filters: {
|
||||
name: exercise
|
||||
},
|
||||
fieldname: "title"
|
||||
}).then((data: { title: string }) => {
|
||||
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>
|
||||
|
||||
@@ -61,7 +61,7 @@ export default defineConfig({
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0', // Accept connections from any network interface
|
||||
allowedHosts: ['ps', 'fs'], // Explicitly allow this host
|
||||
allowedHosts: ['ps', 'fs', 'home'], // Explicitly allow this host
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.33.0"
|
||||
__version__ = "2.34.0"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from lms.lms.doctype.lms_enrollment.lms_enrollment import update_program_progress
|
||||
from lms.lms.utils import get_course_progress
|
||||
|
||||
|
||||
@@ -19,3 +20,4 @@ class LMSCourseProgress(Document):
|
||||
"name",
|
||||
)
|
||||
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
||||
update_program_progress(self.member)
|
||||
|
||||
@@ -13,7 +13,7 @@ class LMSEnrollment(Document):
|
||||
self.validate_membership_in_different_batch_same_course()
|
||||
|
||||
def on_update(self):
|
||||
self.update_program_progress()
|
||||
update_program_progress(self.member)
|
||||
|
||||
def validate_membership_in_same_batch(self):
|
||||
filters = {"member": self.member, "course": self.course, "name": ["!=", self.name]}
|
||||
@@ -59,21 +59,20 @@ class LMSEnrollment(Document):
|
||||
)
|
||||
)
|
||||
|
||||
def update_program_progress(self):
|
||||
programs = frappe.get_all("LMS Program Member", {"member": self.member}, ["parent", "name"])
|
||||
|
||||
for program in programs:
|
||||
total_progress = 0
|
||||
courses = frappe.get_all("LMS Program Course", {"parent": program.parent}, pluck="course")
|
||||
for course in courses:
|
||||
progress = frappe.db.get_value(
|
||||
"LMS Enrollment", {"course": course, "member": self.member}, "progress"
|
||||
)
|
||||
progress = progress or 0
|
||||
total_progress += progress
|
||||
def update_program_progress(member):
|
||||
programs = frappe.get_all("LMS Program Member", {"member": member}, ["parent", "name"])
|
||||
|
||||
average_progress = ceil(total_progress / len(courses))
|
||||
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
|
||||
for program in programs:
|
||||
total_progress = 0
|
||||
courses = frappe.get_all("LMS Program Course", {"parent": program.parent}, pluck="course")
|
||||
for course in courses:
|
||||
progress = frappe.db.get_value("LMS Enrollment", {"course": course, "member": member}, "progress")
|
||||
progress = progress or 0
|
||||
total_progress += progress
|
||||
|
||||
average_progress = ceil(total_progress / len(courses))
|
||||
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -161,8 +161,8 @@
|
||||
"link_fieldname": "payment"
|
||||
}
|
||||
],
|
||||
"modified": "2025-03-13 15:31:38.019002",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-08-19 10:33:15.457678",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Payment",
|
||||
"owner": "Administrator",
|
||||
@@ -180,9 +180,11 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "billing_name"
|
||||
}
|
||||
"title_field": "billing_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"published",
|
||||
"column_break_cwjx",
|
||||
"enforce_course_order",
|
||||
"column_break_mikl",
|
||||
"section_break_vhhu",
|
||||
"program_courses",
|
||||
"program_members"
|
||||
"program_members",
|
||||
"section_break_pppe",
|
||||
"course_count",
|
||||
"column_break_qwhf",
|
||||
"member_count"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -30,12 +39,61 @@
|
||||
"label": "Title",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "published",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Published"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enforce_course_order",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Enforce Course Order"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_vhhu",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_cwjx",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_pppe",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "course_count",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Course Count"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qwhf",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "member_count",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"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-20 12:28:57.238902",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Program",
|
||||
"naming_rule": "By fieldname",
|
||||
@@ -76,10 +134,21 @@
|
||||
"role": "Course Creator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ class LMSProgram(Document):
|
||||
def validate(self):
|
||||
self.validate_program_courses()
|
||||
self.validate_program_members()
|
||||
self.update_count()
|
||||
|
||||
def validate_program_courses(self):
|
||||
courses = [row.course for row in self.program_courses]
|
||||
@@ -30,3 +31,13 @@ class LMSProgram(Document):
|
||||
frappe.bold(next(iter(duplicates)))
|
||||
)
|
||||
)
|
||||
|
||||
def update_count(self):
|
||||
course_count = len(self.program_courses)
|
||||
member_count = len(self.program_members)
|
||||
|
||||
if self.course_count != course_count:
|
||||
self.course_count = course_count
|
||||
|
||||
if self.member_count != member_count:
|
||||
self.member_count = member_count
|
||||
|
||||
@@ -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",
|
||||
|
||||
+432
-73
@@ -2,6 +2,7 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import frappe
|
||||
import razorpay
|
||||
@@ -39,12 +40,12 @@ def slugify(title, used_slugs=None):
|
||||
If a list of used slugs is specified, it will make sure the generated slug
|
||||
is not one of them.
|
||||
|
||||
>>> slugify("Hello World!")
|
||||
'hello-world'
|
||||
>>> slugify("Hello World!", ["hello-world"])
|
||||
'hello-world-2'
|
||||
>>> slugify("Hello World!", ["hello-world", "hello-world-2"])
|
||||
'hello-world-3'
|
||||
>>> slugify("Hello World!")
|
||||
'hello-world'
|
||||
>>> slugify("Hello World!", ["hello-world"])
|
||||
'hello-world-2'
|
||||
>>> slugify("Hello World!", ["hello-world", "hello-world-2"])
|
||||
'hello-world-3'
|
||||
"""
|
||||
if not used_slugs:
|
||||
used_slugs = []
|
||||
@@ -844,14 +845,22 @@ def get_evaluator(course, batch=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_upcoming_evals(student, courses, batch=None):
|
||||
def get_upcoming_evals(courses=None, batch=None):
|
||||
if frappe.session.user == "Guest":
|
||||
return []
|
||||
|
||||
if not courses:
|
||||
courses = []
|
||||
|
||||
filters = {
|
||||
"member": student,
|
||||
"course": ["in", courses],
|
||||
"member": frappe.session.user,
|
||||
"date": [">=", frappe.utils.nowdate()],
|
||||
"status": "Upcoming",
|
||||
}
|
||||
|
||||
if len(courses) > 0:
|
||||
filters["course"] = ["in", courses]
|
||||
|
||||
if batch:
|
||||
filters["batch_name"] = batch
|
||||
|
||||
@@ -1118,6 +1127,7 @@ def get_course_details(course):
|
||||
"lessons",
|
||||
"enrollments",
|
||||
"rating",
|
||||
"card_gradient",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
@@ -1126,7 +1136,7 @@ def get_course_details(course):
|
||||
# course_details.is_instructor = is_instructor(course_details.name)
|
||||
if course_details.paid_course or course_details.paid_certificate:
|
||||
"""course_details.course_price, course_details.currency = check_multicurrency(
|
||||
course_details.course_price, course_details.currency, None, course_details.amount_usd
|
||||
course_details.course_price, course_details.currency, None, course_details.amount_usd
|
||||
)"""
|
||||
course_details.price = fmt_money(course_details.course_price, 0, course_details.currency)
|
||||
|
||||
@@ -1270,7 +1280,6 @@ def get_lesson(course, chapter, lesson):
|
||||
progress = get_progress(course, lesson_details.name)
|
||||
|
||||
lesson_details.chapter_title = frappe.db.get_value("Course Chapter", chapter_name, "title")
|
||||
lesson_details.rendered_content = render_html(lesson_details)
|
||||
neighbours = get_neighbour_lesson(course, chapter, lesson)
|
||||
lesson_details.next = neighbours["next"]
|
||||
lesson_details.progress = progress
|
||||
@@ -1919,80 +1928,93 @@ def update_certificate_purchase(course, payment_name):
|
||||
|
||||
@frappe.whitelist()
|
||||
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"]
|
||||
enrolled_programs = frappe.get_all(
|
||||
"LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"]
|
||||
)
|
||||
for program in enrolled_programs:
|
||||
program.update(
|
||||
frappe.db.get_value(
|
||||
"LMS Program", program.name, ["name", "course_count", "member_count"], as_dict=True
|
||||
)
|
||||
)
|
||||
|
||||
for program in programs:
|
||||
program_courses = frappe.get_all(
|
||||
"LMS Program Course", {"parent": program.name}, ["course"], order_by="idx"
|
||||
)
|
||||
program.courses = []
|
||||
previous_progress = 0
|
||||
for i, course in enumerate(program_courses):
|
||||
details = get_course_details(course.course)
|
||||
if i == 0:
|
||||
details.eligible = True
|
||||
elif previous_progress == 100:
|
||||
details.eligible = True
|
||||
else:
|
||||
details.eligible = False
|
||||
published_programs = frappe.get_all(
|
||||
"LMS Program",
|
||||
{
|
||||
"published": 1,
|
||||
},
|
||||
["name", "course_count", "member_count"],
|
||||
)
|
||||
|
||||
previous_progress = details.membership.progress if details.membership else 0
|
||||
program.courses.append(details)
|
||||
programs_to_remove = []
|
||||
for program in published_programs:
|
||||
if program.name in [p.name for p in enrolled_programs]:
|
||||
programs_to_remove.append(program)
|
||||
published_programs = [program for program in published_programs if program not in programs_to_remove]
|
||||
|
||||
program.members = frappe.db.count("LMS Program Member", {"parent": program.name})
|
||||
|
||||
return programs
|
||||
return {
|
||||
"enrolled": enrolled_programs,
|
||||
"published": published_programs,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def enroll_in_program_course(program, course):
|
||||
enrollment = frappe.db.exists("LMS Enrollment", {"member": frappe.session.user, "course": course})
|
||||
|
||||
if enrollment:
|
||||
enrollment = frappe.db.get_value("LMS Enrollment", enrollment, ["name", "current_lesson"], as_dict=1)
|
||||
enrollment.current_lesson = get_lesson_index(enrollment.current_lesson)
|
||||
return enrollment
|
||||
|
||||
def get_program_details(program_name):
|
||||
program = frappe.db.get_value(
|
||||
"LMS Program",
|
||||
program_name,
|
||||
[
|
||||
"name",
|
||||
"member_count",
|
||||
"course_count",
|
||||
"published",
|
||||
"allow_self_enrollment",
|
||||
"enforce_course_order",
|
||||
],
|
||||
as_dict=1,
|
||||
)
|
||||
program_courses = frappe.get_all(
|
||||
"LMS Program Course", {"parent": program}, ["course", "idx"], order_by="idx"
|
||||
"LMS Program Course", {"parent": program_name}, ["course"], order_by="idx"
|
||||
)
|
||||
current_course_idx = [
|
||||
program_course.idx for program_course in program_courses if program_course.course == course
|
||||
][0]
|
||||
|
||||
for program_course in program_courses:
|
||||
if program_course.idx < current_course_idx:
|
||||
enrollment = frappe.db.get_value(
|
||||
"LMS Enrollment",
|
||||
{"member": frappe.session.user, "course": program_course.course},
|
||||
["name", "progress"],
|
||||
as_dict=1,
|
||||
program.courses = []
|
||||
previous_progress = 0
|
||||
for i, course in enumerate(program_courses):
|
||||
details = get_course_details(course.course)
|
||||
if i == 0:
|
||||
details.eligible = True
|
||||
elif previous_progress == 100:
|
||||
details.eligible = True
|
||||
else:
|
||||
details.eligible = False
|
||||
|
||||
previous_progress = details.membership.progress if details.membership else 0
|
||||
program.courses.append(details)
|
||||
if frappe.session.user != "Guest":
|
||||
program.progress = frappe.db.get_value(
|
||||
"LMS Program Member",
|
||||
{"parent": program_name, "member": frappe.session.user},
|
||||
"progress",
|
||||
)
|
||||
if enrollment and enrollment.progress != 100:
|
||||
frappe.throw(
|
||||
_("Please complete the previous courses in the program to enroll in this course.")
|
||||
)
|
||||
elif not enrollment:
|
||||
frappe.throw(
|
||||
_("Please complete the previous courses in the program to enroll in this course.")
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
enrollment = frappe.new_doc("LMS Enrollment")
|
||||
enrollment.update(
|
||||
{
|
||||
"member": frappe.session.user,
|
||||
"course": course,
|
||||
}
|
||||
)
|
||||
enrollment.save()
|
||||
return enrollment
|
||||
return program
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def enroll_in_program(program):
|
||||
if frappe.session.user == "Guest":
|
||||
frappe.throw(_("Please login to enroll in the program."))
|
||||
if not frappe.db.exists("LMS Program Member", {"parent": program, "member": frappe.session.user}):
|
||||
program_member = frappe.new_doc("LMS Program Member")
|
||||
program_member.update(
|
||||
{
|
||||
"parent": program,
|
||||
"parenttype": "LMS Program",
|
||||
"parentfield": "members",
|
||||
"member": frappe.session.user,
|
||||
}
|
||||
)
|
||||
program_member.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@@ -2120,3 +2142,340 @@ def get_related_courses(course):
|
||||
|
||||
def persona_captured():
|
||||
frappe.db.set_single_value("LMS Settings", "persona_captured", 1)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_my_courses():
|
||||
my_courses = []
|
||||
if frappe.session.user == "Guest":
|
||||
return my_courses
|
||||
|
||||
courses = get_my_latest_courses()
|
||||
|
||||
if not len(courses):
|
||||
courses = get_featured_home_courses()
|
||||
|
||||
if not len(courses):
|
||||
courses = get_popular_courses()
|
||||
|
||||
for course in courses:
|
||||
my_courses.append(get_course_details(course))
|
||||
|
||||
return my_courses
|
||||
|
||||
|
||||
def get_my_latest_courses():
|
||||
return frappe.get_all(
|
||||
"LMS Enrollment",
|
||||
{
|
||||
"member": frappe.session.user,
|
||||
},
|
||||
order_by="creation desc",
|
||||
limit=3,
|
||||
pluck="course",
|
||||
)
|
||||
|
||||
|
||||
def get_featured_home_courses():
|
||||
return frappe.get_all(
|
||||
"LMS Course",
|
||||
{"published": 1, "featured": 1},
|
||||
order_by="published_on desc",
|
||||
limit=3,
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
|
||||
def get_popular_courses():
|
||||
return frappe.get_all(
|
||||
"LMS Course",
|
||||
{
|
||||
"published": 1,
|
||||
},
|
||||
order_by="enrollments desc",
|
||||
limit=3,
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_my_batches():
|
||||
my_batches = []
|
||||
if frappe.session.user == "Guest":
|
||||
return my_batches
|
||||
|
||||
batches = get_my_latest_batches()
|
||||
|
||||
if not len(batches):
|
||||
batches = get_upcoming_batches()
|
||||
|
||||
for batch in batches:
|
||||
batch_details = get_batch_details(batch)
|
||||
if batch_details:
|
||||
my_batches.append(batch_details)
|
||||
|
||||
return my_batches
|
||||
|
||||
|
||||
def get_my_latest_batches():
|
||||
return frappe.get_all(
|
||||
"LMS Batch Enrollment",
|
||||
{
|
||||
"member": frappe.session.user,
|
||||
},
|
||||
order_by="creation desc",
|
||||
limit=4,
|
||||
pluck="batch",
|
||||
)
|
||||
|
||||
|
||||
def get_upcoming_batches():
|
||||
return frappe.get_all(
|
||||
"LMS Batch",
|
||||
{
|
||||
"published": 1,
|
||||
"start_date": [">=", getdate()],
|
||||
},
|
||||
order_by="start_date asc",
|
||||
limit=4,
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_my_live_classes():
|
||||
my_live_classes = []
|
||||
if frappe.session.user == "Guest":
|
||||
return my_live_classes
|
||||
|
||||
batches = frappe.get_all(
|
||||
"LMS Batch Enrollment",
|
||||
{
|
||||
"member": frappe.session.user,
|
||||
},
|
||||
order_by="creation desc",
|
||||
pluck="batch",
|
||||
)
|
||||
|
||||
live_class_details = frappe.get_all(
|
||||
"LMS Live Class",
|
||||
filters={
|
||||
"date": [">=", getdate()],
|
||||
"batch_name": ["in", batches],
|
||||
},
|
||||
fields=[
|
||||
"name",
|
||||
"title",
|
||||
"description",
|
||||
"time",
|
||||
"date",
|
||||
"duration",
|
||||
"attendees",
|
||||
"start_url",
|
||||
"join_url",
|
||||
"owner",
|
||||
],
|
||||
limit=2,
|
||||
order_by="date",
|
||||
)
|
||||
|
||||
if len(live_class_details):
|
||||
for live_class in live_class_details:
|
||||
live_class.course_title = frappe.db.get_value("LMS Course", live_class.course, "title")
|
||||
|
||||
my_live_classes.append(live_class)
|
||||
|
||||
return my_live_classes
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_created_courses():
|
||||
created_courses = []
|
||||
if frappe.session.user == "Guest":
|
||||
return created_courses
|
||||
|
||||
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
||||
Course = frappe.qb.DocType("LMS Course")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(CourseInstructor)
|
||||
.join(Course)
|
||||
.on(CourseInstructor.parent == Course.name)
|
||||
.select(Course.name)
|
||||
.where(CourseInstructor.instructor == frappe.session.user)
|
||||
.orderby(Course.published_on, order=frappe.qb.desc)
|
||||
.limit(3)
|
||||
)
|
||||
|
||||
results = query.run(as_dict=True)
|
||||
courses = [row["name"] for row in results]
|
||||
|
||||
for course in courses:
|
||||
course_details = get_course_details(course)
|
||||
created_courses.append(course_details)
|
||||
|
||||
return created_courses
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_created_batches():
|
||||
created_batches = []
|
||||
if frappe.session.user == "Guest":
|
||||
return created_batches
|
||||
|
||||
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
||||
Batch = frappe.qb.DocType("LMS Batch")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(CourseInstructor)
|
||||
.join(Batch)
|
||||
.on(CourseInstructor.parent == Batch.name)
|
||||
.select(Batch.name)
|
||||
.where(CourseInstructor.instructor == frappe.session.user)
|
||||
.where(Batch.start_date >= getdate())
|
||||
.orderby(Batch.start_date, order=frappe.qb.asc)
|
||||
.limit(4)
|
||||
)
|
||||
|
||||
results = query.run(as_dict=True)
|
||||
batches = [row["name"] for row in results]
|
||||
|
||||
for batch in batches:
|
||||
batch_details = get_batch_details(batch)
|
||||
created_batches.append(batch_details)
|
||||
|
||||
return created_batches
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_admin_live_classes():
|
||||
if frappe.session.user == "Guest":
|
||||
return []
|
||||
|
||||
CourseInstructor = frappe.qb.DocType("Course Instructor")
|
||||
LMSLiveClass = frappe.qb.DocType("LMS Live Class")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(CourseInstructor)
|
||||
.join(LMSLiveClass)
|
||||
.on(CourseInstructor.parent == LMSLiveClass.batch_name)
|
||||
.select(
|
||||
LMSLiveClass.name,
|
||||
LMSLiveClass.title,
|
||||
LMSLiveClass.description,
|
||||
LMSLiveClass.time,
|
||||
LMSLiveClass.date,
|
||||
LMSLiveClass.duration,
|
||||
LMSLiveClass.attendees,
|
||||
LMSLiveClass.start_url,
|
||||
LMSLiveClass.join_url,
|
||||
LMSLiveClass.owner,
|
||||
)
|
||||
.where(CourseInstructor.instructor == frappe.session.user)
|
||||
.where(LMSLiveClass.date >= getdate())
|
||||
.orderby(LMSLiveClass.date, order=frappe.qb.asc)
|
||||
.limit(4)
|
||||
)
|
||||
results = query.run(as_dict=True)
|
||||
return results
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_admin_evals():
|
||||
if frappe.session.user == "Guest":
|
||||
return []
|
||||
|
||||
evals = frappe.get_all(
|
||||
"LMS Certificate Request",
|
||||
{
|
||||
"evaluator": frappe.session.user,
|
||||
"date": [">=", getdate()],
|
||||
},
|
||||
[
|
||||
"name",
|
||||
"date",
|
||||
"start_time",
|
||||
"course",
|
||||
"evaluator",
|
||||
"google_meet_link",
|
||||
"member",
|
||||
"member_name",
|
||||
],
|
||||
limit=4,
|
||||
order_by="date asc",
|
||||
)
|
||||
|
||||
for evaluation in evals:
|
||||
evaluation.course_title = frappe.db.get_value("LMS Course", evaluation.course, "title")
|
||||
|
||||
return evals
|
||||
|
||||
|
||||
def fetch_activity_dates(user):
|
||||
doctypes = [
|
||||
"LMS Course Progress",
|
||||
"LMS Quiz Submission",
|
||||
"LMS Assignment Submission",
|
||||
"LMS Programming Exercise Submission",
|
||||
]
|
||||
|
||||
all_dates = []
|
||||
for dt in doctypes:
|
||||
all_dates.extend(frappe.get_all(dt, {"member": user}, pluck="creation"))
|
||||
|
||||
return sorted({d.date() if hasattr(d, "date") else d for d in all_dates})
|
||||
|
||||
|
||||
def calculate_streaks(all_dates):
|
||||
streak = 0
|
||||
longest_streak = 0
|
||||
prev_day = None
|
||||
|
||||
for d in all_dates:
|
||||
if d.weekday() in (5, 6):
|
||||
continue
|
||||
|
||||
if prev_day:
|
||||
expected = prev_day + timedelta(days=1)
|
||||
while expected.weekday() in (5, 6):
|
||||
expected += timedelta(days=1)
|
||||
|
||||
streak = streak + 1 if d == expected else 1
|
||||
else:
|
||||
streak = 1
|
||||
|
||||
longest_streak = max(longest_streak, streak)
|
||||
prev_day = d
|
||||
|
||||
return streak, longest_streak
|
||||
|
||||
|
||||
def calculate_current_streak(all_dates, streak):
|
||||
if not all_dates:
|
||||
return 0
|
||||
|
||||
last_date = all_dates[-1]
|
||||
today = getdate()
|
||||
|
||||
ref_day = today
|
||||
while ref_day.weekday() in (5, 6):
|
||||
ref_day -= timedelta(days=1)
|
||||
|
||||
if last_date == ref_day or last_date == ref_day - timedelta(days=1):
|
||||
return streak
|
||||
return 0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_streak_info():
|
||||
if frappe.session.user == "Guest":
|
||||
return {}
|
||||
|
||||
all_dates = fetch_activity_dates(frappe.session.user)
|
||||
streak, longest_streak = calculate_streaks(all_dates)
|
||||
current_streak = calculate_current_streak(all_dates, streak)
|
||||
|
||||
return {
|
||||
"current_streak": current_streak,
|
||||
"longest_streak": longest_streak,
|
||||
}
|
||||
|
||||
+425
-376
File diff suppressed because it is too large
Load Diff
+431
-382
File diff suppressed because it is too large
Load Diff
+425
-376
File diff suppressed because it is too large
Load Diff
+7441
File diff suppressed because it is too large
Load Diff
+427
-378
File diff suppressed because it is too large
Load Diff
+431
-382
File diff suppressed because it is too large
Load Diff
+431
-382
File diff suppressed because it is too large
Load Diff
+427
-378
File diff suppressed because it is too large
Load Diff
+427
-378
File diff suppressed because it is too large
Load Diff
+431
-382
File diff suppressed because it is too large
Load Diff
+425
-376
File diff suppressed because it is too large
Load Diff
+426
-377
File diff suppressed because it is too large
Load Diff
+439
-390
File diff suppressed because it is too large
Load Diff
+424
-375
File diff suppressed because it is too large
Load Diff
+7441
File diff suppressed because it is too large
Load Diff
+424
-375
File diff suppressed because it is too large
Load Diff
+428
-379
File diff suppressed because it is too large
Load Diff
+425
-376
File diff suppressed because it is too large
Load Diff
+428
-379
File diff suppressed because it is too large
Load Diff
+428
-379
File diff suppressed because it is too large
Load Diff
+439
-390
File diff suppressed because it is too large
Load Diff
+439
-390
File diff suppressed because it is too large
Load Diff
+431
-382
File diff suppressed because it is too large
Load Diff
+432
-380
File diff suppressed because it is too large
Load Diff
+433
-384
File diff suppressed because it is too large
Load Diff
+424
-375
File diff suppressed because it is too large
Load Diff
+431
-382
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -109,4 +109,5 @@ lms.patches.v2_0.link_zoom_account_to_live_class
|
||||
lms.patches.v2_0.link_zoom_account_to_batch
|
||||
lms.patches.v2_0.sidebar_for_certified_members
|
||||
lms.patches.v2_0.move_batch_instructors_to_evaluators
|
||||
lms.patches.v2_0.enable_programming_exercises_in_sidebar
|
||||
lms.patches.v2_0.enable_programming_exercises_in_sidebar
|
||||
lms.patches.v2_0.count_in_program
|
||||
@@ -0,0 +1,18 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
programs = frappe.get_all("LMS Program", pluck="name")
|
||||
|
||||
for program in programs:
|
||||
course_count = frappe.db.count(
|
||||
"LMS Program Course",
|
||||
{"parent": program, "parenttype": "LMS Program", "parentfield": "program_courses"},
|
||||
)
|
||||
frappe.db.set_value("LMS Program", program, "course_count", course_count)
|
||||
|
||||
member_count = frappe.db.count(
|
||||
"LMS Program Member",
|
||||
{"parent": program, "parenttype": "LMS Program", "parentfield": "program_members"},
|
||||
)
|
||||
frappe.db.set_value("LMS Program", program, "member_count", member_count)
|
||||
@@ -1,105 +0,0 @@
|
||||
function getLiveCodeOptions() {
|
||||
var START = `
|
||||
import sketch
|
||||
code = open("main.py").read()
|
||||
env = dict(sketch.__dict__)
|
||||
exec(code, env)
|
||||
`;
|
||||
|
||||
var SKETCH = `
|
||||
import json
|
||||
|
||||
def sendmsg(msgtype, function, args):
|
||||
"""Sends a message to the frontend.
|
||||
|
||||
The frontend will receive the specified message whenever
|
||||
this function is called. The frontend can decide to some
|
||||
action on each of these messages.
|
||||
"""
|
||||
msg = dict(msgtype=msgtype, function=function, args=args)
|
||||
print("--MSG--", json.dumps(msg))
|
||||
|
||||
def _draw(func, **kwargs):
|
||||
sendmsg(msgtype="draw", function=func, args=kwargs)
|
||||
|
||||
def circle(x, y, d):
|
||||
"""Draws a circle of diameter d with center (x, y).
|
||||
"""
|
||||
_draw("circle", x=x, y=y, d=d)
|
||||
|
||||
def line(x1, y1, x2, y2):
|
||||
"""Draws a line from point (x1, y1) to point (x2, y2).
|
||||
"""
|
||||
_draw("line", x1=x1, y1=y1, x2=x2, y2=y2)
|
||||
|
||||
def rect(x, y, w, h):
|
||||
"""Draws a rectangle on the canvas.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x: x coordinate of the top-left corner of the rectangle
|
||||
y: y coordinate of the top-left corner of the rectangle
|
||||
w: width of the rectangle
|
||||
h: height of the rectangle
|
||||
"""
|
||||
_draw("rect", x=x, y=y, w=w, h=h)
|
||||
|
||||
def clear():
|
||||
_draw("clear")
|
||||
|
||||
# clear the canvas on start
|
||||
clear()
|
||||
`;
|
||||
const CANVAS_FUNCTIONS = {
|
||||
circle: function (ctx, args) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(args.x, args.y, args.d / 2, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
},
|
||||
line: function (ctx, args) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(args.x1, args.y1);
|
||||
ctx.lineTo(args.x2, args.y2);
|
||||
ctx.stroke();
|
||||
},
|
||||
rect: function (ctx, args) {
|
||||
ctx.beginPath();
|
||||
ctx.rect(args.x, args.y, args.w, args.h);
|
||||
ctx.stroke();
|
||||
},
|
||||
clear: function (ctx, args) {
|
||||
var width = 300;
|
||||
var height = 300;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
},
|
||||
};
|
||||
|
||||
function drawOnCanvas(canvasElement, funcName, args) {
|
||||
var ctx = canvasElement.getContext("2d");
|
||||
var func = CANVAS_FUNCTIONS[funcName];
|
||||
|
||||
var scalex = canvasElement.width / 300;
|
||||
var scaley = canvasElement.height / 300;
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(scalex, scaley);
|
||||
func(ctx, args);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
return {
|
||||
runtime: "python",
|
||||
files: [
|
||||
{ filename: "start.py", contents: START },
|
||||
{ filename: "sketch.py", contents: SKETCH },
|
||||
],
|
||||
command: ["python", "start.py"],
|
||||
codemirror: true,
|
||||
onMessage: {
|
||||
draw: function (editor, msg) {
|
||||
const canvasElement = editor.parent.querySelector("canvas");
|
||||
drawOnCanvas(canvasElement, msg.function, msg.args);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
frappe.provide("lms.setup");
|
||||
|
||||
// redirect to desk page 'lms' after setup wizard is complete
|
||||
// 'lms' desk page redirects to '/courses'
|
||||
//frappe.setup.welcome_page = "/app/lms-home";
|
||||
Reference in New Issue
Block a user