feat: batch page new look

This commit is contained in:
Jannat Patel
2026-02-10 19:53:26 +05:30
parent 70872857d1
commit e9f0b12550
27 changed files with 900 additions and 713 deletions

View File

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

View File

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

View File

@@ -0,0 +1,167 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="font-medium text-ink-gray-9">
{{ __('Courses') }}
</div>
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="courses.data?.length">
<ListView
:columns="getCoursesColumns()"
:rows="courses.data"
row-key="batch_course"
:options="{
showTooltip: false,
selectable: user.data?.is_student ? false : true,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.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 getCoursesColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in courses.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeCourses(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No courses added') }}
</div>
<BatchCourseModal
v-model="showCourseModal"
:batch="batch"
v-model:courses="courses"
/>
</div>
</template>
<script setup>
import { ref, inject } from 'vue'
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
import {
createResource,
Button,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
const readOnlyMode = window.read_only_mode
const showCourseModal = ref(false)
const user = inject('$user')
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batch,
},
auto: true,
})
const openCourseModal = () => {
showCourseModal.value = true
}
const getCoursesColumns = () => {
return [
{
label: 'Title',
key: 'title',
width: 2,
},
{
label: 'Lessons',
key: 'lessons',
align: 'right',
},
{
label: 'Enrollments',
align: 'right',
key: 'enrollments',
},
]
}
const deleteCourses = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'Batch Course',
documents: values.courses,
}
},
})
const removeCourses = (selections, unselectAll) => {
deleteCourses.submit(
{
courses: Array.from(selections),
},
{
onSuccess(data) {
courses.reload()
toast.success(__('Courses deleted successfully'))
unselectAll()
},
}
)
}
const canSeeAddButton = () => {
if (readOnlyMode) {
return false
}
return user.data?.is_moderator || user.data?.is_evaluator
}
</script>

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

View File

