chore: resolved conflicts
This commit is contained in:
@@ -130,7 +130,6 @@
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
@@ -185,24 +184,27 @@ const batches = createListResource({
|
||||
cache: ['batches', user.data?.name],
|
||||
pageLength: pageLength.value,
|
||||
start: start.value,
|
||||
onSuccess(data) {
|
||||
let allCategories = data.map((batch) => batch.category)
|
||||
allCategories = allCategories.filter(
|
||||
(category, index) => allCategories.indexOf(category) === index && category
|
||||
)
|
||||
if (categories.value.length <= allCategories.length) {
|
||||
updateCategories(data)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const setCategories = (data) => {
|
||||
let allCategories = data.map((batch) => batch.category)
|
||||
allCategories = allCategories.filter(
|
||||
(category, index) => allCategories.indexOf(category) === index && category
|
||||
)
|
||||
if (categories.value.length <= allCategories.length) {
|
||||
updateCategories(data)
|
||||
}
|
||||
}
|
||||
|
||||
const updateBatches = () => {
|
||||
updateFilters()
|
||||
batches.update({
|
||||
filters: filters.value,
|
||||
orderBy: orderBy.value,
|
||||
})
|
||||
batches.reload()
|
||||
batches.reload().then((data) => {
|
||||
setCategories(data)
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilters = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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="breadcrumbs" />
|
||||
<router-link :to="{ name: 'Batches', query: { certification: true } }">
|
||||
<router-link :to="{ name: 'Courses', query: { certification: true } }">
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<GraduationCap class="h-4 w-4 stroke-1.5" />
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<FormControl
|
||||
v-model="openToOpportunities"
|
||||
:label="__('Open to Opportunities')"
|
||||
v-model="openToWork"
|
||||
:label="__('Open to Work')"
|
||||
type="checkbox"
|
||||
@change="updateParticipants()"
|
||||
/>
|
||||
@@ -134,19 +134,26 @@ import {
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { GraduationCap } from 'lucide-vue-next'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const filters = ref({})
|
||||
const currentCategory = ref('')
|
||||
const nameFilter = ref('')
|
||||
const openToOpportunities = ref(false)
|
||||
const openToWork = ref(false)
|
||||
const hiring = ref(false)
|
||||
const { brand } = sessionStore()
|
||||
const memberCount = ref(0)
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
router.push({ name: 'Courses' })
|
||||
return
|
||||
}
|
||||
setFiltersFromQuery()
|
||||
updateParticipants()
|
||||
})
|
||||
@@ -171,7 +178,7 @@ const categories = createListResource({
|
||||
doctype: 'LMS Certificate',
|
||||
url: 'lms.lms.api.get_certification_categories',
|
||||
cache: ['certification_categories'],
|
||||
auto: true,
|
||||
auto: user.data ? true : false,
|
||||
transform(data) {
|
||||
data.unshift({ label: __(' '), value: ' ' })
|
||||
return data
|
||||
@@ -197,8 +204,8 @@ const updateFilters = () => {
|
||||
...(nameFilter.value && {
|
||||
member_name: ['like', `%${nameFilter.value}%`],
|
||||
}),
|
||||
...(openToOpportunities.value && {
|
||||
open_to_opportunities: true,
|
||||
...(openToWork.value && {
|
||||
open_to_work: true,
|
||||
}),
|
||||
...(hiring.value && {
|
||||
hiring: true,
|
||||
@@ -211,7 +218,7 @@ const setQueryParams = () => {
|
||||
let filterKeys = {
|
||||
category: currentCategory.value,
|
||||
name: nameFilter.value,
|
||||
'open-to-opportunities': openToOpportunities.value,
|
||||
'open-to-work': openToWork.value,
|
||||
hiring: hiring.value,
|
||||
}
|
||||
|
||||
@@ -240,7 +247,7 @@ const setFiltersFromQuery = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
nameFilter.value = queries.get('name') || ''
|
||||
currentCategory.value = queries.get('category') || ''
|
||||
openToOpportunities.value = queries.get('open-to-opportunities') === 'true'
|
||||
openToWork.value = queries.get('open-to-opportunities') === 'true'
|
||||
hiring.value = queries.get('hiring') === 'true'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<template>
|
||||
<div v-if="course.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<div class="flex justify-between w-full space-x-5">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="parseInt(course.data.rating) > 0"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||
<span class="ml-1 text-ink-gray-7">
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Users class="h-4 w-4 text-ink-gray-7" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.enrollment_count" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="course.data.tags" class="flex my-4 w-fit">
|
||||
<Badge
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="mr-2 text-ink-gray-9"
|
||||
v-for="tag in course.data.tags.split(', ')"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="md:hidden my-4">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
<div
|
||||
v-html="course.data.description"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Badge,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch } from 'vue'
|
||||
import { Users, Star } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
watch(course, () => {
|
||||
if (
|
||||
!isInstructor() &&
|
||||
!user.data?.is_moderator &&
|
||||
!course.data?.published &&
|
||||
!course.data?.upcoming
|
||||
) {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isInstructor = () => {
|
||||
let user_is_instructor = false
|
||||
course.data?.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: course?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const courseTitle = ref(null)
|
||||
400
frontend/src/pages/Courses/CourseDashboard.vue
Normal file
400
frontend/src/pages/Courses/CourseDashboard.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-4 gap-5 mb-5">
|
||||
<NumberChartGraph
|
||||
:title="__('Enrolled')"
|
||||
:value="formatAmount(course.data?.enrollments)"
|
||||
/>
|
||||
<NumberChartGraph
|
||||
:title="__('Average Completion Rate')"
|
||||
:value="averageCompletionRate"
|
||||
/>
|
||||
<NumberChartGraph
|
||||
:title="__('Average Rating')"
|
||||
:value="course.data?.rating || 0"
|
||||
>
|
||||
<template #prefix>
|
||||
<Star class="size-5 text-transparent fill-amber-500" />
|
||||
</template>
|
||||
</NumberChartGraph>
|
||||
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
||||
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by name')"
|
||||
type="text"
|
||||
/>
|
||||
<Button @click="showEnrollmentModal = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="progressList.loading || progressList.data?.length"
|
||||
class="max-h-[63vh] overflow-y-auto"
|
||||
>
|
||||
<ListView
|
||||
:columns="progressColumns"
|
||||
:rows="progressList.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in progressColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="progressList.data && progressList.hasNextPage"
|
||||
class="flex justify-center my-3"
|
||||
>
|
||||
<Button @click="progressList.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-if="chartDetails.data?.average_progress > 0"
|
||||
class="border rounded-lg p-4"
|
||||
>
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Progress Summary') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
|
||||
<div class="flex flex-col space-y-4 flex-1 text-sm">
|
||||
<div
|
||||
class="flex items-center"
|
||||
v-for="row in chartDetails.data?.progress_distribution"
|
||||
>
|
||||
<div
|
||||
class="size-2 rounded"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
colors[theme][
|
||||
row.name.startsWith('Just')
|
||||
? 'red'
|
||||
: row.name.startsWith('In')
|
||||
? 'amber'
|
||||
: 'green'
|
||||
][400],
|
||||
}"
|
||||
></div>
|
||||
<Tooltip :text="row.name.split('(')[1].replace(')', '')">
|
||||
<div class="ml-2">
|
||||
{{ row.name.split('(')[0] }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="ml-auto">
|
||||
{{
|
||||
Math.round((row.value / course.data?.enrollments) * 100)
|
||||
}}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ECharts
|
||||
class="w-40 h-20"
|
||||
:options="{
|
||||
color: progressColors,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
scale: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
data: chartDetails.data?.progress_distribution || [],
|
||||
},
|
||||
],
|
||||
showInlineLabels: false,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="lessonProgress.data?.length"
|
||||
class="border rounded-lg pt-4 px-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Lesson Completion') }}
|
||||
</div>
|
||||
<Select
|
||||
:options="lessonProgressSortingOptions"
|
||||
@update:modelValue="(value: string) => updateLessonProgress(value)"
|
||||
:placeholder="__('Sort by')"
|
||||
class="!w-32"
|
||||
/>
|
||||
</div>
|
||||
<div class="divide-y max-h-[43vh] overflow-y-auto">
|
||||
<div
|
||||
v-for="progress in lessonProgress.data"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
>
|
||||
<div class="">
|
||||
<span class="mr-3 text-xs">
|
||||
{{ progress.chapter_idx }}.{{ progress.idx }}
|
||||
</span>
|
||||
<span>
|
||||
{{ progress.title }}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip :text="progress.completion_count">
|
||||
<div>
|
||||
{{
|
||||
Math.ceil(
|
||||
(progress.completion_count / course.data?.enrollments) *
|
||||
100
|
||||
)
|
||||
}}%
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseEnrollmentModal
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:course="course"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
dayjs,
|
||||
Dropdown,
|
||||
ECharts,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
Select,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
|
||||
import { formatAmount } from '@/utils'
|
||||
import colors from '@/utils/frappe-ui-colors.json'
|
||||
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
|
||||
const showEnrollmentModal = ref(false)
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const theme = ref<'darkMode' | 'lightMode'>(
|
||||
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
const chartDetails = createResource({
|
||||
url: 'lms.lms.api.get_course_progress_distribution',
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
course: props.course.data?.name,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'progress',
|
||||
'creation',
|
||||
],
|
||||
pageLength: 100,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const lessonProgress = createResource({
|
||||
url: 'lms.lms.api.get_lesson_completion_stats',
|
||||
params: {
|
||||
course: props.course.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const updateLessonProgress = (value: string) => {
|
||||
if (value == 'completion_rate') {
|
||||
lessonProgress.data?.sort((a: any, b: any) => {
|
||||
const rateA = a.completion_count / (props.course.data?.enrollments || 1)
|
||||
const rateB = b.completion_count / (props.course.data?.enrollments || 1)
|
||||
return rateB - rateA
|
||||
})
|
||||
} else if (value == 'index') {
|
||||
lessonProgress.data?.sort((a: any, b: any) => {
|
||||
return a.chapter_idx - b.chapter_idx || a.idx - b.idx
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
filters: filters,
|
||||
})
|
||||
progressList.reload()
|
||||
})
|
||||
|
||||
const averageCompletionRate = computed(() => {
|
||||
let value = Math.ceil(chartDetails.data?.average_progress) || 0
|
||||
return value + '%'
|
||||
})
|
||||
|
||||
const progressColors = computed(() => {
|
||||
let colorList = []
|
||||
colorList.push(colors[theme.value]['red'][400])
|
||||
colorList.push(colors[theme.value]['amber'][400])
|
||||
colorList.push(colors[theme.value]['green'][400])
|
||||
return colorList
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Name'),
|
||||
key: 'member_name',
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
label: __('Start Date'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const lessonProgressSortingOptions = [
|
||||
{
|
||||
label: __('Lesson Index'),
|
||||
value: 'index',
|
||||
onClick() {
|
||||
updateLessonProgress('index')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Completion Rate'),
|
||||
value: 'completion_rate',
|
||||
onClick() {
|
||||
updateLessonProgress('completion_rate')
|
||||
},
|
||||
},
|
||||
]
|
||||
</script>
|
||||
167
frontend/src/pages/Courses/CourseDetail.vue
Normal file
167
frontend/src/pages/Courses/CourseDetail.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div v-if="course.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div v-if="tabIndex == 2" class="flex items-center space-x-2">
|
||||
<Badge v-if="childRef?.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<Button @click="childRef.trashCourse()">
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="childRef.submitCourse()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<CourseOverview v-if="!isAdmin" :course="course" />
|
||||
<div v-else>
|
||||
<Tabs :tabs="tabs" v-model="tabIndex">
|
||||
<template #tab-panel="{ tab }">
|
||||
<component :is="tab.component" :course="course" ref="childRef" />
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Tabs,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, markRaw, onMounted, ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { List, Settings2, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
|
||||
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
|
||||
import CourseForm from '@/pages/Courses/CourseForm.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const user = inject('$user')
|
||||
const tabIndex = ref(0)
|
||||
const childRef = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateTabIndex()
|
||||
})
|
||||
|
||||
const updateTabIndex = () => {
|
||||
const hash = route.hash
|
||||
if (hash) {
|
||||
tabs.value.forEach((tab, index) => {
|
||||
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
|
||||
tabIndex.value = index
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(tabIndex, () => {
|
||||
const tab = tabs.value[tabIndex.value]
|
||||
if (tab.label != route.hash.replace('#', '')) {
|
||||
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
|
||||
}
|
||||
})
|
||||
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: __('Overview'),
|
||||
component: markRaw(CourseOverview),
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
label: __('Dashboard'),
|
||||
component: markRaw(CourseDashboard),
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
label: __('Settings'),
|
||||
component: markRaw(CourseForm),
|
||||
icon: Settings2,
|
||||
},
|
||||
])
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
watch(course, () => {
|
||||
if (!isAdmin.value && !course.data?.published && !course.data?.upcoming) {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const isInstructor = () => {
|
||||
let user_is_instructor = false
|
||||
course.data?.instructors.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
})
|
||||
return user_is_instructor
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || isInstructor()
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
label: course?.data?.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: course?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
104
frontend/src/pages/Courses/CourseEnrollmentModal.vue
Normal file
104
frontend/src/pages/Courses/CourseEnrollmentModal.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Enroll a Student'),
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:label="__('Purchased Certificate')"
|
||||
v-model="purchasedCertificate"
|
||||
/>
|
||||
<Link
|
||||
doctype="User"
|
||||
:label="__('Student')"
|
||||
placeholder=" "
|
||||
v-model="student"
|
||||
:required="true"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
() => {
|
||||
openSettings('Members')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
v-if="purchasedCertificate"
|
||||
doctype="LMS Payment"
|
||||
:label="__('Payment')"
|
||||
placeholder=" "
|
||||
v-model="payment"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
() => {
|
||||
openSettings('Transactions')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="enrollStudent(close)">
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { ref } from 'vue'
|
||||
import { openSettings } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const student = ref<string | null>(null)
|
||||
const payment = ref<string | null>(null)
|
||||
const purchasedCertificate = ref<boolean>(false)
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
|
||||
const enrollStudent = (close: () => void) => {
|
||||
let validationPassed = validateData()
|
||||
if (!validationPassed) return
|
||||
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.course.data?.name,
|
||||
member: student.value,
|
||||
payment: purchasedCertificate.value ? payment.value : null,
|
||||
purchased_certificate: purchasedCertificate.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Student enrolled successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const validateData = (): boolean => {
|
||||
if (!student.value) {
|
||||
toast.error(__('Please select a student to enroll.'))
|
||||
return false
|
||||
}
|
||||
if (purchasedCertificate.value && !payment.value) {
|
||||
toast.error(__('Please select a payment for the purchased certificate.'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
@@ -1,40 +1,25 @@
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] h-full">
|
||||
<div>
|
||||
<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 class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center mt-3 md:mt-0">
|
||||
<Button v-if="courseResource.data?.name" @click="trashCourse()">
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||
<span>
|
||||
{{ __('Save') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mt-5 mb-5">
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="pl-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] overflow-hidden">
|
||||
<div v-if="courseResource.doc" class="h-[88vh] overflow-y-auto">
|
||||
<div class="my-5">
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="course.title"
|
||||
v-model="courseResource.doc.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
v-model="courseResource.doc.category"
|
||||
:label="__('Category')"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
@@ -45,6 +30,7 @@
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:onCreate="(close) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
@@ -60,8 +46,8 @@
|
||||
<div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
v-if="courseResource.doc.tags"
|
||||
v-for="tag in courseResource.doc.tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
{{ tag }}
|
||||
@@ -76,21 +62,23 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<Uploader
|
||||
v-model="course.image"
|
||||
v-model="courseResource.doc.image"
|
||||
:label="__('Course Image')"
|
||||
:required="false"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
|
||||
<ColorSwatches
|
||||
v-model="course.card_gradient"
|
||||
v-model="courseResource.doc.card_gradient"
|
||||
:label="__('Color')"
|
||||
:description="__('Choose a color for the course card')"
|
||||
class="w-full"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
@@ -101,41 +89,46 @@
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.published"
|
||||
v-model="courseResource.doc.published"
|
||||
:label="__('Published')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="course.published_on"
|
||||
v-model="courseResource.doc.published_on"
|
||||
:label="__('Published On')"
|
||||
type="date"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.upcoming"
|
||||
v-model="courseResource.doc.upcoming"
|
||||
:label="__('Upcoming')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.featured"
|
||||
v-model="courseResource.doc.featured"
|
||||
:label="__('Featured')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.disable_self_learning"
|
||||
v-model="courseResource.doc.disable_self_learning"
|
||||
:label="__('Disable Self Enrollment')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('About the Course') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
v-model="courseResource.doc.short_introduction"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:label="__('Short Introduction')"
|
||||
@@ -145,6 +138,7 @@
|
||||
)
|
||||
"
|
||||
:required="true"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
@@ -152,8 +146,13 @@
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@change="(val) => (course.description = val)"
|
||||
:content="courseResource.doc.description"
|
||||
@change="
|
||||
(val) => {
|
||||
courseResource.doc.description = val
|
||||
makeFormDirty()
|
||||
}
|
||||
"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
@@ -161,92 +160,113 @@
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-model="course.video_link"
|
||||
v-model="courseResource.doc.video_link"
|
||||
:label="__('Preview Video')"
|
||||
:placeholder="
|
||||
__(
|
||||
'Paste the youtube link of a short video introducing the course'
|
||||
)
|
||||
"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="related_courses"
|
||||
doctype="LMS Course"
|
||||
:label="__('Related Courses')"
|
||||
:filters="{ name: ['!=', courseResource.data?.name] }"
|
||||
:filters="{ name: ['!=', courseResource.doc?.name] }"
|
||||
:onCreate="
|
||||
(close) => {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: 'new' },
|
||||
name: 'Courses',
|
||||
query: { newCourse: '1' },
|
||||
})
|
||||
}
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5 border-b">
|
||||
<div class="pr-5 md:pr-10 pb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
|
||||
{{ __('Pricing and Certification') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_course"
|
||||
v-model="courseResource.doc.paid_course"
|
||||
:label="__('Paid Course')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.enable_certification"
|
||||
v-model="courseResource.doc.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_certificate"
|
||||
v-model="courseResource.doc.paid_certificate"
|
||||
:label="__('Paid Certificate')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-if="course.paid_course || course.paid_certificate"
|
||||
v-model="course.course_price"
|
||||
v-if="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
v-model="courseResource.doc.course_price"
|
||||
:label="__('Amount')"
|
||||
:required="course.paid_course || course.paid_certificate"
|
||||
:required="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<Link
|
||||
v-if="course.paid_certificate"
|
||||
v-if="courseResource.doc.paid_certificate"
|
||||
doctype="Course Evaluator"
|
||||
v-model="course.evaluator"
|
||||
v-model="courseResource.doc.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
:required="course.paid_certificate"
|
||||
:required="courseResource.doc.paid_certificate"
|
||||
:onCreate="
|
||||
(value, close) => openSettings('Evaluators', close)
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Link
|
||||
v-if="course.paid_course || course.paid_certificate"
|
||||
v-if="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
doctype="Currency"
|
||||
v-model="course.currency"
|
||||
v-model="courseResource.doc.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
:required="course.paid_course || course.paid_certificate"
|
||||
:required="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="course.paid_certificate"
|
||||
v-model="course.timezone"
|
||||
v-if="courseResource.doc.paid_certificate"
|
||||
v-model="courseResource.doc.timezone"
|
||||
:label="__('Timezone')"
|
||||
:required="course.paid_certificate"
|
||||
:required="courseResource.doc.paid_certificate"
|
||||
:placeholder="__('e.g. IST, UTC, GMT...')"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5">
|
||||
<div class="pr-5 md:pr-10 pb-5 space-y-5">
|
||||
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
@@ -256,6 +276,7 @@
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
@@ -263,16 +284,17 @@
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
:placeholder="__('Comma separated keywords for SEO')"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l">
|
||||
<div class="border-l h-[88vh] overflow-y-auto">
|
||||
<CourseOutline
|
||||
v-if="courseResource.data"
|
||||
:courseName="courseResource.data.name"
|
||||
:title="__('Course Outline')"
|
||||
v-if="courseResource.doc"
|
||||
:courseName="courseResource.doc.name"
|
||||
:title="__('Chapters')"
|
||||
:allowEdit="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -281,10 +303,10 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
TextEditor,
|
||||
Button,
|
||||
createResource,
|
||||
createDocumentResource,
|
||||
FormControl,
|
||||
usePageMeta,
|
||||
toast,
|
||||
@@ -293,7 +315,6 @@ import {
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
computed,
|
||||
ref,
|
||||
reactive,
|
||||
watch,
|
||||
@@ -308,8 +329,7 @@ import {
|
||||
} from '@/utils'
|
||||
import { Trash2, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
@@ -323,39 +343,15 @@ const router = useRouter()
|
||||
const instructors = ref([])
|
||||
const related_courses = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
course: {
|
||||
type: Object,
|
||||
},
|
||||
})
|
||||
|
||||
const course = reactive({
|
||||
title: '',
|
||||
short_introduction: '',
|
||||
description: '',
|
||||
video_link: '',
|
||||
image: null,
|
||||
card_gradient: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
published: false,
|
||||
published_on: '',
|
||||
featured: false,
|
||||
upcoming: false,
|
||||
disable_self_learning: false,
|
||||
enable_certification: false,
|
||||
paid_course: false,
|
||||
paid_certificate: false,
|
||||
course_price: '',
|
||||
currency: '',
|
||||
evaluator: '',
|
||||
timezone: '',
|
||||
})
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
@@ -365,18 +361,92 @@ onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
if (props.courseName !== 'new') {
|
||||
fetchCourseInfo()
|
||||
} else {
|
||||
capture('course_form_opened')
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const fetchCourseInfo = () => {
|
||||
courseResource.reload()
|
||||
getMetaInfo('courses', props.courseName, meta)
|
||||
const courseResource = createDocumentResource({
|
||||
doctype: 'LMS Course',
|
||||
name: props.course.data?.name,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => courseResource.doc,
|
||||
() => {
|
||||
check_permission()
|
||||
getMetaInfo('courses', courseResource.doc?.name, meta)
|
||||
updateCourseData()
|
||||
}
|
||||
)
|
||||
|
||||
const updateCourseData = () => {
|
||||
Object.keys(courseResource.doc).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
instructors.value = []
|
||||
courseResource.doc.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (key == 'related_courses') {
|
||||
related_courses.value = []
|
||||
courseResource.doc.related_courses.forEach((course) => {
|
||||
related_courses.value.push(course.course)
|
||||
})
|
||||
}
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'upcoming',
|
||||
'disable_self_learning',
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
'paid_certificate',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
courseResource.doc[key] = courseResource.doc[key] ? true : false
|
||||
}
|
||||
}
|
||||
|
||||
const submitCourse = () => {
|
||||
validateFields()
|
||||
updateCourse()
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
courseResource.doc.description = sanitizeHTML(courseResource.doc.description)
|
||||
|
||||
Object.keys(courseResource.doc).forEach((key) => {
|
||||
if (key != 'description' && typeof courseResource.doc[key] === 'string') {
|
||||
courseResource.doc[key] = escapeHTML(courseResource.doc[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateCourse = () => {
|
||||
courseResource.setValue.submit(
|
||||
{
|
||||
...courseResource.doc,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateMetaInfo('courses', courseResource.doc?.name, meta)
|
||||
toast.success(__('Course updated successfully'))
|
||||
isDirty.value = false
|
||||
courseResource.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
@@ -394,151 +464,11 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const courseCreationResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Course',
|
||||
image: course.image,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courseEditResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
name: values.course,
|
||||
fieldname: {
|
||||
image: course.image,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...course,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courseResource = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
name: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
instructors.value = []
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (key == 'related_courses') {
|
||||
related_courses.value = []
|
||||
data.related_courses.forEach((course) => {
|
||||
related_courses.value.push(course.course)
|
||||
})
|
||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'upcoming',
|
||||
'disable_self_learning',
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
'paid_certificate',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
course[key] = course[key] ? true : false
|
||||
}
|
||||
|
||||
check_permission()
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
course.description = sanitizeHTML(course.description)
|
||||
|
||||
Object.keys(course).forEach((key) => {
|
||||
if (key != 'description' && typeof course[key] === 'string') {
|
||||
course[key] = escapeHTML(course[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitCourse = () => {
|
||||
validateFields()
|
||||
if (courseResource.data) {
|
||||
editCourse()
|
||||
} else {
|
||||
createCourse()
|
||||
}
|
||||
}
|
||||
|
||||
const createCourse = () => {
|
||||
courseCreationResource.submit(course, {
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('courses', data.name, meta)
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_course', true, false, () => {
|
||||
localStorage.setItem('firstCourse', data.name)
|
||||
})
|
||||
}
|
||||
|
||||
capture('course_created')
|
||||
toast.success(__('Course created successfully'))
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: data.name },
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const editCourse = () => {
|
||||
courseEditResource.submit(
|
||||
{
|
||||
course: courseResource.data.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateMetaInfo('courses', props.courseName, meta)
|
||||
toast.success(__('Course updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteCourse = createResource({
|
||||
url: 'lms.lms.api.delete_course',
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
course: courseResource.doc?.name,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
@@ -567,28 +497,23 @@ const trashCourse = () => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.courseName !== 'new',
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
fetchCourseInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const updateTags = () => {
|
||||
if (newTag.value) {
|
||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||
courseResource.doc.tags = courseResource.doc.tags
|
||||
? `${courseResource.doc.tags}, ${newTag.value}`
|
||||
: newTag.value
|
||||
newTag.value = ''
|
||||
makeFormDirty()
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag) => {
|
||||
course.tags = course.tags
|
||||
courseResource.doc.tags = courseResource.doc.tags
|
||||
?.split(', ')
|
||||
.filter((t) => t !== tag)
|
||||
.join(', ')
|
||||
newTag.value = ''
|
||||
makeFormDirty()
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
@@ -606,30 +531,20 @@ const check_permission = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Courses',
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
]
|
||||
if (courseResource.data) {
|
||||
crumbs.push({
|
||||
label: course.title,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
|
||||
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
const makeFormDirty = () => {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: courseResource.data?.title || __('New Course'),
|
||||
title: courseResource.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
submitCourse,
|
||||
trashCourse,
|
||||
isDirty,
|
||||
})
|
||||
</script>
|
||||
102
frontend/src/pages/Courses/CourseOverview.vue
Normal file
102
frontend/src/pages/Courses/CourseOverview.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="flex justify-between w-full space-x-5">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ course.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ course.data.short_introduction }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Tooltip
|
||||
v-if="parseInt(course.data.rating) > 0"
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||
<span class="ml-1 text-ink-gray-7">
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||
>·</span
|
||||
>
|
||||
<Tooltip
|
||||
v-if="course.data.enrollment_count"
|
||||
:text="__('Enrolled Students')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Users class="h-4 w-4 text-ink-gray-7" />
|
||||
<span class="ml-1">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-if="course.data.enrollment_count" class="mx-3">·</span>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': course.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</span>
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="course.data.tags" class="flex my-4 w-fit">
|
||||
<Badge
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="mr-2 text-ink-gray-9"
|
||||
v-for="tag in course.data.tags.split(', ')"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="md:hidden my-4">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
<div
|
||||
v-html="course.data.description"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
:courseName="course.data.name"
|
||||
:avg_rating="course.data.rating"
|
||||
:membership="course.data.membership"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Star, Users } from 'lucide-vue-next'
|
||||
import { Badge, Tooltip } from 'frappe-ui'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
}>()
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
|
||||
<Dropdown
|
||||
placement="start"
|
||||
placement="right"
|
||||
side="bottom"
|
||||
v-if="canCreateCourse()"
|
||||
:options="[
|
||||
@@ -13,10 +13,7 @@
|
||||
label: __('New Course'),
|
||||
icon: 'book-open',
|
||||
onClick() {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: 'new' },
|
||||
})
|
||||
showCourseModal = true
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -109,6 +106,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<NewCourseModal
|
||||
v-if="showCourseModal"
|
||||
v-model="showCourseModal"
|
||||
:courses="courses"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -128,13 +130,19 @@ import { sessionStore } from '@/stores/session'
|
||||
import { canCreateCourse } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import router from '../router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NewCourseModal from '@/pages/Courses/NewCourseModal.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const start = ref(0)
|
||||
const pageLength = ref(30)
|
||||
const categories = ref([])
|
||||
const categories = ref([
|
||||
{
|
||||
label: '',
|
||||
value: null,
|
||||
},
|
||||
])
|
||||
const currentCategory = ref(null)
|
||||
const title = ref('')
|
||||
const certification = ref(false)
|
||||
@@ -142,17 +150,13 @@ const filters = ref({})
|
||||
const currentTab = ref('Live')
|
||||
const { brand } = sessionStore()
|
||||
const courseCount = ref(0)
|
||||
const router = useRouter()
|
||||
const showCourseModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
updateCourses()
|
||||
getCourseCount()
|
||||
categories.value = [
|
||||
{
|
||||
label: '',
|
||||
value: null,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const setFiltersFromQuery = () => {
|
||||
@@ -160,6 +164,9 @@ const setFiltersFromQuery = () => {
|
||||
title.value = queries.get('title') || ''
|
||||
currentCategory.value = queries.get('category') || null
|
||||
certification.value = queries.get('certification') || false
|
||||
if (queries.get('newCourse') == '1') {
|
||||
showCourseModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const courses = createListResource({
|
||||
@@ -168,9 +175,6 @@ const courses = createListResource({
|
||||
cache: ['courses', user.data?.name],
|
||||
pageLength: pageLength.value,
|
||||
start: start.value,
|
||||
onSuccess(data) {
|
||||
setCategories(data)
|
||||
},
|
||||
})
|
||||
|
||||
const setCategories = (data) => {
|
||||
@@ -205,7 +209,7 @@ const identifyUserPersona = async () => {
|
||||
|
||||
const getCourseCount = () => {
|
||||
if (!user.data) return
|
||||
|
||||
if (!user.data.is_moderator) return
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Course',
|
||||
}).then((data) => {
|
||||
@@ -219,7 +223,9 @@ const updateCourses = () => {
|
||||
courses.update({
|
||||
filters: filters.value,
|
||||
})
|
||||
courses.reload()
|
||||
courses.reload().then((data) => {
|
||||
setCategories(data)
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilters = () => {
|
||||
156
frontend/src/pages/Courses/NewCourseModal.vue
Normal file
156
frontend/src/pages/Courses/NewCourseModal.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Create Course'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-2 gap-5 border-b mb-5">
|
||||
<FormControl
|
||||
v-model="course.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
:label="__('Category')"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="course.instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:onCreate="(close: () => void) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="course.image"
|
||||
:label="__('Course Image')"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
:label="__('Short Introduction')"
|
||||
type="textarea"
|
||||
:required="true"
|
||||
:rows="4"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course Description') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@change="(val: string) => (course.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="saveCourse(close)">
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { openSettings } from '@/utils'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const router = useRouter()
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const user = inject<any>('$user')
|
||||
|
||||
const props = defineProps<{
|
||||
courses: any
|
||||
}>()
|
||||
|
||||
const course = ref({
|
||||
title: '',
|
||||
short_introduction: '',
|
||||
description: '',
|
||||
instructors: [],
|
||||
category: null,
|
||||
image: null,
|
||||
})
|
||||
|
||||
const saveCourse = (close: () => void = () => {}) => {
|
||||
props.courses.insert.submit(
|
||||
{
|
||||
...course.value,
|
||||
instructors: course.value.instructors.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess(data: any) {
|
||||
toast.success(__('Course created successfully'))
|
||||
close()
|
||||
capture('course_created')
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: data.name },
|
||||
hash: '#settings',
|
||||
})
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_course', true, false, () => {
|
||||
localStorage.setItem('firstCourse', data.name)
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.target &&
|
||||
e.target instanceof HTMLElement &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveCourse()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
watch(show, () => {
|
||||
capture('course_form_opened')
|
||||
})
|
||||
</script>
|
||||
@@ -74,7 +74,7 @@
|
||||
}}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: 'CourseForm', params: { courseName: 'new' } }"
|
||||
:to="{ name: 'Courses', query: { newCourse: '1' } }"
|
||||
class="mt-4"
|
||||
>
|
||||
<Button>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<Uploader
|
||||
v-model="job.company_logo"
|
||||
:label="__('Company Logo')"
|
||||
:required="false"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,12 +51,12 @@
|
||||
class="hidden lg:block"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<FormControl
|
||||
type="text"
|
||||
:placeholder="__('Search')"
|
||||
v-model="searchQuery"
|
||||
class="w-full max-w-40"
|
||||
class="w-full"
|
||||
@input="updateJobs"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -79,17 +79,17 @@
|
||||
v-model="jobType"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="w-full"
|
||||
class="w-full min-w-32"
|
||||
:placeholder="__('Type')"
|
||||
@change="updateJobs"
|
||||
@update:modelValue="updateJobs"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="workMode"
|
||||
type="select"
|
||||
:options="workModes"
|
||||
class="w-full"
|
||||
class="w-full min-w-32"
|
||||
:placeholder="__('Work Mode')"
|
||||
@change="updateJobs"
|
||||
@update:modelValue="updateJobs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,13 +218,13 @@ const updateJobs = () => {
|
||||
const updateFilters = () => {
|
||||
filters.value.status = 'Open'
|
||||
|
||||
if (jobType.value) {
|
||||
if (jobType.value && jobType.value !== ' ') {
|
||||
filters.value.type = jobType.value
|
||||
} else {
|
||||
delete filters.value.type
|
||||
}
|
||||
|
||||
if (workMode.value) {
|
||||
if (workMode.value && workMode.value !== ' ') {
|
||||
filters.value.work_mode = workMode.value
|
||||
} else {
|
||||
delete filters.value.work_mode
|
||||
@@ -271,7 +271,7 @@ watch(jobs, () => {
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
{ label: '', value: '' },
|
||||
{ label: ' ', value: ' ' },
|
||||
{ label: __('Full Time'), value: 'Full Time' },
|
||||
{ label: __('Part Time'), value: 'Part Time' },
|
||||
{ label: __('Contract'), value: 'Contract' },
|
||||
@@ -281,7 +281,7 @@ const jobTypes = computed(() => {
|
||||
|
||||
const workModes = computed(() => {
|
||||
return [
|
||||
{ label: '', value: '' },
|
||||
{ label: ' ', value: ' ' },
|
||||
{ label: 'On site', value: 'On-site' },
|
||||
{ label: 'Hybrid', value: 'Hybrid' },
|
||||
{ label: 'Remote', value: 'Remote' },
|
||||
|
||||
@@ -326,6 +326,7 @@
|
||||
@updateNotes="updateNotes"
|
||||
/>
|
||||
<VideoStatistics
|
||||
v-if="showStatsDialog"
|
||||
v-model="showStatsDialog"
|
||||
:lessonName="lesson.data?.name"
|
||||
:lessonTitle="lesson.data?.title"
|
||||
@@ -871,6 +872,7 @@ const scrollDiscussionsIntoView = () => {
|
||||
}
|
||||
|
||||
const updateNotes = () => {
|
||||
if (!user.data) return
|
||||
notes.update({
|
||||
filters: {
|
||||
lesson: lesson.data?.name,
|
||||
|
||||
@@ -471,7 +471,11 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
{
|
||||
label: lessonDetails.data?.course_title,
|
||||
route: { name: 'CourseForm', params: { courseName: props.courseName } },
|
||||
route: {
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: props.courseName },
|
||||
hash: '#settings',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
class="flex space-x-2 px-2 py-4"
|
||||
:class="{
|
||||
'cursor-pointer': log.link,
|
||||
'items-center': !showDetails(log) && !isMention(log),
|
||||
'items-center': !showDetails(log) && !isMentionOrComment(log),
|
||||
}"
|
||||
@click="navigateToPage(log)"
|
||||
>
|
||||
@@ -56,9 +56,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isMention(log)"
|
||||
v-if="isMentionOrComment(log)"
|
||||
v-html="log.email_content"
|
||||
class="bg-surface-gray-2 rounded-md px-3 py-2"
|
||||
class="bg-surface-gray-2 rounded-md px-3 py-2 line-clamp-3 overflow-hidden"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="showDetails(log)"
|
||||
@@ -185,10 +185,9 @@ const unReadNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
url: 'lms.lms.api.get_notifications',
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 0,
|
||||
},
|
||||
auto: true,
|
||||
auto: user.data ? true : false,
|
||||
cache: 'Unread Notifications',
|
||||
})
|
||||
|
||||
@@ -196,18 +195,17 @@ const readNotifications = createListResource({
|
||||
doctype: 'Notification Log',
|
||||
url: 'lms.lms.api.get_notifications',
|
||||
filters: {
|
||||
for_user: user.data?.name,
|
||||
read: 1,
|
||||
},
|
||||
auto: true,
|
||||
auto: user.data ? true : false,
|
||||
cache: 'Read Notifications',
|
||||
})
|
||||
|
||||
const markAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_as_read',
|
||||
url: 'frappe.desk.doctype.notification_log.notification_log.mark_as_read',
|
||||
makeParams(values) {
|
||||
return {
|
||||
name: values.name,
|
||||
docname: values.name,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
@@ -217,7 +215,7 @@ const markAsRead = createResource({
|
||||
})
|
||||
|
||||
const markAllAsRead = createResource({
|
||||
url: 'lms.lms.api.mark_all_as_read',
|
||||
url: 'frappe.desk.doctype.notification_log.notification_log.mark_all_as_read',
|
||||
onSuccess(data) {
|
||||
unReadNotifications.reload()
|
||||
readNotifications.reload()
|
||||
@@ -260,7 +258,7 @@ const navigateToPage = (log) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isMention = (log) => {
|
||||
const isMentionOrComment = (log) => {
|
||||
if (log.type == 'Mention') {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
<Tooltip
|
||||
v-if="profile.data.open_to"
|
||||
:text="
|
||||
profile.data.open_to === 'Opportunities'
|
||||
? __('Open to Opportunities')
|
||||
profile.data.open_to === 'Work'
|
||||
? __('Open to Work')
|
||||
: __('Hiring')
|
||||
"
|
||||
placement="right"
|
||||
@@ -77,7 +77,7 @@
|
||||
<div
|
||||
class="rounded-full w-fit"
|
||||
:class="
|
||||
profile.data.open_to === 'Opportunities'
|
||||
profile.data.open_to === 'Work'
|
||||
? 'bg-surface-green-3'
|
||||
: 'bg-purple-500'
|
||||
"
|
||||
|
||||
@@ -226,7 +226,6 @@ import {
|
||||
onMounted,
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
|
||||
@@ -252,7 +251,9 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const questions = ref([])
|
||||
const questions = computed(() => {
|
||||
return quizDetails.doc?.questions || []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
@@ -273,24 +274,10 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.quizID !== 'new',
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
quizDetails.reload()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const quizDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz',
|
||||
name: props.quizID,
|
||||
auto: false,
|
||||
onSuccess(doc) {
|
||||
if (doc.questions && doc.questions.length > 0) {
|
||||
questions.value = doc.questions.map((question) => question)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
|
||||
@@ -116,20 +116,30 @@ const debouncedSaveProgress = (scormDetails) => {
|
||||
}
|
||||
|
||||
const saveDataToLMS = (key, value) => {
|
||||
if (key === 'cmi.core.lesson_status') {
|
||||
if (value === 'passed') {
|
||||
isSuccessfullyCompleted.value = true
|
||||
saveProgress({
|
||||
is_complete: isSuccessfullyCompleted.value,
|
||||
scorm_content: '',
|
||||
})
|
||||
} else if (value === 'failed' && courseRestartOnFailure) {
|
||||
saveProgress({
|
||||
is_complete: isSuccessfullyCompleted.value,
|
||||
scorm_content: '',
|
||||
})
|
||||
}
|
||||
} else if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) {
|
||||
const isLessonStatus = key === 'cmi.core.lesson_status' && value === 'passed'
|
||||
const isCompletionStatus =
|
||||
key === 'cmi.completion_status' && value === 'completed'
|
||||
const shouldRestart =
|
||||
(key === 'cmi.core.lesson_status' && value === 'failed') ||
|
||||
(key === 'cmi.completion_status' && value === 'incomplete')
|
||||
|
||||
if (isLessonStatus || isCompletionStatus) {
|
||||
isSuccessfullyCompleted.value = true
|
||||
}
|
||||
|
||||
if (
|
||||
isLessonStatus ||
|
||||
isCompletionStatus ||
|
||||
(shouldRestart && courseRestartOnFailure)
|
||||
) {
|
||||
saveProgress({
|
||||
is_complete: isSuccessfullyCompleted.value,
|
||||
scorm_content: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'cmi.suspend_data' && !isSuccessfullyCompleted.value) {
|
||||
debouncedSaveProgress({
|
||||
is_complete: false,
|
||||
scorm_content: value,
|
||||
|
||||
Reference in New Issue
Block a user