Merge remote-tracking branch 'origin/develop' into feat/scorm-progress
This commit is contained in:
@@ -1,191 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<router-link
|
||||
v-if="assignment.doc?.name"
|
||||
:to="{
|
||||
name: 'AssignmentSubmissionList',
|
||||
query: {
|
||||
assignmentID: assignment.doc.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Submission List') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="saveAssignment()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto py-5">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||
<FormControl
|
||||
v-model="model.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="model.type"
|
||||
type="select"
|
||||
:options="assignmentOptions"
|
||||
:label="__('Type')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Question') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="model.question"
|
||||
@change="(val) => (model.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createDocumentResource,
|
||||
createResource,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { showToast } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
assignmentID: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const model = reactive({
|
||||
title: '',
|
||||
type: 'PDF',
|
||||
question: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
props.assignmentID == 'new' &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
if (props.assignmentID !== 'new') {
|
||||
assignment.reload()
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||
saveAssignment()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const assignment = createDocumentResource({
|
||||
doctype: 'LMS Assignment',
|
||||
name: props.assignmentID,
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const newAssignment = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Assignment',
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
|
||||
},
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
},
|
||||
})
|
||||
|
||||
const saveAssignment = () => {
|
||||
if (props.assignmentID == 'new') {
|
||||
newAssignment.submit({
|
||||
...model,
|
||||
})
|
||||
} else {
|
||||
assignment.setValue.submit(
|
||||
{
|
||||
...model,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(__('Success'), __('Assignment saved successfully'), 'check')
|
||||
assignment.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(assignment, () => {
|
||||
Object.keys(assignment.doc).forEach((key) => {
|
||||
model[key] = assignment.doc[key]
|
||||
})
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Assignments'),
|
||||
route: { name: 'Assignments' },
|
||||
},
|
||||
{
|
||||
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
|
||||
},
|
||||
])
|
||||
|
||||
const assignmentOptions = computed(() => {
|
||||
return [
|
||||
{ label: 'PDF', value: 'PDF' },
|
||||
{ label: 'Image', value: 'Image' },
|
||||
{ label: 'Document', value: 'Document' },
|
||||
{ label: 'Text', value: 'Text' },
|
||||
{ label: 'URL', value: 'URL' },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,19 +1,27 @@
|
||||
<template>
|
||||
<header
|
||||
v-if="!fromLesson"
|
||||
class="flex justify-between sticky top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="overflow-hidden h-[calc(100vh-3.2rem)]">
|
||||
<Assignment :assignmentID="assignmentID" :submissionName="submissionName" />
|
||||
<Assignment
|
||||
:assignmentID="assignmentID"
|
||||
:submissionName="submissionName"
|
||||
:showTitle="!fromLesson"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { Breadcrumbs, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Assignment from '@/components/Assignment.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const fromLesson = ref(false)
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
assignmentID: {
|
||||
@@ -42,6 +50,10 @@ onMounted(() => {
|
||||
if (!user.data) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
if (new URLSearchParams(window.location.search).get('fromLesson')) {
|
||||
fromLesson.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
@@ -62,4 +74,11 @@ const breadcrumbs = computed(() => {
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: title.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -84,14 +84,17 @@ import {
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Pencil } from 'lucide-vue-next'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const assignmentID = ref('')
|
||||
const member = ref('')
|
||||
@@ -142,7 +145,6 @@ const submissions = createListResource({
|
||||
},
|
||||
})
|
||||
|
||||
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
||||
watch([assignmentID, member, status], () => {
|
||||
router.push({
|
||||
query: {
|
||||
@@ -214,4 +216,11 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Assignment Submissions'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,32 +3,43 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'AssignmentForm',
|
||||
params: {
|
||||
assignmentID: 'new',
|
||||
},
|
||||
}"
|
||||
<Button
|
||||
v-if="!readOnlyMode"
|
||||
variant="solid"
|
||||
@click="
|
||||
() => {
|
||||
assignmentID = 'new'
|
||||
showAssignmentForm = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" />
|
||||
<FormControl
|
||||
v-model="typeFilter"
|
||||
type="select"
|
||||
:options="assignmentTypes"
|
||||
:placeholder="__('Type')"
|
||||
/>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('{0} Assignments').format(assignmentCount) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="assignments.data?.length || assignmentCount > 0"
|
||||
class="grid grid-cols-2 gap-5"
|
||||
>
|
||||
<FormControl
|
||||
v-model="titleFilter"
|
||||
:placeholder="__('Search by title')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="typeFilter"
|
||||
type="select"
|
||||
:options="assignmentTypes"
|
||||
:placeholder="__('Type')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="assignments.data?.length"
|
||||
@@ -38,31 +49,15 @@
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'AssignmentForm',
|
||||
params: {
|
||||
assignmentID: row.name,
|
||||
},
|
||||
}),
|
||||
onRowClick: (row) => {
|
||||
if (readOnlyMode) return
|
||||
assignmentID = row.name
|
||||
showAssignmentForm = true
|
||||
},
|
||||
}"
|
||||
>
|
||||
</ListView>
|
||||
<div
|
||||
v-else
|
||||
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<Pencil class="size-10 mx-auto stroke-1 text-ink-gray-4" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No assignments found') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'You have not created any assignments yet. To create a new assignment, click on the "New" button above.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Assignments" />
|
||||
<div
|
||||
v-if="assignments.data && assignments.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
@@ -72,30 +67,45 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AssignmentForm
|
||||
v-model="showAssignmentForm"
|
||||
v-model:assignments="assignments"
|
||||
:assignmentID="assignmentID"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FormControl,
|
||||
ListView,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus, Pencil } from 'lucide-vue-next'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import AssignmentForm from '@/components/Modals/AssignmentForm.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const titleFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const showAssignmentForm = ref(false)
|
||||
const assignmentID = ref('new')
|
||||
const assignmentCount = ref(0)
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
getAssignmentCount()
|
||||
titleFilter.value = router.currentRoute.value.query.title
|
||||
typeFilter.value = router.currentRoute.value.query.type
|
||||
})
|
||||
@@ -133,7 +143,7 @@ const assignmentFilter = computed(() => {
|
||||
|
||||
const assignments = createListResource({
|
||||
doctype: 'LMS Assignment',
|
||||
fields: ['name', 'title', 'type', 'creation'],
|
||||
fields: ['name', 'title', 'type', 'creation', 'question'],
|
||||
orderBy: 'modified desc',
|
||||
cache: ['assignments'],
|
||||
transform(data) {
|
||||
@@ -163,11 +173,19 @@ const assignmentColumns = computed(() => {
|
||||
label: __('Created'),
|
||||
key: 'creation',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const getAssignmentCount = () => {
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Assignment',
|
||||
}).then((data) => {
|
||||
assignmentCount.value = data
|
||||
})
|
||||
}
|
||||
|
||||
const assignmentTypes = computed(() => {
|
||||
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
|
||||
return types.map((type) => {
|
||||
@@ -184,4 +202,11 @@ const breadcrumbs = computed(() => [
|
||||
route: { name: 'Assignments' },
|
||||
},
|
||||
])
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Assignments'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createDocumentResource, createResource } from 'frappe-ui'
|
||||
import { createResource, usePageMeta } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
badgeName: {
|
||||
@@ -70,4 +72,11 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: badge.data.badge,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
{{ __('Generate Certificates') }}
|
||||
</Button>
|
||||
<Button v-if="user.data?.is_moderator" @click="openAnnouncementModal()">
|
||||
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
|
||||
<span>
|
||||
{{ __('Make an Announcement') }}
|
||||
</span>
|
||||
@@ -23,7 +23,7 @@
|
||||
</header>
|
||||
<div
|
||||
v-if="batch.data"
|
||||
class="grid grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
|
||||
class="grid grid-cols-1 md:grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
|
||||
>
|
||||
<div class="border-r">
|
||||
<Tabs
|
||||
@@ -67,10 +67,13 @@
|
||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard'">
|
||||
<BatchStudents :batch="batch.data" />
|
||||
<BatchStudents :batch="batch" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Classes'">
|
||||
<LiveClass :batch="batch.data.name" />
|
||||
<LiveClass
|
||||
:batch="batch.data.name"
|
||||
:zoomAccount="batch.data.zoom_account"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Assessments'">
|
||||
<Assessments :batch="batch.data.name" />
|
||||
@@ -88,56 +91,61 @@
|
||||
:scrollToBottom="false"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Feedback'">
|
||||
<BatchFeedback :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="text-ink-gray-7 font-semibold mb-4">
|
||||
{{ __('About this batch') }}:
|
||||
</div>
|
||||
<div
|
||||
v-html="batch.data.description"
|
||||
class="leading-5 mb-4 text-ink-gray-7"
|
||||
></div>
|
||||
|
||||
<div class="flex items-center avatar-group overlap mb-5">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
<div class="p-5 border-t md:border-t-0">
|
||||
<div class="mb-10">
|
||||
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||
{{ __('About this batch') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="batch.data.description"
|
||||
class="leading-5 mb-4 text-ink-gray-7"
|
||||
></div>
|
||||
|
||||
<div class="flex items-center avatar-group overlap mb-5">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.timezone"
|
||||
class="flex items-center mb-3 text-ink-gray-7"
|
||||
>
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-4 text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.timezone"
|
||||
class="flex items-center mb-4 text-ink-gray-7"
|
||||
>
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
|
||||
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||
{{ __('Feedback') }}
|
||||
</div>
|
||||
<BatchFeedback :batch="batch.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
<AnnouncementModal
|
||||
@@ -190,14 +198,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BulkCertificates v-model="openCertificateDialog" :batch="batch.data" />
|
||||
<BulkCertificates
|
||||
v-if="batch.data"
|
||||
v-model="openCertificateDialog"
|
||||
:batch="batch.data"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { computed, inject, ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createResource,
|
||||
Tabs,
|
||||
Badge,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
Clock,
|
||||
LayoutDashboard,
|
||||
@@ -210,7 +227,10 @@ import {
|
||||
Globe,
|
||||
ClipboardPen,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||
import { formatTime } from '@/utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
import BatchCourses from '@/components/BatchCourses.vue'
|
||||
import LiveClass from '@/components/LiveClass.vue'
|
||||
@@ -222,10 +242,52 @@ import Discussions from '@/components/Discussions.vue'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
||||
import BatchFeedback from '@/components/BatchFeedback.vue'
|
||||
import dayjs from 'dayjs/esm'
|
||||
|
||||
const user = inject('$user')
|
||||
const showAnnouncementModal = ref(false)
|
||||
const openCertificateDialog = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
const tabIndex = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
batchTabs.push({
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Classes',
|
||||
icon: Laptop,
|
||||
})
|
||||
|
||||
if (user.data?.is_moderator) {
|
||||
batchTabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
})
|
||||
}
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Announcements',
|
||||
icon: Mail,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Discussions',
|
||||
icon: MessageCircle,
|
||||
})
|
||||
return batchTabs
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
@@ -234,6 +296,17 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const hash = route.hash
|
||||
if (hash) {
|
||||
tabs.value.forEach((tab, index) => {
|
||||
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
|
||||
tabIndex.value = index
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
@@ -271,48 +344,6 @@ const isStudent = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const tabIndex = useRouteQuery('tab', 0)
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
batchTabs.push({
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Classes',
|
||||
icon: Laptop,
|
||||
})
|
||||
|
||||
if (user.data?.is_moderator) {
|
||||
batchTabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
})
|
||||
}
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Announcements',
|
||||
icon: Mail,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Discussions',
|
||||
icon: MessageCircle,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Feedback',
|
||||
icon: ClipboardPen,
|
||||
})
|
||||
return batchTabs
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/lms/batches/${props.batchName}`
|
||||
}
|
||||
@@ -321,12 +352,25 @@ const openAnnouncementModal = () => {
|
||||
showAnnouncementModal.value = true
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: batch.data?.title,
|
||||
description: batch.data?.description,
|
||||
watch(tabIndex, () => {
|
||||
const tab = tabs.value[tabIndex.value]
|
||||
if (tab.label != route.hash.replace('#', '')) {
|
||||
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
const canMakeAnnouncement = () => {
|
||||
if (readOnlyMode) return false
|
||||
|
||||
if (!batch.data?.students?.length) return false
|
||||
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -6,61 +6,35 @@
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5 pb-10">
|
||||
<div>
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2"
|
||||
>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<BookOpen class="h-4 w-4 mr-2" />
|
||||
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<span class="hidden lg:block" v-if="batch.data.courses"
|
||||
>·</span
|
||||
>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
/>
|
||||
<span class="hidden lg:block" v-if="batch.data.start_date"
|
||||
>·</span
|
||||
>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex avatar-group overlap mt-3">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
||||
<div class="order-2 lg:order-none">
|
||||
<div
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-6"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
v-html="batch.data.batch_details"
|
||||
></div>
|
||||
</div>
|
||||
<div class="order-1 lg:order-none">
|
||||
<div class="hidden md:block">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,7 +44,7 @@
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
v-if="batch.data.courses"
|
||||
v-for="course in courses.data"
|
||||
@@ -102,8 +76,9 @@
|
||||
import { computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Breadcrumbs, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||
import DateRange from '../components/Common/DateRange.vue'
|
||||
@@ -112,6 +87,7 @@ import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
@@ -152,14 +128,12 @@ const breadcrumbs = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch.data?.title,
|
||||
description: batch.data?.description,
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
|
||||
@@ -4,117 +4,111 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="saveBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
|
||||
<template #icon>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-1/2 mx-auto py-5">
|
||||
<div class="">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="py-5">
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="space-y-4 mb-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
v-model="batch.description"
|
||||
:label="__('Short Description')"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex items-center space-x-5">
|
||||
<FormControl
|
||||
v-model="batch.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.allow_self_enrollment"
|
||||
type="checkbox"
|
||||
:label="__('Allow self enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.certification"
|
||||
type="checkbox"
|
||||
:label="__('Certification')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-ink-gray-5 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="batch.image.file_url" class="border rounded-md w-40" />
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-ink-gray-5 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
|
||||
<div class="my-10">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
v-model="batch.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.allow_self_enrollment"
|
||||
type="checkbox"
|
||||
:label="__('Allow self enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.certification"
|
||||
type="checkbox"
|
||||
:label="__('Certification')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Date and Time') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Start Date')"
|
||||
:label="__('Batch Start Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('End Date')"
|
||||
:label="__('Batch End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Session Start Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('Session End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
@@ -123,32 +117,38 @@
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Start Time')"
|
||||
type="time"
|
||||
v-model="batch.evaluation_end_date"
|
||||
:label="__('Evaluation End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-10">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div>
|
||||
<label class="block text-sm text-ink-gray-5 mb-1">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="batch.batch_details"
|
||||
@change="(val) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Configurations') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
@@ -156,19 +156,28 @@
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.evaluation_end_date"
|
||||
:label="__('Evaluation End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
doctype="Email Template"
|
||||
:label="__('Email Template')"
|
||||
v-model="batch.confirmation_email_template"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Email Templates', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Zoom Settings"
|
||||
:label="__('Zoom Account')"
|
||||
v-model="batch.zoom_account"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Zoom Accounts', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.medium"
|
||||
type="select"
|
||||
@@ -189,26 +198,85 @@
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
v-model="batch.category"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __('Meta Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!batch.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="border rounded-md w-fit py-5 px-5 md:px-20 cursor-pointer"
|
||||
@click="openFileSelector"
|
||||
>
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||
{{
|
||||
__('Appears when the batch URL is shared on socials')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="batch.image.file_url"
|
||||
class="border rounded-md w-40"
|
||||
/>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-ink-gray-5 text-sm">
|
||||
{{
|
||||
__(
|
||||
'Appears when the batch URL is shared on any online platform'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Payment') }}
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Pricing') }}
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batch.paid_batch"
|
||||
type="checkbox"
|
||||
:label="__('Paid Batch')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.paid_batch"
|
||||
type="checkbox"
|
||||
:label="__('Paid Batch')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.paid_batch"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-5"
|
||||
>
|
||||
<FormControl
|
||||
v-model="batch.amount"
|
||||
:label="__('Amount')"
|
||||
type="number"
|
||||
class="my-4"
|
||||
/>
|
||||
<Link
|
||||
doctype="Currency"
|
||||
@@ -219,29 +287,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-10">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Description') }}
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Short Description')"
|
||||
type="textarea"
|
||||
class="my-4"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
/>
|
||||
<div>
|
||||
<label class="block text-sm text-ink-gray-5 mb-1">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="batch.batch_details"
|
||||
@change="(val) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="meta.description"
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
:label="__('Meta Keywords')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
:placeholder="__('Comma separated keywords for SEO')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,10 +313,11 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
getCurrentInstance,
|
||||
inject,
|
||||
reactive,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue'
|
||||
import {
|
||||
@@ -264,16 +327,32 @@ import {
|
||||
Button,
|
||||
TextEditor,
|
||||
createResource,
|
||||
usePageMeta,
|
||||
toast,
|
||||
call,
|
||||
Toast,
|
||||
} from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { Image, Trash2 } from 'lucide-vue-next'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import {
|
||||
openSettings,
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
} from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
@@ -303,20 +382,29 @@ const batch = reactive({
|
||||
paid_batch: false,
|
||||
currency: '',
|
||||
amount: 0,
|
||||
zoom_account: '',
|
||||
})
|
||||
|
||||
const instructors = ref([])
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) window.location.href = '/login'
|
||||
if (props.batchName != 'new') {
|
||||
batchDetail.reload()
|
||||
fetchBatchInfo()
|
||||
} else {
|
||||
capture('batch_form_opened')
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const fetchBatchInfo = () => {
|
||||
batchDetail.reload()
|
||||
getMetaInfo('batches', props.batchName, meta)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
@@ -425,6 +513,12 @@ const createNewBatch = () => {
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_batch', true, false, () => {
|
||||
localStorage.setItem('firstBatch', data.name)
|
||||
})
|
||||
}
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
capture('batch_created')
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
@@ -434,7 +528,7 @@ const createNewBatch = () => {
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -445,6 +539,7 @@ const editBatchDetails = () => {
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
@@ -453,12 +548,44 @@ const editBatchDetails = () => {
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteBatch = () => {
|
||||
$dialog({
|
||||
title: __('Confirm your action to delete'),
|
||||
message: __(
|
||||
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
trashBatch(close)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const trashBatch = (close) => {
|
||||
call('lms.lms.api.delete_batch', {
|
||||
batch: props.batchName,
|
||||
}).then(() => {
|
||||
toast.success(__('Batch deleted successfully'))
|
||||
close()
|
||||
router.push({
|
||||
name: 'Batches',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
batch.image = file
|
||||
}
|
||||
@@ -467,13 +594,6 @@ const removeImage = () => {
|
||||
batch.image = null
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
@@ -500,4 +620,11 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.batchName == 'new' ? 'New Batch' : batchDetail.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator"
|
||||
v-if="canCreateBatch()"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: { batchName: 'new' },
|
||||
@@ -14,7 +14,7 @@
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
@@ -22,22 +22,17 @@
|
||||
<div
|
||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||
>
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('All Batches') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||
class="flex flex-col space-y-3 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||
>
|
||||
<TabButtons
|
||||
v-if="user.data"
|
||||
:buttons="batchTabs"
|
||||
v-model="currentTab"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
type="checkbox"
|
||||
@change="updateBatches()"
|
||||
class="w-fit"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
@@ -57,6 +52,13 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
type="checkbox"
|
||||
@change="updateBatches()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -70,22 +72,8 @@
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!batches.list.loading"
|
||||
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
|
||||
<div class="text-lg font-medium mb-1">
|
||||
{{ __('No batches found') }}
|
||||
</div>
|
||||
<div class="leading-5 w-2/5 text-center">
|
||||
{{
|
||||
__(
|
||||
'There are no batches matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else-if="!batches.list.loading" type="Batches" />
|
||||
|
||||
<div
|
||||
v-if="!batches.list.loading && batches.hasNextPage"
|
||||
class="flex justify-center mt-5"
|
||||
@@ -100,18 +88,22 @@
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FormControl,
|
||||
Select,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
const start = ref(0)
|
||||
const pageLength = ref(20)
|
||||
const categories = ref([])
|
||||
@@ -119,8 +111,10 @@ const currentCategory = ref(null)
|
||||
const title = ref('')
|
||||
const certification = ref(false)
|
||||
const filters = ref({})
|
||||
const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming')
|
||||
const is_student = computed(() => user.data?.is_student)
|
||||
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
||||
const orderBy = ref('start_date')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
@@ -204,12 +198,12 @@ const updateTabFilter = () => {
|
||||
if (!user.data) {
|
||||
return
|
||||
}
|
||||
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
|
||||
if (currentTab.value == 'Enrolled' && is_student.value) {
|
||||
filters.value['enrolled'] = 1
|
||||
delete filters.value['start_date']
|
||||
delete filters.value['published']
|
||||
orderBy.value = 'start_date desc'
|
||||
} else if (user.data?.is_student) {
|
||||
} else if (is_student.value) {
|
||||
delete filters.value['enrolled']
|
||||
} else {
|
||||
delete filters.value['start_date']
|
||||
@@ -228,7 +222,7 @@ const updateTabFilter = () => {
|
||||
}
|
||||
|
||||
const updateStudentFilter = () => {
|
||||
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
|
||||
if (!user.data || (is_student.value && currentTab.value != 'Enrolled')) {
|
||||
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
|
||||
filters.value['published'] = 1
|
||||
}
|
||||
@@ -250,7 +244,12 @@ const setQueryParams = () => {
|
||||
}
|
||||
})
|
||||
|
||||
history.replaceState({}, '', `${location.pathname}?${queries.toString()}`)
|
||||
let queryString = ''
|
||||
if (queries.toString()) {
|
||||
queryString = `?${queries.toString()}`
|
||||
}
|
||||
|
||||
history.replaceState({}, '', `${location.pathname}${queryString}`)
|
||||
}
|
||||
|
||||
const updateCategories = (data) => {
|
||||
@@ -270,34 +269,38 @@ watch(currentTab, () => {
|
||||
updateBatches()
|
||||
})
|
||||
|
||||
const batchType = computed(() => {
|
||||
let types = [
|
||||
{ label: __(''), value: null },
|
||||
{ label: __('Upcoming'), value: 'Upcoming' },
|
||||
{ label: __('Archived'), value: 'Archived' },
|
||||
]
|
||||
if (user.data?.is_moderator) {
|
||||
types.push({ label: __('Unpublished'), value: 'Unpublished' })
|
||||
}
|
||||
return types
|
||||
})
|
||||
|
||||
const batchTabs = computed(() => {
|
||||
let tabs = [
|
||||
{
|
||||
label: __('All'),
|
||||
},
|
||||
]
|
||||
if (user.data?.is_student) {
|
||||
tabs.push({ label: __('Enrolled') })
|
||||
} else {
|
||||
|
||||
if (
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
) {
|
||||
tabs.push({ label: __('Upcoming') })
|
||||
tabs.push({ label: __('Archived') })
|
||||
tabs.push({ label: __('Unpublished') })
|
||||
} else if (user.data) {
|
||||
tabs.push({ label: __('Enrolled') })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
const canCreateBatch = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Batches'),
|
||||
@@ -305,12 +308,10 @@ const breadcrumbs = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Batches',
|
||||
description: 'All upcoming batches.',
|
||||
title: __('Batches'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
/>
|
||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||
<FormControl
|
||||
:label="__('State')"
|
||||
:label="__('State/Province')"
|
||||
v-model="billingDetails.state"
|
||||
/>
|
||||
</div>
|
||||
@@ -151,19 +151,20 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
createResource,
|
||||
FormControl,
|
||||
Breadcrumbs,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, computed } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
import { showToast } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
|
||||
onMounted(() => {
|
||||
const script = document.createElement('script')
|
||||
@@ -245,12 +246,10 @@ const paymentLink = createResource({
|
||||
})
|
||||
|
||||
const generatePaymentLink = () => {
|
||||
console.log('called')
|
||||
paymentLink.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
console.log('validation start')
|
||||
if (!billingDetails.source) {
|
||||
return __('Please let us know where you heard about us from.')
|
||||
}
|
||||
@@ -260,7 +259,7 @@ const generatePaymentLink = () => {
|
||||
window.location.href = data
|
||||
},
|
||||
onError(err) {
|
||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -304,6 +303,7 @@ const validateAddress = () => {
|
||||
'Gujarat',
|
||||
'Haryana',
|
||||
'Himachal Pradesh',
|
||||
'Jammu and Kashmir',
|
||||
'Jharkhand',
|
||||
'Karnataka',
|
||||
'Kerala',
|
||||
@@ -334,14 +334,7 @@ const validateAddress = () => {
|
||||
}
|
||||
|
||||
const showError = (err) => {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
toast.error(err.messages?.[0] || err)
|
||||
}
|
||||
|
||||
const changeCurrency = (country) => {
|
||||
@@ -358,4 +351,11 @@ const redirectTo = computed(() => {
|
||||
return `/lms/courses/${props.name}/certification`
|
||||
}
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Billing Details'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link :to="{ name: 'Batches' }">
|
||||
<router-link :to="{ name: 'Batches', query: { certification: true } }">
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<GraduationCap class="h-4 w-4 stroke-1.5" />
|
||||
@@ -12,12 +12,10 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
<div class="p-5 lg:w-3/4 mx-auto">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5"
|
||||
>
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('All Certified Participants') }}
|
||||
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
|
||||
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||
{{ memberCount }} {{ __('certified members') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
@@ -40,57 +38,65 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="participants.data?.length">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div v-if="participants.data?.length" class="divide-y">
|
||||
<template v-for="participant in participants.data">
|
||||
<router-link
|
||||
v-for="participant in participants.data"
|
||||
:to="{
|
||||
name: 'ProfileCertificates',
|
||||
params: { username: participant.username },
|
||||
params: {
|
||||
username: participant.username,
|
||||
},
|
||||
}"
|
||||
class="flex sm:rounded px-3 py-2 sm:h-15 hover:bg-surface-gray-2"
|
||||
>
|
||||
<div
|
||||
class="flex items-center space-x-2 border rounded-md hover:bg-surface-menu-bar p-2 text-ink-gray-7"
|
||||
>
|
||||
<div class="flex items-center w-full space-x-3">
|
||||
<Avatar
|
||||
:image="participant.user_image"
|
||||
class="size-8 rounded-full object-contain"
|
||||
:label="participant.full_name"
|
||||
size="2xl"
|
||||
/>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="font-medium">
|
||||
{{ participant.full_name }}
|
||||
<div class="flex flex-col md:flex-row w-full">
|
||||
<div class="flex-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ participant.full_name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="participant.headline"
|
||||
class="mt-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
{{ participant.headline }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="participant.headline"
|
||||
class="headline text-sm text-ink-gray-7"
|
||||
class="flex items-center space-x-3 md:space-x-24 text-sm md:text-base mt-1.5"
|
||||
>
|
||||
{{ participant.headline }}
|
||||
<div class="text-ink-gray-5">
|
||||
{{ participant.certificate_count }}
|
||||
{{
|
||||
participant.certificate_count > 1
|
||||
? __('certificates')
|
||||
: __('certificate')
|
||||
}}
|
||||
</div>
|
||||
<span class="text-ink-gray-4 md:hidden">·</span>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ dayjs(participant.issue_date).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-if="!participants.list.loading && participants.hasNextPage"
|
||||
class="flex justify-center mt-5"
|
||||
>
|
||||
<Button @click="participants.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<EmptyState v-else type="Certified Members" />
|
||||
<div
|
||||
v-else-if="!participants.list.loading"
|
||||
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
|
||||
v-if="!participants.list.loading && participants.hasNextPage"
|
||||
class="flex justify-center mt-5"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
|
||||
<div class="text-lg font-medium mb-1">
|
||||
{{ __('No participants found') }}
|
||||
</div>
|
||||
<div class="leading-5 w-2/5 text-center">
|
||||
{{ __('There are no participants matching this criteria.') }}
|
||||
</div>
|
||||
<Button @click="participants.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -99,30 +105,45 @@ import {
|
||||
Avatar,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FormControl,
|
||||
Select,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { BookOpen, GraduationCap } from 'lucide-vue-next'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { GraduationCap } from 'lucide-vue-next'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const currentCategory = ref('')
|
||||
const filters = ref({})
|
||||
const nameFilter = ref('')
|
||||
const { brand } = sessionStore()
|
||||
const memberCount = ref(0)
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
onMounted(() => {
|
||||
getMemberCount()
|
||||
updateParticipants()
|
||||
})
|
||||
|
||||
const participants = createListResource({
|
||||
doctype: 'LMS Certificate',
|
||||
url: 'lms.lms.api.get_certified_participants',
|
||||
cache: ['certified_participants'],
|
||||
start: 0,
|
||||
pageLength: 30,
|
||||
cache: ['certified_participants'],
|
||||
pageLength: 100,
|
||||
})
|
||||
|
||||
const getMemberCount = () => {
|
||||
call('lms.lms.api.get_count_of_certified_members', {
|
||||
filters: filters.value,
|
||||
}).then((data) => {
|
||||
memberCount.value = data
|
||||
})
|
||||
}
|
||||
|
||||
const categories = createListResource({
|
||||
doctype: 'LMS Certificate',
|
||||
url: 'lms.lms.api.get_certification_categories',
|
||||
@@ -136,6 +157,7 @@ const categories = createListResource({
|
||||
|
||||
const updateParticipants = () => {
|
||||
updateFilters()
|
||||
getMemberCount()
|
||||
participants.update({
|
||||
filters: filters.value,
|
||||
})
|
||||
@@ -158,18 +180,17 @@ const updateFilters = () => {
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Certified Participants'),
|
||||
label: __('Certified Members'),
|
||||
route: { name: 'CertifiedParticipants' },
|
||||
},
|
||||
])
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Certified Participants',
|
||||
description: 'All participants that have been certified.',
|
||||
title: __('Certified Members'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.headline {
|
||||
|
||||
@@ -36,14 +36,18 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Breadcrumbs, call, createResource } from 'frappe-ui'
|
||||
import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const courseTitle = ref(null)
|
||||
const evaluator = ref(null)
|
||||
const { brand } = sessionStore()
|
||||
const courses = ref([])
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -53,6 +57,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchEnrollmentDetails()
|
||||
fetchCourseDetails()
|
||||
})
|
||||
|
||||
@@ -66,10 +71,26 @@ const certificate = createResource({
|
||||
},
|
||||
fieldname: ['name', 'template', 'issue_date'],
|
||||
},
|
||||
auto: true,
|
||||
cache: [user.data?.name, props.courseName],
|
||||
})
|
||||
|
||||
const fetchEnrollmentDetails = () => {
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: { member: user.data?.name, course: props.courseName },
|
||||
fieldname: ['purchased_certificate'],
|
||||
}).then((data) => {
|
||||
if (data.purchased_certificate) {
|
||||
certificate.reload()
|
||||
} else {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: props.courseName },
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchCourseDetails = () => {
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Course',
|
||||
@@ -114,4 +135,11 @@ const breadcrumbs = computed(() => [
|
||||
label: __('Certification'),
|
||||
},
|
||||
])
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: courseTitle.value,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="flex justify-between w-full space-x-5">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ course.data.title }}
|
||||
@@ -20,7 +20,7 @@
|
||||
:text="__('Average Rating')"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
||||
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||
<span class="ml-1 text-ink-gray-7">
|
||||
{{ course.data.rating }}
|
||||
</span>
|
||||
@@ -56,26 +56,29 @@
|
||||
<CourseInstructors :instructors="course.data.instructors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mt-3 mb-4 w-fit">
|
||||
<div v-if="course.data.tags" class="flex my-4 w-fit">
|
||||
<Badge
|
||||
theme="gray"
|
||||
size="lg"
|
||||
class="mr-2 text-ink-gray-9"
|
||||
v-for="tag in course.data.tags"
|
||||
v-for="tag in course.data.tags.split(', ')"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
||||
<div class="md:hidden my-4">
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
<div
|
||||
v-html="course.data.description"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
></div>
|
||||
<div class="mt-10">
|
||||
<CourseOutline
|
||||
:title="__('Course Outline')"
|
||||
:courseName="course.data.name"
|
||||
:showOutline="true"
|
||||
:getProgress="course.data.membership ? true : false"
|
||||
/>
|
||||
</div>
|
||||
<CourseReviews
|
||||
@@ -88,19 +91,29 @@
|
||||
<CourseCardOverlay :course="course" />
|
||||
</div>
|
||||
</div>
|
||||
<RelatedCourses :courseName="course.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs, Badge, Tooltip } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Badge,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, watch } from 'vue'
|
||||
import { Users, Star } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import CourseReviews from '@/components/CourseReviews.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
@@ -112,12 +125,21 @@ const props = defineProps({
|
||||
const course = createResource({
|
||||
url: 'lms.lms.utils.get_course_details',
|
||||
cache: ['course', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
makeParams() {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.courseName,
|
||||
() => {
|
||||
course.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||
items.push({
|
||||
@@ -127,14 +149,12 @@ const breadcrumbs = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: course?.data?.title,
|
||||
description: course?.data?.short_introduction,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.avatar-group {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="grid md:grid-cols-[70%,30%] h-full">
|
||||
<div class="h-full">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] h-full">
|
||||
<div>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
@@ -8,12 +8,9 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center mt-3 md:mt-0">
|
||||
<Button v-if="courseResource.data?.name" @click="trashCourse()">
|
||||
<template #prefix>
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||
<span>
|
||||
@@ -22,62 +19,104 @@
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mt-5 mb-10">
|
||||
<div class="container mb-5">
|
||||
<div class="mt-5 mb-5">
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
:label="__('Short Introduction')"
|
||||
:placeholder="
|
||||
__(
|
||||
'A one line introduction to the course that appears on the course card'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course Description') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@change="(val) => (course.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="course.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
:label="__('Category')"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Course Image') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-5 px-20">
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:onCreate="(close) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __('Tags') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Add a keyword and then press enter')"
|
||||
:class="['w-full', 'flex-1', 'my-1']"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
{{ tag }}
|
||||
<X
|
||||
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Course Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!course.course_image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||
@click="openFileSelector"
|
||||
>
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||
{{
|
||||
__('Appears on the course card in the course list')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="course.course_image.file_url"
|
||||
class="border rounded-md w-40"
|
||||
/>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-ink-gray-5 text-sm">
|
||||
{{
|
||||
@@ -86,84 +125,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="course.course_image.file_url"
|
||||
class="border rounded-md w-40"
|
||||
/>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
<div class="mt-2 text-ink-gray-5 text-sm">
|
||||
{{ __('Appears on the course card in the course list') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.video_link"
|
||||
:label="__('Preview Video')"
|
||||
:placeholder="
|
||||
__(
|
||||
'Paste the youtube link of a short video introducing the course'
|
||||
)
|
||||
"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||
{{ __('Tags') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
|
||||
>
|
||||
{{ tag }}
|
||||
<X
|
||||
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Add a keyword and then press enter')"
|
||||
class="w-72"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/2 mb-4">
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
:label="__('Category')"
|
||||
:onCreate="(value, close) => openSettings(close)"
|
||||
|
||||
<ColorSwatches
|
||||
v-model="course.card_gradient"
|
||||
:label="__('Color')"
|
||||
:description="__('Choose a color for the course card')"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-10 mb-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-4"
|
||||
class="flex flex-col space-y-5"
|
||||
>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
@@ -174,10 +155,9 @@
|
||||
v-model="course.published_on"
|
||||
:label="__('Published On')"
|
||||
type="date"
|
||||
class="mb-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.upcoming"
|
||||
@@ -196,11 +176,68 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container border-t space-y-4">
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('About the Course') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.short_introduction"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
:label="__('Short Introduction')"
|
||||
:placeholder="
|
||||
__(
|
||||
'A one line introduction to the course that appears on the course card'
|
||||
)
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course Description') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="course.description"
|
||||
@change="(val) => (course.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-model="course.video_link"
|
||||
:label="__('Preview Video')"
|
||||
:placeholder="
|
||||
__(
|
||||
'Paste the youtube link of a short video introducing the course'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="related_courses"
|
||||
doctype="LMS Course"
|
||||
:label="__('Related Courses')"
|
||||
:filters="{ name: ['!=', courseResource.data?.name] }"
|
||||
:onCreate="
|
||||
(close) => {
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: 'new' },
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mt-5">
|
||||
{{ __('Pricing and Certification') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_course"
|
||||
@@ -217,27 +254,60 @@
|
||||
:label="__('Paid Certificate')"
|
||||
/>
|
||||
</div>
|
||||
<FormControl v-model="course.course_price" :label="__('Amount')" />
|
||||
<Link
|
||||
doctype="Currency"
|
||||
v-model="course.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
<Link
|
||||
v-if="course.paid_certificate"
|
||||
doctype="Course Evaluator"
|
||||
v-model="course.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
/>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-if="course.paid_course || course.paid_certificate"
|
||||
v-model="course.course_price"
|
||||
:label="__('Amount')"
|
||||
/>
|
||||
<Link
|
||||
v-if="course.paid_certificate"
|
||||
doctype="Course Evaluator"
|
||||
v-model="course.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
:onCreate="
|
||||
(value, close) => openSettings('Evaluators', close)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
v-if="course.paid_course || course.paid_certificate"
|
||||
doctype="Currency"
|
||||
v-model="course.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5">
|
||||
<div class="text-lg font-semibold mt-5">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="meta.description"
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
:label="__('Meta Keywords')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
:placeholder="__('Comma separated keywords for SEO')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l pt-5">
|
||||
<div class="border-l">
|
||||
<CourseOutline
|
||||
v-if="courseResource.data"
|
||||
:courseName="courseResource.data.name"
|
||||
:title="course.title"
|
||||
:title="__('Course Outline')"
|
||||
:allowEdit="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -247,11 +317,14 @@
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
call,
|
||||
TextEditor,
|
||||
Button,
|
||||
createResource,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
inject,
|
||||
@@ -263,21 +336,30 @@ import {
|
||||
watch,
|
||||
getCurrentInstance,
|
||||
} from 'vue'
|
||||
import { showToast, updateDocumentTitle } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { Image, Trash2, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import {
|
||||
openSettings,
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
} from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import ColorSwatches from '@/components/Controls/ColorSwatches.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const newTag = ref('')
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const instructors = ref([])
|
||||
const settingsStore = useSettings()
|
||||
const related_courses = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
@@ -292,6 +374,7 @@ const course = reactive({
|
||||
description: '',
|
||||
video_link: '',
|
||||
course_image: null,
|
||||
card_gradient: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
published: false,
|
||||
@@ -307,23 +390,30 @@ const course = reactive({
|
||||
evaluator: '',
|
||||
})
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
props.courseName == 'new' &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
if (props.courseName !== 'new') {
|
||||
courseResource.reload()
|
||||
fetchCourseInfo()
|
||||
} else {
|
||||
capture('course_form_opened')
|
||||
startRecording()
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const fetchCourseInfo = () => {
|
||||
courseResource.reload()
|
||||
getMetaInfo('courses', props.courseName, meta)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
@@ -337,6 +427,7 @@ const keyboardShortcut = (e) => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
stopRecording()
|
||||
})
|
||||
|
||||
const courseCreationResource = createResource({
|
||||
@@ -349,6 +440,9 @@ const courseCreationResource = createResource({
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...values,
|
||||
},
|
||||
}
|
||||
@@ -367,6 +461,9 @@ const courseEditResource = createResource({
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
related_courses: related_courses.value.map((course) => ({
|
||||
course: course,
|
||||
})),
|
||||
...course,
|
||||
},
|
||||
}
|
||||
@@ -389,6 +486,11 @@ const courseResource = createResource({
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (key == 'related_courses') {
|
||||
related_courses.value = []
|
||||
data.related_courses.forEach((course) => {
|
||||
related_courses.value.push(course.course)
|
||||
})
|
||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
@@ -398,7 +500,7 @@ const courseResource = createResource({
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
'paid_certifiate',
|
||||
'paid_certificate',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
@@ -425,37 +527,50 @@ const imageResource = createResource({
|
||||
|
||||
const submitCourse = () => {
|
||||
if (courseResource.data) {
|
||||
courseEditResource.submit(
|
||||
{
|
||||
course: courseResource.data.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
showToast('Success', 'Course updated successfully', 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
editCourse()
|
||||
} else {
|
||||
courseCreationResource.submit(course, {
|
||||
onSuccess(data) {
|
||||
capture('course_created')
|
||||
showToast('Success', 'Course created successfully', 'check')
|
||||
/* if (!settingsStore.onboardingDetails.data?.is_onboarded) {
|
||||
settingsStore.onboardingDetails.reload()
|
||||
} */
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: data.name },
|
||||
createCourse()
|
||||
}
|
||||
}
|
||||
|
||||
const createCourse = () => {
|
||||
courseCreationResource.submit(course, {
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('courses', data.name, meta)
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_course', true, false, () => {
|
||||
localStorage.setItem('firstCourse', data.name)
|
||||
})
|
||||
}
|
||||
|
||||
capture('course_created')
|
||||
toast.success(__('Course created successfully'))
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: { courseName: data.name },
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const editCourse = () => {
|
||||
courseEditResource.submit(
|
||||
{
|
||||
course: courseResource.data.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateMetaInfo('courses', props.courseName, meta)
|
||||
toast.success(__('Course updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteCourse = createResource({
|
||||
@@ -466,7 +581,7 @@ const deleteCourse = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast(__('Success'), __('Course deleted successfully'), 'check')
|
||||
toast.success(__('Course deleted successfully'))
|
||||
router.push({ name: 'Courses' })
|
||||
},
|
||||
})
|
||||
@@ -495,18 +610,11 @@ watch(
|
||||
() => props.courseName !== 'new',
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
courseResource.reload()
|
||||
fetchCourseInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||
return __('Only image file is allowed.')
|
||||
}
|
||||
}
|
||||
|
||||
const updateTags = () => {
|
||||
if (newTag.value) {
|
||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||
@@ -530,12 +638,6 @@ const removeImage = () => {
|
||||
course.course_image = null
|
||||
}
|
||||
|
||||
const openSettings = (close) => {
|
||||
close()
|
||||
settingsStore.activeTab = 'Categories'
|
||||
settingsStore.isSettingsOpen = true
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
let user_is_instructor = false
|
||||
if (user.data?.is_moderator) return
|
||||
@@ -571,12 +673,10 @@ const breadcrumbs = computed(() => {
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Create a Course',
|
||||
description: 'Create or edit a course for your learning system.',
|
||||
title: courseResource.data?.title || __('New Course'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -1,318 +1,344 @@
|
||||
<template>
|
||||
<div v-if="courses.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
<header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
v-if="canCreateCourse()"
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: { courseName: 'new' },
|
||||
}"
|
||||
>
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||
/>
|
||||
<div class="flex space-x-2 justify-end">
|
||||
<div class="w-40 md:w-44">
|
||||
<FormControl
|
||||
v-if="categories.data?.length"
|
||||
type="select"
|
||||
v-model="currentCategory"
|
||||
:options="categories.data"
|
||||
:placeholder="__('Category')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-28 md:w-36">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</header>
|
||||
<div class="p-5 pb-10">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||
>
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('All Courses') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-3 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||
>
|
||||
<TabButtons :buttons="courseTabs" v-model="currentTab" class="w-fit" />
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
v-model="title"
|
||||
:placeholder="__('Search by Title')"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
v-model="searchQuery"
|
||||
@input="courses.reload()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search
|
||||
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
|
||||
name="search"
|
||||
/>
|
||||
</template>
|
||||
</FormControl>
|
||||
class="w-full lg:min-w-0 lg:w-32 xl:w-40"
|
||||
@input="updateCourses()"
|
||||
/>
|
||||
<div class="w-full lg:min-w-0 lg:w-32 xl:w-40">
|
||||
<Select
|
||||
v-if="categories.length"
|
||||
v-model="currentCategory"
|
||||
:options="categories"
|
||||
:placeholder="__('Category')"
|
||||
@change="updateCourses()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
|
||||
<FormControl
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
type="checkbox"
|
||||
@change="updateCourses()"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="">
|
||||
<Tabs
|
||||
v-if="hasCourses"
|
||||
as="div"
|
||||
v-model="tabIndex"
|
||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||
:tabs="makeTabs"
|
||||
</div>
|
||||
<div
|
||||
v-if="courses.data?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8"
|
||||
>
|
||||
<router-link
|
||||
v-for="course in courses.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||
>
|
||||
<template #tab="{ tab, selected }">
|
||||
<div>
|
||||
<button
|
||||
class="group -mb-px flex items-center gap-2 overflow-hidden border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
|
||||
:class="{ 'text-ink-gray-9': selected }"
|
||||
>
|
||||
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
|
||||
{{ __(tab.label) }}
|
||||
<Badge theme="gray">
|
||||
{{ tab.count }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #tab-panel="{ tab }">
|
||||
<div
|
||||
v-if="tab.courses && tab.courses.value.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-7 my-5 mx-5"
|
||||
>
|
||||
<router-link
|
||||
v-for="course in tab.courses.value"
|
||||
:to="
|
||||
course.membership && course.current_lesson
|
||||
? {
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: course.current_lesson.split('-')[0],
|
||||
lessonNumber: course.current_lesson.split('-')[1],
|
||||
},
|
||||
}
|
||||
: course.membership
|
||||
? {
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: 1,
|
||||
lessonNumber: 1,
|
||||
},
|
||||
}
|
||||
: {
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: course.name },
|
||||
}
|
||||
"
|
||||
>
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="p-5 italic text-ink-gray-4">
|
||||
{{ __('No {0} courses').format(tab.label.toLowerCase()) }}
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
<div
|
||||
v-else-if="
|
||||
!courses.loading &&
|
||||
(user.data?.is_moderator || user.data?.is_instructor)
|
||||
"
|
||||
class="grid grid-cols-3 p-5"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="bg-surface-menu-bar py-32 px-5 rounded-md">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<Plus
|
||||
class="size-10 stroke-1 text-ink-gray-8 p-1 rounded-full border bg-surface-white"
|
||||
/>
|
||||
<div class="font-medium">
|
||||
{{ __('Create a Course') }}
|
||||
</div>
|
||||
<span class="text-ink-gray-7 text-sm leading-4">
|
||||
{{ __('You can add chapters and lessons to it.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!courses.loading && !hasCourses"
|
||||
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No courses found') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'There are no courses available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
<EmptyState v-else-if="!courses.list.loading" type="Courses" />
|
||||
<div
|
||||
v-if="!courses.list.loading && courses.hasNextPage"
|
||||
class="flex justify-center mt-5"
|
||||
>
|
||||
<Button @click="courses.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
createListResource,
|
||||
FormControl,
|
||||
Tabs,
|
||||
Select,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { canCreateCourse } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { BookOpen, Plus, Search } from 'lucide-vue-next'
|
||||
import { ref, computed, inject, onMounted, watch } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import router from '../router'
|
||||
|
||||
const user = inject('$user')
|
||||
const searchQuery = ref('')
|
||||
const dayjs = inject('$dayjs')
|
||||
const start = ref(0)
|
||||
const pageLength = ref(30)
|
||||
const categories = ref([])
|
||||
const currentCategory = ref(null)
|
||||
const hasCourses = ref(false)
|
||||
const router = useRouter()
|
||||
const settings = useSettings()
|
||||
const title = ref('')
|
||||
const certification = ref(false)
|
||||
const filters = ref({})
|
||||
const currentTab = ref('Live')
|
||||
const { brand } = sessionStore()
|
||||
const courseCount = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
checkLearningPath()
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (queries.has('category')) {
|
||||
currentCategory.value = queries.get('category')
|
||||
}
|
||||
setFiltersFromQuery()
|
||||
updateCourses()
|
||||
getCourseCount()
|
||||
categories.value = [
|
||||
{
|
||||
label: '',
|
||||
value: null,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const checkLearningPath = () => {
|
||||
if (
|
||||
settings.learningPaths.data &&
|
||||
(!user.data?.is_moderator || !user.data?.is_instructor)
|
||||
) {
|
||||
router.push({ name: 'Programs' })
|
||||
const setFiltersFromQuery = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
title.value = queries.get('title') || ''
|
||||
currentCategory.value = queries.get('category') || null
|
||||
certification.value = queries.get('certification') || false
|
||||
}
|
||||
|
||||
const courses = createListResource({
|
||||
doctype: 'LMS Course',
|
||||
url: 'lms.lms.utils.get_courses',
|
||||
cache: ['courses', user.data?.name],
|
||||
pageLength: pageLength.value,
|
||||
start: start.value,
|
||||
onSuccess(data) {
|
||||
setCategories(data)
|
||||
},
|
||||
})
|
||||
|
||||
const setCategories = (data) => {
|
||||
let allCategories = data.map((course) => course.category)
|
||||
allCategories = allCategories.filter(
|
||||
(category, index) => allCategories.indexOf(category) === index && category
|
||||
)
|
||||
if (categories.value.length <= allCategories.length) {
|
||||
updateCategories(data)
|
||||
}
|
||||
}
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_courses',
|
||||
cache: ['courses', user.data?.email],
|
||||
auto: true,
|
||||
const isPersonaCaptured = async () => {
|
||||
let persona = await call('frappe.client.get_single_value', {
|
||||
doctype: 'LMS Settings',
|
||||
field: 'persona_captured',
|
||||
})
|
||||
return persona
|
||||
}
|
||||
|
||||
const identifyUserPersona = async () => {
|
||||
if (user.data?.is_system_manager && !user.data?.developer_mode) {
|
||||
let personaCaptured = await isPersonaCaptured()
|
||||
if (personaCaptured) return
|
||||
if (!courseCount.value) {
|
||||
router.push({
|
||||
name: 'PersonaForm',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getCourseCount = () => {
|
||||
if (!user.data) return
|
||||
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Course',
|
||||
}).then((data) => {
|
||||
courseCount.value = data
|
||||
identifyUserPersona()
|
||||
})
|
||||
}
|
||||
|
||||
const updateCourses = () => {
|
||||
updateFilters()
|
||||
courses.update({
|
||||
filters: filters.value,
|
||||
})
|
||||
courses.reload()
|
||||
}
|
||||
|
||||
const updateFilters = () => {
|
||||
updateCategoryFilter()
|
||||
updateTitleFilter()
|
||||
updateCertificationFilter()
|
||||
updateTabFilter()
|
||||
updateStudentFilter()
|
||||
setQueryParams()
|
||||
}
|
||||
|
||||
const updateCategoryFilter = () => {
|
||||
if (currentCategory.value) {
|
||||
filters.value['category'] = currentCategory.value
|
||||
} else {
|
||||
delete filters.value['category']
|
||||
}
|
||||
}
|
||||
|
||||
const updateTitleFilter = () => {
|
||||
if (title.value) {
|
||||
filters.value['title'] = ['like', `%${title.value}%`]
|
||||
} else {
|
||||
delete filters.value['title']
|
||||
}
|
||||
}
|
||||
|
||||
const updateCertificationFilter = () => {
|
||||
if (certification.value) {
|
||||
filters.value['certification'] = 1
|
||||
} else {
|
||||
delete filters.value['certification']
|
||||
}
|
||||
}
|
||||
|
||||
const updateTabFilter = () => {
|
||||
delete filters.value['live']
|
||||
delete filters.value['created']
|
||||
delete filters.value['published_on']
|
||||
delete filters.value['upcoming']
|
||||
|
||||
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
|
||||
filters.value['enrolled'] = 1
|
||||
delete filters.value['published']
|
||||
} else {
|
||||
delete filters.value['published']
|
||||
delete filters.value['enrolled']
|
||||
|
||||
if (currentTab.value == 'Live') {
|
||||
filters.value['published'] = 1
|
||||
filters.value['upcoming'] = 0
|
||||
filters.value['live'] = 1
|
||||
} else if (currentTab.value == 'Upcoming') {
|
||||
filters.value['upcoming'] = 1
|
||||
} else if (currentTab.value == 'New') {
|
||||
filters.value['published'] = 1
|
||||
filters.value['published_on'] = [
|
||||
'>=',
|
||||
dayjs().add(-3, 'month').format('YYYY-MM-DD'),
|
||||
]
|
||||
} else if (currentTab.value == 'Created') {
|
||||
filters.value['created'] = 1
|
||||
} else if (currentTab.value == 'Unpublished') {
|
||||
filters.value['published'] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStudentFilter = () => {
|
||||
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
|
||||
filters.value['published'] = 1
|
||||
}
|
||||
}
|
||||
|
||||
const setQueryParams = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
let filterKeys = {
|
||||
title: title.value,
|
||||
category: currentCategory.value,
|
||||
certification: certification.value,
|
||||
}
|
||||
|
||||
Object.keys(filterKeys).forEach((key) => {
|
||||
if (filterKeys[key]) {
|
||||
queries.set(key, filterKeys[key])
|
||||
} else {
|
||||
queries.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
let queryString = ''
|
||||
if (queries.toString()) {
|
||||
queryString = `?${queries.toString()}`
|
||||
}
|
||||
|
||||
history.replaceState({}, '', `${location.pathname}${queryString}`)
|
||||
}
|
||||
|
||||
const updateCategories = (data) => {
|
||||
data.forEach((course) => {
|
||||
if (
|
||||
course.category &&
|
||||
!categories.value.find((category) => category.value === course.category)
|
||||
)
|
||||
categories.value.push({
|
||||
label: course.category,
|
||||
value: course.category,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(currentTab, () => {
|
||||
updateCourses()
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
let tabs
|
||||
|
||||
const makeTabs = computed(() => {
|
||||
tabs = []
|
||||
addToTabs('Live')
|
||||
addToTabs('New')
|
||||
addToTabs('Upcoming')
|
||||
|
||||
if (user.data) {
|
||||
addToTabs('Enrolled')
|
||||
|
||||
if (
|
||||
user.data.is_moderator ||
|
||||
user.data.is_instructor ||
|
||||
courses.data?.created?.length
|
||||
) {
|
||||
addToTabs('Created')
|
||||
}
|
||||
|
||||
if (user.data.is_moderator) {
|
||||
addToTabs('Under Review')
|
||||
}
|
||||
const courseTabs = computed(() => {
|
||||
let tabs = [
|
||||
{
|
||||
label: __('Live'),
|
||||
},
|
||||
{
|
||||
label: __('New'),
|
||||
},
|
||||
{
|
||||
label: __('Upcoming'),
|
||||
},
|
||||
]
|
||||
if (
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
) {
|
||||
tabs.push({ label: __('Created') })
|
||||
tabs.push({ label: __('Unpublished') })
|
||||
} else if (user.data) {
|
||||
tabs.push({ label: __('Enrolled') })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
const addToTabs = (label) => {
|
||||
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
|
||||
tabs.push({
|
||||
label,
|
||||
courses: computed(() => courses),
|
||||
count: computed(() => courses.length),
|
||||
})
|
||||
}
|
||||
|
||||
const getCourses = (type) => {
|
||||
let courseList = courses.data[type]
|
||||
if (searchQuery.value) {
|
||||
let query = searchQuery.value.toLowerCase()
|
||||
courseList = courseList.filter(
|
||||
(course) =>
|
||||
course.title.toLowerCase().includes(query) ||
|
||||
course.short_introduction.toLowerCase().includes(query) ||
|
||||
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
||||
)
|
||||
}
|
||||
if (currentCategory.value && currentCategory.value != '') {
|
||||
courseList = courseList.filter(
|
||||
(course) => course.category == currentCategory.value
|
||||
)
|
||||
}
|
||||
return courseList
|
||||
}
|
||||
|
||||
const categories = createResource({
|
||||
url: 'lms.lms.api.get_categories',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'LMS Course',
|
||||
filters: {
|
||||
published: 1,
|
||||
},
|
||||
}
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Courses'),
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
cache: ['courseCategories'],
|
||||
auto: true,
|
||||
transform(data) {
|
||||
data.unshift({
|
||||
label: '',
|
||||
value: null,
|
||||
})
|
||||
},
|
||||
})
|
||||
])
|
||||
|
||||
watch(courses, () => {
|
||||
if (courses.data) {
|
||||
Object.keys(courses.data).forEach((section) => {
|
||||
if (courses.data[section].length) {
|
||||
hasCourses.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentCategory.value,
|
||||
() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
if (currentCategory.value) {
|
||||
queries.set('category', currentCategory.value)
|
||||
} else {
|
||||
queries.delete('category')
|
||||
}
|
||||
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
|
||||
}
|
||||
)
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Courses',
|
||||
description: 'All Courses divided by categories',
|
||||
title: __('Courses'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-3xl py-12 mx-auto">
|
||||
<Button
|
||||
icon-left="code"
|
||||
@click="$resources.ping.fetch"
|
||||
:loading="$resources.ping.loading"
|
||||
>
|
||||
Click to send 'ping' request
|
||||
</Button>
|
||||
<div>
|
||||
{{ $resources.ping.data }}
|
||||
</div>
|
||||
<pre>{{ $resources.ping }}</pre>
|
||||
|
||||
<Button @click="showDialog = true">Open Dialog</Button>
|
||||
<Dialog title="Title" v-model="showDialog"> Dialog content </Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from 'frappe-ui'
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
data() {
|
||||
return {
|
||||
showDialog: false,
|
||||
}
|
||||
},
|
||||
resources: {
|
||||
ping: {
|
||||
url: 'ping',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Dialog,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
263
frontend/src/pages/Home/AdminHome.vue
Normal file
263
frontend/src/pages/Home/AdminHome.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="createdCourses.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{ __('Courses Created') }}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<router-link
|
||||
v-for="course in createdCourses.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||
>
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="createdBatches.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{ __('Upcoming Batches') }}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<router-link
|
||||
v-for="batch in createdBatches.data"
|
||||
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
|
||||
>
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!createdCourses.data?.length && !createdBatches.data?.length"
|
||||
class="flex flex-col items-center justify-center mt-60"
|
||||
>
|
||||
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
|
||||
<div class="text-lg font-semibold text-ink-gray-7 mb-1.5">
|
||||
{{ __('No courses created') }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'There are no courses currently. Create your first course to get started!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: 'CourseForm', params: { courseName: 'new' } }"
|
||||
class="mt-4"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create Course') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5 mt-10">
|
||||
<div v-if="evals?.data?.length">
|
||||
<div class="font-semibold text-lg mb-3">
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div
|
||||
v-for="evaluation in evals?.data"
|
||||
class="border rounded-md p-3 flex flex-col h-full cursor-pointer"
|
||||
@click="redirectToProfile()"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ evaluation.course_title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm">
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ dayjs(evaluation.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ formatTime(evaluation.start_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<GraduationCap class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ evaluation.member_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="liveClasses?.data?.length">
|
||||
<div class="font-semibold text-lg mb-3">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div v-for="cls in liveClasses?.data" class="border rounded-md p-3">
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
GraduationCap,
|
||||
Info,
|
||||
Monitor,
|
||||
MoveRight,
|
||||
Plus,
|
||||
Video,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
liveClasses?: { data?: any[] }
|
||||
evals?: { data?: any[] }
|
||||
}>()
|
||||
|
||||
const createdCourses = createResource({
|
||||
url: 'lms.lms.utils.get_created_courses',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const createdBatches = createResource({
|
||||
url: 'lms.lms.utils.get_created_batches',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getClassEnd = (cls: { date: string; time: string; duration: number }) => {
|
||||
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const canAccessClass = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const redirectToProfile = () => {
|
||||
router.push({
|
||||
name: 'ProfileEvaluationSchedule',
|
||||
params: { username: user.data?.username },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
157
frontend/src/pages/Home/Home.vue
Normal file
157
frontend/src/pages/Home/Home.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<!-- <header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="[{ label: __('Home'), route: { name: 'Home' } }]" />
|
||||
</header> -->
|
||||
<div class="w-full px-5 pt-10 pb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="text-xl font-bold">
|
||||
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
|
||||
</div>
|
||||
<div class="text-lg text-ink-gray-6">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<TabButtons v-if="isAdmin" v-model="currentTab" :buttons="tabs" />
|
||||
<div
|
||||
v-else
|
||||
@click="showStreakModal = true"
|
||||
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
<span> 🔥 </span>
|
||||
<span>
|
||||
{{ streakInfo.data?.current_streak }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminHome
|
||||
v-if="isAdmin && currentTab === 'instructor'"
|
||||
:liveClasses="adminLiveClasses"
|
||||
:evals="adminEvals"
|
||||
/>
|
||||
<StudentHome v-else :myLiveClasses="myLiveClasses" />
|
||||
</div>
|
||||
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import StudentHome from '@/pages/Home/StudentHome.vue'
|
||||
import AdminHome from '@/pages/Home/AdminHome.vue'
|
||||
import Streak from '@/pages/Home/Streak.vue'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const { brand } = sessionStore()
|
||||
const evalCount = ref(0)
|
||||
const currentTab = ref<'student' | 'instructor'>('instructor')
|
||||
const showStreakModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
call('lms.lms.utils.get_upcoming_evals').then((data: any) => {
|
||||
evalCount.value = data.length
|
||||
})
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return (
|
||||
user.data?.is_moderator ||
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
)
|
||||
})
|
||||
|
||||
const myLiveClasses = createResource({
|
||||
url: 'lms.lms.utils.get_my_live_classes',
|
||||
auto: !isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const adminLiveClasses = createResource({
|
||||
url: 'lms.lms.utils.get_admin_live_classes',
|
||||
auto: isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const adminEvals = createResource({
|
||||
url: 'lms.lms.utils.get_admin_evals',
|
||||
auto: isAdmin.value ? true : false,
|
||||
})
|
||||
|
||||
const streakInfo = createResource({
|
||||
url: 'lms.lms.utils.get_streak_info',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (isAdmin.value) {
|
||||
let liveClassSuffix =
|
||||
adminLiveClasses.data.length > 1 ? __('live classes') : __('live class')
|
||||
let evalSuffix =
|
||||
adminEvals.data.length > 1 ? __('evaluations') : __('evaluation')
|
||||
if (adminLiveClasses.data?.length > 0 && adminEvals.data?.length > 0) {
|
||||
return __('You have {0} upcoming {1} and {2} {3} scheduled.').format(
|
||||
adminLiveClasses.data.length,
|
||||
liveClassSuffix,
|
||||
adminEvals.data.length,
|
||||
evalSuffix
|
||||
)
|
||||
} else if (adminLiveClasses.data?.length > 0) {
|
||||
return __('You have {0} upcoming {1}.').format(
|
||||
adminLiveClasses.data.length,
|
||||
liveClassSuffix
|
||||
)
|
||||
} else if (adminEvals.data?.length > 0) {
|
||||
return __('You have {0} {1} scheduled.').format(
|
||||
adminEvals.data.length,
|
||||
evalSuffix
|
||||
)
|
||||
}
|
||||
return __('Manage your courses and batches at a glance')
|
||||
} else {
|
||||
let liveClassSuffix =
|
||||
myLiveClasses.data.length > 1 ? __('live classes') : __('live class')
|
||||
let evalSuffix = evalCount.value > 1 ? __('evaluations') : __('evaluation')
|
||||
if (myLiveClasses.data?.length > 0 && evalCount.value > 0) {
|
||||
return __('You have {0} upcoming {1} and {2} {3} scheduled.').format(
|
||||
myLiveClasses.data.length,
|
||||
liveClassSuffix,
|
||||
evalCount.value,
|
||||
evalSuffix
|
||||
)
|
||||
} else if (myLiveClasses.data?.length > 0) {
|
||||
return __('You have {0} upcoming {1}.').format(
|
||||
myLiveClasses.data.length,
|
||||
liveClassSuffix
|
||||
)
|
||||
} else if (evalCount.value > 0) {
|
||||
return __('You have {0} {1} scheduled.').format(
|
||||
evalCount.value,
|
||||
evalSuffix
|
||||
)
|
||||
}
|
||||
return __('Resume where you left off')
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ label: __('Student'), value: 'student' },
|
||||
{ label: __('Instructor'), value: 'instructor' },
|
||||
]
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Home'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
78
frontend/src/pages/Home/Streak.vue
Normal file
78
frontend/src/pages/Home/Streak.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Learning Consistency'),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="text-center">
|
||||
<div class="text-[30px]">🔥</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-ink-gray-5 mb-1">
|
||||
{{
|
||||
streakInfo.data?.current_streak < 1
|
||||
? __('You can do better,')
|
||||
: streakInfo.data?.current_streak < 10
|
||||
? __('Keep going,')
|
||||
: __('You rock,')
|
||||
}}
|
||||
{{ __(' you are on a') }}
|
||||
</div>
|
||||
<div class="font-semibold text-xl">
|
||||
{{ streakInfo.data?.current_streak }} {{ __('day streak') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 bg-surface-gray-1 px-2.5 py-2 rounded-md mt-8"
|
||||
>
|
||||
<div class="space-y-1 border-r border-outline-gray-2 mr-4">
|
||||
<div class="text-ink-gray-6">
|
||||
{{ __('Current Streak') }}
|
||||
</div>
|
||||
<div class="font-semibold text-lg">
|
||||
{{ streakInfo.data?.current_streak }} {{ __('days') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-ink-gray-6">
|
||||
{{ __('Longest Streak') }}
|
||||
</div>
|
||||
<div class="font-semibold text-lg">
|
||||
{{ streakInfo.data?.longest_streak }} {{ __('days') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-ink-gray-7 border border-outline-gray-1 px-2.5 py-2 rounded-md text-xs leading-5 mt-5"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
'Your learning streak counts the number of days in a row you’ve kept up your learning, whether it’s a lesson, quiz, or assignment. Don’t worry, weekends don’t break your streak.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog } from 'frappe-ui'
|
||||
|
||||
const show = defineModel<boolean>({
|
||||
default: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
streakInfo: {
|
||||
data: {
|
||||
current_streak: number
|
||||
longest_streak: number
|
||||
}
|
||||
}
|
||||
}>()
|
||||
</script>
|
||||
195
frontend/src/pages/Home/StudentHome.vue
Normal file
195
frontend/src/pages/Home/StudentHome.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="myCourses.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{
|
||||
myCourses.data[0].membership
|
||||
? __('My Courses')
|
||||
: __('Our Popular Courses')
|
||||
}}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x-1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<router-link
|
||||
v-for="course in myCourses.data"
|
||||
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||
>
|
||||
<CourseCard :course="course" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="myBatches.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
{{
|
||||
myBatches.data?.[0].students.includes(user.data?.name)
|
||||
? __('My Batches')
|
||||
: __('Our Upcoming Batches')
|
||||
}}
|
||||
</span>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
}"
|
||||
>
|
||||
<span class="flex items-center space-x- 1 text-ink-gray-5 text-xs">
|
||||
<span>
|
||||
{{ __('See all') }}
|
||||
</span>
|
||||
<MoveRight class="size-3 stroke-1.5" />
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<router-link
|
||||
v-for="batch in myBatches.data"
|
||||
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
|
||||
>
|
||||
<BatchCard :batch="batch" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5 mt-10">
|
||||
<UpcomingEvaluations :forHome="true" />
|
||||
<div v-if="myLiveClasses.data?.length">
|
||||
<div class="font-semibold text-lg mb-3">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div v-for="cls in myLiveClasses.data" class="border rounded-md p-2">
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { createResource, Tooltip } from 'frappe-ui'
|
||||
import { formatTime } from '@/utils'
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Info,
|
||||
Monitor,
|
||||
MoveRight,
|
||||
Video,
|
||||
} from 'lucide-vue-next'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
const user = inject<any>('$user')
|
||||
|
||||
const props = defineProps<{
|
||||
myLiveClasses: any
|
||||
}>()
|
||||
|
||||
const myCourses = createResource({
|
||||
url: 'lms.lms.utils.get_my_courses',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const myBatches = createResource({
|
||||
url: 'lms.lms.utils.get_my_batches',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getClassEnd = (cls: { date: string; time: string; duration: number }) => {
|
||||
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const canAccessClass = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls: {
|
||||
date: string
|
||||
time: string
|
||||
duration: number
|
||||
}) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
</script>
|
||||
@@ -16,21 +16,30 @@
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="user.data?.name" class="flex">
|
||||
<div
|
||||
v-if="user.data?.name && !readOnlyMode"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<router-link
|
||||
v-if="user.data.name == job.data?.owner"
|
||||
:to="{
|
||||
name: 'JobCreation',
|
||||
name: 'JobForm',
|
||||
params: { jobName: job.data?.name },
|
||||
}"
|
||||
>
|
||||
<Button class="mr-2">
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<Pencil class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button @click="redirectToWebsite(job.data?.company_website)">
|
||||
<template #prefix>
|
||||
<SquareArrowOutUpRight class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Visit Website') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!jobApplication.data?.length"
|
||||
variant="solid"
|
||||
@@ -41,8 +50,14 @@
|
||||
</template>
|
||||
{{ __('Apply') }}
|
||||
</Button>
|
||||
<Badge v-else variant="subtle" theme="green" size="lg">
|
||||
<template #prefix>
|
||||
<Check class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('You have applied') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else-if="!readOnlyMode">
|
||||
<Button @click="redirectToLogin(job.data?.name)">
|
||||
<span>
|
||||
{{ __('Login to apply') }}
|
||||
@@ -50,87 +65,63 @@
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="job.data" class="max-w-3xl mx-auto">
|
||||
<div v-if="job.data" class="max-w-3xl mx-auto pt-5">
|
||||
<div class="p-4">
|
||||
<div class="space-y-5 mb-10">
|
||||
<div class="flex items-center">
|
||||
<div class="space-y-5 mb-12">
|
||||
<div class="flex">
|
||||
<img
|
||||
:src="job.data.company_logo"
|
||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||
class="size-10 rounded-lg object-contain cursor-pointer mr-4"
|
||||
:alt="job.data.company_name"
|
||||
@click="redirectToWebsite(job.data.company_website)"
|
||||
/>
|
||||
<div class="text-2xl text-ink-gray-9 font-semibold mb-4">
|
||||
{{ job.data.job_title }}
|
||||
<div class="">
|
||||
<div class="text-2xl text-ink-gray-9 font-semibold mb-1">
|
||||
{{ job.data.job_title }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5 font-semibold">
|
||||
{{ job.data.company_name }} - {{ job.data.location }},
|
||||
{{ job.data.country }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<Building2 class="h-4 w-4 text-ink-green-2" />
|
||||
<div class="flex flex-col space-y-2 text-ink-gray-7">
|
||||
<span class="text-xs text-ink-gray-5 font-medium uppercase">
|
||||
{{ __('Organisation') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.company_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<MapPin class="size-4 text-ink-red-3" />
|
||||
<div class="flex flex-col space-y-2 text-ink-gray-7">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
{{ __('Location') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<ClipboardType class="h-4 w-4 text-yellow-500" />
|
||||
<div class="flex flex-col space-y-2 text-ink-gray-7">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
{{ __('Category') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ job.data.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<CalendarDays class="h-4 w-4 text-ink-blue-2" />
|
||||
<div class="flex flex-col space-y-2 text-ink-gray-7">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
{{ __('Posted on') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="applicationCount.data"
|
||||
class="flex items-center space-x-4"
|
||||
>
|
||||
<SquareUserRound class="h-4 w-4 text-purple-500" />
|
||||
<div class="flex flex-col space-y-2 text-ink-gray-7">
|
||||
<span class="text-xs font-medium uppercase">
|
||||
{{ __('Applications Received') }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold">
|
||||
{{ applicationCount.data }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-x-5">
|
||||
<Badge size="lg">
|
||||
<template #prefix>
|
||||
<CalendarDays class="size-3 stroke-2 text-ink-gray-7" />
|
||||
</template>
|
||||
{{ dayjs(job.data.creation).fromNow() }}
|
||||
</Badge>
|
||||
<Badge size="lg">
|
||||
<template #prefix>
|
||||
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
|
||||
</template>
|
||||
{{ job.data.type }}
|
||||
</Badge>
|
||||
<Badge v-if="applicationCount.data" size="lg">
|
||||
<template #prefix>
|
||||
<SquareUserRound class="size-3 stroke-2 text-ink-gray-7" />
|
||||
</template>
|
||||
{{ applicationCount.data }}
|
||||
{{
|
||||
applicationCount.data == 1 ? __('applicant') : __('applicants')
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="bg-surface-gray-2 h-px m-1 w-1/2"></div>
|
||||
<div>
|
||||
<FileText class="size-3 stroke-1 text-ink-gray-5" />
|
||||
</div>
|
||||
<div class="bg-surface-gray-2 h-px m-1 w-1/2"></div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-html="job.data.description"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-6"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-12"
|
||||
></p>
|
||||
</div>
|
||||
<JobApplicationModal
|
||||
@@ -142,23 +133,32 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { inject, ref } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||
import {
|
||||
MapPin,
|
||||
Check,
|
||||
SendHorizonal,
|
||||
Pencil,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ClipboardType,
|
||||
SquareUserRound,
|
||||
SquareArrowOutUpRight,
|
||||
FileText,
|
||||
ClipboardType,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
const showApplicationModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
job: {
|
||||
@@ -215,12 +215,23 @@ const redirectToLogin = (job) => {
|
||||
window.location.href = `/login?redirect-to=/job-openings/${job}`
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
const redirectToWebsite = (url) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: job.data?.job_title,
|
||||
description: job.data?.description,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
p {
|
||||
margin-bottom: 0.5rem !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
p span {
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,34 +9,39 @@
|
||||
</Button>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="container border-b mb-4 pb-4">
|
||||
<div class="container border-b mb-4 pb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Job Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="job.job_title"
|
||||
:label="__('Title')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="job.location"
|
||||
:label="__('Location')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="job.type"
|
||||
:label="__('Type')"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="job.location"
|
||||
:label="__('City')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="job.country"
|
||||
doctype="Country"
|
||||
:label="__('Country')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="jobName != 'new'"
|
||||
v-model="job.status"
|
||||
:label="__('Status')"
|
||||
type="select"
|
||||
@@ -45,25 +50,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="block text-ink-gray-5 text-xs mb-1">
|
||||
{{ __('Description') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="job.description"
|
||||
@change="(val) => (job.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mb-4 pb-4">
|
||||
<div class="container border-b mb-4 pb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Company Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="job.company_name"
|
||||
@@ -128,6 +120,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mt-4">
|
||||
<label class="block text-ink-gray-5 text-xs mb-1">
|
||||
{{ __('Description') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="job.description"
|
||||
@change="(val) => (job.description = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -139,14 +144,18 @@ import {
|
||||
Button,
|
||||
TextEditor,
|
||||
FileUploader,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, reactive, inject } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize, showToast } from '../utils'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
jobName: {
|
||||
@@ -214,6 +223,7 @@ const imageResource = createResource({
|
||||
const job = reactive({
|
||||
job_title: '',
|
||||
location: '',
|
||||
country: '',
|
||||
type: 'Full Time',
|
||||
status: 'Open',
|
||||
company_name: '',
|
||||
@@ -250,7 +260,7 @@ const createNewJob = () => {
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -269,7 +279,7 @@ const editJobDetails = () => {
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -283,13 +293,6 @@ const removeImage = () => {
|
||||
job.image = null
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
{ label: 'Full Time', value: 'Full Time' },
|
||||
@@ -314,9 +317,16 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
{
|
||||
label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
|
||||
route: { name: 'JobCreation' },
|
||||
route: { name: 'JobForm' },
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.jobName == 'new' ? 'New Job' : jobDetail.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -10,13 +10,13 @@
|
||||
<router-link
|
||||
v-if="user.data?.name"
|
||||
:to="{
|
||||
name: 'JobCreation',
|
||||
name: 'JobForm',
|
||||
params: {
|
||||
jobName: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<Button v-if="!readOnlyMode" variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
@@ -25,43 +25,50 @@
|
||||
</router-link>
|
||||
</header>
|
||||
<div>
|
||||
<div class="lg:w-3/4 mx-auto p-5">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||
>
|
||||
<div class="text-xl text-ink-gray-9 font-semibold">
|
||||
{{ __('Find the perfect job for you') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
type="text"
|
||||
:placeholder="__('Search')"
|
||||
v-model="searchQuery"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
@input="updateJobs"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search
|
||||
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
|
||||
name="search"
|
||||
/>
|
||||
</template>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
v-model="jobType"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
:placeholder="__('Type')"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
||||
>
|
||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||
{{ __('{0} Open Jobs').format(jobCount) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="jobs.data?.length"
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-5"
|
||||
class="grid grid-cols-1 gap-2"
|
||||
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
|
||||
>
|
||||
<FormControl
|
||||
type="text"
|
||||
:placeholder="__('Search')"
|
||||
v-model="searchQuery"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
@input="updateJobs"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search
|
||||
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
|
||||
name="search"
|
||||
/>
|
||||
</template>
|
||||
</FormControl>
|
||||
<Link
|
||||
v-if="user.data"
|
||||
doctype="Country"
|
||||
v-model="country"
|
||||
:placeholder="__('Country')"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="jobType"
|
||||
type="select"
|
||||
:options="jobTypes"
|
||||
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||
:placeholder="__('Type')"
|
||||
@change="updateJobs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<router-link
|
||||
v-for="job in jobs.data"
|
||||
:to="{
|
||||
@@ -73,25 +80,36 @@
|
||||
<JobCard :job="job" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 italic p-5 w-fit mx-auto">
|
||||
{{ __('No jobs posted') }}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Job Openings" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Breadcrumbs, createResource, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
FormControl,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { inject, computed, ref, onMounted } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { inject, computed, ref, onMounted, watch } from 'vue'
|
||||
import JobCard from '@/components/JobCard.vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const jobType = ref(null)
|
||||
const { brand } = sessionStore()
|
||||
const searchQuery = ref('')
|
||||
const country = ref(null)
|
||||
const filters = ref({})
|
||||
const orFilters = ref({})
|
||||
const jobCount = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
@@ -136,8 +154,22 @@ const updateFilters = () => {
|
||||
} else {
|
||||
orFilters.value = {}
|
||||
}
|
||||
|
||||
if (country.value) {
|
||||
filters.value.country = country.value
|
||||
} else {
|
||||
delete filters.value.country
|
||||
}
|
||||
}
|
||||
|
||||
watch(country, (val) => {
|
||||
updateJobs()
|
||||
})
|
||||
|
||||
watch(jobs, () => {
|
||||
jobCount.value = jobs.data?.length || 0
|
||||
})
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
'',
|
||||
@@ -147,12 +179,11 @@ const jobTypes = computed(() => {
|
||||
{ label: __('Freelance'), value: 'Freelance' },
|
||||
]
|
||||
})
|
||||
const pageMeta = computed(() => {
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Jobs',
|
||||
description: 'An open job board for the community',
|
||||
title: __('Jobs'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,7 +78,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createResource,
|
||||
FormControl,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
@@ -87,18 +94,20 @@ import {
|
||||
ref,
|
||||
onBeforeUnmount,
|
||||
} from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonHelp from '@/components/LessonHelp.vue'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle, createToast, getEditorTools } from '@/utils'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { getEditorTools, enablePlyr } from '@/utils'
|
||||
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const editor = ref(null)
|
||||
const instructorEditor = ref(null)
|
||||
const user = inject('$user')
|
||||
const openInstructorEditor = ref(false)
|
||||
const settingsStore = useSettings()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
let autoSaveInterval
|
||||
let showSuccessMessage = false
|
||||
|
||||
@@ -122,9 +131,11 @@ onMounted(() => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
capture('lesson_form_opened')
|
||||
startRecording()
|
||||
editor.value = renderEditor('content')
|
||||
instructorEditor.value = renderEditor('instructor-notes')
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
enablePlyr()
|
||||
})
|
||||
|
||||
const renderEditor = (holder) => {
|
||||
@@ -133,6 +144,9 @@ const renderEditor = (holder) => {
|
||||
tools: getEditorTools(true),
|
||||
autofocus: true,
|
||||
defaultBlock: 'markdown',
|
||||
onChange: async (api, event) => {
|
||||
enablePlyr()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +227,7 @@ const keyboardShortcut = (e) => {
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(autoSaveInterval)
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
stopRecording()
|
||||
})
|
||||
|
||||
const newLessonResource = createResource({
|
||||
@@ -370,8 +385,10 @@ const saveLesson = (e) => {
|
||||
showSuccessMessage = true
|
||||
}
|
||||
editor.value.save().then((outputData) => {
|
||||
outputData = removeEmptyBlocks(outputData)
|
||||
lesson.content = JSON.stringify(outputData)
|
||||
instructorEditor.value.save().then((outputData) => {
|
||||
outputData = removeEmptyBlocks(outputData)
|
||||
lesson.instructor_content = JSON.stringify(outputData)
|
||||
if (lessonDetails.data?.lesson) {
|
||||
editCurrentLesson()
|
||||
@@ -382,6 +399,14 @@ const saveLesson = (e) => {
|
||||
})
|
||||
}
|
||||
|
||||
const removeEmptyBlocks = (outputData) => {
|
||||
let blocks = outputData.blocks.filter((block) => {
|
||||
return Object.keys(block.data).length > 0 || block.type == 'paragraph'
|
||||
})
|
||||
outputData.blocks = blocks
|
||||
return outputData
|
||||
}
|
||||
|
||||
const createNewLesson = () => {
|
||||
newLessonResource.submit(
|
||||
{},
|
||||
@@ -394,18 +419,18 @@ const createNewLesson = () => {
|
||||
{ lesson: data.name },
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('create_first_lesson')
|
||||
|
||||
capture('lesson_created')
|
||||
showToast('Success', 'Lesson created successfully', 'check')
|
||||
/* if (!settingsStore.onboardingDetails.data?.is_onboarded) {
|
||||
settingsStore.onboardingDetails.reload()
|
||||
} */
|
||||
toast.success(__('Lesson created successfully'))
|
||||
lessonDetails.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -422,11 +447,11 @@ const editCurrentLesson = () => {
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessMessage
|
||||
? showToast('Success', 'Lesson updated successfully', 'check')
|
||||
? toast.success(__('Lesson updated successfully'))
|
||||
: ''
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.message, 'x')
|
||||
toast.error(err.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -441,20 +466,6 @@ const validateLesson = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (title, text, icon) => {
|
||||
createToast({
|
||||
title: title,
|
||||
text: text,
|
||||
icon: icon,
|
||||
iconClasses:
|
||||
icon == 'check'
|
||||
? 'bg-surface-green-3 text-ink-white rounded-md p-px'
|
||||
: 'bg-surface-red-5 text-ink-white rounded-md p-px',
|
||||
position: icon == 'check' ? 'bottom-right' : 'top-center',
|
||||
timeout: icon == 'check' ? 5 : 10,
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
@@ -494,14 +505,14 @@ const breadcrumbs = computed(() => {
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Lesson Editor',
|
||||
description: 'Create and edit lessons for your course',
|
||||
title: lessonDetails?.data?.lesson
|
||||
? lessonDetails.data.lesson.title
|
||||
: 'New Lesson',
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.embed-tool__caption,
|
||||
@@ -616,11 +627,95 @@ updateDocumentTitle(pageMeta)
|
||||
}
|
||||
|
||||
iframe {
|
||||
border-top: 3px solid theme('colors.gray.700');
|
||||
border-bottom: 3px solid theme('colors.gray.700');
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.tc-table {
|
||||
border-left: 1px solid #e8e8eb;
|
||||
}
|
||||
|
||||
.ce-toolbox__button[data-tool='markdown'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ce-popover-item[data-item-name='markdown'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.plyr__volume input[type='range'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(0, 0, 0, 0.4) 0%,
|
||||
rgba(0, 0, 0, 0.5) 50%
|
||||
);
|
||||
}
|
||||
|
||||
.plyr__control:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.plyr--video {
|
||||
border: 1px solid theme('colors.gray.200');
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ce-popover__container {
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.cdx-search-field {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cdx-search-field__input {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cdx-search-field__input::before {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cdx-search-field__input:focus {
|
||||
--tw-ring-color: theme('colors.gray.100');
|
||||
}
|
||||
|
||||
.ce-popover-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ce-popover-item__icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.ce-popover--opened > .ce-popover__container {
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.cdx-search-field__icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.cdx-search-field__icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.cdx-block.embed-tool {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:root {
|
||||
--plyr-range-fill-background: white;
|
||||
--plyr-video-control-background-hover: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
|
||||
<div class="w-full md:w-3/4 mx-auto px-5 pt-6 divide-y">
|
||||
<div
|
||||
v-if="notifications?.length"
|
||||
v-for="log in notifications"
|
||||
:key="log.name"
|
||||
class="flex items-center py-2 justify-between"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
@@ -32,22 +33,20 @@
|
||||
<Link
|
||||
v-if="log.link"
|
||||
:to="log.link"
|
||||
@click="markAsRead.submit({ name: log.name })"
|
||||
@click="(e) => handleMarkAsRead(e, log.name)"
|
||||
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
||||
>
|
||||
{{ __('View') }}
|
||||
</Link>
|
||||
<Tooltip :text="__('Mark as read')">
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@click="markAsRead.submit({ name: log.name })"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@click.stop="(e) => handleMarkAsRead(e, log.name)"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-5">
|
||||
@@ -64,13 +63,14 @@ import {
|
||||
Link,
|
||||
TabButtons,
|
||||
Button,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, ref, onMounted } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const socket = inject('$socket')
|
||||
const activeTab = ref('Unread')
|
||||
@@ -133,6 +133,14 @@ const markAllAsRead = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const handleMarkAsRead = (e, logName) => {
|
||||
markAsRead.submit({ name: logName })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('publish_lms_notifications')
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
@@ -145,14 +153,12 @@ const breadcrumbs = computed(() => {
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Notifications',
|
||||
description: 'All your notifications in one place.',
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
<style>
|
||||
.notification strong {
|
||||
|
||||
154
frontend/src/pages/PersonaForm.vue
Normal file
154
frontend/src/pages/PersonaForm.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden sm:bg-gray-50">
|
||||
<div class="relative h-full z-10 mx-auto sm:w-max pt-40">
|
||||
<div class="mx-auto flex items-center justify-center space-x-2">
|
||||
<LMSLogo class="size-7" />
|
||||
<span
|
||||
class="select-none text-xl font-semibold tracking-tight text-gray-900"
|
||||
>
|
||||
Learning
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx-auto w-full h-fit bg-white py-8 sm:mt-6 sm:w-96 sm:rounded-lg sm:px-8 sm:shadow-xl"
|
||||
>
|
||||
<div class="font-medium text-center mb-8">
|
||||
{{ __('Help us understand your needs') }}
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<div class="text-sm text-gray-700 mb-2">
|
||||
{{ __('What is your use case for Frappe Learning?') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="persona.useCase"
|
||||
type="select"
|
||||
:options="useCaseOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<div class="text-sm text-gray-700 mb-2">
|
||||
{{ __('What best describes your role?') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="persona.role"
|
||||
type="select"
|
||||
:options="roleOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<Button variant="solid" class="mx-auto" @click="submitPersona()">
|
||||
{{ __('Submit and Continue') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-center absolute bottom-0 right-0 left-0 mx-auto cursor-pointer text-sm pb-4"
|
||||
@click="skipPersonaForm()"
|
||||
>
|
||||
{{ __('Skip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
import { Button, call, FormControl, usePageMeta } from 'frappe-ui'
|
||||
import { computed, inject, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const persona = reactive({
|
||||
role: null,
|
||||
useCase: null,
|
||||
})
|
||||
|
||||
const submitPersona = () => {
|
||||
let responses = {
|
||||
site: user.data?.sitename,
|
||||
role: persona.role,
|
||||
use_case: persona.useCase,
|
||||
}
|
||||
call('lms.lms.api.capture_user_persona', {
|
||||
responses: JSON.stringify(responses),
|
||||
}).then(() => {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const skipPersonaForm = () => {
|
||||
call('frappe.client.set_value', {
|
||||
doctype: 'LMS Settings',
|
||||
name: null,
|
||||
fieldname: 'persona_captured',
|
||||
value: 1,
|
||||
}).then(() => {
|
||||
router.push({
|
||||
name: 'Courses',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
const options = [
|
||||
'Trainer / Instructor',
|
||||
'Freelancer / Consultant',
|
||||
'HR / L&D Professional',
|
||||
'School / University Admin',
|
||||
'Software Developer',
|
||||
'Community Manager',
|
||||
'Business Owner / Team Lead',
|
||||
'Other',
|
||||
]
|
||||
|
||||
return options.map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))
|
||||
})
|
||||
|
||||
const noOfStudentsOptions = computed(() => {
|
||||
const options = [
|
||||
'Less than 50',
|
||||
'50-200',
|
||||
'200-1000',
|
||||
'1000+',
|
||||
'Not sure yet',
|
||||
]
|
||||
|
||||
return options.map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))
|
||||
})
|
||||
|
||||
const useCaseOptions = computed(() => {
|
||||
const options = [
|
||||
'Teaching students in a school/university',
|
||||
'Training employees in my company',
|
||||
'Onboarding and educating my users/community',
|
||||
'Selling courses and earning income',
|
||||
'Other',
|
||||
]
|
||||
|
||||
return options.map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: 'Persona',
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -18,14 +18,18 @@
|
||||
class="h-[130px] w-full"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-0 left-1/2 mb-4 flex -translate-x-1/2 space-x-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
|
||||
class="absolute bottom-[30%] md:bottom-0 left-[50%] mb-4 flex -translate-x-1/2 space-x-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
|
||||
v-if="isSessionUser()"
|
||||
>
|
||||
<EditCoverImage
|
||||
@select="(imageUrl) => coverImage.submit({ url: imageUrl })"
|
||||
>
|
||||
<template v-slot="{ togglePopover }">
|
||||
<Button variant="outline" @click="togglePopover()">
|
||||
<Button
|
||||
v-if="!readOnlyMode"
|
||||
variant="outline"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Edit class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
@@ -58,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isSessionUser()"
|
||||
v-if="isSessionUser() && !readOnlyMode"
|
||||
class="mt-3 sm:mt-0 md:ml-auto"
|
||||
@click="editProfile()"
|
||||
>
|
||||
@@ -76,7 +80,7 @@
|
||||
v-model="activeTab"
|
||||
/>
|
||||
</div>
|
||||
<router-view :profile="profile" />
|
||||
<router-view :profile="profile" :key="profile.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
<EditProfile
|
||||
@@ -86,23 +90,30 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, createResource, Button, TabButtons } from 'frappe-ui'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
Button,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Edit } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
import { convertToTitleCase, updateDocumentTitle } from '@/utils'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import EditProfile from '@/components/Modals/EditProfile.vue'
|
||||
import EditCoverImage from '@/components/Modals/EditCoverImage.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
const { user, brand } = sessionStore()
|
||||
const $user = inject('$user')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = ref('')
|
||||
const showProfileModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
username: {
|
||||
@@ -215,12 +226,10 @@ const breadcrumbs = computed(() => {
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: profile.data?.full_name,
|
||||
description: profile.data?.headline,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -58,6 +58,7 @@ const evaluations = createListResource({
|
||||
doctype: 'LMS Certificate Request',
|
||||
filters: {
|
||||
evaluator: user.data?.name,
|
||||
status: ['!=', 'Cancelled'],
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
@@ -76,7 +77,7 @@ const evaluations = createListResource({
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'creation desc',
|
||||
limit: 100,
|
||||
pageLength: 500,
|
||||
cache: ['schedule', user.data?.name],
|
||||
transform(data) {
|
||||
return data.map((d) => {
|
||||
|
||||
@@ -4,134 +4,150 @@
|
||||
{{ __('My availability') }}
|
||||
</h2>
|
||||
|
||||
<div class="">
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
|
||||
>
|
||||
<div>
|
||||
{{ __('Day') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Start Time') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('End Time') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="evaluator.data"
|
||||
v-for="slot in evaluator.data.slots.schedule"
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="days"
|
||||
v-model="slot.day"
|
||||
@focusout.stop="update(slot.name, 'day', slot.day)"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.start_time"
|
||||
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.end_time"
|
||||
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
|
||||
/>
|
||||
<X
|
||||
@click="deleteRow(slot.name)"
|
||||
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
|
||||
v-show="showSlotsTemplate"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="days"
|
||||
v-model="newSlot.day"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.start_time"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.end_time"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="showSlotsTemplate = 1">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
{{ __('Add Slot') }}
|
||||
</Button>
|
||||
<div
|
||||
v-if="readOnlyMode"
|
||||
class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
|
||||
>
|
||||
<CircleAlert class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{
|
||||
__(
|
||||
'You cannot change the availability when the site is being updated.'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="my-10">
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('I am unavailable') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
v-model="from"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_from',
|
||||
value: from,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('To')"
|
||||
v-model="to"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_to',
|
||||
value: to,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div v-else>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
|
||||
>
|
||||
<div>
|
||||
{{ __('Day') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Start Time') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('End Time') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="evaluator.data"
|
||||
v-for="slot in evaluator.data.slots.schedule"
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="days"
|
||||
v-model="slot.day"
|
||||
@focusout.stop="update(slot.name, 'day', slot.day)"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.start_time"
|
||||
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.end_time"
|
||||
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
|
||||
/>
|
||||
<X
|
||||
@click="deleteRow(slot.name)"
|
||||
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
|
||||
v-show="showSlotsTemplate"
|
||||
>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="days"
|
||||
v-model="newSlot.day"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.start_time"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.end_time"
|
||||
@focusout.stop="add()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="showSlotsTemplate = 1">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
{{ __('Add Slot') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('My calendar') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
|
||||
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
|
||||
>
|
||||
<Check class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
{{ __('Your calendar is set.') }}
|
||||
<div class="my-10">
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('I am unavailable') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
v-model="from"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_from',
|
||||
value: from,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
type="date"
|
||||
:label="__('To')"
|
||||
v-model="to"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
field: 'unavailable_to',
|
||||
value: to,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('My calendar') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
|
||||
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
|
||||
>
|
||||
<Check class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
{{ __('Your calendar is set.') }}
|
||||
</div>
|
||||
<Button @click="() => authorizeCalendar.submit()">
|
||||
{{ __('Authorize Google Calendar Access') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button @click="() => authorizeCalendar.submit()">
|
||||
{{ __('Authorize Google Calendar Access') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, FormControl, Button } from 'frappe-ui'
|
||||
import { createResource, FormControl, Button, Badge, toast } from 'frappe-ui'
|
||||
import { computed, reactive, ref, onMounted, inject } from 'vue'
|
||||
import { showToast, convertToTitleCase } from '@/utils'
|
||||
import { Plus, X, Check } from 'lucide-vue-next'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
@@ -182,7 +198,7 @@ const createSlot = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Slot added successfully', 'check')
|
||||
toast.success(__('Slot added successfully'))
|
||||
evaluator.reload()
|
||||
showSlotsTemplate.value = 0
|
||||
newSlot.day = ''
|
||||
@@ -190,7 +206,7 @@ const createSlot = createResource({
|
||||
newSlot.end_time = ''
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -205,10 +221,10 @@ const updateSlot = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Availability updated successfully', 'check')
|
||||
toast.success(__('Availability updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -221,11 +237,11 @@ const deleteSlot = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Slot deleted successfully', 'check')
|
||||
toast.success(__('Slot deleted successfully'))
|
||||
evaluator.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -240,10 +256,10 @@ const updateUnavailability = createResource({
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Unavailability updated successfully', 'check')
|
||||
toast.success(__('Unavailability updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
{{ __('Settings') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="readOnlyMode"
|
||||
class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
|
||||
>
|
||||
<CircleAlert class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('You cannot change the roles in read-only mode.') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
|
||||
>
|
||||
<FormControl
|
||||
@@ -34,14 +44,16 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { showToast, convertToTitleCase } from '@/utils'
|
||||
import { FormControl, createResource, toast } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { CircleAlert } from 'lucide-vue-next'
|
||||
|
||||
const moderator = ref(false)
|
||||
const course_creator = ref(false)
|
||||
const batch_evaluator = ref(false)
|
||||
const lms_student = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
@@ -54,10 +66,9 @@ const roles = createResource({
|
||||
url: 'lms.lms.utils.get_roles',
|
||||
makeParams(values) {
|
||||
return {
|
||||
name: props.profile.data?.name,
|
||||
name: values.member,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
let roles = [
|
||||
'moderator',
|
||||
@@ -71,8 +82,18 @@ const roles = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.profile,
|
||||
(newValue) => {
|
||||
roles.reload({
|
||||
member: newValue.data?.name,
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const updateRole = createResource({
|
||||
url: 'lms.overrides.user.save_role',
|
||||
url: 'lms.lms.api.save_role',
|
||||
makeParams(values) {
|
||||
return {
|
||||
user: props.profile.data?.name,
|
||||
@@ -85,12 +106,15 @@ const updateRole = createResource({
|
||||
const changeRole = (role) => {
|
||||
updateRole.submit(
|
||||
{
|
||||
role: convertToTitleCase(role.split('_').join(' ')),
|
||||
role:
|
||||
role == 'lms_student'
|
||||
? 'LMS Student'
|
||||
: convertToTitleCase(role.split('_').join(' ')),
|
||||
value: eval(role).value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast('Success', 'Role updated successfully', 'check')
|
||||
toast.success(__('Role updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadbrumbs" />
|
||||
<Button variant="solid" @click="saveProgram()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="program.doc" class="pt-5 px-5 w-3/4 mx-auto space-y-10">
|
||||
<FormControl v-model="program.doc.title" :label="__('Title')" />
|
||||
|
||||
<!-- Courses -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Program Courses') }}
|
||||
</div>
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
currentForm = 'course'
|
||||
showDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
:columns="courseColumns"
|
||||
:rows="program.doc.program_courses"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in courseColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<Draggable
|
||||
:list="program.doc.program_courses"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOrder"
|
||||
class="cursor-move"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<ListRow :row="row" />
|
||||
</template>
|
||||
</Draggable>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'program_courses')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
|
||||
<!-- Members -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Program Members') }}
|
||||
</div>
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
currentForm = 'member'
|
||||
showDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
:columns="memberColumns"
|
||||
:rows="program.doc.program_members"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in memberColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in program.doc.program_members" />
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'program_members')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title:
|
||||
currentForm == 'course'
|
||||
? __('New Program Course')
|
||||
: __('New Program Member'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick: () =>
|
||||
currentForm == 'course'
|
||||
? addProgramCourse(close)
|
||||
: addProgramMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link
|
||||
v-if="currentForm == 'course'"
|
||||
v-model="course"
|
||||
doctype="LMS Course"
|
||||
:filters="{
|
||||
disable_self_learning: 1,
|
||||
}"
|
||||
:label="__('Program Course')"
|
||||
:description="
|
||||
__(
|
||||
'Only courses for which self learning is disabled can be added to program.'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-if="currentForm == 'member'"
|
||||
v-model="member"
|
||||
doctype="User"
|
||||
:filters="{
|
||||
ignore_user_type: 1,
|
||||
}"
|
||||
:label="__('Program Member')"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createDocumentResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { showToast } from '@/utils/'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const showDialog = ref(false)
|
||||
const currentForm = ref(null)
|
||||
const course = ref(null)
|
||||
const member = ref(null)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
programName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const program = createDocumentResource({
|
||||
doctype: 'LMS Program',
|
||||
name: props.programName,
|
||||
auto: true,
|
||||
cache: ['program', props.programName],
|
||||
})
|
||||
|
||||
const addProgramCourse = () => {
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_courses: [
|
||||
...program.doc.program_courses,
|
||||
{ course: course.value },
|
||||
],
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showDialog.value = false
|
||||
course.value = null
|
||||
showToast(__('Success'), __('Course added to program'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const addProgramMember = () => {
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_members: [
|
||||
...program.doc.program_members,
|
||||
{ member: member.value },
|
||||
],
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showDialog.value = false
|
||||
member.value = null
|
||||
showToast(__('Success'), __('Member added to program'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const remove = (selections, unselectAll, doctype) => {
|
||||
selections = Array.from(selections)
|
||||
program.setValue.submit(
|
||||
{
|
||||
[doctype]: program.doc[doctype].filter(
|
||||
(row) => !selections.includes(row.name)
|
||||
),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
unselectAll()
|
||||
showToast(__('Success'), __('Items removed successfully'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateOrder = (e) => {
|
||||
let sourceIdx = e.from.dataset.idx
|
||||
let targetIdx = e.to.dataset.idx
|
||||
let courses = program.doc.program_courses
|
||||
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
|
||||
|
||||
courses.forEach((course, index) => {
|
||||
course.idx = index + 1
|
||||
})
|
||||
|
||||
program.setValue.submit(
|
||||
{
|
||||
program_courses: courses,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(__('Success'), __('Course moved successfully'), 'check')
|
||||
program.reload()
|
||||
},
|
||||
onError(err) {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const saveProgram = () => {
|
||||
call('frappe.model.rename_doc.update_document_title', {
|
||||
doctype: 'LMS Program',
|
||||
docname: program.doc.name,
|
||||
name: program.doc.title,
|
||||
}).then((data) => {
|
||||
router.push({ name: 'ProgramForm', params: { programName: data } })
|
||||
})
|
||||
}
|
||||
|
||||
const courseColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'course_title',
|
||||
width: 3,
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
key: 'course',
|
||||
width: 3,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const memberColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Member',
|
||||
key: 'member',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Progress (%)',
|
||||
key: 'progress',
|
||||
width: 3,
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadbrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Programs',
|
||||
route: { name: 'Programs' },
|
||||
},
|
||||
{
|
||||
label: props.programName === 'new' ? 'New Program' : props.programName,
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '5xl' }">
|
||||
<template #body-title>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{
|
||||
props.exerciseID === 'new'
|
||||
? __('Create Programming Exercise')
|
||||
: __('Edit Programming Exercise')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="exercise.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="exercise.language"
|
||||
:label="__('Language')"
|
||||
type="select"
|
||||
:options="languageOptions"
|
||||
:required="true"
|
||||
/>
|
||||
<ChildTable
|
||||
v-model="exercise.test_cases"
|
||||
:label="__('Test Cases')"
|
||||
:columns="testCaseColumns"
|
||||
:required="true"
|
||||
:addable="true"
|
||||
:deletable="true"
|
||||
:editable="true"
|
||||
:placeholder="__('Add Test Case')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Problem Statement') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="exercise.problem_statement"
|
||||
@change="(val: string) => (exercise.problem_statement = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[21rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="flex justify-end space-x-2 group">
|
||||
<Button
|
||||
v-if="exerciseID != 'new'"
|
||||
@click="deleteExercise(close)"
|
||||
variant="outline"
|
||||
theme="red"
|
||||
class="invisible group-hover:visible"
|
||||
>
|
||||
<template #prefix>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Delete') }}
|
||||
</Button>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: props.exerciseID,
|
||||
submissionID: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<Play class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Test this Exercise') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
query: {
|
||||
exercise: props.exerciseID,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<ClipboardList class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Check Submission') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="saveExercise(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
ProgrammingExercise,
|
||||
ProgrammingExercises,
|
||||
TestCase,
|
||||
} from '@/types/programming-exercise'
|
||||
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const show = defineModel()
|
||||
const exercises = defineModel<ProgrammingExercises>('exercises')
|
||||
|
||||
const exercise = ref<ProgrammingExercise>({
|
||||
title: '',
|
||||
language: 'Python',
|
||||
problem_statement: '',
|
||||
test_cases: [],
|
||||
})
|
||||
|
||||
const languageOptions = [
|
||||
{ label: 'Python', value: 'Python' },
|
||||
{ label: 'JavaScript', value: 'JavaScript' },
|
||||
]
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
exerciseID: string
|
||||
}>(),
|
||||
{
|
||||
exerciseID: 'new',
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.exerciseID,
|
||||
() => {
|
||||
setExerciseData()
|
||||
fetchTestCases()
|
||||
}
|
||||
)
|
||||
|
||||
const setExerciseData = () => {
|
||||
let isNew = true
|
||||
exercises.value?.data.forEach((ex: ProgrammingExercise) => {
|
||||
if (ex.name === props.exerciseID) {
|
||||
isNew = false
|
||||
exercise.value = { ...ex }
|
||||
}
|
||||
})
|
||||
|
||||
if (isNew) {
|
||||
exercise.value = {
|
||||
title: '',
|
||||
language: 'Python',
|
||||
problem_statement: '',
|
||||
test_cases: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testCases = createListResource({
|
||||
doctype: 'LMS Test Case',
|
||||
fields: ['input', 'expected_output', 'name'],
|
||||
cache: ['testCases', props.exerciseID],
|
||||
parent: 'LMS Programming Exercise',
|
||||
onSuccess(data: TestCase[]) {
|
||||
exercise.value.test_cases = data
|
||||
},
|
||||
})
|
||||
|
||||
const fetchTestCases = () => {
|
||||
testCases.update({
|
||||
filters: {
|
||||
parent: props.exerciseID,
|
||||
parenttype: 'LMS Programming Exercise',
|
||||
parentfield: 'test_cases',
|
||||
},
|
||||
})
|
||||
testCases.reload()
|
||||
}
|
||||
|
||||
const saveExercise = (close: () => void) => {
|
||||
if (props.exerciseID == 'new') createNewExercise(close)
|
||||
else updateExercise(close)
|
||||
}
|
||||
|
||||
const createNewExercise = (close: () => void) => {
|
||||
exercises.value?.insert.submit(
|
||||
{
|
||||
...exercise.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
exercises.value?.reload()
|
||||
toast.success(__('Programming Exercise created successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateExercise = (close: () => void) => {
|
||||
exercises.value?.setValue.submit(
|
||||
{
|
||||
name: props.exerciseID,
|
||||
...exercise.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
exercises.value?.reload()
|
||||
toast.success(__('Programming Exercise updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const testCaseColumns = computed(() => {
|
||||
return ['Input', 'Expected Output']
|
||||
})
|
||||
|
||||
const deleteExercise = (close: () => void) => {
|
||||
if (props.exerciseID == 'new') return
|
||||
exercises.value?.delete.submit(props.exerciseID, {
|
||||
onSuccess() {
|
||||
toast.success(__('Programming Exercise deleted successfully'))
|
||||
close()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a programming exercise to your lesson'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: () => {
|
||||
saveExercise()
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<Link
|
||||
v-model="exercise"
|
||||
doctype="LMS Programming Exercise"
|
||||
:label="__('Select a Programming Exercise')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog } from 'frappe-ui'
|
||||
import { onMounted, nextTick, ref } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = ref(false)
|
||||
const exercise = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
onSave: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
show.value = true
|
||||
})
|
||||
|
||||
const saveExercise = () => {
|
||||
props.onSave(exercise.value)
|
||||
show.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<header
|
||||
v-if="!fromLesson"
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div
|
||||
v-if="falconError"
|
||||
class="flex items-center justify-between p-3 text-sm bg-surface-amber-1 text-ink-amber-3"
|
||||
>
|
||||
<span>
|
||||
{{ falconError }}
|
||||
</span>
|
||||
<Button v-if="user.data?.is_moderator" @click="openSettings('General')">
|
||||
<template #prefix>
|
||||
<Settings class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Settings') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
|
||||
<div class="border-r py-5 px-8 h-full">
|
||||
<div class="font-semibold mb-2">
|
||||
{{ __('Problem Statement') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="exercise.doc?.problem_statement"
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||
></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
|
||||
<div class="font-semibold">
|
||||
{{ exercise.doc?.language }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="submission.doc?.status"
|
||||
:theme="submission.doc.status == 'Passed' ? 'green' : 'red'"
|
||||
>
|
||||
{{ submission.doc.status }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-if="
|
||||
!falconError &&
|
||||
(submissionID == 'new' ||
|
||||
user.data?.name == submission.doc?.owner)
|
||||
"
|
||||
variant="solid"
|
||||
@click="submitCode"
|
||||
>
|
||||
<template #prefix>
|
||||
<Play class="size-3" />
|
||||
</template>
|
||||
{{ __('Run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4 pt-5 border-b">
|
||||
<Code
|
||||
v-model="code"
|
||||
:language="exercise.doc?.language.toLowerCase()"
|
||||
height="400px"
|
||||
maxHeight="1000px"
|
||||
/>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
|
||||
{{ __('Compiler Message') }}:
|
||||
</span>
|
||||
<textarea
|
||||
v-if="error"
|
||||
v-model="errorMessage"
|
||||
class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
|
||||
</div>
|
||||
|
||||
<div ref="testCaseSection" class="p-5">
|
||||
<span class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Test Cases') }}
|
||||
</span>
|
||||
<div v-if="testCases.length" class="divide-y mt-5">
|
||||
<div
|
||||
v-for="(testCase, index) in testCases"
|
||||
:key="testCase.input"
|
||||
class="py-3"
|
||||
>
|
||||
<div class="flex items-center mb-3">
|
||||
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
|
||||
<span
|
||||
class="font-semibold ml-2 mr-1"
|
||||
:class="
|
||||
testCase.status === 'Passed'
|
||||
? 'text-ink-green-3'
|
||||
: 'text-ink-red-3'
|
||||
"
|
||||
>
|
||||
{{ testCase.status }}
|
||||
</span>
|
||||
<!-- <span v-if="testCase.status === 'Passed'">
|
||||
<Check class="size-4 text-ink-green-3" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<X class="size-4 text-ink-red-3" />
|
||||
</span> -->
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-[60%]">
|
||||
<div v-if="testCase.input" class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Input') }}
|
||||
</div>
|
||||
<div>{{ testCase.input }}</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Your Output') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ testCase.output }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Expected Output') }}
|
||||
</div>
|
||||
<div>{{ testCase.expected_output }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-ink-gray-6 mt-4">
|
||||
{{ __('Please run the code to execute the test cases.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createDocumentResource,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Play, X, Check, Settings } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { openSettings } from '@/utils'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const code = ref<string | null>('')
|
||||
const output = ref<string | null>(null)
|
||||
const error = ref<boolean | null>(null)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const testCaseSection = ref<HTMLElement | null>(null)
|
||||
const testCases = ref<TestCase[]>([])
|
||||
const boilerplate = ref<string>('')
|
||||
const { brand, livecodeURL } = sessionStore()
|
||||
const router = useRouter()
|
||||
const fromLesson = ref(false)
|
||||
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||
const falconError = ref<string | null>(null)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
exerciseID: string
|
||||
submissionID?: string
|
||||
}>(),
|
||||
{
|
||||
submissionID: 'new',
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadFalcon()
|
||||
checkIfUserIsPermitted()
|
||||
checkIfInLesson()
|
||||
fetchSubmission()
|
||||
})
|
||||
|
||||
const checkIfInLesson = () => {
|
||||
if (new URLSearchParams(window.location.search).get('fromLesson')) {
|
||||
fromLesson.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSubmission = (name: string = '') => {
|
||||
if (name) {
|
||||
submission.name = name
|
||||
submission.reload()
|
||||
} else if (props.submissionID != 'new') {
|
||||
submission.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const exercise = createDocumentResource({
|
||||
doctype: 'LMS Programming Exercise',
|
||||
name: props.exerciseID,
|
||||
cache: ['programmingExercise', props.exerciseID],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const submission = createDocumentResource({
|
||||
doctype: 'LMS Programming Exercise Submission',
|
||||
name: props.submissionID,
|
||||
auto: false,
|
||||
onError(error: any) {
|
||||
if (error.messages?.[0].includes('not found')) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||
})
|
||||
} else {
|
||||
toast.error(__(error.messages?.[0] || error))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(exercise, () => {
|
||||
updateCode()
|
||||
})
|
||||
|
||||
const updateCode = (submissionCode = '') => {
|
||||
updateBoilerPlate()
|
||||
if (!code.value?.includes(boilerplate.value)) {
|
||||
code.value = `${boilerplate.value}${code.value}`
|
||||
}
|
||||
if (submissionCode && !code.value?.includes(submissionCode)) {
|
||||
code.value = `${code.value}${submissionCode}`
|
||||
} else if (!submissionCode && !code.value) {
|
||||
code.value = boilerplate.value
|
||||
}
|
||||
}
|
||||
|
||||
const updateBoilerPlate = () => {
|
||||
if (exercise.doc?.language == 'Python') {
|
||||
boilerplate.value = `with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
|
||||
} else if (exercise.doc?.language == 'JavaScript') {
|
||||
boilerplate.value = `const fs = require('fs');\n\nlet input = fs.readFileSync('/app/stdin', 'utf8').trim();\nconst inputs = input.split("\\n");\n// inputs is an array of strings\n// write your code below\n`
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfUserIsPermitted = (doc: any = null) => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
|
||||
}
|
||||
|
||||
if (!doc) return
|
||||
if (
|
||||
doc.owner != user.data?.name &&
|
||||
!user.data?.is_instructor &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data.is_evaluator
|
||||
) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const updateTestCases = (doc: any) => {
|
||||
if (testCases.value.length === 0) {
|
||||
testCases.value = doc.test_cases || []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => submission.doc,
|
||||
(doc) => {
|
||||
if (doc) {
|
||||
checkIfUserIsPermitted(doc)
|
||||
updateTestCases(doc)
|
||||
updateCode(doc.code)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadFalcon = () => {
|
||||
if (livecodeURL.data) {
|
||||
falconURL.value = livecodeURL.data
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = `${falconURL.value}static/livecode.js`
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
const submitCode = async () => {
|
||||
await runCode()
|
||||
createSubmission()
|
||||
}
|
||||
|
||||
const runCode = async () => {
|
||||
if (!exercise.doc?.test_cases?.length) return
|
||||
|
||||
testCases.value = []
|
||||
if (testCaseSection.value) {
|
||||
testCaseSection.value.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
for (const test_case of exercise.doc.test_cases) {
|
||||
let result = await execute(test_case.input)
|
||||
if (error.value) {
|
||||
errorMessage.value = result
|
||||
break
|
||||
} else {
|
||||
output.value = result
|
||||
}
|
||||
let status =
|
||||
result.trim() === test_case.expected_output.trim() ? 'Passed' : 'Failed'
|
||||
testCases.value.push({
|
||||
input: test_case.input,
|
||||
output: result,
|
||||
expected_output: test_case.expected_output,
|
||||
status: status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const createSubmission = () => {
|
||||
if (!testCases.value.length) return
|
||||
let codeToSave = code.value?.replace(boilerplate.value, '') || ''
|
||||
|
||||
call('lms.lms.api.create_programming_exercise_submission', {
|
||||
exercise: props.exerciseID,
|
||||
submission: props.submissionID,
|
||||
code: codeToSave,
|
||||
test_cases: testCases.value,
|
||||
})
|
||||
.then((data: any) => {
|
||||
if (props.submissionID == 'new') {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: data },
|
||||
})
|
||||
fetchSubmission(data)
|
||||
} else {
|
||||
fetchSubmission(props.submissionID)
|
||||
}
|
||||
toast.success(__('Submission saved!'))
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Error creating submission:', error)
|
||||
toast.error(
|
||||
__('Failed to submit. Please try again. {0}').format({ error })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const execute = (stdin = ''): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let outputChunks: string[] = []
|
||||
let hasExited = false
|
||||
let hasError = false
|
||||
|
||||
let session = new LiveCodeSession({
|
||||
base_url: falconURL.value,
|
||||
runtime: exercise.doc?.language.toLowerCase() || 'python',
|
||||
code: code.value,
|
||||
files: [{ filename: 'stdin', contents: stdin }],
|
||||
onMessage: (msg: any) => {
|
||||
console.log('msg', msg)
|
||||
|
||||
if (msg.msgtype === 'write' && msg.file === 'stdout') {
|
||||
outputChunks.push(msg.data)
|
||||
}
|
||||
|
||||
if (msg.msgtype === 'write' && msg.file === 'stderr') {
|
||||
hasError = true
|
||||
errorMessage.value = msg.data
|
||||
}
|
||||
|
||||
if (msg.msgtype === 'exitstatus') {
|
||||
hasExited = true
|
||||
if (msg.exitstatus !== 0) {
|
||||
error.value = true
|
||||
} else {
|
||||
error.value = false
|
||||
}
|
||||
resolve(outputChunks.join('').trim())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasExited) {
|
||||
error.value = true
|
||||
errorMessage.value = 'Execution timed out.'
|
||||
reject('Execution timed out.')
|
||||
}
|
||||
}, 20000)
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Programming Exercise Submissions'),
|
||||
route: { name: 'ProgrammingExerciseSubmissions' },
|
||||
},
|
||||
{ label: exercise.doc?.title },
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programming Exercise Submission'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.ProseMirror pre {
|
||||
background: theme('colors.gray.200');
|
||||
color: theme('colors.gray.900');
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between space-x-32 mb-5">
|
||||
<div class="text-xl font-semibold text-ink-gray-7">
|
||||
{{
|
||||
submissions.data?.length
|
||||
? __('{0} Submissions').format(submissions.data.length)
|
||||
: __('No Submissions')
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="submissions.data?.length"
|
||||
class="grid grid-cols-3 gap-5 flex-1"
|
||||
>
|
||||
<Link
|
||||
doctype="LMS Programming Exercise"
|
||||
v-model="filters.exercise"
|
||||
:placeholder="__('Filter by Exercise')"
|
||||
/>
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="filters.member"
|
||||
:placeholder="__('Filter by Member')"
|
||||
:readonly="isStudent"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="filters.status"
|
||||
type="select"
|
||||
:options="[
|
||||
{ label: __(''), value: '' },
|
||||
{ label: __('Passed'), value: 'Passed' },
|
||||
{ label: __('Failed'), value: 'Failed' },
|
||||
]"
|
||||
:placeholder="__('Filter by Status')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="submissions.loading || submissions.data?.length"
|
||||
:columns="submissionColumns"
|
||||
:rows="submissions.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: true,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in submissionColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<router-link
|
||||
v-for="row in submissions.data"
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: row.exercise,
|
||||
submissionID: row.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="column.key == 'status'">
|
||||
<Badge
|
||||
:theme="row[column.key] === 'Passed' ? 'green' : 'red'"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteExercises(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<EmptyState v-else type="Programming Exercise Submissions" />
|
||||
<div
|
||||
v-if="submissions.data && submissions.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="submissions.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import type {
|
||||
ProgrammingExerciseSubmission,
|
||||
Filters,
|
||||
} from '@/pages/ProgrammingExercises/types'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const dayjs = inject('$dayjs') as any
|
||||
const user = inject('$user') as any
|
||||
const filterFields = ['exercise', 'member', 'status']
|
||||
const filters = ref<Filters>({
|
||||
exercise: '',
|
||||
member: '',
|
||||
status: '',
|
||||
})
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromRoute()
|
||||
fetchBasedOnRole()
|
||||
})
|
||||
|
||||
const setFiltersFromRoute = () => {
|
||||
filterFields.forEach((field) => {
|
||||
if (router.currentRoute.value.query[field]) {
|
||||
filters.value[field as keyof Filters] = router.currentRoute.value.query[
|
||||
field
|
||||
] as string
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchBasedOnRole = () => {
|
||||
if (isStudent.value) {
|
||||
filters.value['member'] = user.data?.name
|
||||
} else {
|
||||
submissions.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const submissions = createListResource({
|
||||
doctype: 'LMS Programming Exercise Submission',
|
||||
fields: [
|
||||
'name',
|
||||
'exercise',
|
||||
'exercise_title',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'status',
|
||||
'modified',
|
||||
],
|
||||
orderBy: 'modified desc',
|
||||
transform(data: ProgrammingExercise[]) {
|
||||
return data.map((submission: ProgrammingExerciseSubmission) => {
|
||||
return {
|
||||
...submission,
|
||||
modified: dayjs(submission.modified).fromNow(),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
watch(filters.value, () => {
|
||||
let filtersToApply: Record<string, any> = {}
|
||||
filterFields.forEach((field) => {
|
||||
if (filters.value[field as keyof Filters]) {
|
||||
filtersToApply[field] = filters.value[field as keyof Filters]
|
||||
router.push({
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
[field]: filters.value[field as keyof Filters],
|
||||
},
|
||||
})
|
||||
} else {
|
||||
delete filtersToApply[field]
|
||||
const query = { ...router.currentRoute.value.query }
|
||||
delete query[field]
|
||||
router.push({
|
||||
query,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
submissions.update({
|
||||
filters: {
|
||||
...filtersToApply,
|
||||
},
|
||||
})
|
||||
submissions.reload()
|
||||
})
|
||||
|
||||
const deleteExercises = (selections: Set<string>, unselectAll: () => void) => {
|
||||
Array.from(selections).forEach(async (submission: string) => {
|
||||
await submissions.delete.submit(submission)
|
||||
})
|
||||
unselectAll()
|
||||
toast.success(__('Submissions deleted successfully'))
|
||||
}
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return (
|
||||
!user.data?.is_instructor &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_evaluator
|
||||
)
|
||||
})
|
||||
|
||||
const submissionColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: '30%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Exercise'),
|
||||
key: 'exercise_title',
|
||||
width: '30%',
|
||||
icon: 'code',
|
||||
},
|
||||
{
|
||||
label: __('Status'),
|
||||
key: 'status',
|
||||
width: '20%',
|
||||
icon: 'check-circle',
|
||||
},
|
||||
{
|
||||
label: __('Modified'),
|
||||
key: 'modified',
|
||||
width: '15%',
|
||||
icon: 'clock',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Programming Exercise Submissions'),
|
||||
route: {
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programming Exercises'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
171
frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue
Normal file
171
frontend/src/pages/ProgrammingExercises/ProgrammingExercises.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<ClipboardList class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Check All Submissions') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
v-if="!readOnlyMode"
|
||||
variant="solid"
|
||||
@click="
|
||||
() => {
|
||||
exerciseID = 'new'
|
||||
showForm = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="md:w-4/5 md:mx-auto p-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div v-if="exerciseCount" class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('{0} Exercises').format(exerciseCount) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="exercises.data?.length || exerciseCount > 0"
|
||||
class="grid grid-cols-2 gap-5"
|
||||
>
|
||||
<!-- <FormControl
|
||||
v-model="titleFilter"
|
||||
:placeholder="__('Search by title')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="typeFilter"
|
||||
type="select"
|
||||
:options="assignmentTypes"
|
||||
:placeholder="__('Type')"
|
||||
/> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="exercises.data?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div
|
||||
v-for="exercise in exercises.data"
|
||||
:key="exercise.name"
|
||||
@click="
|
||||
() => {
|
||||
exerciseID = exercise.name
|
||||
showForm = true
|
||||
}
|
||||
"
|
||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3 space-y-2 cursor-pointer"
|
||||
>
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ exercise.title }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ exercise.language }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="Programming Exercises" />
|
||||
<div
|
||||
v-if="exercises.data && exercises.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="exercises.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ProgrammingExerciseForm
|
||||
v-model="showForm"
|
||||
:exerciseID="exerciseID"
|
||||
v-model:exercises="exercises"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { ClipboardList, Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgrammingExerciseForm from '@/pages/ProgrammingExercises/ProgrammingExerciseForm.vue'
|
||||
|
||||
const exerciseCount = ref<number>(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { brand } = sessionStore()
|
||||
const showForm = ref<boolean>(false)
|
||||
const exerciseID = ref<string | null>('new')
|
||||
const user = inject<any>('$user')
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
validatePermissions()
|
||||
getExerciseCount()
|
||||
})
|
||||
|
||||
const validatePermissions = () => {
|
||||
if (
|
||||
!user.data?.is_instructor &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_evaluator
|
||||
) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmissions',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getExerciseCount = () => {
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Programming Exercise',
|
||||
})
|
||||
.then((count: number) => {
|
||||
exerciseCount.value = count
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('Error fetching exercise count:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const exercises = createListResource({
|
||||
doctype: 'LMS Programming Exercise',
|
||||
cache: ['programmingExercises'],
|
||||
fields: ['name', 'title', 'language', 'problem_statement'],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programming Exercises'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Programming Exercises'),
|
||||
route: { name: 'ProgrammingExercises' },
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
47
frontend/src/pages/ProgrammingExercises/types.ts
Normal file
47
frontend/src/pages/ProgrammingExercises/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
interface ProgrammingExercise {
|
||||
name: string;
|
||||
title: string;
|
||||
language: 'Python' | 'JavaScript';
|
||||
test_cases_count: number;
|
||||
problem_statement: string;
|
||||
test_cases: [TestCase];
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
input: string;
|
||||
expected_output: string;
|
||||
output: string;
|
||||
status: 'Passed' | 'Failed';
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
exercise?: string,
|
||||
member?: string,
|
||||
status?: string
|
||||
}
|
||||
|
||||
type ProgrammingExercises = {
|
||||
data: ProgrammingExercise[]
|
||||
reload: () => void
|
||||
hasNextPage: boolean
|
||||
next: () => void
|
||||
setValue: {
|
||||
submit: (
|
||||
data: ProgrammingExercise,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void
|
||||
}
|
||||
insert: {
|
||||
submit: (
|
||||
data: ProgrammingExercise,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void
|
||||
}
|
||||
delete: {
|
||||
submit: (
|
||||
name: string,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadbrumbs" />
|
||||
<Button
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
@click="showDialog = true"
|
||||
variant="solid"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="programs.data?.length" class="pt-5 px-5">
|
||||
<div v-for="program in programs.data" class="mb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Badge
|
||||
v-if="program.members"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="lg"
|
||||
>
|
||||
{{ program.members }}
|
||||
{{ program.members == 1 ? __('member') : __('members') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="program.progress"
|
||||
variant="subtle"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
>
|
||||
{{ program.progress }}{{ __('% completed') }}
|
||||
</Badge>
|
||||
|
||||
<router-link
|
||||
v-if="user.data?.is_moderator || user.data?.is_instructor"
|
||||
:to="{
|
||||
name: 'ProgramForm',
|
||||
params: { programName: program.name },
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<Edit class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="program.courses?.length"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div v-for="course in program.courses" class="relative group">
|
||||
<CourseCard
|
||||
:course="course"
|
||||
@click="enrollMember(program.name, course.name)"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
<div
|
||||
v-if="lockCourse(course)"
|
||||
class="absolute inset-0 bg-black-overlay-500 opacity-60 rounded-md"
|
||||
></div>
|
||||
<div
|
||||
v-if="lockCourse(course)"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<LockKeyhole class="size-10 text-ink-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5 mt-4">
|
||||
{{ __('No courses in this program') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No programs found') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'There are no programs available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title: __('New Program'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => createProgram(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FormControl :label="__('Title')" v-model="title" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const user = inject('$user')
|
||||
const showDialog = ref(false)
|
||||
const router = useRouter()
|
||||
const title = ref('')
|
||||
const settings = useSettings()
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
!settings.learningPaths.data &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
})
|
||||
|
||||
const programs = createResource({
|
||||
url: 'lms.lms.utils.get_programs',
|
||||
auto: true,
|
||||
cache: 'programs',
|
||||
})
|
||||
|
||||
const createProgram = (close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Program',
|
||||
title: title.value,
|
||||
},
|
||||
}).then((res) => {
|
||||
router.push({ name: 'ProgramForm', params: { programName: res.name } })
|
||||
})
|
||||
}
|
||||
|
||||
const enrollMember = (program, course) => {
|
||||
call('lms.lms.utils.enroll_in_program_course', {
|
||||
program: program,
|
||||
course: course,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.current_lesson) {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course,
|
||||
chapterNumber: data.current_lesson.split('-')[0],
|
||||
lessonNumber: data.current_lesson.split('-')[1],
|
||||
},
|
||||
})
|
||||
} else if (data) {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course,
|
||||
chapterNumber: 1,
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast('Error', err.messages?.[0] || err, 'x')
|
||||
})
|
||||
}
|
||||
|
||||
const lockCourse = (course) => {
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return false
|
||||
if (course.membership) return false
|
||||
if (course.eligible) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const breadbrumbs = computed(() => [
|
||||
{
|
||||
label: 'Programs',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
140
frontend/src/pages/Programs/ProgramDetail.vue
Normal file
140
frontend/src/pages/Programs/ProgramDetail.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="program.data" class="pt-5 px-5 pb-10 mx-auto">
|
||||
<div class="flex items-center space-x-2 mb-5">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ program.data.name }}
|
||||
</div>
|
||||
|
||||
<Badge :theme="program.data.progress < 100 ? 'orange' : 'green'">
|
||||
{{ program.data.progress }}% {{ __('completed') }}
|
||||
</Badge>
|
||||
|
||||
<Tooltip
|
||||
v-if="program.data.enforce_course_order"
|
||||
placement="right"
|
||||
:text="
|
||||
__(
|
||||
'Courses must be completed in order. You can only start the next course after completing the previous one.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Info class="size-3 cursor-pointer" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
|
||||
<div
|
||||
v-for="course in program.data.courses"
|
||||
:key="course.name"
|
||||
class="relative group"
|
||||
:class="
|
||||
(course.eligible && program.data.enforce_course_order) ||
|
||||
!program.data.enforce_course_order
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-default'
|
||||
"
|
||||
>
|
||||
<CourseCard
|
||||
:course="course"
|
||||
@click="openCourse(course, program.data.enforce_course_order)"
|
||||
/>
|
||||
<div
|
||||
v-if="!course.eligible && program.data.enforce_course_order"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center space-y-2 text-ink-white rounded-md invisible group-hover:visible"
|
||||
:style="{
|
||||
background: 'radial-gradient(circle, darkgray 0%, lightgray 100%)',
|
||||
}"
|
||||
>
|
||||
<LockKeyhole class="size-5" />
|
||||
<span class="font-medium text-center leading-5 px-10">
|
||||
{{ __('Please complete the previous course to unlock this one.') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { LockKeyhole, Info } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const user = inject<any>('$user')
|
||||
|
||||
const props = defineProps<{
|
||||
programName: string
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
checkIfEnrolled()
|
||||
})
|
||||
|
||||
const checkIfEnrolled = () => {
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Program Member',
|
||||
filters: {
|
||||
member: user.data.name,
|
||||
parent: props.programName,
|
||||
},
|
||||
parent: 'LMS Program',
|
||||
fieldname: 'name',
|
||||
}).then((data: { name: string }) => {
|
||||
if (data.name) {
|
||||
program.reload()
|
||||
} else {
|
||||
router.push({ name: 'Programs' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const program = createResource({
|
||||
url: 'lms.lms.utils.get_program_details',
|
||||
params: {
|
||||
program_name: props.programName,
|
||||
},
|
||||
})
|
||||
|
||||
const openCourse = (course: any, enforceCourseOrder: boolean) => {
|
||||
if (!course.eligible && enforceCourseOrder) return
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: course.name },
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{ label: __('Programs'), route: { name: 'Programs' } },
|
||||
{
|
||||
label: props.programName,
|
||||
route: {
|
||||
name: 'ProgramDetail',
|
||||
params: { programName: props.programName },
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.programName,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
157
frontend/src/pages/Programs/ProgramEnrollment.vue
Normal file
157
frontend/src/pages/Programs/ProgramEnrollment.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '2xl',
|
||||
}"
|
||||
>
|
||||
<template #body-title>
|
||||
<div v-if="program.data" class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ __('Enrollment for Program {0}').format(program.data?.name) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div v-if="program.data" class="text-base">
|
||||
<div class="bg-surface-blue-2 text-ink-blue-3 p-2 rounded-md leading-5">
|
||||
<span>
|
||||
{{
|
||||
__('This program consists of {0} courses').format(
|
||||
program.data.courses.length
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-if="program.data.enforce_course_order">
|
||||
{{
|
||||
__(
|
||||
' designed as a structured learning path to guide your progress. Courses in this program must be taken in order, and each course will unlock as you complete the previous one. '
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
__(
|
||||
' designed as a learning path to guide your progress. You may take the courses in any order that suits you. '
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Are you sure you want to enroll?') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<div class="text-sm font-semibold text-ink-gray-5">
|
||||
{{ __('Courses in this Program') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div
|
||||
v-for="course in program.data.courses"
|
||||
class="flex flex-col border p-2 rounded-md h-full"
|
||||
>
|
||||
<div class="font-semibold leading-5 mb-2">
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
<!-- <div class="text-sm text-ink-gray-7 mb-8">
|
||||
{{ course.short_introduction }}
|
||||
</div> -->
|
||||
|
||||
<div
|
||||
class="flex items-center space-x-5 text-sm text-ink-gray-5 mb-8"
|
||||
>
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center space-x-1">
|
||||
<BookOpen class="size-3 stroke-1.5" />
|
||||
<span> {{ course.lessons }} {{ __('lessons') }} </span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center space-x-1">
|
||||
<User class="size-3 stroke-1.5" />
|
||||
<span> {{ course.enrollments }} {{ __('students') }} </span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<!-- <Tooltip v-if="course.rating" :text="__('Average Rating')">
|
||||
<span class="flex items-center space-x-1">
|
||||
<Star class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ course.rating }} {{ __("rating") }}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip> -->
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1 mt-auto">
|
||||
<UserAvatar :user="course.instructors[0]" />
|
||||
<span>
|
||||
{{ course.instructors[0].full_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="flex justify-end space-x-2 group">
|
||||
<Button variant="solid" @click="enrollInProgram(close)">
|
||||
{{ __('Confirm Enrollment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, createResource, Dialog, toast, Tooltip } from 'frappe-ui'
|
||||
import { inject, watch } from 'vue'
|
||||
import { BookOpen, Star, User } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const user = inject<any>('$user')
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
programName: any
|
||||
}>()
|
||||
|
||||
const program = createResource({
|
||||
url: 'lms.lms.utils.get_program_details',
|
||||
makeParams(values: any) {
|
||||
return {
|
||||
program_name: props.programName,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.programName,
|
||||
() => {
|
||||
if (props.programName) {
|
||||
program.reload()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const enrollInProgram = (close: () => void) => {
|
||||
call('lms.lms.utils.enroll_in_program', {
|
||||
program: props.programName,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Successfully enrolled in program'))
|
||||
router.push({
|
||||
name: 'ProgramDetail',
|
||||
params: { programName: props.programName },
|
||||
})
|
||||
close()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(__('Failed to enroll in program: {0}').format(error.message))
|
||||
console.error('Enrollment Error:', error)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
586
frontend/src/pages/Programs/ProgramForm.vue
Normal file
586
frontend/src/pages/Programs/ProgramForm.vue
Normal file
@@ -0,0 +1,586 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '2xl',
|
||||
}"
|
||||
>
|
||||
<template #body-title>
|
||||
<div class="flex items-center justify-between space-x-2 text-base w-full">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{
|
||||
programName === 'new' ? __('Create Program') : __('Edit Program')
|
||||
}}
|
||||
</div>
|
||||
<Badge theme="orange" v-if="dirty">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pb-5">
|
||||
<FormControl
|
||||
v-model="program.name"
|
||||
:label="__('Title')"
|
||||
type="text"
|
||||
:required="true"
|
||||
@change="dirty = true"
|
||||
/>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<FormControl
|
||||
v-model="program.published"
|
||||
:label="__('Published')"
|
||||
type="checkbox"
|
||||
@change="dirty = true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="program.enforce_course_order"
|
||||
:label="__('Enforce Course Order')"
|
||||
type="checkbox"
|
||||
@change="dirty = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pb-5">
|
||||
<div class="flex items-center justify-between mt-5 mb-4">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button @click="openForm('course')">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="programCourses.data.length > 0"
|
||||
:columns="courseColumns"
|
||||
:rows="programCourses.data"
|
||||
:options="{
|
||||
selectable: true,
|
||||
resizeColumn: true,
|
||||
showTooltip: false,
|
||||
}"
|
||||
rowKey="name"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in courseColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<Draggable
|
||||
:list="programCourses.data"
|
||||
item-key="name"
|
||||
group="items"
|
||||
@end="updateOrder"
|
||||
class="cursor-move"
|
||||
>
|
||||
<template #item="{ element: row }">
|
||||
<ListRow :row="row" />
|
||||
</template>
|
||||
</Draggable>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'courses')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No courses added yet.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mt-5 mb-4">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Members') }}
|
||||
</div>
|
||||
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
showProgressDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Progress Summary') }}
|
||||
</Button>
|
||||
<Button @click="openForm('member')">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="programMembers.data.length > 0"
|
||||
:columns="memberColumns"
|
||||
:rows="programMembers.data"
|
||||
:options="{
|
||||
selectable: true,
|
||||
resizeColumn: true,
|
||||
}"
|
||||
rowKey="name"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in memberColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in programMembers.data" />
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="remove(selections, unselectAll, 'members')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No members added yet.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showFormDialog"
|
||||
:options="{
|
||||
title:
|
||||
currentForm == 'course'
|
||||
? __('Add Course to Program')
|
||||
: __('Enroll Member to Program'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick: ({ close }: { close: () => void }) =>
|
||||
currentForm == 'course'
|
||||
? addCourse(close)
|
||||
: addMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div @click.stop>
|
||||
<Link
|
||||
v-if="currentForm == 'course'"
|
||||
v-model="course"
|
||||
doctype="LMS Course"
|
||||
:label="__('Course')"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-if="currentForm == 'member'"
|
||||
v-model="member"
|
||||
doctype="User"
|
||||
:filters="{
|
||||
ignore_user_type: 1,
|
||||
}"
|
||||
:label="__('Program Member')"
|
||||
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ProgramProgressSummary
|
||||
v-model="showProgressDialog"
|
||||
:programName="programName"
|
||||
:programMembers="programMembers.data"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="flex justify-end space-x-2 group">
|
||||
<Button
|
||||
v-if="programName != 'new'"
|
||||
@click="deleteProgram(close)"
|
||||
variant="outline"
|
||||
theme="red"
|
||||
class="invisible group-hover:visible"
|
||||
>
|
||||
<template #prefix>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Delete') }}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveProgram(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
ListSelectBanner,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||
import { Programs, Program } from '@/types/programs'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import ProgramProgressSummary from '@/pages/Programs/ProgramProgressSummary.vue'
|
||||
|
||||
const show = defineModel<boolean>()
|
||||
const programs = defineModel<Programs>('programs')
|
||||
const showFormDialog = ref(false)
|
||||
const currentForm = ref<'course' | 'member'>('course')
|
||||
const course = ref<string>('')
|
||||
const member = ref<string>('')
|
||||
const showProgressDialog = ref(false)
|
||||
const dirty = ref(false)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
programName: string | null
|
||||
}>(),
|
||||
{
|
||||
programName: 'new',
|
||||
}
|
||||
)
|
||||
|
||||
const program = ref<Program>({
|
||||
name: '',
|
||||
title: '',
|
||||
published: false,
|
||||
enforce_course_order: false,
|
||||
program_courses: [],
|
||||
program_members: [],
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.programName,
|
||||
() => {
|
||||
setProgramData()
|
||||
fetchCourses()
|
||||
fetchMembers()
|
||||
}
|
||||
)
|
||||
|
||||
const setProgramData = () => {
|
||||
let isNew = true
|
||||
programs.value?.data.forEach((p: Program) => {
|
||||
if (p.name === props.programName) {
|
||||
isNew = false
|
||||
program.value = { ...p }
|
||||
}
|
||||
})
|
||||
|
||||
if (isNew) {
|
||||
program.value = {
|
||||
name: '',
|
||||
title: '',
|
||||
published: false,
|
||||
enforce_course_order: false,
|
||||
program_courses: [],
|
||||
program_members: [],
|
||||
}
|
||||
}
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
const programCourses = createListResource({
|
||||
doctype: 'LMS Program Course',
|
||||
fields: ['course', 'course_title', 'name', 'idx'],
|
||||
cache: ['programCourses', props.programName],
|
||||
parent: 'LMS Program',
|
||||
orderBy: 'idx',
|
||||
onSuccess(data: ProgramCourse[]) {
|
||||
program.value.program_courses = data
|
||||
},
|
||||
})
|
||||
|
||||
const programMembers = createListResource({
|
||||
doctype: 'LMS Program Member',
|
||||
fields: ['member', 'full_name', 'progress', 'name'],
|
||||
cache: ['programMembers', props.programName],
|
||||
parent: 'LMS Program',
|
||||
orderBy: 'creation desc',
|
||||
onSuccess(data: ProgramMember[]) {
|
||||
program.value.program_members = data
|
||||
},
|
||||
})
|
||||
|
||||
const fetchCourses = () => {
|
||||
programCourses.update({
|
||||
filters: {
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_courses',
|
||||
},
|
||||
})
|
||||
programCourses.reload()
|
||||
}
|
||||
|
||||
const fetchMembers = () => {
|
||||
programMembers.update({
|
||||
filters: {
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_members',
|
||||
},
|
||||
})
|
||||
programMembers.reload()
|
||||
}
|
||||
|
||||
const saveProgram = (close: () => void) => {
|
||||
if (props.programName === 'new') createNewProgram(close)
|
||||
else updateProgram(close)
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
const createNewProgram = (close: () => void) => {
|
||||
programs.value.insert.submit(
|
||||
{
|
||||
...program.value,
|
||||
title: program.value.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
programs.value.reload()
|
||||
toast.success(__('Program created successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateProgram = (close: () => void) => {
|
||||
programs.value.setValue.submit(
|
||||
{
|
||||
name: props.programName,
|
||||
...program.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
programs.value.reload()
|
||||
toast.success(__('Program updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const openForm = (formType: 'course' | 'member') => {
|
||||
currentForm.value = formType
|
||||
showFormDialog.value = true
|
||||
if (formType === 'course') {
|
||||
course.value = ''
|
||||
} else {
|
||||
member.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const addCourse = (close: () => void) => {
|
||||
if (!course.value) {
|
||||
toast.warning(__('Please select a course'))
|
||||
return
|
||||
}
|
||||
|
||||
programCourses.insert.submit(
|
||||
{
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_courses',
|
||||
course: course.value,
|
||||
idx: programCourses.data.length + 1,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateCounts('course', 'add')
|
||||
close()
|
||||
toast.success(__('Course added to program successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
if (!member.value) {
|
||||
toast.warning(__('Please select a member'))
|
||||
return
|
||||
}
|
||||
|
||||
programMembers.insert.submit(
|
||||
{
|
||||
parent: props.programName,
|
||||
parenttype: 'LMS Program',
|
||||
parentfield: 'program_members',
|
||||
member: member.value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
updateCounts('member', 'add')
|
||||
close()
|
||||
toast.success(__('Member added to program successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateCounts = async (
|
||||
type: 'member' | 'course',
|
||||
action: 'add' | 'remove'
|
||||
) => {
|
||||
if (!props.programName) return
|
||||
|
||||
let memberCount = programMembers.data?.length || 0
|
||||
let courseCount = programCourses.data?.length || 0
|
||||
|
||||
if (type === 'member') {
|
||||
memberCount += action === 'add' ? 1 : -1
|
||||
} else {
|
||||
courseCount += action === 'add' ? 1 : -1
|
||||
}
|
||||
|
||||
await programs.value.setValue.submit(
|
||||
{
|
||||
name: props.programName,
|
||||
member_count: memberCount,
|
||||
course_count: courseCount,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
setProgramData()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateOrder = async (e: DragEvent) => {
|
||||
let sourceIdx = e.from.dataset.idx
|
||||
let targetIdx = e.to.dataset.idx
|
||||
let courses = programCourses.data
|
||||
courses.splice(targetIdx, 0, courses.splice(sourceIdx, 1)[0])
|
||||
|
||||
for (const [index, course] of courses.entries()) {
|
||||
programCourses.setValue.submit(
|
||||
{
|
||||
name: course.name,
|
||||
idx: index + 1,
|
||||
},
|
||||
{
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
}
|
||||
)
|
||||
await wait(100)
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))
|
||||
|
||||
const remove = async (
|
||||
selections: string[],
|
||||
unselectAll: () => void,
|
||||
type: string
|
||||
) => {
|
||||
selections = Array.from(selections)
|
||||
for (const selection of selections) {
|
||||
if (type == 'courses') {
|
||||
await programCourses.delete.submit(selection)
|
||||
await updateCounts('course', 'remove')
|
||||
} else {
|
||||
await programMembers.delete.submit(selection)
|
||||
await updateCounts('member', 'remove')
|
||||
}
|
||||
await programs.value.reload()
|
||||
await wait(100)
|
||||
}
|
||||
unselectAll()
|
||||
}
|
||||
|
||||
const deleteProgram = (close: () => void) => {
|
||||
if (props.programName == 'new') return
|
||||
programs.value?.delete.submit(props.programName, {
|
||||
onSuccess() {
|
||||
toast.success(__('Program deleted successfully'))
|
||||
close()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const courseColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'course_title',
|
||||
width: 1,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const memberColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Member',
|
||||
key: 'member',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: 3,
|
||||
align: 'left',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
137
frontend/src/pages/Programs/ProgramProgressSummary.vue
Normal file
137
frontend/src/pages/Programs/ProgramProgressSummary.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Progress Summary for {0}').format(programName),
|
||||
size: '2xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center justify-between space-x-4 mb-4">
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: programMembers.length || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: averageProgress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: progressDistribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
theme.colors.red['400'],
|
||||
theme.colors.amber['400'],
|
||||
theme.colors.pink['400'],
|
||||
theme.colors.blue['400'],
|
||||
theme.colors.green['400'],
|
||||
],
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="mt-10">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<ListView
|
||||
v-if="progressList.length"
|
||||
:columns="progressColumns"
|
||||
:rows="progressList"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
/>
|
||||
<div v-else class="text-center text-gray-500">
|
||||
{{ __('No members found.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DonutChart,
|
||||
FormControl,
|
||||
ListView,
|
||||
NumberChart,
|
||||
} from 'frappe-ui'
|
||||
import type { ProgramMember } from '@/types'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
programName: string
|
||||
programMembers: ProgramMember[]
|
||||
}>()
|
||||
|
||||
const progressList = ref<ProgramMember[]>(props.programMembers || [])
|
||||
|
||||
const progressDistribution = computed(() => {
|
||||
const categories = ['0-20%', '20-40%', '40-60%', '60-80%', '80-100%']
|
||||
const distribution = categories.map((category) => {
|
||||
const [min, max] = category.slice(0, -1).split('-').map(Number)
|
||||
return {
|
||||
category,
|
||||
count: props.programMembers.filter((member) => {
|
||||
const progress = member.progress || 0
|
||||
return progress >= min && progress < max
|
||||
}).length,
|
||||
}
|
||||
})
|
||||
return distribution
|
||||
})
|
||||
|
||||
const averageProgress = computed(() => {
|
||||
if (props.programMembers.length === 0) return 0
|
||||
const totalProgress = props.programMembers.reduce(
|
||||
(sum, member) => sum + (member.progress || 0),
|
||||
0
|
||||
)
|
||||
return totalProgress / props.programMembers.length
|
||||
})
|
||||
|
||||
watch(searchFilter, () => {
|
||||
if (searchFilter.value) {
|
||||
progressList.value = props.programMembers.filter((member) =>
|
||||
member.full_name.toLowerCase().includes(searchFilter.value?.toLowerCase())
|
||||
)
|
||||
} else {
|
||||
progressList.value = props.programMembers
|
||||
}
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'full_name',
|
||||
width: '50%',
|
||||
},
|
||||
{
|
||||
label: __('Progress (%)'),
|
||||
key: 'progress',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
123
frontend/src/pages/Programs/Programs.vue
Normal file
123
frontend/src/pages/Programs/Programs.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Button v-if="canCreateProgram()" @click="openForm('new')" variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="programs.data?.length && !isStudent" class="py-10 w-3/4 mx-auto">
|
||||
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
||||
{{
|
||||
__('{0} {1}').format(
|
||||
programs.data.length,
|
||||
programs.data.length == 1 ? __('Program') : __('Programs')
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<div
|
||||
v-for="program in programs.data"
|
||||
@click="openForm(program.name)"
|
||||
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer space-y-2"
|
||||
>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<span>
|
||||
{{ program.course_count }}
|
||||
{{ program.course_count == 1 ? __('Course') : __('Courses') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<User class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<span>
|
||||
{{ program.member_count || 0 }}
|
||||
{{ program.member_count == 1 ? __('member') : __('members') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StudentPrograms v-else-if="isStudent" />
|
||||
<EmptyState v-else type="Programs" />
|
||||
<ProgramForm
|
||||
v-model="showForm"
|
||||
:programName="currentProgram"
|
||||
v-model:programs="programs"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Button, usePageMeta, createListResource } from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { BookOpen, Plus, User } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import ProgramForm from '@/pages/Programs/ProgramForm.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import StudentPrograms from '@/pages/Programs/StudentPrograms.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const showForm = ref(false)
|
||||
const currentProgram = ref(null)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) {
|
||||
programs.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const programs = createListResource({
|
||||
doctype: 'LMS Program',
|
||||
cache: ['program'],
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'member_count',
|
||||
'course_count',
|
||||
'published',
|
||||
'enforce_course_order',
|
||||
],
|
||||
auto: false,
|
||||
orderBy: 'creation desc',
|
||||
})
|
||||
|
||||
const canCreateProgram = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const openForm = (programName) => {
|
||||
if (!canCreateProgram()) return
|
||||
currentProgram.value = programName
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return user.data?.is_student || false
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Programs'),
|
||||
},
|
||||
])
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Programs'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
105
frontend/src/pages/Programs/StudentPrograms.vue
Normal file
105
frontend/src/pages/Programs/StudentPrograms.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="py-5 px-5 w-full lg:w-3/4 lg:px-0 mx-auto">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('All Programs') }}
|
||||
</div>
|
||||
<TabButtons v-model="currentTab" :buttons="tabs" class="w-fit" />
|
||||
</div>
|
||||
<div v-for="(data, category) in programs.data">
|
||||
<div v-if="category == currentTab">
|
||||
<div
|
||||
v-if="data.length > 0"
|
||||
class="grid grid-cols-1 lg:grid-cols-3 gap-5"
|
||||
>
|
||||
<div
|
||||
v-for="program in data"
|
||||
@click="openDetails(program.name, category)"
|
||||
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer"
|
||||
>
|
||||
<div class="text-lg font-semibold mb-2">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-5 text-sm text-ink-gray-7">
|
||||
<div class="flex items-center space-x-1">
|
||||
<BookOpen class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ program.course_count }}
|
||||
{{ program.course_count == 1 ? __('course') : __('courses') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<User class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ program.member_count || 0 }}
|
||||
{{ program.member_count == 1 ? __('member') : __('members') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="Object.keys(program).includes('progress')" class="mt-5">
|
||||
<ProgressBar :progress="program.progress" />
|
||||
<div class="text-sm mt-1">
|
||||
{{ Math.ceil(program.progress) }}% {{ __('completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else :type="convertToTitleCase(category) + ' Programs'" />
|
||||
<!-- <div v-else class="col-span-3 text-center text-ink-gray-5">
|
||||
{{ __('No programs found in this category.') }}
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProgramEnrollment
|
||||
v-model="showEnrollmentConfirmation"
|
||||
:programName="enrollmentProgram"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, TabButtons } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { BookOpen, User } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import ProgramEnrollment from '@/pages/Programs/ProgramEnrollment.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const currentTab = ref('enrolled')
|
||||
const router = useRouter()
|
||||
const showEnrollmentConfirmation = ref(false)
|
||||
const enrollmentProgram = ref(null)
|
||||
|
||||
const programs = createResource({
|
||||
url: 'lms.lms.utils.get_programs',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openDetails = (programName: any, category: string) => {
|
||||
if (category === 'enrolled') {
|
||||
router.push({
|
||||
name: 'ProgramDetail',
|
||||
params: { programName: programName },
|
||||
})
|
||||
} else {
|
||||
showEnrollmentConfirmation.value = true
|
||||
enrollmentProgram.value = programName
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Enrolled'),
|
||||
value: 'enrolled',
|
||||
},
|
||||
{
|
||||
label: __('Published'),
|
||||
value: 'published',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
50
frontend/src/pages/Programs/types.ts
Normal file
50
frontend/src/pages/Programs/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
interface Program {
|
||||
name: string;
|
||||
title: string;
|
||||
published: boolean;
|
||||
enforce_course_order: boolean;
|
||||
program_courses: ProgramCourse[];
|
||||
program_batches: ProgramMember[];
|
||||
course_count: number;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
interface ProgramCourse {
|
||||
course: string;
|
||||
course_title: string;
|
||||
idx: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProgramMember {
|
||||
member: string;
|
||||
full_name: string;
|
||||
progress: number;
|
||||
idx: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Programs {
|
||||
data: Program[];
|
||||
reload: () => void;
|
||||
hasNextPage: boolean;
|
||||
next: () => void;
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Program,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void;
|
||||
};
|
||||
insert: {
|
||||
submit: (
|
||||
data: Program,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void;
|
||||
};
|
||||
delete: {
|
||||
submit: (
|
||||
name: string,
|
||||
options?: { onSuccess?: () => void }
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
@@ -3,31 +3,40 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<div v-if="!readOnlyMode" class="space-x-2">
|
||||
<Badge v-if="quizDetails.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
v-if="quizDetails.doc?.name"
|
||||
:to="{
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
quizID: quizDetails.doc.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Open') }}
|
||||
<template #prefix>
|
||||
<ListChecks class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Test Quiz') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="quizDetails.data?.name"
|
||||
v-if="quizDetails.doc?.name"
|
||||
:to="{
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: quizDetails.data.name,
|
||||
quizID: quizDetails.doc.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Submission List') }}
|
||||
<template #prefix>
|
||||
<ClipboardList class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Check Submissions') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button variant="solid" @click="submitQuiz()">
|
||||
@@ -35,144 +44,152 @@
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="w-3/4 mx-auto py-5">
|
||||
<!-- Details -->
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold mb-4">
|
||||
<div v-if="quizDetails.doc" class="py-5">
|
||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="quiz.title"
|
||||
:label="
|
||||
quizDetails.data?.name
|
||||
? __('Title')
|
||||
: __('Enter a title and save the quiz to proceed')
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<div v-if="quizDetails.data?.name">
|
||||
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.max_attempts"
|
||||
v-model="quizDetails.doc.max_attempts"
|
||||
:label="__('Maximum Attempts')"
|
||||
/>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="quiz.duration"
|
||||
v-model="quizDetails.doc.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="quiz.total_marks"
|
||||
v-model="quizDetails.doc.total_marks"
|
||||
:label="__('Total Marks')"
|
||||
disabled
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.passing_percentage"
|
||||
v-model="quizDetails.doc.passing_percentage"
|
||||
:label="__('Passing Percentage')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 my-4">
|
||||
<FormControl
|
||||
v-model="quiz.show_answers"
|
||||
type="checkbox"
|
||||
:label="__('Show Answers')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quiz.show_submission_history"
|
||||
type="checkbox"
|
||||
:label="__('Show Submission History')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="font-semibold mb-4">
|
||||
{{ __('Shuffle Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3">
|
||||
<FormControl
|
||||
v-model="quiz.shuffle_questions"
|
||||
type="checkbox"
|
||||
:label="__('Shuffle Questions')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quiz.shuffle_questions"
|
||||
v-model="quiz.limit_questions_to"
|
||||
:label="__('Limit Questions To')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Questions -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-semibold">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Question') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
:columns="questionColumns"
|
||||
:rows="quiz.questions"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in quiz.questions"
|
||||
@click="openQuestionModal(row)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key == 'question_detail'"
|
||||
class="text-xs truncate h-4"
|
||||
v-html="item"
|
||||
></div>
|
||||
<div v-else class="text-xs">
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuestions(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<div class="flex flex-col space-y-10">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.show_answers"
|
||||
type="checkbox"
|
||||
:label="__('Show Answers')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.show_submission_history"
|
||||
type="checkbox"
|
||||
:label="__('Show Submission History')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.shuffle_questions"
|
||||
type="checkbox"
|
||||
:label="__('Shuffle Questions')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quizDetails.doc.shuffle_questions"
|
||||
v-model="quizDetails.doc.limit_questions_to"
|
||||
:label="__('Limit Questions To')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
v-model="quizDetails.doc.enable_negative_marking"
|
||||
type="checkbox"
|
||||
:label="__('Enable Negative Marking')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="quizDetails.doc.enable_negative_marking"
|
||||
v-model="quizDetails.doc.marks_to_cut"
|
||||
:label="__('Marks to Cut')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-20 pb-5 space-y-5 mb-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Questions') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openQuestionModal()">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Question') }}
|
||||
</Button>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="questions.length"
|
||||
:columns="questionColumns"
|
||||
:rows="questions"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ idx, column, item }"
|
||||
v-for="row in questions"
|
||||
@click="openQuestionModal(row)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key == 'question_detail'"
|
||||
class="text-xs truncate h-4"
|
||||
v-html="item"
|
||||
></div>
|
||||
<div v-else class="text-xs">
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuestions(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-6 text-sm">
|
||||
{{ __('No questions added yet') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Question
|
||||
v-model="showQuestionModal"
|
||||
:questionDetail="currentQuestion"
|
||||
@@ -197,6 +214,10 @@ import {
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
Button,
|
||||
usePageMeta,
|
||||
toast,
|
||||
createDocumentResource,
|
||||
Badge,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
computed,
|
||||
@@ -207,11 +228,12 @@ import {
|
||||
onBeforeUnmount,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Question from '@/components/Modals/Question.vue'
|
||||
import { showToast, updateDocumentTitle } from '@/utils'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Question from '@/components/Modals/Question.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const showQuestionModal = ref(false)
|
||||
const currentQuestion = reactive({
|
||||
question: '',
|
||||
@@ -220,6 +242,7 @@ const currentQuestion = reactive({
|
||||
})
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
quizID: {
|
||||
@@ -228,18 +251,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const quiz = reactive({
|
||||
title: '',
|
||||
total_marks: 0,
|
||||
passing_percentage: 0,
|
||||
max_attempts: 0,
|
||||
duration: 0,
|
||||
limit_questions_to: 0,
|
||||
show_answers: true,
|
||||
show_submission_history: false,
|
||||
shuffle_questions: false,
|
||||
questions: [],
|
||||
})
|
||||
const questions = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
@@ -275,90 +287,30 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const quizDetails = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return { doctype: 'LMS Quiz', name: props.quizID }
|
||||
},
|
||||
const quizDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz',
|
||||
name: props.quizID,
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(quiz, key)) quiz[key] = data[key]
|
||||
})
|
||||
|
||||
let checkboxes = [
|
||||
'show_answers',
|
||||
'show_submission_history',
|
||||
'shuffle_questions',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
quiz[key] = quiz[key] ? true : false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const quizCreate = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Quiz',
|
||||
...quiz,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const quizUpdate = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
auto: false,
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Quiz',
|
||||
name: values.quizID,
|
||||
fieldname: {
|
||||
total_marks: calculateTotalMarks(),
|
||||
...quiz,
|
||||
},
|
||||
onSuccess(doc) {
|
||||
if (doc.questions && doc.questions.length > 0) {
|
||||
questions.value = doc.questions.map((question) => question)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitQuiz = () => {
|
||||
if (quizDetails.data?.name) updateQuiz()
|
||||
else createQuiz()
|
||||
}
|
||||
|
||||
const createQuiz = () => {
|
||||
quizCreate.submit(
|
||||
{},
|
||||
quizDetails.setValue.submit(
|
||||
{
|
||||
...quizDetails.doc,
|
||||
total_marks: calculateTotalMarks(),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(__('Success'), __('Quiz created successfully'), 'check')
|
||||
router.push({
|
||||
name: 'QuizForm',
|
||||
params: { quizID: data.name },
|
||||
})
|
||||
quizDetails.doc.total_marks = data.total_marks
|
||||
toast.success(__('Quiz updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateQuiz = () => {
|
||||
quizUpdate.submit(
|
||||
{ quizID: quizDetails.data?.name },
|
||||
{
|
||||
onSuccess(data) {
|
||||
quiz.total_marks = data.total_marks
|
||||
showToast(__('Success'), __('Quiz updated successfully'), 'check')
|
||||
},
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -366,9 +318,15 @@ const updateQuiz = () => {
|
||||
|
||||
const calculateTotalMarks = () => {
|
||||
let totalMarks = 0
|
||||
if (quiz.limit_questions_to && quiz.questions.length > 0)
|
||||
return quiz.questions[0].marks * quiz.limit_questions_to
|
||||
quiz.questions.forEach((question) => {
|
||||
if (
|
||||
quizDetails.doc?.limit_questions_to &&
|
||||
quizDetails.doc?.questions.length > 0
|
||||
)
|
||||
return (
|
||||
quizDetails.doc.questions[0].marks * quizDetails.doc.limit_questions_to
|
||||
)
|
||||
|
||||
quizDetails.doc?.questions.forEach((question) => {
|
||||
totalMarks += question.marks
|
||||
})
|
||||
return totalMarks
|
||||
@@ -424,7 +382,7 @@ const deleteQuestions = (selections, unselectAll) => {
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
showToast(__('Success'), __('Questions deleted successfully'), 'check')
|
||||
toast.success(__('Questions deleted successfully'))
|
||||
quizDetails.reload()
|
||||
unselectAll()
|
||||
},
|
||||
@@ -441,24 +399,18 @@ const breadcrumbs = computed(() => {
|
||||
},
|
||||
},
|
||||
]
|
||||
/* if (quizDetails.data) {
|
||||
crumbs.push({
|
||||
label: quiz.title,
|
||||
})
|
||||
} */
|
||||
|
||||
crumbs.push({
|
||||
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||
route: { name: 'QuizForm', params: { quizID: props.quizID } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
|
||||
description: __('Form to create and edit quizzes'),
|
||||
title: props.quizID == 'new' ? __('New Quiz') : quizDetails.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
<template>
|
||||
<header
|
||||
v-if="!fromLesson"
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="md:w-7/12 md:mx-auto mx-4 py-10">
|
||||
<div
|
||||
class="md:w-7/12 md:mx-auto mx-4 py-10"
|
||||
:class="{ 'pt-4 md:w-full': fromLesson }"
|
||||
>
|
||||
<Quiz :quizName="quizID" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Quiz from '@/components/Quiz.vue'
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { createResource, Breadcrumbs, usePageMeta } from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const fromLesson = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
|
||||
if (new URLSearchParams(window.location.search).get('fromLesson')) {
|
||||
fromLesson.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
@@ -47,12 +57,10 @@ const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submission') }, { label: title.data?.title }]
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: title.data?.title,
|
||||
description: __('Quiz Submission'),
|
||||
title: `${title.data?.title}`,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" />
|
||||
<Breadcrumbs v-if="submissionDetails.doc" :items="breadcrumbs" />
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="submisisonDetails.isDirty"
|
||||
v-if="submissionDetails.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
@@ -15,19 +15,19 @@
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-5">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ submisisonDetails.doc.member_name }}
|
||||
<div v-if="submissionDetails.doc" class="w-2/3 border-x mx-auto py-5">
|
||||
<div class="text-xl px-10 font-semibold text-ink-gray-9 mb-5">
|
||||
{{ submissionDetails.doc.member_name }}
|
||||
</div>
|
||||
<div class="space-y-4 border p-5 rounded-md">
|
||||
<div class="space-y-4 border-b pb-5 px-10">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.quiz_title"
|
||||
v-model="submissionDetails.doc.quiz_title"
|
||||
:label="__('Quiz')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.member_name"
|
||||
v-model="submissionDetails.doc.member_name"
|
||||
:label="__('Member')"
|
||||
:disabled="true"
|
||||
/>
|
||||
@@ -35,39 +35,39 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.score"
|
||||
v-model="submissionDetails.doc.score"
|
||||
:label="__('Score')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="submisisonDetails.doc.percentage"
|
||||
v-model="submissionDetails.doc.percentage"
|
||||
:label="__('Percentage')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(row, index) in submisisonDetails.doc.result"
|
||||
class="border p-5 rounded-md space-y-4"
|
||||
>
|
||||
<div class="flex items-start space-x-1 font-semibold text-ink-gray-9">
|
||||
<!-- <span>
|
||||
{{ index + 1 }}.
|
||||
</span> -->
|
||||
<span class="leading-5" v-html="row.question"> </span>
|
||||
</div>
|
||||
<div class="leading-5 text-ink-gray-7 space-x-1">
|
||||
<span> {{ __('Answer') }}: </span>
|
||||
<span v-html="row.answer"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl v-model="row.marks" :label="__('Marks')" />
|
||||
<FormControl
|
||||
v-model="row.marks_out_of"
|
||||
:label="__('Marks out of')"
|
||||
:disabled="true"
|
||||
/>
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="(row, index) in submissionDetails.doc.result"
|
||||
class="py-5 px-10 space-y-4"
|
||||
>
|
||||
<div class="text-ink-gray-9">
|
||||
<span class="font-semibold"> {{ __('Question') }}: </span>
|
||||
<span class="leading-5" v-html="row.question"> </span>
|
||||
</div>
|
||||
<div class="">
|
||||
<span class="font-semibold"> {{ __('Answer') }} </span>
|
||||
<span class="leading-5" v-html="row.answer"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl v-model="row.marks" :label="__('Marks')" />
|
||||
<FormControl
|
||||
v-model="row.marks_out_of"
|
||||
:label="__('Marks out of')"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,11 +79,14 @@ import {
|
||||
FormControl,
|
||||
Button,
|
||||
Badge,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onBeforeUnmount, onMounted, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -116,7 +119,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const submisisonDetails = createDocumentResource({
|
||||
const submissionDetails = createDocumentResource({
|
||||
doctype: 'LMS Quiz Submission',
|
||||
name: props.submission,
|
||||
auto: true,
|
||||
@@ -129,24 +132,31 @@ const breadcrumbs = computed(() => {
|
||||
route: {
|
||||
name: 'QuizSubmissionList',
|
||||
params: {
|
||||
quizID: submisisonDetails.doc.quiz,
|
||||
quizID: submissionDetails.doc.quiz,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: submisisonDetails.doc.quiz_title,
|
||||
label: submissionDetails.doc.quiz_title,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const saveSubmission = () => {
|
||||
submisisonDetails.save.submit(
|
||||
submissionDetails.save.submit(
|
||||
{},
|
||||
{
|
||||
onError(err) {
|
||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: `${submissionDetails.doc?.quiz_title}`,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else />
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -51,10 +52,14 @@ import {
|
||||
ListRows,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, inject } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -105,4 +110,11 @@ const quizColumns = computed(() => {
|
||||
const breadcrumbs = computed(() => {
|
||||
return [{ label: __('Quiz Submissions') }]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Quiz Submissions'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,33 +3,42 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'QuizForm',
|
||||
params: {
|
||||
quizID: 'new',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('New Quiz') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-if="!readOnlyMode" variant="solid" @click="showForm = true">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="py-5 mx-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-7">
|
||||
{{
|
||||
quizzes.data?.length
|
||||
? __('{0} Quizzes').format(quizzes.data.length)
|
||||
: __('No Quizzes')
|
||||
}}
|
||||
</div>
|
||||
<FormControl v-model="search" type="text" placeholder="Search">
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="size-4 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="quizzes.data?.length"
|
||||
:columns="quizColumns"
|
||||
:rows="quizzes.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false, selectable: false }"
|
||||
:options="{ showTooltip: false, selectable: true }"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
@@ -42,92 +51,220 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row" />
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'show_answers'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="row[column.key]"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-xs text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="deleteQuiz(selections, unselectAll)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<div class="flex justify-center my-5">
|
||||
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
|
||||
<EmptyState v-else type="Quizzes" />
|
||||
<div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
|
||||
<Button @click="quizzes.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Create a Quiz'),
|
||||
size: 'sm',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
insertQuiz(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
|
||||
<div class="text-xl font-medium">
|
||||
{{ __('No quizzes found') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'You have not created any quizzes yet. To create a new quiz, click on the "New Quiz" button above.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<template #body-content>
|
||||
<FormControl v-model="title" :label="__('Title')" type="text" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createListResource,
|
||||
Dialog,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const router = useRouter()
|
||||
const search = ref('')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const quizFilters = ref({})
|
||||
const showForm = ref(false)
|
||||
const title = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
} else if (!user.data?.is_moderator) {
|
||||
quizFilters.value['owner'] = user.data?.name
|
||||
}
|
||||
})
|
||||
|
||||
const quizFilter = computed(() => {
|
||||
if (user.data?.is_moderator) return {}
|
||||
return {
|
||||
owner: user.data?.name,
|
||||
}
|
||||
watch(search, () => {
|
||||
quizFilters.value['title'] = ['like', `%${search.value}%`]
|
||||
quizzes.update({
|
||||
filters: quizFilters.value,
|
||||
})
|
||||
quizzes.reload()
|
||||
})
|
||||
|
||||
const quizzes = createListResource({
|
||||
doctype: 'LMS Quiz',
|
||||
filters: quizFilter,
|
||||
fields: ['name', 'title', 'passing_percentage', 'total_marks'],
|
||||
filters: quizFilters,
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'passing_percentage',
|
||||
'total_marks',
|
||||
'show_answers',
|
||||
'max_attempts',
|
||||
'modified',
|
||||
],
|
||||
auto: true,
|
||||
cache: ['quizzes', user.data?.name],
|
||||
orderBy: 'modified desc',
|
||||
transform(data) {
|
||||
return data.map((quiz) => {
|
||||
return {
|
||||
...quiz,
|
||||
modified: dayjs(quiz.modified).fromNow(),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const insertQuiz = (close) => {
|
||||
quizzes.insert.submit(
|
||||
{
|
||||
title: title.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Quiz created successfully'))
|
||||
close()
|
||||
title.value = ''
|
||||
router.push({
|
||||
name: 'QuizForm',
|
||||
params: {
|
||||
quizID: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(__('Error creating quiz: {0}', error.message))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteQuiz = (selections, unselectAll) => {
|
||||
Array.from(selections).forEach(async (quizName) => {
|
||||
await quizzes.delete.submit(quizName)
|
||||
})
|
||||
unselectAll()
|
||||
toast.success(__('Quizzes deleted successfully'))
|
||||
}
|
||||
|
||||
const quizColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Title'),
|
||||
key: 'title',
|
||||
width: 2,
|
||||
icon: 'file-text',
|
||||
},
|
||||
{
|
||||
label: __('Total Marks'),
|
||||
key: 'total_marks',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'hash',
|
||||
},
|
||||
{
|
||||
label: __('Passing Percentage'),
|
||||
key: 'passing_percentage',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'percent',
|
||||
},
|
||||
{
|
||||
label: __('Max Attempts'),
|
||||
key: 'max_attempts',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'repeat',
|
||||
},
|
||||
{
|
||||
label: __('Show Answers'),
|
||||
key: 'show_answers',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'eye',
|
||||
},
|
||||
{
|
||||
label: __('Modified'),
|
||||
key: 'modified',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -143,12 +280,10 @@ const breadcrumbs = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Quizzes'),
|
||||
description: __('List of quizzes'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -42,11 +42,13 @@ import {
|
||||
createDocumentResource,
|
||||
createListResource,
|
||||
createResource,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onBeforeMount, ref } from 'vue'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
const sidebarStore = useSidebar()
|
||||
const user = inject('$user')
|
||||
const readyToRender = ref(false)
|
||||
@@ -222,14 +224,10 @@ const breadcrumbs = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: chapter?.doc?.title,
|
||||
description: __('This is a chapter in the course {0}').format(
|
||||
chapter?.doc?.course_title
|
||||
),
|
||||
title: chapter.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
@@ -7,109 +7,125 @@
|
||||
</header>
|
||||
<div v-if="chartDetails.data" class="p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div
|
||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
||||
>
|
||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
||||
<BookOpen class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.courses) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
||||
>
|
||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
||||
<LogIn class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.users) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Signups') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
||||
>
|
||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
||||
<BookOpenCheck class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.enrollments) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Enrollments') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
||||
>
|
||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
||||
<FileCheck class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.completions) }}
|
||||
</div>
|
||||
<div>
|
||||
{{ __('Completions') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
||||
>
|
||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
||||
<FileCheck2 class="w-18 h-18 stroke-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ formatNumber(chartDetails.data.lesson_completions) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{ __('Milestones') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip :text="__('Published Courses')">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: 'Courses', value: chartDetails.data.courses }"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Active Members')">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: 'Signups', value: chartDetails.data.users }"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Course Enrollments')">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: 'Enrollments',
|
||||
value: chartDetails.data.enrollments,
|
||||
}"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Course Completions')">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: 'Completions',
|
||||
value: chartDetails.data.completions,
|
||||
}"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Certified Members')">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: 'Certifications',
|
||||
value: chartDetails.data.certifications,
|
||||
}"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
<div class="border rounded-md p-5 min-h-72">
|
||||
<Line
|
||||
<div class="border rounded-md min-h-72">
|
||||
<AxisChart
|
||||
v-if="signupsChart.data"
|
||||
:data="signupsChart.data"
|
||||
:options="signupChartOptions()"
|
||||
:config="{
|
||||
data: signupsChart.data,
|
||||
title: 'Signups',
|
||||
subtitle: 'Signups per day',
|
||||
xAxis: {
|
||||
key: 'date',
|
||||
type: 'time',
|
||||
title: 'Date',
|
||||
timeGrain: 'day',
|
||||
},
|
||||
yAxis: {
|
||||
title: 'Signups',
|
||||
},
|
||||
series: [{ name: 'signups', type: 'line', showDataPoints: true }],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-md p-5 min-h-72">
|
||||
<Line
|
||||
<div class="border rounded-md min-h-72">
|
||||
<AxisChart
|
||||
v-if="enrollmentChart.data"
|
||||
:data="enrollmentChart.data"
|
||||
:options="enrollmentChartOptions()"
|
||||
:config="{
|
||||
data: enrollmentChart.data,
|
||||
title: 'Enrollments',
|
||||
subtitle: 'Enrollments per day',
|
||||
xAxis: {
|
||||
key: 'date',
|
||||
type: 'time',
|
||||
title: 'Date',
|
||||
timeGrain: 'day',
|
||||
},
|
||||
yAxis: {
|
||||
title: 'Enrollments',
|
||||
},
|
||||
series: [
|
||||
{ name: 'enrollments', type: 'line', showDataPoints: true },
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-md p-5">
|
||||
<Line
|
||||
v-if="lessonCompletion.data"
|
||||
:data="lessonCompletion.data"
|
||||
:options="lessonChartOptions()"
|
||||
<div class="border rounded-md">
|
||||
<AxisChart
|
||||
v-if="certification.data"
|
||||
:config="{
|
||||
data: certification.data,
|
||||
title: 'Certifications',
|
||||
subtitle: 'Certifications per day',
|
||||
xAxis: {
|
||||
key: 'date',
|
||||
type: 'time',
|
||||
title: 'Date',
|
||||
timeGrain: 'day',
|
||||
},
|
||||
yAxis: {
|
||||
title: 'Certifications',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'certifications',
|
||||
type: 'line',
|
||||
showDataPoints: true,
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-md p-5">
|
||||
<Pie
|
||||
<div class="border rounded-md">
|
||||
<DonutChart
|
||||
v-if="courseCompletion.data"
|
||||
:data="courseCompletion.data"
|
||||
:options="courseChartOptions()"
|
||||
:config="{
|
||||
data: courseCompletion.data,
|
||||
title: 'Completions',
|
||||
subtitle: 'Course Completion',
|
||||
categoryColumn: 'label',
|
||||
valueColumn: 'value',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,44 +133,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { updateDocumentTitle } from '@/utils'
|
||||
import { formatNumber } from '@/utils'
|
||||
import { Line, Pie } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
AxisChart,
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
DonutChart,
|
||||
NumberChart,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
ArcElement,
|
||||
Filler,
|
||||
} from 'chart.js'
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
ArcElement,
|
||||
Filler
|
||||
)
|
||||
import {
|
||||
BookOpen,
|
||||
LogIn,
|
||||
FileCheck,
|
||||
FileCheck2,
|
||||
BookOpenCheck,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
@@ -175,11 +166,18 @@ const chartDetails = createResource({
|
||||
|
||||
const signupsChart = createResource({
|
||||
url: 'lms.lms.utils.get_chart_data',
|
||||
cache: ['signups'],
|
||||
params: {
|
||||
chart_name: 'New Signups',
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
date: new Date(item.date),
|
||||
signups: item.count,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const enrollmentChart = createResource({
|
||||
@@ -189,15 +187,31 @@ const enrollmentChart = createResource({
|
||||
chart_name: 'Course Enrollments',
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
date: new Date(item.date),
|
||||
enrollments: item.count,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const lessonCompletion = createResource({
|
||||
const certification = createResource({
|
||||
url: 'lms.lms.utils.get_chart_data',
|
||||
cache: ['lessonCompletion'],
|
||||
cache: ['certifications'],
|
||||
params: {
|
||||
chart_name: 'Lesson Completion',
|
||||
chart_name: 'Certification',
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
date: new Date(item.date),
|
||||
certifications: item.count,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const courseCompletion = createResource({
|
||||
@@ -206,123 +220,10 @@ const courseCompletion = createResource({
|
||||
cache: ['courseCompletion'],
|
||||
})
|
||||
|
||||
const signupChartOptions = () => {
|
||||
let options = chartOptions(false)
|
||||
options.plugins.title.text = 'Signups'
|
||||
options.borderColor = '#4563f0'
|
||||
options.backgroundColor = (ctx) => {
|
||||
const canvas = ctx.chart.ctx
|
||||
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
|
||||
gradient.addColorStop(0, '#4563f0')
|
||||
gradient.addColorStop(0.5, '#e8ecfe')
|
||||
gradient.addColorStop(1, '#f6f7ff')
|
||||
|
||||
return gradient
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
const enrollmentChartOptions = () => {
|
||||
let options = chartOptions(false)
|
||||
options.plugins.title.text = 'Enrollments'
|
||||
options.borderColor = '#4563f0'
|
||||
options.backgroundColor = (ctx) => {
|
||||
const canvas = ctx.chart.ctx
|
||||
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
|
||||
gradient.addColorStop(0, '#4563f0')
|
||||
gradient.addColorStop(0.5, '#e8ecfe')
|
||||
gradient.addColorStop(1, '#f6f7ff')
|
||||
|
||||
return gradient
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
const lessonChartOptions = () => {
|
||||
let options = chartOptions(false)
|
||||
options.plugins.title.text = 'Milestones'
|
||||
options.borderColor = '#4563f0'
|
||||
options.backgroundColor = (ctx) => {
|
||||
const canvas = ctx.chart.ctx
|
||||
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
|
||||
gradient.addColorStop(0, '#B6DEC5')
|
||||
gradient.addColorStop(0.5, '#e8ecfe')
|
||||
gradient.addColorStop(1, '#f6f7ff')
|
||||
|
||||
return gradient
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
const courseChartOptions = () => {
|
||||
let options = chartOptions(true)
|
||||
options.plugins.title.text = 'Completions'
|
||||
options.backgroundColor = ['#4563f0', '#f683ae']
|
||||
return options
|
||||
}
|
||||
|
||||
const chartOptions = (isPie) => {
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
fill: true,
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
pointStyle: 'cross',
|
||||
ticks: {
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 5,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: isPie ? true : false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
align: 'start',
|
||||
font: {
|
||||
size: 14,
|
||||
weight: '500',
|
||||
},
|
||||
color: '#171717',
|
||||
padding: {
|
||||
bottom: 20,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: isPie ? false : true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: isPie ? false : true,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
display: isPie ? false : true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: isPie ? false : true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const pageMeta = computed(() => {
|
||||
return {
|
||||
title: 'Statistics',
|
||||
description: 'Statistics of the platform',
|
||||
title: __('Statistics'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
updateDocumentTitle(pageMeta)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user