@@ -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) => {

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

View File

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

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

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

View File

@@ -0,0 +1,252 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Assessments') }}
</div>
<Button v-if="canAddAssessments()" @click="showModal = true">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="assessments.data?.length" class="text-sm">
<ListView
:columns="getAssessmentColumns()"
:rows="assessments.data"
row-key="name"
:options="{
showTooltip: false,
getRowRoute: (row) => getRowRoute(row),
selectable: user.data?.is_student ? false : 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 getAssessmentColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assessments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'">
{{ getAssessmentTypeLabel(row[column.key]) }}
</div>
<div v-else-if="column.key == 'title'">
{{ row[column.key] }}
</div>
<div v-else-if="isNaN(row[column.key])">
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAssessments(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No Assessments') }}
</div>
</div>
<AssessmentModal
v-model="showModal"
v-model:assessments="assessments"
:batch="props.batch"
/>
</template>
<script setup>
import {
ListView,
ListRow,
ListRows,
ListHeader,
ListHeaderItem,
ListRowItem,
ListSelectBanner,
createResource,
Button,
Badge,
} from 'frappe-ui'
import { inject, ref } from 'vue'
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user')
const showModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: String,
required: true,
},
rows: {
type: Array,
},
columns: {
type: Array,
},
options: {
type: Object,
default: () => ({
selectable: true,
totalCount: 0,
rowCount: 0,
}),
},
})
const assessments = createResource({
url: 'lms.lms.utils.get_assessments',
params: {
batch: props.batch,
},
auto: true,
})
const deleteAssessments = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Assessment',
documents: values.assessments,
}
},
})
const removeAssessments = (selections, unselectAll) => {
deleteAssessments.submit(
{ assessments: Array.from(selections) },
{
onSuccess(data) {
assessments.reload()
unselectAll()
},
}
)
}
const getRowRoute = (row) => {
if (row.assessment_type == 'LMS Assignment') {
if (row.submission) {
return {
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment_name,
submissionName: row.submission.name,
},
}
} else {
return {
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment_name,
submissionName: 'new',
},
}
}
} else if (row.assessment_type == 'LMS Programming Exercise') {
if (row.submission) {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: row.submission.name,
},
}
} else {
return {
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment_name,
submissionID: 'new',
},
}
}
} else {
return {
name: 'QuizPage',
params: {
quizID: row.assessment_name,
},
}
}
}
const canAddAssessments = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
const getAssessmentColumns = () => {
let columns = [
{
label: __('Assessment'),
key: 'title',
width: '25rem',
},
{
label: __('Type'),
key: 'assessment_type',
width: '15rem',
},
]
if (!user.data?.is_moderator) {
columns.push({
label: __('Status/Percentage'),
key: 'status',
align: 'left',
width: '10rem',
})
}
return columns
}
const getStatusTheme = (status) => {
if (status === 'Pass' || status === 'Passed') {
return 'green'
} else if (status === 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
const getAssessmentTypeLabel = (type) => {
if (type == 'LMS Assignment') {
return __('Assignment')
} else if (type == 'LMS Quiz') {
return __('Quiz')
} else if (type == 'LMS Programming Exercise') {
return __('Programming Exercise')
}
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
style="min-height: 150px"
>
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
{{ batch.title }}
</div>
<div
v-if="batch.seat_count && batch.seats_left > 0"
class="text-xs bg-green-100 text-green-700 self-start px-2 py-0.5 rounded-md"
>
{{ batch.seats_left }}
<span v-if="batch.seats_left > 1">
{{ __('Seats Left') }}
</span>
<span v-else-if="batch.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
v-else-if="batch.seat_count && batch.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 self-start px-2 py-0.5 rounded-md"
>
{{ __('Sold Out') }}
</div>
<div class="short-introduction text-sm text-ink-gray-7">
{{ batch.description }}
</div>
<div v-if="batch.amount" class="font-semibold text-ink-gray-9 mb-4">
{{ batch.price }}
</div>
<div class="flex flex-col space-y-2 mt-auto">
<DateRange
:startDate="batch.start_date"
:endDate="batch.end_date"
class="text-sm text-ink-gray-7"
/>
<div class="flex items-center text-sm text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-7" />
<span>
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
</span>
</div>
<div
v-if="batch.timezone"
class="flex items-center text-sm text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-ink-gray-5" />
<span>
{{ batch.timezone }}
</span>
</div>
</div>
<div
v-if="batch.instructors?.length"
class="flex avatar-group overlap mt-4"
>
<div
class="h-6 mr-1"
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
>
<UserAvatar
v-for="instructor in batch.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.instructors" />
</div>
</div>
</template>
<script setup>
import { formatTime } from '@/utils'
import { Clock, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
</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 1rem;
line-height: 1.5;
}
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
.avatar-group.overlap .avatar + .avatar {
margin-left: calc(-8px);
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<div class="space-y-10">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/pages/Batches/components/Assessments.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
</script>

View File

@@ -0,0 +1,177 @@
<template>
<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 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>
</div>
</div>
<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 v-else class="text-ink-gray-7 leading-5">
{{ __('No feedback received yet.') }}
</div>
</div>
<FeedbackModal
v-if="feedbackList.data?.length"
v-model="showAllFeedback"
:feedbackList="feedbackList.data"
/>
</template>
<script setup>
import { inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false)
const average = reactive({})
const feedback = reactive({})
const showFeedbackForm = ref(true)
const showAllFeedback = ref(false)
const props = defineProps({
batch: {
type: String,
required: true,
},
})
onMounted(() => {
let filters = {
batch: props.batch,
}
if (user.data?.is_student) {
filters['member'] = user.data?.name
}
feedbackList.update({
filters: filters,
})
feedbackList.reload()
})
const feedbackList = createListResource({
doctype: 'LMS Batch Feedback',
filters: {
batch: props.batch,
},
fields: [
'content',
'instructors',
'value',
'feedback',
'name',
'member',
'member_name',
'member_image',
],
cache: ['feedbackList', props.batch, user.data?.name],
})
watch(
() => feedbackList.data,
() => {
if (feedbackList.data.length) {
let data = feedbackList.data
readOnly.value = true
showFeedbackForm.value = false
ratingKeys.forEach((key) => {
average[key] = 0
})
data.forEach((row) => {
Object.keys(row).forEach((key) => {
if (ratingKeys.includes(key)) row[key] = row[key] * 5
feedback[key] = row[key]
})
ratingKeys.forEach((key) => {
average[key] += row[key]
})
})
Object.keys(average).forEach((key) => {
average[key] = average[key] / data.length
})
}
}
)
const submitFeedback = () => {
ratingKeys.forEach((key) => {
feedback[key] = feedback[key] / 5
})
feedbackList.insert.submit(
{
member: user.data?.name,
batch: props.batch,
...feedback,
},
{
onSuccess: () => {
feedbackList.reload()
showFeedbackForm.value = false
},
}
)
}
</script>
<style>
.feedback-list > button > div {
align-items: start;
padding: 0.15rem 0;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div
v-if="batch.data.seat_count && batch.data.seats_left > 0"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
>
{{ batch.data.seats_left }}
<span v-if="batch.data.seats_left > 1">
{{ __('Seats Left') }}
</span>
<span v-else-if="batch.data.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
>
{{ __('Sold Out') }}
</div>
<div
v-if="batch.data.amount"
class="text-lg font-semibold mb-5 text-ink-gray-9"
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
<div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</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 text-ink-gray-7">
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
</div>
<div v-if="!readOnlyMode">
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
<template #prefix>
<GraduationCap class="size-4 stroke-1.5" />
</template>
{{ __('Enroll Now') }}
</Button>
</div>
</div>
</template>
<script setup>
import { inject, computed } from 'vue'
import { Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
Clock,
CreditCard,
Globe,
GraduationCap,
LogIn,
Pencil,
Settings,
} from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const enroll = createResource({
url: 'lms.lms.utils.enroll_in_batch',
makeParams(values) {
return {
batch: props.batch.data.name,
}
},
})
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
}
enroll.submit(
{},
{
onSuccess(data) {
toast.success(__('You have been enrolled in this batch'))
router.push({
name: 'Batch',
params: {
batchName: props.batch.data.name,
},
})
},
}
)
}
const isStudent = computed(() => {
return user.data
? props.batch.data?.students?.includes(user.data?.name)
: false
})
const isModerator = computed(() => {
return user.data?.is_moderator
})
const isEvaluator = computed(() => {
return user.data?.is_evaluator
})
const isInstructor = computed(() => {
return (
props.batch.data?.instructors?.filter(
(instructor) => instructor.name === user.data?.name
).length > 0
)
})
const canAccessBatch = computed(() => {
if (!user.data) {
return false
}
return isModerator.value || isStudent.value || isEvaluator.value
})
const canEditBatch = computed(() => {
return isModerator.value || isInstructor.value
})
</script>

View File

@@ -0,0 +1,226 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-ink-gray-9 font-medium">
{{ studentCount.data ?? 0 }} {{ __('Students') }}
</div>
<Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
class="max-h-[75vh]"
:columns="studentColumns"
:rows="students.data"
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 studentColumns"
:title="item.label"
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
<Button @click="students.next()">
{{ __('Load More') }}
</Button>
</div>
</ListView>
</div>
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
{{ __('There are no students in this batch.') }}
</div>
</div>
<StudentModal
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/>
</template>
<script setup>
import {
Avatar,
Button,
createListResource,
createResource,
FeatherIcon,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: Object,
default: 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 students = createListResource({
doctype: 'LMS Batch Enrollment',
url: 'lms.lms.utils.get_batch_students',
cache: ['batch_students', props.batch?.data?.name],
pageLength: 50,
filters: {
batch: props.batch?.data?.name,
},
auto: true,
})
const studentColumns = [
{
label: 'Full Name',
key: 'full_name',
width: '25rem',
icon: 'user',
},
{
label: 'Progress',
key: 'progress',
width: '15rem',
icon: 'activity',
},
{
label: 'Last Active',
key: 'last_active',
width: '10rem',
align: 'center',
icon: 'clock',
},
]
const openStudentModal = () => {
showStudentModal.value = true
}
const openStudentProgressModal = (row) => {
showStudentProgressModal.value = true
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Batch Enrollment',
documents: values.students,
}
},
})
const removeStudents = (selections, unselectAll) => {
deleteStudents.submit(
{
students: Array.from(selections),
},
{
onSuccess(data) {
students.reload()
studentCount.reload()
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
)
}
</script>

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

View File

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

View File

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

View File

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