feat: instructors in courses and batches
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<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"
|
style="min-height: 150px"
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
theme="green"
|
theme="green"
|
||||||
class="self-start mb-2"
|
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>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||||
@@ -23,11 +25,11 @@
|
|||||||
<div class="short-introduction">
|
<div class="short-introduction">
|
||||||
{{ batch.description }}
|
{{ batch.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-auto">
|
<div class="flex flex-col space-y-2 mt-auto">
|
||||||
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
|
<div v-if="batch.amount" class="font-semibold text-lg">
|
||||||
{{ batch.price }}
|
{{ batch.price }}
|
||||||
</div>
|
</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" />
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
:endDate="batch.end_date"
|
:endDate="batch.end_date"
|
||||||
class="mb-3"
|
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" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||||
@@ -48,6 +50,18 @@
|
|||||||
{{ batch.timezone }}
|
{{ batch.timezone }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,6 +70,8 @@ import { Badge } from 'frappe-ui'
|
|||||||
import { formatTime } from '../utils'
|
import { formatTime } from '../utils'
|
||||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -75,4 +91,17 @@ const props = defineProps({
|
|||||||
margin: 0.25rem 0 1.25rem;
|
margin: 0.25rem 0 1.25rem;
|
||||||
line-height: 1.5;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
theme="green"
|
theme="green"
|
||||||
class="self-start mb-2 float-right"
|
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>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||||
|
|||||||
255
frontend/src/components/Controls/MultiSelect.vue
Normal file
255
frontend/src/components/Controls/MultiSelect.vue
Normal 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>
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.data.current_lesson
|
chapterNumber: course.data.current_lesson
|
||||||
? course.data.current_lesson.split('.')[0]
|
? course.data.current_lesson.split('-')[0]
|
||||||
: 1,
|
: 1,
|
||||||
lessonNumber: course.data.current_lesson
|
lessonNumber: course.data.current_lesson
|
||||||
? course.data.current_lesson.split('.')[1]
|
? course.data.current_lesson.split('-')[1]
|
||||||
: 1,
|
: 1,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -80,8 +80,22 @@
|
|||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-2xl font-semibold mb-2">
|
||||||
{{ batch.data.title }}
|
{{ batch.data.title }}
|
||||||
</div>
|
</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
|
<DateRange
|
||||||
:startDate="batch.data.start_date"
|
:startDate="batch.data.start_date"
|
||||||
:endDate="batch.data.end_date"
|
:endDate="batch.data.end_date"
|
||||||
@@ -155,6 +169,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div class="py-5">
|
<div class="w-1/2 mx-auto py-5">
|
||||||
<div class="container border-b">
|
<div class="">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Details') }}
|
{{ __('Details') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -20,13 +20,23 @@
|
|||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.published"
|
v-model="batch.published"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:label="__('Published')"
|
:label="__('Published')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="batch.allow_self_enrollment"
|
||||||
|
type="checkbox"
|
||||||
|
:label="__('Allow self enrollment')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!batch.image"
|
v-if="!batch.image"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@@ -34,19 +44,15 @@
|
|||||||
:validateFile="validateFile"
|
:validateFile="validateFile"
|
||||||
@success="(file) => saveImage(file)"
|
@success="(file) => saveImage(file)"
|
||||||
>
|
>
|
||||||
<template
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
|
||||||
>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
{{
|
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
||||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
|
||||||
}}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
<div v-else class="my-4">
|
<div v-else class="mb-4">
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
{{ __('Meta Image') }}
|
{{ __('Meta Image') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -68,15 +74,14 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
</div>
|
||||||
v-model="batch.allow_self_enrollment"
|
<MultiSelect
|
||||||
type="checkbox"
|
v-model="instructors"
|
||||||
:label="__('Allow self enrollment')"
|
doctype="User"
|
||||||
|
:label="__('Instructors')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-4">
|
||||||
</div>
|
|
||||||
<div class="container border-b mb-4">
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.description"
|
v-model="batch.description"
|
||||||
:label="__('Description')"
|
:label="__('Description')"
|
||||||
@@ -96,7 +101,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-b mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Date and Time') }}
|
{{ __('Date and Time') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +142,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-b mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +187,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Payment') }}
|
{{ __('Payment') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +215,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, inject, reactive, onBeforeUnmount } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
inject,
|
||||||
|
reactive,
|
||||||
|
onBeforeUnmount,
|
||||||
|
ref,
|
||||||
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -220,6 +232,7 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize, showToast } from '../utils'
|
import { getFileSize, showToast } from '../utils'
|
||||||
import { X, FileText } from 'lucide-vue-next'
|
import { X, FileText } from 'lucide-vue-next'
|
||||||
@@ -255,6 +268,8 @@ const batch = reactive({
|
|||||||
amount: 0,
|
amount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const instructors = ref([])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) window.location.href = '/login'
|
if (!user.data) window.location.href = '/login'
|
||||||
if (props.batchName != 'new') {
|
if (props.batchName != 'new') {
|
||||||
@@ -285,6 +300,9 @@ const newBatch = createResource({
|
|||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
meta_image: batch.image?.file_url,
|
meta_image: batch.image?.file_url,
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -301,9 +319,13 @@ const batchDetail = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
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)
|
||||||
})
|
})
|
||||||
let checkboxes = ['published', 'paid_batch']
|
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||||
|
})
|
||||||
|
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
|
||||||
for (let idx in checkboxes) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
batch[key] = batch[key] ? true : false
|
batch[key] = batch[key] ? true : false
|
||||||
@@ -320,6 +342,9 @@ const editBatch = createResource({
|
|||||||
name: props.batchName,
|
name: props.batchName,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
meta_image: batch.image?.file_url,
|
meta_image: batch.image?.file_url,
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,20 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
||||||
<div class="order-2 lg:order-none">
|
<div class="order-2 lg:order-none">
|
||||||
@@ -91,6 +105,8 @@ import { Breadcrumbs, createResource } from 'frappe-ui'
|
|||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||||
import DateRange from '../components/Common/DateRange.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 user = inject('$user')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<template #default="{ tab }">
|
<template #default="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.batches && tab.batches.value.length"
|
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
|
<router-link
|
||||||
v-for="batch in tab.batches.value"
|
v-for="batch in tab.batches.value"
|
||||||
|
|||||||
@@ -99,12 +99,13 @@
|
|||||||
:label="__('Preview Video')"
|
:label="__('Preview Video')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div class="mb-4">
|
||||||
<div class="mb-1.5 text-xs text-gray-600">
|
<div class="mb-1.5 text-xs text-gray-600">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div
|
||||||
|
v-if="course.tags"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
@@ -121,6 +122,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="instructors"
|
||||||
|
doctype="User"
|
||||||
|
:label="__('Instructors')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
@@ -188,7 +194,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l pt-5">
|
<div class="border-l pt-5">
|
||||||
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
|
|
||||||
<CourseOutline
|
<CourseOutline
|
||||||
v-if="courseResource.data"
|
v-if="courseResource.data"
|
||||||
:courseName="courseResource.data.name"
|
:courseName="courseResource.data.name"
|
||||||
@@ -222,6 +227,7 @@ import Link from '@/components/Controls/Link.vue'
|
|||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
@@ -288,6 +294,9 @@ const courseCreationResource = createResource({
|
|||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
image: course.course_image?.file_url || '',
|
image: course.course_image?.file_url || '',
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -303,6 +312,9 @@ const courseEditResource = createResource({
|
|||||||
name: values.course,
|
name: values.course,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
image: course.course_image?.file_url || '',
|
image: course.course_image?.file_url || '',
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -320,7 +332,12 @@ const courseResource = createResource({
|
|||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
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 = [
|
let checkboxes = [
|
||||||
'published',
|
'published',
|
||||||
@@ -334,7 +351,6 @@ const courseResource = createResource({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.image) imageResource.reload({ image: data.image })
|
if (data.image) imageResource.reload({ image: data.image })
|
||||||
instructors.value = data.instructors
|
|
||||||
check_permission()
|
check_permission()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -446,7 +462,7 @@ const check_permission = () => {
|
|||||||
if (user.data?.is_moderator) return
|
if (user.data?.is_moderator) return
|
||||||
|
|
||||||
instructors.value.forEach((instructor) => {
|
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
|
user_is_instructor = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,10 +26,12 @@
|
|||||||
"evaluation_end_date",
|
"evaluation_end_date",
|
||||||
"section_break_6",
|
"section_break_6",
|
||||||
"description",
|
"description",
|
||||||
"batch_details_raw",
|
|
||||||
"column_break_hlqw",
|
"column_break_hlqw",
|
||||||
"batch_details",
|
"instructors",
|
||||||
"meta_image",
|
"meta_image",
|
||||||
|
"section_break_khcn",
|
||||||
|
"batch_details",
|
||||||
|
"batch_details_raw",
|
||||||
"section_break_jgji",
|
"section_break_jgji",
|
||||||
"students",
|
"students",
|
||||||
"courses",
|
"courses",
|
||||||
@@ -308,11 +310,22 @@
|
|||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Timezone",
|
"label": "Timezone",
|
||||||
"reqd": 1
|
"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,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-06-21 11:49:32.582832",
|
"modified": "2024-06-24 16:24:45.536453",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Batch",
|
"name": "LMS Batch",
|
||||||
|
|||||||
@@ -119,7 +119,8 @@
|
|||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Instructors",
|
"label": "Instructors",
|
||||||
"max_height": "50px",
|
"max_height": "50px",
|
||||||
"options": "Course Instructor"
|
"options": "Course Instructor",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_7",
|
"fieldname": "section_break_7",
|
||||||
@@ -275,7 +276,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-05-24 18:03:38.330443",
|
"modified": "2024-06-24 17:44:45.903164",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course",
|
"name": "LMS Course",
|
||||||
|
|||||||
@@ -196,8 +196,7 @@ def get_instructors(course):
|
|||||||
instructors = frappe.get_all(
|
instructors = frappe.get_all(
|
||||||
"Course Instructor", {"parent": course}, order_by="idx", pluck="instructor"
|
"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:
|
for instructor in instructors:
|
||||||
instructor_details.append(
|
instructor_details.append(
|
||||||
frappe.db.get_value(
|
frappe.db.get_value(
|
||||||
@@ -1517,6 +1516,8 @@ def get_batch_details(batch):
|
|||||||
as_dict=True,
|
as_dict=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
batch_details.instructors = get_instructors(batch)
|
||||||
|
|
||||||
batch_details.courses = frappe.get_all(
|
batch_details.courses = frappe.get_all(
|
||||||
"Batch Course", filters={"parent": batch}, fields=["course", "title"]
|
"Batch Course", filters={"parent": batch}, fields=["course", "title"]
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user