feat: instructors in courses and batches
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
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: {
|
||||
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,
|
||||
},
|
||||
}"
|
||||
|
||||
Reference in New Issue
Block a user