feat: students and assessment tab in dashboard

This commit is contained in:
Jannat Patel
2024-01-10 21:36:02 +05:30
parent 09ae61492f
commit 1a6a119f35
51 changed files with 4084 additions and 2325 deletions

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -377,7 +377,7 @@ iframe {
background: #011627;
color: #d6deeb;
border-radius: 0.5rem;
margin-bottom: 1rem;
margin: 1rem 0;
}
.lesson-content a {

View File

@@ -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

View File

@@ -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',
]
}

View File

@@ -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: {

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from lms.lms.utils import get_evaluator
from datetime import datetime
class CourseEvaluator(Document):
@@ -39,10 +40,14 @@ class CourseEvaluator(Document):
@frappe.whitelist()
def get_schedule(course, date, batch=None):
evaluator = get_evaluator(course, batch)
day = datetime.strptime(date, "%Y-%m-%d").strftime("%A")
all_slots = frappe.get_all(
"Evaluator Schedule",
filters={"parent": evaluator},
filters={
"parent": evaluator,
"day": day,
},
fields=["day", "start_time", "end_time"],
order_by="start_time",
)

View File

@@ -14,8 +14,13 @@ from frappe.utils import (
format_datetime,
get_time,
)
from lms.lms.utils import get_lessons, get_lesson_index, get_lesson_url
from lms.www.utils import get_quiz_details, get_assignment_details
from lms.lms.utils import (
get_lessons,
get_lesson_index,
get_lesson_url,
get_quiz_details,
get_assignment_details,
)
from frappe.email.doctype.email_template.email_template import get_email_template

View File

@@ -109,7 +109,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-29 15:00:30.617298",
"modified": "2024-01-09 10:05:13.918890",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate Request",
@@ -151,6 +151,16 @@
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"sort_field": "modified",

View File

@@ -126,7 +126,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-09-20 11:29:20.899897",
"modified": "2024-01-09 11:22:33.272341",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Live Class",
@@ -155,6 +155,15 @@
"role": "Moderator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"show_title_field_in_link": 1,

View File

@@ -27,6 +27,7 @@ from frappe.utils import (
pretty_date,
get_time_str,
nowtime,
format_datetime,
)
from frappe.utils.dateutils import get_period
from lms.lms.md import find_macros, markdown_to_html
@@ -886,6 +887,7 @@ def get_evaluator(course, batch=None):
return evaluator
@frappe.whitelist()
def get_upcoming_evals(student, courses):
upcoming_evals = frappe.get_all(
"LMS Certificate Request",
@@ -1378,27 +1380,19 @@ def get_batch_details(batch):
"amount",
"currency",
"paid_batch",
"evaluation_end_date",
],
as_dict=True,
)
batch_details.courses = frappe.get_all(
"Batch Course", {"parent": batch}, pluck="course"
"Batch Course", filters={"parent": batch}, fields=["course", "title"]
)
batch_details.students = frappe.get_all(
"Batch Student", {"parent": batch}, pluck="student"
)
batch_details.price = fmt_money(batch_details.amount, 0, batch_details.currency)
is_student = frappe.session.user in batch_details.students
if frappe.session.user != "Guest":
if is_student:
batch_details.upcoming_evals = get_upcoming_evals(
frappe.session.user, batch_details.courses
)
if is_student or has_course_moderator_role():
batch_details.assessments = get_assessments(batch, frappe.session.user)
if batch_details.seat_count:
students_enrolled = frappe.db.count(
"Batch Student",
@@ -1479,6 +1473,7 @@ def get_batch_courses(batch):
return courses
@frappe.whitelist()
def get_assessments(batch, member=None):
if not member:
member = frappe.session.user
@@ -1520,6 +1515,10 @@ def get_assignment_details(assessment, member):
as_dict=True,
)
assessment.completed = True
assessment.status = assessment.submission.status
else:
assessment.status = "Not Attempted"
assessment.color = "red"
assessment.edit_url = f"/assignments/{assessment.assessment_name}"
submission_name = existing_submission if existing_submission else "new-submission"
@@ -1548,10 +1547,12 @@ def get_quiz_details(assessment, member):
if len(existing_submission):
assessment.submission = existing_submission[0]
assessment.completed = False
if assessment.submission:
assessment.completed = True
assessment.status = assessment.submission.score
else:
assessment.status = "Not Attempted"
assessment.color = "red"
assessment.completed = False
assessment.edit_url = f"/quizzes/{assessment.assessment_name}"
submission_name = (
@@ -1560,3 +1561,58 @@ def get_quiz_details(assessment, member):
assessment.url = f"/quiz-submission/{assessment.assessment_name}/{submission_name}"
return assessment
@frappe.whitelist()
def get_batch_students(batch):
students = []
students_list = frappe.get_all(
"Batch Student", filters={"parent": batch}, fields=["student", "name"]
)
batch_courses = frappe.get_all("Batch Course", {"parent": batch}, pluck="course")
assessments = frappe.get_all(
"LMS Assessment",
filters={"parent": batch},
fields=["name", "assessment_type", "assessment_name"],
)
for student in students_list:
courses_completed = 0
assessments_completed = 0
detail = frappe.db.get_value(
"User",
student.student,
["full_name", "email", "username", "last_active", "user_image"],
as_dict=True,
)
detail.last_active = format_datetime(detail.last_active, "dd MMM YY")
detail.name = student.name
students.append(detail)
for course in batch_courses:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": course, "member": student.student}, "progress"
)
if progress == 100:
courses_completed += 1
detail.courses_completed = courses_completed
for assessment in assessments:
if has_submitted_assessment(
assessment.assessment_name, assessment.assessment_type, student.student
):
assessments_completed += 1
detail.assessments_completed = assessments_completed
return students
@frappe.whitelist()
def get_users():
return frappe.get_all("User", {"enabled": 1}, pluck="name")

