feat: students and assessment tab in dashboard
This commit is contained in:
@@ -3,25 +3,25 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.16",
|
||||
"frappe-ui": "^0.1.22",
|
||||
"lucide-vue-next": "^0.259.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vue": "^3.2.25",
|
||||
"dayjs": "^1.11.6",
|
||||
"vue-router": "^4.0.12",
|
||||
"markdown-it": "^14.0.0"
|
||||
"vue-router": "^4.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^2.0.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.5",
|
||||
"vite": "^3.0.0"
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Assessments') }}
|
||||
</div>
|
||||
<div v-if="assessments?.length">
|
||||
<div v-if="assessments.data?.length">
|
||||
<ListView
|
||||
:columns="getAssessmentColumns()"
|
||||
:rows="attempts?.data"
|
||||
:rows="assessments.data"
|
||||
row-key="name"
|
||||
:options="{ selectable: false, showTooltip: false }"
|
||||
>
|
||||
@@ -18,30 +18,59 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ListView } from 'frappe-ui'
|
||||
import { ListView, createResource } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
assessments: {
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
rows: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
selectable: true,
|
||||
totalCount: 0,
|
||||
rowCount: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const getSubmissionColumns = () => {
|
||||
return [
|
||||
const assessments = createResource({
|
||||
url: 'lms.lms.utils.get_assessments',
|
||||
params: {
|
||||
batch: props.batch,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getAssessmentColumns = () => {
|
||||
let columns = [
|
||||
{
|
||||
label: 'Assessment',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
key: 'type',
|
||||
key: 'assessment_type',
|
||||
},
|
||||
{
|
||||
]
|
||||
|
||||
if (!user.data?.is_moderator) {
|
||||
columns.push({
|
||||
label: 'Status/Score',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
return columns
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<UpcomingEvaluations :upcoming_evals="batch.data.upcoming_evals" />
|
||||
<Assessments :assessments="batch.data.assessments" />
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
:isStudent="isStudent"
|
||||
/>
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from './UpcomingEvaluations.vue'
|
||||
import Assessments from './Assessments.vue'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -35,11 +35,21 @@
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button v-if="user?.data?.is_moderator" class="w-full mt-4">
|
||||
<span>
|
||||
{{ __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="user?.data?.is_moderator"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<span>
|
||||
{{ __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
v-else-if="batch.data.paid_batch"
|
||||
class="w-full mt-4"
|
||||
|
||||
153
frontend/src/components/BatchStudents.vue
Normal file
153
frontend/src/components/BatchStudents.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<Button class="float-right mb-3" variant="solid" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add Student') }}
|
||||
</Button>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
:columns="getStudentColumns()"
|
||||
:rows="students.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getStudentColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in students.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'full_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['user_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" @click="removeStudents(selections)">
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
label="Unselect all"
|
||||
@click="unselectAll.toString()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<StudentModal
|
||||
:batch="props.batch"
|
||||
v-model="showStudentModal"
|
||||
v-model:reloadStudents="students"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createResource,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
Avatar,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { Settings, Trash2, Plus } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import StudentModal from '@/components/StudentModal.vue'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const students = createResource({
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
cache: ['students', props.batch],
|
||||
params: {
|
||||
batch: props.batch,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getStudentColumns = () => {
|
||||
return [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
},
|
||||
{
|
||||
label: 'Courses Done',
|
||||
key: 'courses_completed',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
label: 'Assessments Done',
|
||||
key: 'assessments_completed',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
label: 'Last Active',
|
||||
key: 'last_active',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const openStudentModal = () => {
|
||||
showStudentModal.value = true
|
||||
}
|
||||
|
||||
const removeStudent = createResource({
|
||||
url: 'frappe.client.delete',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Batch Student',
|
||||
name: values.student,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections) => {
|
||||
selections.forEach(async (student) => {
|
||||
console.log(student)
|
||||
removeStudent.submit({ student })
|
||||
await setTimeout(1000)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
277
frontend/src/components/Controls/Autocomplete.vue
Normal file
277
frontend/src/components/Controls/Autocomplete.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-gray-500" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-1.5 text-base',
|
||||
{ 'bg-gray-100': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover, Button } from 'frappe-ui'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const search = ref(null)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
function close() {
|
||||
showOptions.value = false
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options || props.options.length == 0) return []
|
||||
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) {
|
||||
return options
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value]
|
||||
return searchTexts.some((text) =>
|
||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
let selectedOption = allOptions.find((o) => o.value === option)
|
||||
return selectedOption?.label || option
|
||||
}
|
||||
return option?.label
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit('update:query', q)
|
||||
})
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? 'text-gray-600' : 'text-gray-800'
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
let sizeClasses = {
|
||||
sm: 'text-base rounded h-7',
|
||||
md: 'text-base rounded h-8',
|
||||
lg: 'text-lg rounded-md h-10',
|
||||
xl: 'text-xl rounded-md h-10',
|
||||
}[props.size]
|
||||
|
||||
let paddingClasses = {
|
||||
sm: 'py-1.5 px-2',
|
||||
md: 'py-1.5 px-2.5',
|
||||
lg: 'py-1.5 px-3',
|
||||
xl: 'py-1.5 px-3',
|
||||
}[props.size]
|
||||
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||
outline:
|
||||
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||
disabled: [
|
||||
'border bg-gray-50 placeholder-gray-400',
|
||||
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
|
||||
],
|
||||
}[variant]
|
||||
|
||||
return [
|
||||
sizeClasses,
|
||||
paddingClasses,
|
||||
variantClasses,
|
||||
textColor.value,
|
||||
'transition-colors w-full',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
146
frontend/src/components/Controls/Link.vue
Normal file
146
frontend/src/components/Controls/Link.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block" :class="labelClasses" v-if="attrs.label">
|
||||
{{ attrs.label }}
|
||||
</label>
|
||||
<Autocomplete
|
||||
ref="autocomplete"
|
||||
:options="options.data"
|
||||
v-model="value"
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||
</template>
|
||||
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
|
||||
<template #item-prefix="{ active, selected, option }">
|
||||
<slot name="item-prefix" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template #item-label="{ active, selected, option }">
|
||||
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
label="Create New"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (text.value === val) return
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.doctype,
|
||||
() => reload(''),
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
method: 'POST',
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
transform: (data) => {
|
||||
return data.map((option) => {
|
||||
return {
|
||||
label: option.value,
|
||||
value: option.value,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[attrs.size || 'sm'],
|
||||
'text-gray-600',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
38
frontend/src/components/Controls/Rating.vue
Normal file
38
frontend/src/components/Controls/Rating.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex text-center">
|
||||
<div v-for="index in 5">
|
||||
<Star
|
||||
:class="{ 'fill-orange-500': index <= rating }"
|
||||
class="h-5 w-5 fill-gray-400 text-gray-200 mr-1 cursor-pointer"
|
||||
@click="markRating(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
let rating = ref(props.modelValue)
|
||||
|
||||
let emitChange = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function markRating(index) {
|
||||
emitChange(index)
|
||||
rating.value = index
|
||||
}
|
||||
</script>
|
||||
@@ -1,125 +1,146 @@
|
||||
<template>
|
||||
<div v-if="reviews.data" class="my-10">
|
||||
<div class="text-2xl font-semibold mb-5">
|
||||
{{ __("Reviews") }}
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col items-center">
|
||||
<div v-if="avg_rating" class="text-3xl font-semibold mb-2">
|
||||
{{ avg_rating }}
|
||||
</div>
|
||||
<div class="flex mb-2">
|
||||
<Star v-for="index in 5" class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-1" :class="(index <= Math.ceil(avg_rating)) ? 'fill-orange-500' : 'fill-gray-600'"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
{{ reviews.data.length }} {{ __("reviews") }}
|
||||
</div>
|
||||
<Button v-if="membership" @click="openReviewModal()">
|
||||
<span>
|
||||
{{ __("Write a review") }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="border border-gray-300 mx-4"></div>
|
||||
<div class="flex flex-col">
|
||||
<div v-for="index in reversedRange(5)">
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="mr-2">
|
||||
{{ index }} {{ __("stars") }}
|
||||
</span>
|
||||
<div class="bg-gray-200 rounded-full w-52 mr-2">
|
||||
<div class="bg-gray-900 h-1 rounded-full" :style="{ width: rating_percent[index] + '%' }"></div>
|
||||
</div>
|
||||
<span>
|
||||
{{ Math.floor(rating_percent[index]) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="my-4">
|
||||
<div class="flex items-center">
|
||||
<UserAvatar :user="review.owner_details" :size="'2xl'"/>
|
||||
<div class="mx-4">
|
||||
<span class="text-lg font-medium mr-4">
|
||||
{{ review.owner_details.full_name }}
|
||||
</span>
|
||||
<span>
|
||||
{{ review.creation }}
|
||||
</span>
|
||||
<div class="flex mt-2">
|
||||
<Star v-for="index in 5" class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2" :class="(index <= Math.ceil(review.rating)) ? 'fill-orange-500' : 'fill-gray-600'"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 leading-5">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-3 h-px border-t border-gray-200" v-if="index < reviews.data.length - 1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewModal v-model="showReviewModal" v-model:reloadReviews="reviews" :courseName="courseName"/>
|
||||
<div v-if="reviews.data" class="my-10">
|
||||
<div class="text-2xl font-semibold mb-5">
|
||||
{{ __('Reviews') }}
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col items-center">
|
||||
<div v-if="avg_rating" class="text-3xl font-semibold mb-2">
|
||||
{{ avg_rating }}
|
||||
</div>
|
||||
<div class="flex mb-2">
|
||||
<Star
|
||||
v-for="index in 5"
|
||||
class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-1"
|
||||
:class="
|
||||
index <= Math.ceil(avg_rating)
|
||||
? 'fill-orange-500'
|
||||
: 'fill-gray-600'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">{{ reviews.data.length }} {{ __('reviews') }}</div>
|
||||
<Button v-if="membership" @click="openReviewModal()">
|
||||
<span>
|
||||
{{ __('Write a review') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="border border-gray-300 mx-4"></div>
|
||||
<div class="flex flex-col">
|
||||
<div v-for="index in reversedRange(5)">
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="mr-2"> {{ index }} {{ __('stars') }} </span>
|
||||
<div class="bg-gray-200 rounded-full w-52 mr-2">
|
||||
<div
|
||||
class="bg-gray-900 h-1 rounded-full"
|
||||
:style="{ width: rating_percent[index] + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span> {{ Math.floor(rating_percent[index]) }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="my-4">
|
||||
<div class="flex items-center">
|
||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||
<div class="mx-4">
|
||||
<span class="text-lg font-medium mr-4">
|
||||
{{ review.owner_details.full_name }}
|
||||
</span>
|
||||
<span>
|
||||
{{ review.creation }}
|
||||
</span>
|
||||
<div class="flex mt-2">
|
||||
<Star
|
||||
v-for="index in 5"
|
||||
class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2"
|
||||
:class="
|
||||
index <= Math.ceil(review.rating)
|
||||
? 'fill-orange-500'
|
||||
: 'fill-gray-600'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 leading-5">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx-3 h-px border-t border-gray-200"
|
||||
v-if="index < reviews.data.length - 1"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewModal
|
||||
v-model="showReviewModal"
|
||||
v-model:reloadReviews="reviews"
|
||||
:courseName="courseName"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { createResource, Button } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import ReviewModal from '@/components/ReviewModal.vue';
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ReviewModal from '@/components/ReviewModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
avg_rating: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
avg_rating: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
const reversedRange = (count) => Array.from({ length: count }, (_, index) => count - index);
|
||||
const reversedRange = (count) =>
|
||||
Array.from({ length: count }, (_, index) => count - index)
|
||||
|
||||
const reviews = createResource({
|
||||
url: "lms.lms.utils.get_reviews",
|
||||
cache: ["course_reviews", props.courseName],
|
||||
params: {
|
||||
course: props.courseName
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
url: 'lms.lms.utils.get_reviews',
|
||||
cache: ['course_reviews', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const rating_percent = computed(() => {
|
||||
let rating_count = {};
|
||||
let rating_percent = {};
|
||||
let rating_count = {}
|
||||
let rating_percent = {}
|
||||
|
||||
for (const key of [1, 2, 3, 4, 5]) {
|
||||
rating_count[key] = 0;
|
||||
}
|
||||
for (const key of [1, 2, 3, 4, 5]) {
|
||||
rating_count[key] = 0
|
||||
}
|
||||
|
||||
for (const review of reviews?.data) {
|
||||
rating_count[review.rating] += 1;
|
||||
}
|
||||
for (const review of reviews?.data) {
|
||||
rating_count[review.rating] += 1
|
||||
}
|
||||
|
||||
[1,2,3,4,5].forEach((key) => {
|
||||
rating_percent[key] = (rating_count[key] / reviews.data.length * 100).toFixed(2);
|
||||
});
|
||||
return rating_percent;
|
||||
});
|
||||
;[1, 2, 3, 4, 5].forEach((key) => {
|
||||
rating_percent[key] = (
|
||||
(rating_count[key] / reviews.data.length) *
|
||||
100
|
||||
).toFixed(2)
|
||||
})
|
||||
return rating_percent
|
||||
})
|
||||
const showReviewModal = ref(false)
|
||||
|
||||
function openReviewModal() {
|
||||
console.log("called")
|
||||
showReviewModal.value = true;
|
||||
showReviewModal.value = true
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
185
frontend/src/components/EvaluationModal.vue
Normal file
185
frontend/src/components/EvaluationModal.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Schedule Evaluation'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
variant: 'solid',
|
||||
onClick: (close) => submitEvaluation(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Course') }}
|
||||
</div>
|
||||
<Select v-model="evaluation.course" :options="getCourses()" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<DatePicker v-model="evaluation.date" />
|
||||
</div>
|
||||
<div v-if="slots.data">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Select a slot') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div v-for="slot in slots.data">
|
||||
<div
|
||||
class="text-base text-center border rounded-md bg-gray-200 p-2 cursor-pointer"
|
||||
@click="saveSlot(slot)"
|
||||
:class="{
|
||||
'border-gray-900': evaluation.start_time == slot.start_time,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(slot.start_time) }} -
|
||||
{{ formatTime(slot.end_time) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, Select, DatePicker } from 'frappe-ui'
|
||||
import { defineModel, reactive, watch, inject } from 'vue'
|
||||
import { createToast, formatTime } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const show = defineModel()
|
||||
const evaluations = defineModel('reloadEvals')
|
||||
|
||||
const props = defineProps({
|
||||
courses: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
batch: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
let evaluation = reactive({
|
||||
course: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
day: '',
|
||||
batch: props.batch,
|
||||
member: user.data.name,
|
||||
})
|
||||
|
||||
const createEvaluation = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Certificate Request',
|
||||
batch_name: values.batch,
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function submitEvaluation(close) {
|
||||
createEvaluation.submit(evaluation, {
|
||||
validate() {
|
||||
if (!evaluation.course) {
|
||||
return 'Please select a course.'
|
||||
}
|
||||
if (!evaluation.date) {
|
||||
return 'Please select a date.'
|
||||
}
|
||||
if (!evaluation.start_time) {
|
||||
return 'Please select a slot.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isSameOrBefore(dayjs(), 'day')) {
|
||||
return 'Please select a future date.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||
return `Please select a date before the end date ${dayjs(
|
||||
props.endDate
|
||||
).format('DD MMMM YYYY')}.`
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
evaluations.value.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getCourses = () => {
|
||||
let courses = []
|
||||
for (const course of props.courses) {
|
||||
courses.push({
|
||||
label: course.title,
|
||||
value: course.course,
|
||||
})
|
||||
}
|
||||
return courses
|
||||
}
|
||||
|
||||
const slots = createResource({
|
||||
url: 'lms.lms.doctype.course_evaluator.course_evaluator.get_schedule',
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: values.course,
|
||||
date: values.date,
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => evaluation.date,
|
||||
(date) => {
|
||||
evaluation.start_time = ''
|
||||
if (date) {
|
||||
slots.submit(evaluation)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => evaluation.course,
|
||||
(course) => {
|
||||
evaluation.date = ''
|
||||
evaluation.start_time = ''
|
||||
slots.reset()
|
||||
}
|
||||
)
|
||||
|
||||
const saveSlot = (slot) => {
|
||||
evaluation.start_time = slot.start_time
|
||||
evaluation.end_time = slot.end_time
|
||||
evaluation.day = slot.day
|
||||
}
|
||||
</script>
|
||||
18
frontend/src/components/Icons/IndicatorIcon.vue
Normal file
18
frontend/src/components/Icons/IndicatorIcon.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="4.5"
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
110
frontend/src/components/LiveClass.vue
Normal file
110
frontend/src/components/LiveClass.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="user.data.is_moderator"
|
||||
variant="solid"
|
||||
class="float-right mb-3"
|
||||
@click="openLiveClassModal"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Create') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||
<div v-for="cls in liveClasses.data">
|
||||
<div class="border rounded-md p-3">
|
||||
<div class="font-semibold text-lg mb-4">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-5">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ formatTime(cls.time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-gray-600">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
<LiveClassModal
|
||||
:batch="props.batch"
|
||||
v-model="showLiveClassModal"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button } from 'frappe-ui'
|
||||
import { Plus, Clock, Calendar, Video, Monitor } from 'lucide-vue-next'
|
||||
import { inject } from 'vue'
|
||||
import LiveClassModal from '@/components/LiveClassModal.vue'
|
||||
import { ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch: props.batchName,
|
||||
date: ['>=', new Date()],
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
</script>
|
||||
227
frontend/src/components/LiveClassModal.vue
Normal file
227
frontend/src/components/LiveClassModal.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Create a Live Class'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
variant: 'solid',
|
||||
onClick: (close) => submitLiveClass(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Title') }}
|
||||
</div>
|
||||
<Input type="text" v-model="liveClass.title" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
<Tooltip
|
||||
class="flex items-center"
|
||||
:text="
|
||||
__(
|
||||
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ __('Time') }}
|
||||
</span>
|
||||
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input v-model="liveClass.time" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Timezone') }}
|
||||
</div>
|
||||
<Select
|
||||
v-model="liveClass.timezone"
|
||||
:options="getTimezoneOptions()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<DatePicker v-model="liveClass.date" inputClass="w-full" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
<Tooltip
|
||||
class="flex items-center"
|
||||
:text="__('Duration of the live class in minutes')"
|
||||
>
|
||||
<span>
|
||||
{{ __('Duration') }}
|
||||
</span>
|
||||
<Info class="stroke-2 w-3 h-3 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input type="number" v-model="liveClass.duration" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Auto Recording') }}
|
||||
</div>
|
||||
<Select
|
||||
v-model="liveClass.auto_recording"
|
||||
:options="getRecordingOptions()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Description') }}
|
||||
</div>
|
||||
<Textarea v-model="liveClass.description" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Input,
|
||||
DatePicker,
|
||||
Select,
|
||||
Textarea,
|
||||
Dialog,
|
||||
createResource,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject } from 'vue'
|
||||
import { getTimezones, createToast } from '@/utils/'
|
||||
import { Info } from 'lucide-vue-next'
|
||||
|
||||
const liveClasses = defineModel('reloadLiveClasses')
|
||||
const show = defineModel()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
let liveClass = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
time: '',
|
||||
duration: '',
|
||||
timezone: '',
|
||||
auto_recording: 'No Recording',
|
||||
batch: props.batch,
|
||||
host: user.data.name,
|
||||
})
|
||||
console.log(liveClass)
|
||||
|
||||
const getTimezoneOptions = () => {
|
||||
return getTimezones().map((timezone) => {
|
||||
return {
|
||||
label: timezone,
|
||||
value: timezone,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getRecordingOptions = () => {
|
||||
return [
|
||||
{
|
||||
label: 'No Recording',
|
||||
value: 'No Recording',
|
||||
},
|
||||
{
|
||||
label: 'Local',
|
||||
value: 'Local',
|
||||
},
|
||||
{
|
||||
label: 'Cloud',
|
||||
value: 'Cloud',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const createLiveClass = createResource({
|
||||
url: 'lms.lms.doctype.lms_batch.lms_batch.create_live_class',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Live Class',
|
||||
batch_name: values.batch,
|
||||
...values,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitLiveClass = (close) => {
|
||||
createLiveClass.submit(liveClass, {
|
||||
validate() {
|
||||
if (!liveClass.title) {
|
||||
return 'Please enter a title.'
|
||||
}
|
||||
if (!liveClass.date) {
|
||||
return 'Please select a date.'
|
||||
}
|
||||
if (dayjs(liveClass.date).isSameOrBefore(dayjs(), 'day')) {
|
||||
return 'Please select a future date.'
|
||||
}
|
||||
if (!liveClass.time) {
|
||||
return 'Please select a time.'
|
||||
}
|
||||
if (!valideTime()) {
|
||||
return 'Please enter a valid time in the format HH:mm.'
|
||||
}
|
||||
if (!liveClass.duration) {
|
||||
return 'Please select a duration.'
|
||||
}
|
||||
if (!liveClass.timezone) {
|
||||
return 'Please select a timezone.'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
liveClasses.value.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const valideTime = () => {
|
||||
let time = liveClass.time.split(':')
|
||||
if (time.length != 2) {
|
||||
return false
|
||||
}
|
||||
if (time[0] < 0 || time[0] > 23) {
|
||||
return false
|
||||
}
|
||||
if (time[1] < 0 || time[1] > 59) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div class="flex text-center">
|
||||
<div v-for="index in 5">
|
||||
<Star :class="{'fill-orange-500': index <= rating}"
|
||||
class="h-5 w-5 fill-gray-400 text-gray-200 mr-1 cursor-pointer" @click="markRating(index)"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
let rating = ref(props.modelValue)
|
||||
|
||||
let emitChange = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function markRating(index) {
|
||||
emitChange(index);
|
||||
rating.value = index;
|
||||
}
|
||||
</script>
|
||||
@@ -1,83 +1,87 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options='{
|
||||
title: __("Write a Review"),
|
||||
size: "xl",
|
||||
actions: [
|
||||
{
|
||||
label: "Submit",
|
||||
variant: "solid",
|
||||
onClick: (close) => submitReview(close)
|
||||
}
|
||||
]
|
||||
}'>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __("Rating") }}
|
||||
</div>
|
||||
<Rating v-model="review.rating"/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __("Review") }}
|
||||
</div>
|
||||
<Textarea type="text" size="md" rows=5 v-model="review.review"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Write a Review'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
variant: 'solid',
|
||||
onClick: (close) => submitReview(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Rating') }}
|
||||
</div>
|
||||
<Rating v-model="review.rating" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-gray-600">
|
||||
{{ __('Review') }}
|
||||
</div>
|
||||
<Textarea type="text" size="md" rows="5" v-model="review.review" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Textarea, createResource } from 'frappe-ui'
|
||||
import { defineModel, reactive } from "vue"
|
||||
import Rating from '@/components/Rating.vue';
|
||||
import { createToast } from "@/utils/"
|
||||
import { defineModel, reactive } from 'vue'
|
||||
import Rating from '@/components/Controls/Rating.vue'
|
||||
import { createToast } from '@/utils/'
|
||||
|
||||
const show = defineModel()
|
||||
const reviews = defineModel("reloadReviews")
|
||||
const reviews = defineModel('reloadReviews')
|
||||
let review = reactive({
|
||||
review: "",
|
||||
rating: 0,
|
||||
review: '',
|
||||
rating: 0,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const createReview = createResource({
|
||||
url: "frappe.client.insert",
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: "LMS Course Review",
|
||||
course: props.courseName,
|
||||
...values,
|
||||
}
|
||||
}
|
||||
}
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Course Review',
|
||||
course: props.courseName,
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
function submitReview(close) {
|
||||
review.rating = (review.rating)/5;
|
||||
createReview.submit(review, {
|
||||
validate() {
|
||||
if (!review.rating) {
|
||||
return "Please enter a rating."
|
||||
}
|
||||
}, onSuccess() {
|
||||
reviews.value.reload()
|
||||
},
|
||||
onError(err) {
|
||||
createToast({
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600 bg-red-300',
|
||||
})
|
||||
}
|
||||
})
|
||||
close();
|
||||
review.rating = review.rating / 5
|
||||
createReview.submit(review, {
|
||||
validate() {
|
||||
if (!review.rating) {
|
||||
return 'Please enter a rating.'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
reviews.value.reload()
|
||||
},
|
||||
onError(err) {
|
||||
createToast({
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600 bg-red-300',
|
||||
})
|
||||
},
|
||||
})
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
70
frontend/src/components/StudentModal.vue
Normal file
70
frontend/src/components/StudentModal.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a Student'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
variant: 'solid',
|
||||
onClick: (close) => addStudent(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="student"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const students = defineModel('reloadStudents')
|
||||
const student = ref()
|
||||
const show = defineModel()
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const studentResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Batch Student',
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'students',
|
||||
student: student.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addStudent = (close) => {
|
||||
studentResource.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
students.value.reload()
|
||||
close()
|
||||
student.value = null
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<div class="mb-10">
|
||||
<Button v-if="isStudent" @click="openEvalModal" class="float-right">
|
||||
{{ __('Schedule Evaluation') }}
|
||||
</Button>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<div v-if="upcoming_evals.length">
|
||||
<div class="grid grid-cols-2">
|
||||
<div v-for="evl in upcoming_evals">
|
||||
<div v-if="upcoming_evals.data">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border rounded-md p-3">
|
||||
<div class="font-medium mb-3">
|
||||
{{ evl.course_title }}
|
||||
@@ -24,7 +27,7 @@
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<UserCog2 class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
<span class="ml-2 font-medium">
|
||||
{{ evl.evaluator_name }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -36,18 +39,55 @@
|
||||
{{ __('No upcoming evaluations.') }}
|
||||
</div>
|
||||
</div>
|
||||
<EvaluationModal
|
||||
:batch="batch"
|
||||
:endDate="endDate"
|
||||
:courses="courses"
|
||||
v-model="showEvalModal"
|
||||
v-model:reloadEvals="upcoming_evals"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Calendar, Clock, UserCog2 } from 'lucide-vue-next'
|
||||
import { inject } from 'vue'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '../utils'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import EvaluationModal from '@/components/EvaluationModal.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const showEvalModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
upcoming_evals: {
|
||||
batch: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
courses: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const upcoming_evals = createResource({
|
||||
url: 'lms.lms.utils.get_upcoming_evals',
|
||||
cache: ['upcoming_evals', user.data.name],
|
||||
params: {
|
||||
student: user.data.name,
|
||||
courses: props.courses.map((course) => course.course),
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
function openEvalModal() {
|
||||
showEvalModal.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="user.data?.is_moderator || is_student" class="h-screen text-base">
|
||||
<div v-if="user.data?.is_moderator || isStudent" class="h-screen text-base">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ tab }">
|
||||
<div class="p-10">
|
||||
<div class="pt-5 px-10 pb-10">
|
||||
<div v-if="tab.label == 'Courses'">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __('Courses') }}
|
||||
@@ -59,7 +59,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard'">
|
||||
<BatchDashboard :batch="batch" />
|
||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Live Class'">
|
||||
<LiveClass :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Students'">
|
||||
<BatchStudents :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Assessments'">
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -72,8 +81,8 @@
|
||||
<div class="flex items-center mb-3">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
||||
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
||||
{{ dayjs(batch.data.start_date).format('DD MMMM YYYY') }} -
|
||||
{{ dayjs(batch.data.end_date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
@@ -97,14 +106,18 @@
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="mb-4 leading-6">
|
||||
<div v-if="user.data" class="mb-4 leading-6">
|
||||
{{
|
||||
__(
|
||||
'You are not a member of this batch. Please checkout our upcoming batches.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else class="mb-4 leading-6">
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
params: {
|
||||
@@ -116,6 +129,14 @@
|
||||
{{ __('Upcoming Batches') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
v-else
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@click="redirectToLogin()"
|
||||
>
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,10 +144,21 @@
|
||||
<script setup>
|
||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { Calendar, Clock, LayoutDashboard, BookOpen } from 'lucide-vue-next'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Laptop,
|
||||
BookOpenCheck,
|
||||
Contact2,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
import LiveClass from '@/components/LiveClass.vue'
|
||||
import BatchStudents from '@/components/BatchStudents.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
@@ -161,7 +193,7 @@ const breadcrumbs = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const is_student = computed(() => {
|
||||
const isStudent = computed(() => {
|
||||
return (
|
||||
user?.data &&
|
||||
batch.data?.students.length &&
|
||||
@@ -172,16 +204,31 @@ const is_student = computed(() => {
|
||||
const tabIndex = ref(0)
|
||||
const tabs = []
|
||||
|
||||
if (is_student) {
|
||||
if (isStudent.value) {
|
||||
tabs.push({
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
})
|
||||
}
|
||||
|
||||
if (user.data?.is_moderator) {
|
||||
tabs.push({
|
||||
label: 'Students',
|
||||
icon: Contact2,
|
||||
})
|
||||
tabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
})
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
label: 'Live Class',
|
||||
icon: Laptop,
|
||||
})
|
||||
|
||||
tabs.push({
|
||||
label: 'Courses',
|
||||
count: computed(() => courses?.data?.length),
|
||||
icon: BookOpen,
|
||||
})
|
||||
|
||||
@@ -193,4 +240,8 @@ const courses = createResource({
|
||||
cache: ['batchCourses', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/batches`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -377,7 +377,7 @@ iframe {
|
||||
background: #011627;
|
||||
color: #d6deeb;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.lesson-content a {
|
||||
|
||||
@@ -3,10 +3,14 @@ import relativeTime from 'dayjs/esm/plugin/relativeTime'
|
||||
import localizedFormat from 'dayjs/esm/plugin/localizedFormat'
|
||||
import updateLocale from 'dayjs/esm/plugin/updateLocale'
|
||||
import isToday from 'dayjs/esm/plugin/isToday'
|
||||
import isSameOrBefore from 'dayjs/esm/plugin/isSameOrBefore'
|
||||
import isSameOrAfter from 'dayjs/esm/plugin/isSameOrAfter'
|
||||
|
||||
dayjs.extend(updateLocale)
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(localizedFormat)
|
||||
dayjs.extend(isToday)
|
||||
dayjs.extend(isSameOrBefore)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
|
||||
export default dayjs
|
||||
|
||||
@@ -39,3 +39,143 @@ export function formatNumberIntoCurrency(number, currency) {
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function getTimezones() {
|
||||
return [
|
||||
'Pacific/Midway',
|
||||
'Pacific/Pago_Pago',
|
||||
'Pacific/Honolulu',
|
||||
'America/Anchorage',
|
||||
'America/Vancouver',
|
||||
'America/Los_Angeles',
|
||||
'America/Tijuana',
|
||||
'America/Edmonton',
|
||||
'America/Denver',
|
||||
'America/Phoenix',
|
||||
'America/Mazatlan',
|
||||
'America/Winnipeg',
|
||||
'America/Regina',
|
||||
'America/Chicago',
|
||||
'America/Mexico_City',
|
||||
'America/Guatemala',
|
||||
'America/El_Salvador',
|
||||
'America/Managua',
|
||||
'America/Costa_Rica',
|
||||
'America/Montreal',
|
||||
'America/New_York',
|
||||
'America/Indianapolis',
|
||||
'America/Panama',
|
||||
'America/Bogota',
|
||||
'America/Lima',
|
||||
'America/Halifax',
|
||||
'America/Puerto_Rico',
|
||||
'America/Caracas',
|
||||
'America/Santiago',
|
||||
'America/St_Johns',
|
||||
'America/Montevideo',
|
||||
'America/Araguaina',
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Godthab',
|
||||
'America/Sao_Paulo',
|
||||
'Atlantic/Azores',
|
||||
'Canada/Atlantic',
|
||||
'Atlantic/Cape_Verde',
|
||||
'UTC',
|
||||
'Etc/Greenwich',
|
||||
'Europe/Belgrade',
|
||||
'CET',
|
||||
'Atlantic/Reykjavik',
|
||||
'Europe/Dublin',
|
||||
'Europe/London',
|
||||
'Europe/Lisbon',
|
||||
'Africa/Casablanca',
|
||||
'Africa/Nouakchott',
|
||||
'Europe/Oslo',
|
||||
'Europe/Copenhagen',
|
||||
'Europe/Brussels',
|
||||
'Europe/Berlin',
|
||||
'Europe/Helsinki',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Rome',
|
||||
'Europe/Stockholm',
|
||||
'Europe/Vienna',
|
||||
'Europe/Luxembourg',
|
||||
'Europe/Paris',
|
||||
'Europe/Zurich',
|
||||
'Europe/Madrid',
|
||||
'Africa/Bangui',
|
||||
'Africa/Algiers',
|
||||
'Africa/Tunis',
|
||||
'Africa/Harare',
|
||||
'Africa/Nairobi',
|
||||
'Europe/Warsaw',
|
||||
'Europe/Prague',
|
||||
'Europe/Budapest',
|
||||
'Europe/Sofia',
|
||||
'Europe/Istanbul',
|
||||
'Europe/Athens',
|
||||
'Europe/Bucharest',
|
||||
'Asia/Nicosia',
|
||||
'Asia/Beirut',
|
||||
'Asia/Damascus',
|
||||
'Asia/Jerusalem',
|
||||
'Asia/Amman',
|
||||
'Africa/Tripoli',
|
||||
'Africa/Cairo',
|
||||
'Africa/Johannesburg',
|
||||
'Europe/Moscow',
|
||||
'Asia/Baghdad',
|
||||
'Asia/Kuwait',
|
||||
'Asia/Riyadh',
|
||||
'Asia/Bahrain',
|
||||
'Asia/Qatar',
|
||||
'Asia/Aden',
|
||||
'Asia/Tehran',
|
||||
'Africa/Khartoum',
|
||||
'Africa/Djibouti',
|
||||
'Africa/Mogadishu',
|
||||
'Asia/Dubai',
|
||||
'Asia/Muscat',
|
||||
'Asia/Baku',
|
||||
'Asia/Kabul',
|
||||
'Asia/Yekaterinburg',
|
||||
'Asia/Tashkent',
|
||||
'Asia/Calcutta',
|
||||
'Asia/Kathmandu',
|
||||
'Asia/Novosibirsk',
|
||||
'Asia/Almaty',
|
||||
'Asia/Dacca',
|
||||
'Asia/Krasnoyarsk',
|
||||
'Asia/Dhaka',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Saigon',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Irkutsk',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Hong_Kong',
|
||||
'Asia/Taipei',
|
||||
'Asia/Kuala_Lumpur',
|
||||
'Asia/Singapore',
|
||||
'Australia/Perth',
|
||||
'Asia/Yakutsk',
|
||||
'Asia/Seoul',
|
||||
'Asia/Tokyo',
|
||||
'Australia/Darwin',
|
||||
'Australia/Adelaide',
|
||||
'Asia/Vladivostok',
|
||||
'Pacific/Port_Moresby',
|
||||
'Australia/Brisbane',
|
||||
'Australia/Sydney',
|
||||
'Australia/Hobart',
|
||||
'Asia/Magadan',
|
||||
'SST',
|
||||
'Pacific/Noumea',
|
||||
'Asia/Kamchatka',
|
||||
'Pacific/Fiji',
|
||||
'Pacific/Auckland',
|
||||
'Asia/Kolkata',
|
||||
'Europe/Kiev',
|
||||
'America/Tegucigalpa',
|
||||
'Pacific/Apia',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: `../${path.basename(path.resolve('..'))}/public/frontend`,
|
||||
outDir: `../lms/public/frontend`,
|
||||
emptyOutDir: true,
|
||||
commonjsOptions: {
|
||||
include: [/tailwind.config.js/, /node_modules/],
|
||||
},
|
||||
sourcemap: true,
|
||||
target: 'es2015',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
1376
frontend/yarn.lock
1376
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user