refactor: batch student progress

This commit is contained in:
Jannat Patel
2026-02-17 19:38:19 +05:30
parent 44b7a210ce
commit af273a9a1c
11 changed files with 533 additions and 378 deletions

View File

@@ -1,92 +1,142 @@
<template>
<div>
<!-- Label -->
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</div>
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
<div class="relative w-full">
<ComboboxInput
class="form-input w-full"
:class="inputClasses"
type="text"
:value="selectedValue"
autocomplete="off"
@click="onFocus"
/>
<ComboboxButton ref="trigger" class="hidden" />
<!-- Dropdown -->
<ComboboxOptions
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-white py-1 text-base border-2 shadow-lg"
>
<input
ref="search"
v-model="query"
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
type="text"
placeholder="Search"
autocomplete="off"
/>
<!-- Options -->
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
<template v-for="group in groups" :key="group.key">
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
<Combobox
v-model="selectedValue"
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="
() => {
showOptions = !showOptions
togglePopover()
}
"
:disabled="attrs.readonly"
>
{{ group.group }}
</div>
<ComboboxOption
v-for="option in group.items"
:key="option.value"
:value="option.value"
v-slot="{ active }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value === option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</template>
<div class="flex items-center w-[90%]">
<slot name="prefix" />
<span
class="block truncate text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen" class="">
<div
v-if="groups.length === 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
{{ __('No results found') }}
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value == option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
No results found
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
</div>
</div>
<!-- Footer -->
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{
value: selectedValue,
close,
}"
/>
</div>
</ComboboxOptions>
</div>
</template>
</Popover>
</Combobox>
</div>
</template>
@@ -97,16 +147,15 @@ import {
ComboboxInput,
ComboboxOptions,
ComboboxOption,
ComboboxButton,
} from '@headlessui/vue'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next'
import { watchDebounced } from '@vueuse/core'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Object],
default: null,
type: String,
default: '',
},
options: {
type: Array,
@@ -137,93 +186,107 @@ const props = defineProps({
default: true,
},
})
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
const trigger = ref(null)
const query = ref('')
const showOptions = ref(false)
const search = ref(null)
const attrs = useAttrs()
const slots = useSlots()
const selectedValue = ref(props.modelValue)
const query = ref('')
const valuePropPassed = computed(() => 'value' in attrs)
watch(selectedValue, (val) => {
query.value = ''
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
const selectedValue = computed({
get() {
return valuePropPassed.value ? attrs.value : props.modelValue
},
set(val) {
query.value = ''
if (val) {
showOptions.value = false
}
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
},
})
function clearValue() {
emit('update:modelValue', null)
function close() {
showOptions.value = false
}
const groups = computed(() => {
if (!props.options?.length) return []
if (!props.options || props.options.length == 0) return []
const normalized = props.options[0]?.group
let groups = props.options[0]?.group
? props.options
: [{ group: '', items: props.options }]
return normalized
.map((group, i) => ({
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}))
return groups
.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}
})
.filter((group) => group.items.length > 0)
})
function filterOptions(options) {
if (!query.value) return options
const q = query.value.toLowerCase()
return options.filter((option) =>
[option.label, option.value]
.filter(Boolean)
.some((text) => text.toString().toLowerCase().includes(q))
)
}
watchDebounced(
query,
(val) => {
emit('update:query', val)
},
{ debounce: 300 }
)
const onFocus = () => {
trigger.value?.$el.click()
nextTick(() => {
search.value?.focus()
if (!query.value) {
return options
}
return options.filter((option) => {
let searchTexts = [option.label, option.value]
return searchTexts.some((text) =>
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
)
})
}
const close = () => {
selectedValue.value = null
trigger.value?.$el.click()
function displayValue(option) {
if (typeof option === 'string') {
let allOptions = groups.value.flatMap((group) => group.items)
let selectedOption = allOptions.find((o) => o.value === option)
return selectedOption?.label || option
}
return option?.label
}
const textColor = computed(() =>
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
)
watch(query, (q) => {
emit('update:query', q)
})
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus()
})
}
})
const textColor = computed(() => {
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
})
const inputClasses = computed(() => {
const sizeClasses = {
let sizeClasses = {
sm: 'text-base rounded h-7',
md: 'text-base rounded h-8',
lg: 'text-lg rounded-md h-10',
xl: 'text-xl rounded-md h-10',
}[props.size]
const paddingClasses = {
let paddingClasses = {
sm: 'py-1.5 px-2',
md: 'py-1.5 px-2.5',
lg: 'py-1.5 px-3',
xl: 'py-1.5 px-3',
}[props.size]
const variant = props.disabled ? 'disabled' : props.variant
const variantClasses = {
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
subtle:
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
outline:
@@ -244,4 +307,6 @@ const inputClasses = computed(() => {
'transition-colors w-full',
]
})
defineExpose({ query })
</script>

View File

@@ -95,7 +95,8 @@ const value = computed({
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
set: (val) => {
return (
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
)
},
})

View File

@@ -20,11 +20,15 @@
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
placeholder=" "
@update:modelValue="() => (assessment = null)"
/>
<Link
v-if="assessmentType"
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
placeholder=" "
:onCreate="
(value, close) => {
close()
@@ -49,7 +53,7 @@
</template>
<script setup>
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { Link } from 'frappe-ui/frappe'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'

View File

@@ -1,146 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ student.full_name }}
</div>
<Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ student.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<div
v-if="Object.keys(student.assessments).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Assessment') }}
</span>
<span>
{{ __('Percentage/Status') }}
</span>
</div>
<router-link
v-for="assessment in Object.keys(student.assessments)"
class="flex items-center text-ink-gray-7 font-medium"
:to="{
name:
student.assessments[assessment].type == 'LMS Assignment'
? 'AssignmentSubmission'
: '',
params:
student.assessments[assessment].type == 'LMS Assignment'
? {
assignmentID:
student.assessments[assessment].assessment,
submissionName:
student.assessments[assessment].submission,
}
: {},
}"
>
<span class="flex-1">
{{ assessment }}
</span>
<span v-if="isAssignment(student.assessments[assessment].status)">
<Badge
:theme="
getStatusTheme(student.assessments[assessment].status)
"
>
{{ student.assessments[assessment].status }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment].status }}
</span>
</router-link>
</div>
<!-- Courses -->
<div
v-if="Object.keys(student.courses).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-ink-gray-7 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div>
<!-- Heatmap -->
<StudentHeatmap :member="student.email" :days="120" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel()
const props = defineProps({
student: {
type: Object,
default: null,
},
})
const isAssignment = (value) => {
return isNaN(value)
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>

View File

@@ -17,22 +17,20 @@
{{ __('Save') }}
</Button>
</div>
<div v-else-if="isAdmin" class="space-x-2">
<Button
v-if="batch.data?.certification"
@click="openCertificateDialog = true"
>
{{ __('Generate Certificates') }}
</Button>
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
<span>
{{ __('Make an Announcement') }}
</span>
<template #suffix>
<SendIcon class="h-4 stroke-1.5" />
</template>
</Button>
</div>
<Dropdown
v-else-if="isAdmin"
:options="batchMenu"
placement="left"
side="left"
>
<template v-slot="{ open }">
<Button variant="ghost">
<template #icon>
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Dropdown>
</header>
<div>
<BatchOverview v-if="!isAdmin && !isStudent" :batch="batch" />
@@ -76,6 +74,7 @@
<script setup>
import {
ClipboardPen,
EllipsisVertical,
Laptop,
List,
Mail,
@@ -92,6 +91,7 @@ import {
Breadcrumbs,
Button,
createResource,
Dropdown,
Tabs,
usePageMeta,
} from 'frappe-ui'
@@ -205,6 +205,26 @@ const canMakeAnnouncement = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const batchMenu = computed(() => {
let options = [
{
label: __('Generate Certificates'),
onClick() {
openCertificateDialog.value = true
},
condition: () => batch.data?.certification,
},
{
label: __('Make an Announcement'),
onClick() {
openAnnouncementModal()
},
condition: () => canMakeAnnouncement(),
},
]
return options
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
crumbs.push({

View File

@@ -55,6 +55,10 @@
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
currentStudent = row.member
showProgressModal = true
},
}"
>
<ListHeader
@@ -68,16 +72,7 @@
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in students.data" class="max-h-[500px]">
<ListRow
:row="row"
@click="
() => {
/* showProgressModal = true
currentStudent = row */
}
"
class="cursor-pointer"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
@@ -167,6 +162,12 @@
:batch="batch"
:students="students"
/>
<BatchStudentProgress
v-if="showProgressModal"
v-model="showProgressModal"
:student="currentStudent"
:batch="batch?.data?.name"
/>
</template>
<script setup lang="ts">
import {
@@ -188,11 +189,14 @@ 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 BatchStudentProgress from '@/pages/Batches/components/BatchStudentProgress.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 showProgressModal = ref<boolean>(false)
const currentStudent = ref<any>(null)
const props = defineProps<{
batch: { [key: string]: any } | null

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-[75%] mx-auto mt-5">
<div class="font-semibold text-lg mb-5">
<div class="text-ink-gray-9 font-semibold text-lg mb-5">
{{ __('Announcements') }}
</div>
<div v-if="communications.data?.length">

View File

@@ -3,7 +3,7 @@
<div class="grid grid-cols-[2fr,1fr] gap-5">
<div class="p-5">
<div class="mb-8 space-y-2">
<div class="text-lg font-semibold">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Curriculum') }}
</div>
<div class="text-ink-gray-7">
@@ -16,7 +16,7 @@
</div>
<div class="space-y-10">
<div>
<div class="font-semibold mb-4">
<div class="text-ink-gray-9 font-semibold mb-4">
{{ __('Courses') }}
</div>
<ListView

View File

@@ -0,0 +1,222 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div v-if="studentDetails.data" class="p-5 space-y-10 text-sm">
<div class="flex items-center space-x-2">
<Avatar :image="studentDetails.data.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ studentDetails.data.full_name }}
</div>
<Badge
v-if="
Object.keys(studentDetails.data.assessments).length ||
Object.keys(studentDetails.data.courses).length
"
:theme="studentDetails.data.progress === 100 ? 'green' : 'red'"
>
{{ studentDetails.data.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ studentDetails.data.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<ListView
:columns="assessmentColumns"
:rows="studentDetails.data.assessments"
row-key="title"
class="border rounded-lg"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
redirectToAssessment(row)
}
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows v-for="row in studentDetails.data.assessments">
<ListRow :row="row" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<div
v-if="column.key == 'status' && isAssignment(row.status)"
>
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<!-- Courses -->
<ListView
:columns="courseColumns"
:rows="studentDetails.data.courses"
row-key="title"
class="border rounded-lg"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
redirectToCourse(row)
}
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows v-for="row in studentDetails.data.courses">
<ListRow :row="row" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<ProgressBar
v-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4 max-w-32"
/>
</template>
<div
v-if="column.key == 'progress'"
class="text-xs !ml-0 !mr-3 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Badge,
createResource,
Dialog,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const show = defineModel()
const router = useRouter()
const props = defineProps<{
student: string
batch: string
}>()
const studentDetails = createResource({
url: 'lms.lms.utils.get_batch_student_progress',
makeParams() {
return {
member: props.student,
batch: props.batch,
}
},
auto: true,
})
const redirectToAssessment = (row: any) => {
console.log(row)
if (!row.submission) return
if (row.type == 'LMS Assignment') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment,
submissionName: row.submission,
},
})
} else if (row.type == 'LMS Programming Exercise') {
router.push({
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment,
submissionID: row.submission,
},
})
} else if (row.type == 'LMS Quiz') {
router.push({
name: 'QuizSubmission',
params: {
submission: row.submission,
},
})
}
}
const redirectToCourse = (row: any) => {
router.push({
name: 'CourseDetail',
params: {
courseName: row.course,
},
})
}
const assessmentColumns = [
{ key: 'title', label: 'Assessment', align: 'left' },
{ key: 'status', label: 'Percentage/Status', align: 'right' },
]
const courseColumns = [
{ key: 'title', label: 'Course', align: 'left', width: '70%' },
{ key: 'progress', label: 'Progress', align: 'right' },
]
const isAssignment = (value: any) => {
return isNaN(value)
}
const getStatusTheme = (status: string) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>