View File

@@ -23,13 +23,13 @@ import {
a0 as H,
a1 as O,
$ as S,
} from "./frappe-ui.f2211ca2.js";
import { f as $ } from "./index.05189aed.js";
import { _ as q } from "./CourseCard.6a41330a.js";
import { C as L, a as V } from "./clock.4d13ba48.js";
import { c as U, B as J } from "./index.43e529db.js";
import "./UserAvatar.b64a03ac.js";
import "./star.d3e8ecca.js";
} from "./frappe-ui.a747cf9c.js";
import { f as $ } from "./index.6f049c1a.js";
import { _ as q } from "./CourseCard.bf057db6.js";
import { C as L, a as V } from "./clock.b36d19aa.js";
import { c as U, B as J } from "./index.51e5b051.js";
import "./UserAvatar.3cd4adb4.js";
import "./star.d358f014.js";
const K = U("LayoutDashboardIcon", [
[
"rect",

View File

@@ -18,13 +18,13 @@ import {
K as j,
L,
Z as T,
} from "./frappe-ui.f2211ca2.js";
import { a as H, f as p } from "./index.05189aed.js";
import { B as $ } from "./index.43e529db.js";
import { C as B, a as C } from "./clock.4d13ba48.js";
import { _ as O } from "./CourseCard.6a41330a.js";
import "./UserAvatar.b64a03ac.js";
import "./star.d3e8ecca.js";
} from "./frappe-ui.a747cf9c.js";
import { a as H, f as p } from "./index.6f049c1a.js";
import { B as $ } from "./index.51e5b051.js";
import { C as B, a as C } from "./clock.b36d19aa.js";
import { _ as O } from "./CourseCard.bf057db6.js";
import "./UserAvatar.3cd4adb4.js";
import "./star.d358f014.js";
const S = { key: 0, class: "shadow rounded-md p-5", style: { width: "300px" } },
V = { key: 2, class: "text-lg font-semibold mb-3" },
E = { class: "flex items-center mb-3" },

View File

@@ -22,11 +22,11 @@ import {
Z as z,
$ as L,
a1 as P,
} from "./frappe-ui.f2211ca2.js";
import { f as B } from "./index.05189aed.js";
import { B as A } from "./index.43e529db.js";
import { C as E, a as O } from "./clock.4d13ba48.js";
import { P as S } from "./plus.8f4bce9f.js";
} from "./frappe-ui.a747cf9c.js";
import { f as B } from "./index.6f049c1a.js";
import { B as A } from "./index.51e5b051.js";
import { C as E, a as O } from "./clock.b36d19aa.js";
import { P as S } from "./plus.d245902e.js";
const F = {
class: "flex flex-col border border-gray-200 rounded-md p-4 h-full",
style: { "min-height": "150px" },

View File

@@ -1,5 +1,5 @@
import { _ as f } from "./UserAvatar.b64a03ac.js";
import { s as g, B as v, U as y } from "./index.43e529db.js";
import { _ as f } from "./UserAvatar.3cd4adb4.js";
import { s as g, B as v, U as y } from "./index.51e5b051.js";
import {
s,
u as r,
@@ -16,8 +16,8 @@ import {
X as b,
a0 as k,
y as w,
} from "./frappe-ui.f2211ca2.js";
import { S as _ } from "./star.d3e8ecca.js";
} from "./frappe-ui.a747cf9c.js";
import { S as _ } from "./star.d358f014.js";
const C = {
key: 0,
class: "flex flex-col border border-gray-200 h-full rounded-md shadow-sm text-base overflow-auto",

View File

@@ -44,12 +44,12 @@ import {
a2 as K,
c as Z,
Z as G,
} from "./frappe-ui.f2211ca2.js";
import { c as N } from "./index.05189aed.js";
import { U as B, B as I } from "./index.43e529db.js";
import { S as x } from "./star.d3e8ecca.js";
import { _ as J } from "./CourseOutline.df6c648a.js";
import { _ as E } from "./UserAvatar.b64a03ac.js";
} from "./frappe-ui.a747cf9c.js";
import { c as N } from "./index.6f049c1a.js";
import { U as B, B as I } from "./index.51e5b051.js";
import { S as x } from "./star.d358f014.js";
import { _ as J } from "./CourseOutline.2110618a.js";
import { _ as E } from "./UserAvatar.3cd4adb4.js";
const Q = { class: "shadow rounded-md", style: { width: "300px" } },
X = ["src"],
Y = { class: "p-5" },

View File

@@ -18,8 +18,8 @@ import {
F as z,
X as B,
ab as I,
} from "./frappe-ui.f2211ca2.js";
import { c as i } from "./index.43e529db.js";
} from "./frappe-ui.a747cf9c.js";
import { c as i } from "./index.51e5b051.js";
const L = i("ChevronRightIcon", [
["path", { d: "m9 18 6-6-6-6", key: "mthhwq" }],
]),

View File

@@ -22,12 +22,12 @@ import {
$ as D,
a0 as E,
a1 as P,
} from "./frappe-ui.f2211ca2.js";
import { _ as U } from "./CourseCard.6a41330a.js";
import { P as A } from "./plus.8f4bce9f.js";
import "./UserAvatar.b64a03ac.js";
import "./index.43e529db.js";
import "./star.d3e8ecca.js";
} from "./frappe-ui.a747cf9c.js";
import { _ as U } from "./CourseCard.bf057db6.js";
import { P as A } from "./plus.d245902e.js";
import "./UserAvatar.3cd4adb4.js";
import "./index.51e5b051.js";
import "./star.d358f014.js";
const F = { class: "h-screen" },
R = { key: 0 },
S = {

View File

@@ -15,7 +15,7 @@ import {
L as m,
a2 as h,
B as b,
} from "./frappe-ui.f2211ca2.js";
} from "./frappe-ui.a747cf9c.js";
const v = {
name: "FontColor",
props: ["editor"],

View File

@@ -9,7 +9,7 @@ import {
X as l,
A as u,
E as p,
} from "./frappe-ui.f2211ca2.js";
} from "./frappe-ui.a747cf9c.js";
const D = {
name: "Home",
data() {

View File

@@ -16,7 +16,7 @@ import {
F as v,
X as u,
K as w,
} from "./frappe-ui.f2211ca2.js";
} from "./frappe-ui.a747cf9c.js";
const b = {
name: "InsertImage",
props: ["editor"],

View File

@@ -14,7 +14,7 @@ import {
am as _,
X as v,
K as w,
} from "./frappe-ui.f2211ca2.js";
} from "./frappe-ui.a747cf9c.js";
const x = {
name: "InsertLink",
props: ["editor"],

View File

@@ -17,7 +17,7 @@ import {
y as U,
F as p,
K as F,
} from "./frappe-ui.f2211ca2.js";
} from "./frappe-ui.a747cf9c.js";
const I = {
name: "InsertImage",
props: ["editor"],

View File

@@ -27,11 +27,11 @@ import {
B as V0,
a2 as G0,
Z as Q0,
} from "./frappe-ui.f2211ca2.js";
import { C as W0, _ as J0 } from "./CourseOutline.df6c648a.js";
import { _ as Y0 } from "./UserAvatar.b64a03ac.js";
import { t as X0, c as K0 } from "./index.05189aed.js";
import { c as Cu } from "./index.43e529db.js";
} from "./frappe-ui.a747cf9c.js";
import { C as W0, _ as J0 } from "./CourseOutline.2110618a.js";
import { _ as Y0 } from "./UserAvatar.3cd4adb4.js";
import { t as X0, c as K0 } from "./index.6f049c1a.js";
import { c as Cu } from "./index.51e5b051.js";
const ue = Cu("CheckCircleIcon", [
["path", { d: "M22 11.08V12a10 10 0 1 1-5.93-9.14", key: "g774vq" }],
["polyline", { points: "22 4 12 14.01 9 11.01", key: "6xbx8j" }],

View File

@@ -5,7 +5,7 @@ import {
D as l,
a4 as u,
F as n,
} from "./frappe-ui.f2211ca2.js";
} from "./frappe-ui.a747cf9c.js";
const i = {
__name: "UserAvatar",
props: { user: { type: Object, default: null }, size: { type: String } },

View File

@@ -1,4 +1,4 @@
import { c as e } from "./index.43e529db.js";
import { c as e } from "./index.51e5b051.js";
const c = e("CalendarIcon", [
[
"rect",

View File

@@ -97,7 +97,7 @@ import {
U as It,
V as At,
W as Tt,
} from "./frappe-ui.f2211ca2.js";
} from "./frappe-ui.a747cf9c.js";
(function () {
const n = document.createElement("link").relList;
if (n && n.supports && n.supports("modulepreload")) return;
@@ -438,10 +438,10 @@ const we = We("lms-users", () => ({
name: "Home",
component: () =>
F(
() => import("./Home.6f16d409.js"),
() => import("./Home.28a136f6.js"),
[
"assets/Home.6f16d409.js",
"assets/frappe-ui.f2211ca2.js",
"assets/Home.28a136f6.js",
"assets/frappe-ui.a747cf9c.js",
"assets/frappe-ui.7692ed2d.css",
]
),
@@ -451,16 +451,16 @@ const we = We("lms-users", () => ({
name: "Courses",
component: () =>
F(
() => import("./Courses.3f5a0719.js"),
() => import("./Courses.52ce2794.js"),
[
"assets/Courses.3f5a0719.js",
"assets/frappe-ui.f2211ca2.js",
"assets/Courses.52ce2794.js",
"assets/frappe-ui.a747cf9c.js",
"assets/frappe-ui.7692ed2d.css",
"assets/CourseCard.6a41330a.js",
"assets/UserAvatar.b64a03ac.js",
"assets/star.d3e8ecca.js",
"assets/CourseCard.bf057db6.js",
"assets/UserAvatar.3cd4adb4.js",
"assets/star.d358f014.js",
"assets/CourseCard.04c5bb55.css",
"assets/plus.8f4bce9f.js",
"assets/plus.d245902e.js",
]
),
},
@@ -469,16 +469,16 @@ const we = We("lms-users", () => ({
name: "CourseDetail",
component: () =>
F(
() => import("./CourseDetail.f6fd1d68.js"),
() => import("./CourseDetail.e391d1e0.js"),
[
"assets/CourseDetail.f6fd1d68.js",
"assets/frappe-ui.f2211ca2.js",
"assets/CourseDetail.e391d1e0.js",
"assets/frappe-ui.a747cf9c.js",
"assets/frappe-ui.7692ed2d.css",
"assets/index.05189aed.js",
"assets/star.d3e8ecca.js",
"assets/CourseOutline.df6c648a.js",
"assets/index.6f049c1a.js",
"assets/star.d358f014.js",
"assets/CourseOutline.2110618a.js",
"assets/CourseOutline.6dd858fb.css",
"assets/UserAvatar.b64a03ac.js",
"assets/UserAvatar.3cd4adb4.js",
"assets/CourseDetail.6888eccf.css",
]
),
@@ -489,15 +489,15 @@ const we = We("lms-users", () => ({
name: "Lesson",
component: () =>
F(
() => import("./Lesson.c80fc3b7.js"),
() => import("./Lesson.19d410ae.js"),
[
"assets/Lesson.c80fc3b7.js",
"assets/frappe-ui.f2211ca2.js",
"assets/Lesson.19d410ae.js",
"assets/frappe-ui.a747cf9c.js",
"assets/frappe-ui.7692ed2d.css",
"assets/CourseOutline.df6c648a.js",
"assets/CourseOutline.2110618a.js",
"assets/CourseOutline.6dd858fb.css",
"assets/UserAvatar.b64a03ac.js",
"assets/index.05189aed.js",
"assets/UserAvatar.3cd4adb4.js",
"assets/index.6f049c1a.js",
"assets/Lesson.3532a62c.css",
]
),
@@ -508,14 +508,14 @@ const we = We("lms-users", () => ({
name: "Batches",
component: () =>
F(
() => import("./Batches.f9864378.js"),
() => import("./Batches.6064501b.js"),
[
"assets/Batches.f9864378.js",
"assets/frappe-ui.f2211ca2.js",
"assets/Batches.6064501b.js",
"assets/frappe-ui.a747cf9c.js",
"assets/frappe-ui.7692ed2d.css",
"assets/index.05189aed.js",
"assets/clock.4d13ba48.js",
"assets/plus.8f4bce9f.js",
"assets/index.6f049c1a.js",
"assets/clock.b36d19aa.js",
"assets/plus.d245902e.js",
"assets/Batches.70c9cf07.css",
]
),
@@ -525,16 +525,16 @@ const we = We("lms-users", () => ({
name: "BatchDetail",
component: () =>
F(
() => import("./BatchDetail.c5dd0840.js"),
() => import("./BatchDetail.9bef2d15.js"),
[
"assets/BatchDetail.c5dd0840.js",
"assets/frappe-ui.f2211ca2.js",
"assets/BatchDetail.9bef2d15.js",
"assets/frappe-ui.a747cf9c.js",
"assets/frappe-ui.7692ed2d.css",
"assets/index.05189aed.js",
"assets/clock.4d13ba48.js",
"assets/CourseCard.6a41330a.js",
"assets/UserAvatar.b64a03ac.js",
"assets/star.d3e8ecca.js",
"assets/index.6f049c1a.js",
"assets/clock.b36d19aa.js",
"assets/CourseCard.bf057db6.js",
"assets/UserAvatar.3cd4adb4.js",
"assets/star.d358f014.js",
"assets/CourseCard.04c5bb55.css",
"assets/BatchDetail.f109aa14.css",
]
@@ -546,17 +546,17 @@ const we = We("lms-users", () => ({
name: "Batch",
component: () =>
F(
() => import("./Batch.6cc6d79c.js"),
() => import("./Batch.3bb9da4e.js"),
[
"assets/Batch.6cc6d79c.js",
"assets/frappe-ui.f2211ca2.js",
"assets/Batch.3bb9da4e.js",
"assets/frappe-ui.a747cf9c.js",
"assets/frappe-ui.7692ed2d.css",
"assets/index.05189aed.js",
"assets/CourseCard.6a41330a.js",
"assets/UserAvatar.b64a03ac.js",
"assets/star.d3e8ecca.js",
"assets/index.6f049c1a.js",
"assets/CourseCard.bf057db6.js",
"assets/UserAvatar.3cd4adb4.js",
"assets/star.d358f014.js",
"assets/CourseCard.04c5bb55.css",
"assets/clock.4d13ba48.js",
"assets/clock.b36d19aa.js",
]
),
props: !0,

View File

@@ -16,7 +16,7 @@ var n = (t, e, r) =>
if (o) for (var r of o(e)) c.call(e, r) && n(t, r, e[r]);
return t;
};
import { ac as s, ad as f } from "./frappe-ui.f2211ca2.js";
import { ac as s, ad as f } from "./frappe-ui.a747cf9c.js";
function y(t) {
s(a({ position: "bottom-right" }, t));
}

View File

@@ -1,4 +1,4 @@
import { c as e } from "./index.43e529db.js";
import { c as e } from "./index.51e5b051.js";
const n = e("PlusIcon", [
["line", { x1: "12", x2: "12", y1: "5", y2: "19", key: "pwfkuu" }],
["line", { x1: "5", x2: "19", y1: "12", y2: "12", key: "13b5wn" }],

View File

@@ -1,4 +1,4 @@
import { c as o } from "./index.43e529db.js";
import { c as o } from "./index.51e5b051.js";
const c = o("StarIcon", [
[
"polygon",

View File

@@ -5,10 +5,10 @@
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frappe UI App</title>
<script type="module" crossorigin src="/assets/index.43e529db.js"></script>
<link rel="modulepreload" crossorigin href="/assets/frappe-ui.f2211ca2.js">
<script type="module" crossorigin src="/assets/index.51e5b051.js"></script>
<link rel="modulepreload" crossorigin href="/assets/frappe-ui.a747cf9c.js">
<link rel="stylesheet" href="/assets/frappe-ui.7692ed2d.css">
<link rel="stylesheet" href="/assets/index.64bc1bc1.css">
<link rel="stylesheet" href="/assets/index.7337873e.css">
</head>
<body>
<div id="app"></div>

View File

@@ -1,4 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1