feat: instructors in courses and batches

This commit is contained in:
Jannat Patel
2024-06-25 18:43:28 +05:30
parent 8625ac048a
commit 96028c9f42
12 changed files with 449 additions and 75 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col border border-gray-200 rounded-md p-4 h-full"
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
style="min-height: 150px"
>
<Badge
@@ -8,7 +8,9 @@
theme="green"
class="self-start mb-2"
>
{{ batch.seats_left }} <span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
{{ batch.seats_left }}
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
</Badge>
<Badge
v-else-if="batch.seat_count && batch.seats_left <= 0"
@@ -23,11 +25,11 @@
<div class="short-introduction">
{{ batch.description }}
</div>
<div class="mt-auto">
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
<div class="flex flex-col space-y-2 mt-auto">
<div v-if="batch.amount" class="font-semibold text-lg">
{{ batch.price }}
</div>
<div class="flex items-center mb-3">
<div class="flex items-center">
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
</div>
@@ -36,7 +38,7 @@
:endDate="batch.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3">
<div class="flex items-center">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
<span>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
@@ -48,6 +50,18 @@
{{ batch.timezone }}
</span>
</div>
<div v-if="batch.instructors.length" class="flex avatar-group overlap">
<div
class="mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div>
</div>
</div>
</template>
@@ -56,6 +70,8 @@ import { Badge } from 'frappe-ui'
import { formatTime } from '../utils'
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const props = defineProps({
batch: {
@@ -75,4 +91,17 @@ const props = defineProps({
margin: 0.25rem 0 1.25rem;
line-height: 1.5;
}
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
}
</style>

View File

@@ -5,7 +5,9 @@
theme="green"
class="self-start mb-2 float-right"
>
{{ seats_left }} <span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
{{ seats_left }}
<span v-if="batch.data.seats_left > 1">{{ __('Seats Left') }}</span>
<span v-else-if="batch.data.seats_left == 1">{{ __('Seat Left') }}</span>
</Badge>
<Badge
v-else-if="batch.data.seat_count && seats_left <= 0"

View File

@@ -0,0 +1,255 @@
<template>
<div>
<label class="block mb-1" :class="labelClasses" v-if="label">
{{ label }}
</label>
<div class="grid grid-cols-3 gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded-md"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
</template>
</Button>
<div class="">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-gray-100': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium">
{{ option.description }}
</div>
<div class="text-sm text-gray-600">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
</div>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X } from 'lucide-vue-next'
const props = defineProps({
label: {
type: String,
},
size: {
type: String,
default: 'sm',
},
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: () => ({}),
},
validate: {
type: Function,
default: null,
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
val?.value && addValue(val.value)
},
})
watchDebounced(
query,
(val) => {
val = val || ''
if (text.value === val) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true }
)
const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
cache: [text.value, props.doctype],
params: {
txt: text.value,
doctype: props.doctype,
},
/* transform: (data) => {
let allData = data
.filter((c) => {
return c.description.split(', ')[1]
})
.map((option) => {
let email = option.description.split(', ')[1]
return {
label: option.label || email,
value: email,
}
})
return allData
}, */
})
const options = computed(() => {
return filterOptions.data || []
})
function reload(val) {
filterOptions.update({
params: {
txt: val,
doctype: props.doctype,
},
})
filterOptions.reload()
}
const addValue = (value) => {
error.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
})
!error.value && (value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[props.size || 'sm'],
'text-gray-600',
]
})
</script>

View File

@@ -16,10 +16,10 @@
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('.')[0]
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('.')[1]
? course.data.current_lesson.split('-')[1]
: 1,
},
}"

View File

@@ -80,8 +80,22 @@
<div class="text-2xl font-semibold mb-2">
{{ batch.data.title }}
</div>
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
<div class="flex avatar-group overlap mb-5">
<div
class="mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
@@ -155,6 +169,8 @@
<script setup>
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
import { computed, inject, ref } from 'vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import {
Clock,
LayoutDashboard,

View File

