feat: batch page new look
This commit is contained in:
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="">
|
||||
<div class="w-full flex items-center justify-between pb-4">
|
||||
<div class="font-medium text-ink-gray-7">
|
||||
{{ __('Statistics') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: studentCount.data || 0 }"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Certified'),
|
||||
value: certificationCount.data || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Courses'),
|
||||
value: batch?.data?.courses?.length || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Assessments'), value: assessmentCount.data || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border rounded-lg p-3 min-h-[300px]"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
swapXY: true,
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
batch: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const studentCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_student_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: { batch: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const assessmentCount = createResource({
|
||||
url: 'lms.lms.utils.get_batch_assessment_count',
|
||||
cache: ['batch_assessment_count', props.batch?.data?.name],
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const chartData = createResource({
|
||||
url: 'lms.lms.utils.get_batch_chart_data',
|
||||
cache: ['batch_chart_data', props.batch?.data?.name],
|
||||
params: { batch: props.batch?.data?.name },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_certificate_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: { batch_name: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const filteredChartData = computed(() =>
|
||||
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
|
||||
)
|
||||
|
||||
const showProgressChart = computed(
|
||||
() =>
|
||||
studentCount.data &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.data)
|
||||
)
|
||||
</script>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No announcements') }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasPermission() && !props.zoomAccount"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3 text-xs"
|
||||
>
|
||||
<AlertCircle class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Please add a zoom account to the batch to create live classes.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': hasPermission() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<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>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ 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 v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
:batch="props.batch"
|
||||
:zoomAccount="props.zoomAccount"
|
||||
v-model="showLiveClassModal"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!props.zoomAccount) return false
|
||||
return hasPermission()
|
||||
}
|
||||
|
||||
const hasPermission = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
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 getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!hasPermission()) return
|
||||
if (cls.attendees <= 0) return
|
||||
showAttendance.value = true
|
||||
attendanceFor.value = cls
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Enroll a Student'),
|
||||
size: 'sm',
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
@@ -52,8 +52,6 @@ import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const students = defineModel('reloadStudents')
|
||||
const batchModal = defineModel('batchModal')
|
||||
const student = ref(null)
|
||||
const payment = ref(null)
|
||||
const user = inject('$user')
|
||||
@@ -62,33 +60,37 @@ const show = defineModel()
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
students: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const addStudent = (close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
batch: props.batch,
|
||||
props.students.insert.submit(
|
||||
{
|
||||
member: student.value,
|
||||
payment: payment.value,
|
||||
batch: props.batch.data?.name,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
|
||||
students.value.reload()
|
||||
batchModal.value.reload()
|
||||
student.value = null
|
||||
payment.value = null
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
student.value = null
|
||||
payment.value = null
|
||||
props.batch.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5 pb-10">
|
||||
<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>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<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="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="hidden md:block">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.courses.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
:key="course.course"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :course="course" :key="course.name" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.batch_details_raw">
|
||||
<div
|
||||
v-html="batch.data.batch_details_raw"
|
||||
class="batch-description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||
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'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
router.push({ name: 'Batches' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
cache: ['batchCourses', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.batch-description strong {
|
||||
font-weight: 600;
|
||||
color: theme('colors.gray.900') !important;
|
||||
}
|
||||
</style>
|
||||
@@ -235,18 +235,6 @@ 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'
|
||||
import BatchStudents from '@/components/BatchStudents.vue'
|
||||
import AdminBatchDashboard from '@/components/AdminBatchDashboard.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
import Announcements from '@/components/Annoucements.vue'
|
||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||
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'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
|
||||
143
frontend/src/pages/Batches/BatchDetail.vue
Normal file
143
frontend/src/pages/Batches/BatchDetail.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div>
|
||||
<BatchOverview v-if="!isAdmin && !isStudent" :batch="batch" />
|
||||
<div v-else>
|
||||
<Tabs :tabs="tabs" v-model="tabIndex">
|
||||
<template #tab-panel="{ tab }">
|
||||
<component :is="tab.component" :batch="batch" ref="childRef" />
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject, markRaw, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Laptop,
|
||||
List,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Settings2,
|
||||
TrendingUp,
|
||||
} from 'lucide-vue-next'
|
||||
import { Breadcrumbs, createResource, Tabs, usePageMeta } from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import AdminBatchDashboard from '@/pages/Batches/components/AdminBatchDashboard.vue'
|
||||
import StudentBatchDashboard from '@/pages/Batches/components/BatchDashboard.vue'
|
||||
import BatchOverview from '@/pages/Batches/BatchOverview.vue'
|
||||
import LiveClass from '@/pages/Batches/components/LiveClass.vue'
|
||||
import Announcements from '@/pages/Batches/components/Annoucements.vue'
|
||||
import BatchForm from '@/pages/Batches/BatchForm.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const childRef = ref(null)
|
||||
const tabIndex = ref(5)
|
||||
const tabs = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
router.push({ name: 'Batches' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(batch, () => {
|
||||
updateTabs()
|
||||
})
|
||||
|
||||
const updateTabs = () => {
|
||||
addToTabs('Overview', markRaw(BatchOverview), List)
|
||||
if (!user.data) return
|
||||
if (isAdmin.value) {
|
||||
addToTabs('Dashboard', markRaw(AdminBatchDashboard), TrendingUp)
|
||||
} else if (isStudent.value) {
|
||||
addToTabs('Dashboard', markRaw(StudentBatchDashboard), null)
|
||||
}
|
||||
addToTabs('Classes', markRaw(LiveClass), Laptop)
|
||||
addToTabs('Announcements', markRaw(Announcements), Mail)
|
||||
addToTabs('Discussions', markRaw(Discussions), MessageCircle)
|
||||
if (isAdmin.value) {
|
||||
addToTabs('Settings', markRaw(BatchForm), Settings2)
|
||||
}
|
||||
}
|
||||
|
||||
const addToTabs = (label, component, icon) => {
|
||||
if (!tabs.value.some((tab) => tab.label === label)) {
|
||||
tabs.value.push({
|
||||
label,
|
||||
component,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || batch.data?.is_evaluator
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return batch.data?.students?.includes(user.data?.name)
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.batch-description strong {
|
||||
font-weight: 600;
|
||||
color: theme('colors.gray.900') !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<header
|
||||
<!-- <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 class="h-7" :items="breadcrumbs" />
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
</header> -->
|
||||
<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">
|
||||
@@ -298,7 +298,7 @@ import {
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
@@ -313,8 +313,8 @@ const { capture } = useTelemetry()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
@@ -361,7 +361,7 @@ onMounted(() => {
|
||||
|
||||
const fetchBatchInfo = () => {
|
||||
batchDetail.reload()
|
||||
getMetaInfo('batches', props.batchName, meta)
|
||||
getMetaInfo('batches', props.batch?.data?.name, meta)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
90
frontend/src/pages/Batches/BatchOverview.vue
Normal file
90
frontend/src/pages/Batches/BatchOverview.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="m-5 pb-10">
|
||||
<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>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<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="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="hidden md:block">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.courses.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
:key="course.course"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :course="course" :key="course.name" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.batch_details_raw">
|
||||
<div
|
||||
v-html="batch.data.batch_details_raw"
|
||||
class="batch-description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource } from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchOverlay from '@/pages/Batches/components/BatchOverlay.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
cache: ['batchCourses', props.batch?.data?.name],
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
@@ -141,7 +141,7 @@ import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronDown, Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
281
frontend/src/pages/Batches/components/AdminBatchDashboard.vue
Normal file
281
frontend/src/pages/Batches/components/AdminBatchDashboard.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="p-5">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChartGraph
|
||||
:title="__('Enrolled')"
|
||||
:value="formatAmount(batch.data?.students?.length) || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
:title="__('Certified')"
|
||||
:value="certificationCount.data || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
class="border rounded-md"
|
||||
:title="__('Courses')"
|
||||
:value="batch?.data?.courses?.length || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
class="border rounded-md"
|
||||
:title="__('Assessments')"
|
||||
:value="assessmentCount.data || 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[3fr_2fr] gap-5 items-start">
|
||||
<div v-if="students.data?.length" class="border rounded-lg py-3 px-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by name')"
|
||||
type="text"
|
||||
/>
|
||||
<Button @click="showEnrollmentModal = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="students.loading || students.data?.length"
|
||||
class="max-h-[63vh] overflow-y-auto"
|
||||
>
|
||||
<ListView
|
||||
:columns="studentColumns"
|
||||
:rows="students.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in studentColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in students.data" class="max-h-[500px]">
|
||||
<ListRow
|
||||
:row="row"
|
||||
@click="
|
||||
() => {
|
||||
/* showProgressModal = true
|
||||
currentStudent = row */
|
||||
}
|
||||
"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<!-- <ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
/> -->
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="students.data && students.hasNextPage"
|
||||
class="flex justify-center my-3"
|
||||
>
|
||||
<Button @click="students.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border rounded-lg p-3 min-h-[300px]"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="p-4 border rounded-lg mt-5">
|
||||
<BatchFeedback v-if="batch.data" :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StudentModal
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:batch="batch"
|
||||
:students="students"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AxisChart,
|
||||
createResource,
|
||||
createListResource,
|
||||
dayjs,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
Avatar,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { formatAmount } from '@/utils'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const showEnrollmentModal = ref<boolean>(false)
|
||||
|
||||
const props = defineProps<{
|
||||
batch: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const assessmentCount = createResource({
|
||||
url: 'lms.lms.utils.get_batch_assessment_count',
|
||||
cache: ['batch_assessment_count', props.batch?.data?.name],
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const chartData = createResource({
|
||||
url: 'lms.lms.utils.get_batch_chart_data',
|
||||
cache: ['batch_chart_data', props.batch?.data?.name],
|
||||
params: { batch: props.batch?.data?.name },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_certificate_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: { batch_name: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const students = createListResource({
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_username',
|
||||
'member_image',
|
||||
'creation',
|
||||
],
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const filteredChartData = computed(() =>
|
||||
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
|
||||
)
|
||||
|
||||
watch(searchFilter, () => {
|
||||
let filters: Record<string, any> = {
|
||||
batch: props.batch?.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
}
|
||||
|
||||
students.update({ filters })
|
||||
students.reload()
|
||||
})
|
||||
|
||||
const studentColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Name'),
|
||||
key: 'member_name',
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
label: __('Enrolled On'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const showProgressChart = computed(
|
||||
() =>
|
||||
students.data?.length &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.data)
|
||||
)
|
||||
</script>
|
||||
55
frontend/src/pages/Batches/components/Annoucements.vue
Normal file
55
frontend/src/pages/Batches/components/Annoucements.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No announcements have been made yet for this batch') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch.data?.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
import Assessments from '@/pages/Batches/components/Assessments.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
@@ -1,63 +1,68 @@
|
||||
<template>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
@click="showFeedbackForm = !showFeedbackForm"
|
||||
class="underline cursor-pointer"
|
||||
>{{ __('Click here') }}</span
|
||||
>
|
||||
{{ __('to view your feedback.') }}
|
||||
<div>
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
||||
{{ __('Feedback') }}
|
||||
</div>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
@click="showFeedbackForm = !showFeedbackForm"
|
||||
class="underline cursor-pointer"
|
||||
>{{ __('Click here') }}</span
|
||||
>
|
||||
{{ __('to view your feedback.') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ __('Help us improve by providing your feedback.') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ __('Help us improve by providing your feedback.') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="feedback[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="feedback[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="feedback.feedback"
|
||||
type="textarea"
|
||||
:label="__('Feedback')"
|
||||
:rows="9"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
<Button v-if="!readOnly" @click="submitFeedback">
|
||||
{{ __('Submit Feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="feedback.feedback"
|
||||
type="textarea"
|
||||
:label="__('Feedback')"
|
||||
:rows="9"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
<Button v-if="!readOnly" @click="submitFeedback">
|
||||
{{ __('Submit Feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="feedbackList.data?.length">
|
||||
<div class="leading-5 text-sm mb-2 mt-5">
|
||||
{{ __('Average Feedback Received') }}
|
||||
<div v-else-if="feedbackList.data?.length">
|
||||
<div class="leading-5 text-sm mb-2 mt-5">
|
||||
{{ __('Average Feedback Received') }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="average[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
|
||||
{{ __('View all feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="average[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="true"
|
||||
/>
|
||||
<div v-else class="text-ink-gray-7 leading-5">
|
||||
{{ __('No feedback received yet.') }}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
|
||||
{{ __('View all feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 mt-5 leading-5">
|
||||
{{ __('No feedback received yet.') }}
|
||||
</div>
|
||||
<FeedbackModal
|
||||
v-if="feedbackList.data?.length"
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.amount"
|
||||
class="text-lg font-semibold mb-3 text-ink-gray-9"
|
||||
class="text-lg font-semibold mb-5 text-ink-gray-9"
|
||||
>
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</div>
|
||||
@@ -56,25 +56,6 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="canAccessBatch"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<template #prefix>
|
||||
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||
<Settings v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
@@ -83,7 +64,7 @@
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="
|
||||
v-if="
|
||||
batch.data.paid_batch &&
|
||||
batch.data.seats_left > 0 &&
|
||||
batch.data.accept_enrollments
|
||||
@@ -113,24 +94,6 @@
|
||||
</template>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="canEditBatch"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full mt-2">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
227
frontend/src/pages/Batches/components/LiveClass.vue
Normal file
227
frontend/src/pages/Batches/components/LiveClass.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div
|
||||
v-if="isAdmin() && !props.zoomAccount"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 px-3 py-2 rounded-lg text-ink-amber-3"
|
||||
>
|
||||
<AlertCircle class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{
|
||||
__(
|
||||
'Link a Zoom account to this batch from the Settings tab to create live classes'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': isAdmin() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<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>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ 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 v-else class="text-ink-gray-7 mt-5">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
:batch="props.batch"
|
||||
:zoomAccount="props.zoomAccount"
|
||||
v-model="showLiveClassModal"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!props.zoomAccount) return false
|
||||
return isAdmin()
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
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 getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!isAdmin()) return
|
||||
if (cls.attendees <= 0) return
|
||||
showAttendance.value = true
|
||||
attendanceFor.value = cls
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -349,14 +349,12 @@ const updateLessonProgress = (value: string) => {
|
||||
}
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
@@ -392,7 +390,7 @@ const progressColumns = computed(() => {
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
label: __('Start Date'),
|
||||
label: __('Enrolled On'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
|
||||
@@ -210,7 +210,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
|
||||
@@ -150,7 +150,7 @@ import {
|
||||
Video,
|
||||
} from 'lucide-vue-next'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
|
||||
@@ -42,18 +42,12 @@ const routes = [
|
||||
{
|
||||
path: '/batches',
|
||||
name: 'Batches',
|
||||
component: () => import('@/pages/Batches.vue'),
|
||||
},
|
||||
{
|
||||
path: '/batches/details/:batchName',
|
||||
name: 'BatchDetail',
|
||||
component: () => import('@/pages/BatchDetail.vue'),
|
||||
props: true,
|
||||
component: () => import('@/pages/Batches/Batches.vue'),
|
||||
},
|
||||
{
|
||||
path: '/batches/:batchName',
|
||||
name: 'Batch',
|
||||
component: () => import('@/pages/Batch.vue'),
|
||||
name: 'BatchDetail',
|
||||
component: () => import('@/pages/Batches/BatchDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -125,12 +119,6 @@ const routes = [
|
||||
component: () => import('@/pages/LessonForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/batches/:batchName/edit',
|
||||
name: 'BatchForm',
|
||||
component: () => import('@/pages/BatchForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/job-opening/:jobName/edit',
|
||||
name: 'JobForm',
|
||||
|
||||
Reference in New Issue
Block a user