feat: program progress summary
This commit is contained in:
@@ -12,9 +12,6 @@
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<!-- <div class="text-xl font-semibold text-ink-gray-6">
|
||||
{{ __('{0} Members').format(memberCount) }}
|
||||
</div> -->
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
@@ -151,7 +148,7 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
@@ -225,7 +222,6 @@ const progressColumns = computed(() => {
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
width: '30%',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
|
||||
4
frontend/src/global.d.ts
vendored
4
frontend/src/global.d.ts
vendored
@@ -2,6 +2,10 @@ export {}
|
||||
|
||||
declare global {
|
||||
function __(text: string): string
|
||||
|
||||
interface String {
|
||||
format(...args: any[]): string
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
|
||||
@@ -5,21 +5,25 @@
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="program.data" class="pt-5 px-5 pb-10 mx-auto">
|
||||
<div class="flex items-center mb-5">
|
||||
<div class="flex items-center space-x-2 mb-5">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ program.data.name }}
|
||||
</div>
|
||||
|
||||
<Badge :theme="program.data.progress < 100 ? 'orange' : 'green'">
|
||||
{{ program.data.progress }}% {{ __('completed') }}
|
||||
</Badge>
|
||||
|
||||
<Tooltip
|
||||
v-if="program.data.enforce_course_order"
|
||||
placement="right"
|
||||
:text="
|
||||
__(
|
||||
'Courses must be completed in order. You can only start the next course after completing the previous one.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Route
|
||||
class="size-5 ml-2 hover:bg-surface-gray-3 cursor-pointer p-1 rounded-sm"
|
||||
/>
|
||||
<Info class="size-3 cursor-pointer" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mb-5">
|
||||
@@ -57,6 +61,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted } from 'vue'
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
@@ -64,7 +69,7 @@ import {
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { LockKeyhole, Route } from 'lucide-vue-next'
|
||||
import { LockKeyhole, Info } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}"
|
||||
>
|
||||
<template #body-title>
|
||||
<div class="flex items-center justify-between text-base w-full space-x-2">
|
||||
<div class="flex items-center justify-between text-base w-full">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{
|
||||
programName === 'new' ? __('Create Program') : __('Edit Program')
|
||||
@@ -109,12 +109,28 @@
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Members') }}
|
||||
</div>
|
||||
<Button @click="openForm('member')">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
@click="
|
||||
() => {
|
||||
showProgressDialog = true
|
||||
console.log('show progress dialog', showProgressDialog)
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Progress Summary') }}
|
||||
</Button>
|
||||
<Button @click="openForm('member')">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ListView
|
||||
v-if="programMembers.data.length > 0"
|
||||
@@ -153,7 +169,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
v-model="showFormDialog"
|
||||
:options="{
|
||||
title:
|
||||
currentForm == 'course'
|
||||
@@ -193,6 +209,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ProgramProgressSummary
|
||||
v-model="showProgressDialog"
|
||||
:programName="programName"
|
||||
:programMembers="programMembers.data"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="flex justify-end space-x-2 group">
|
||||
@@ -212,6 +234,7 @@
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
{{ showProgressDialog }}
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -231,18 +254,20 @@ import {
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { Plus, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||
import { Programs, Program } from '@/types/programs'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import ProgramProgressSummary from '@/pages/Programs/ProgramProgressSummary.vue'
|
||||
|
||||
const show = defineModel<boolean>()
|
||||
const programs = defineModel<Programs>('programs')
|
||||
const showDialog = ref(false)
|
||||
const showFormDialog = ref(false)
|
||||
const currentForm = ref<'course' | 'member'>('course')
|
||||
const course = ref<string>('')
|
||||
const member = ref<string>('')
|
||||
const showProgressDialog = ref(false)
|
||||
const dirty = ref(false)
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -384,7 +409,7 @@ const updateProgram = (close: () => void) => {
|
||||
|
||||
const openForm = (formType: 'course' | 'member') => {
|
||||
currentForm.value = formType
|
||||
showDialog.value = true
|
||||
showFormDialog.value = true
|
||||
if (formType === 'course') {
|
||||
course.value = ''
|
||||
} else {
|
||||
|
||||
137
frontend/src/pages/Programs/ProgramProgressSummary.vue
Normal file
137
frontend/src/pages/Programs/ProgramProgressSummary.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Progress Summary for {0}').format(programName),
|
||||
size: '2xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center justify-between space-x-4 mb-4">
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: programMembers.length || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: averageProgress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: progressDistribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
theme.colors.red['400'],
|
||||
theme.colors.amber['400'],
|
||||
theme.colors.pink['400'],
|
||||
theme.colors.blue['400'],
|
||||
theme.colors.green['400'],
|
||||
],
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="mt-10">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<ListView
|
||||
v-if="progressList.length"
|
||||
:columns="progressColumns"
|
||||
:rows="progressList"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
}"
|
||||
/>
|
||||
<div v-else class="text-center text-gray-500">
|
||||
{{ __('No members found.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DonutChart,
|
||||
FormControl,
|
||||
ListView,
|
||||
NumberChart,
|
||||
} from 'frappe-ui'
|
||||
import type { ProgramMember } from '@/types'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
programName: string
|
||||
programMembers: ProgramMember[]
|
||||
}>()
|
||||
|
||||
const progressList = ref<ProgramMember[]>(props.programMembers || [])
|
||||
|
||||
const progressDistribution = computed(() => {
|
||||
const categories = ['0-20%', '20-40%', '40-60%', '60-80%', '80-100%']
|
||||
const distribution = categories.map((category) => {
|
||||
const [min, max] = category.slice(0, -1).split('-').map(Number)
|
||||
return {
|
||||
category,
|
||||
count: props.programMembers.filter((member) => {
|
||||
const progress = member.progress || 0
|
||||
return progress >= min && progress < max
|
||||
}).length,
|
||||
}
|
||||
})
|
||||
return distribution
|
||||
})
|
||||
|
||||
const averageProgress = computed(() => {
|
||||
if (props.programMembers.length === 0) return 0
|
||||
const totalProgress = props.programMembers.reduce(
|
||||
(sum, member) => sum + (member.progress || 0),
|
||||
0
|
||||
)
|
||||
return totalProgress / props.programMembers.length
|
||||
})
|
||||
|
||||
watch(searchFilter, () => {
|
||||
if (searchFilter.value) {
|
||||
progressList.value = props.programMembers.filter((member) =>
|
||||
member.full_name.toLowerCase().includes(searchFilter.value?.toLowerCase())
|
||||
)
|
||||
} else {
|
||||
progressList.value = props.programMembers
|
||||
}
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'full_name',
|
||||
width: '50%',
|
||||
},
|
||||
{
|
||||
label: __('Progress (%)'),
|
||||
key: 'progress',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user