@@ -8,8 +8,8 @@
{{ __('Save') }}
</Button>
</header>
<div class="py-5">
<div class="container border-b">
<div class="w-1/2 mx-auto py-5">
<div class="">
<div class="text-lg font-semibold mb-4">
{{ __('Details') }}
</div>
@@ -20,54 +20,13 @@
:label="__('Title')"
class="mb-4"
/>
</div>
<div class="flex flex-col space-y-2">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
</div>
<div class="flex flex-col">
<FileUploader
v-if="!batch.image"
class="mt-4"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="my-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Meta Image') }}
</div>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ batch.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(batch.image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
@@ -76,7 +35,53 @@
</div>
</div>
</div>
<div class="container border-b mb-4">
<div class="mb-4">
<div>
<FileUploader
v-if="!batch.image"
class="mt-4"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="text-xs text-gray-600 mb-1">
{{ __('Meta Image') }}
</div>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
</div>
<div class="flex flex-col">
<span>
{{ batch.image.file_name }}
</span>
<span class="text-sm text-gray-500 mt-1">
{{ getFileSize(batch.image.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
</div>
<div class="mb-4">
<FormControl
v-model="batch.description"
:label="__('Description')"
@@ -96,7 +101,7 @@
/>
</div>
</div>
<div class="container border-b mb-4">
<div class="mb-4">
<div class="text-lg font-semibold mb-4">
{{ __('Date and Time') }}
</div>
@@ -137,7 +142,7 @@
</div>
</div>
</div>
<div class="container border-b mb-4">
<div class="mb-4">
<div class="text-lg font-semibold mb-4">
{{ __('Settings') }}
</div>
@@ -182,7 +187,7 @@
</div>
</div>
<div class="container">
<div class="">
<div class="text-lg font-semibold mb-4">
{{ __('Payment') }}
</div>
@@ -210,7 +215,14 @@
</div>
</template>
<script setup>
import { computed, onMounted, inject, reactive, onBeforeUnmount } from 'vue'
import {
computed,
onMounted,
inject,
reactive,
onBeforeUnmount,
ref,
} from 'vue'
import {
Breadcrumbs,
FormControl,
@@ -220,6 +232,7 @@ import {
createResource,
} from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils'
import { X, FileText } from 'lucide-vue-next'
@@ -255,6 +268,8 @@ const batch = reactive({
amount: 0,
})
const instructors = ref([])
onMounted(() => {
if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') {
@@ -285,6 +300,9 @@ const newBatch = createResource({
doc: {
doctype: 'LMS Batch',
meta_image: batch.image?.file_url,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...batch,
},
}
@@ -301,9 +319,13 @@ const batchDetail = createResource({
},
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (Object.hasOwn(batch, key)) batch[key] = data[key]
if (key == 'instructors') {
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
})
let checkboxes = ['published', 'paid_batch']
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
for (let idx in checkboxes) {
let key = checkboxes[idx]
batch[key] = batch[key] ? true : false
@@ -320,6 +342,9 @@ const editBatch = createResource({
name: props.batchName,
fieldname: {
meta_image: batch.image?.file_url,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...batch,
},
}

View File

@@ -36,6 +36,20 @@
</span>
</div>
</div>
<div class="flex avatar-group overlap mt-3">
<div
class="mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
</div>
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
<div class="order-2 lg:order-none">
@@ -91,6 +105,8 @@ import { Breadcrumbs, createResource } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import BatchOverlay from '@/components/BatchOverlay.vue'
import DateRange from '../components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const user = inject('$user')
const router = useRouter()

View File

@@ -62,7 +62,7 @@
<template #default="{ tab }">
<div
v-if="tab.batches && tab.batches.value.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mt-5 mx-5"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5"
>
<router-link
v-for="batch in tab.batches.value"

View File

@@ -99,12 +99,13 @@
:label="__('Preview Video')"
class="mb-4"
/>
<div>
<div class="mb-4">
<div class="mb-1.5 text-xs text-gray-600">
{{ __('Tags') }}
</div>
<div class="flex items-center">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
>
@@ -121,6 +122,11 @@
/>
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
/>
</div>
<div class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4">
@@ -188,7 +194,6 @@
</div>
</div>
<div class="border-l pt-5">
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
<CourseOutline
v-if="courseResource.data"
:courseName="courseResource.data.name"
@@ -222,6 +227,7 @@ import Link from '@/components/Controls/Link.vue'
import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
const user = inject('$user')
const newTag = ref('')
@@ -288,6 +294,9 @@ const courseCreationResource = createResource({
doc: {
doctype: 'LMS Course',
image: course.course_image?.file_url || '',
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...values,
},
}
@@ -303,6 +312,9 @@ const courseEditResource = createResource({
name: values.course,
fieldname: {
image: course.course_image?.file_url || '',
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...course,
},
}
@@ -320,7 +332,12 @@ const courseResource = createResource({
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (Object.hasOwn(course, key)) course[key] = data[key]
if (key == 'instructors') {
instructors.value = []
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (Object.hasOwn(course, key)) course[key] = data[key]
})
let checkboxes = [
'published',
@@ -334,7 +351,6 @@ const courseResource = createResource({
}
if (data.image) imageResource.reload({ image: data.image })
instructors.value = data.instructors
check_permission()
},
})
@@ -446,7 +462,7 @@ const check_permission = () => {
if (user.data?.is_moderator) return
instructors.value.forEach((instructor) => {
if (!user_is_instructor && instructor.instructor == user.data?.name) {
if (!user_is_instructor && instructor == user.data?.name) {
user_is_instructor = true
}
})

View File

@@ -26,10 +26,12 @@
"evaluation_end_date",
"section_break_6",
"description",
"batch_details_raw",
"column_break_hlqw",
"batch_details",
"instructors",
"meta_image",
"section_break_khcn",
"batch_details",
"batch_details_raw",
"section_break_jgji",
"students",
"courses",
@@ -308,11 +310,22 @@
"fieldtype": "Data",
"label": "Timezone",
"reqd": 1
},
{
"fieldname": "instructors",
"fieldtype": "Table MultiSelect",
"label": "Instructors",
"options": "Course Instructor",
"reqd": 1
},
{
"fieldname": "section_break_khcn",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-06-21 11:49:32.582832",
"modified": "2024-06-24 16:24:45.536453",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Batch",

View File

@@ -119,7 +119,8 @@
"in_standard_filter": 1,
"label": "Instructors",
"max_height": "50px",
"options": "Course Instructor"
"options": "Course Instructor",
"reqd": 1
},
{
"fieldname": "section_break_7",
@@ -275,7 +276,7 @@
}
],
"make_attachments_public": 1,
"modified": "2024-05-24 18:03:38.330443",
"modified": "2024-06-24 17:44:45.903164",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",

View File

@@ -196,8 +196,7 @@ def get_instructors(course):
instructors = frappe.get_all(
"Course Instructor", {"parent": course}, order_by="idx", pluck="instructor"
)
if not instructors:
instructors = frappe.db.get_value("LMS Course", course, "owner").split(" ")
for instructor in instructors:
instructor_details.append(
frappe.db.get_value(
@@ -1517,6 +1516,8 @@ def get_batch_details(batch):
as_dict=True,
)
batch_details.instructors = get_instructors(batch)
batch_details.courses = frappe.get_all(
"Batch Course", filters={"parent": batch}, fields=["course", "title"]
)