Merge pull request #1447 from pateljannat/certification-redesign

fix: improved UI of certified participants page
This commit is contained in:
Jannat Patel
2025-04-17 12:48:27 +05:30
committed by GitHub
6 changed files with 116 additions and 59 deletions

View File

@@ -1,8 +1,7 @@
name: Create weekly release name: Create weekly release
on: on:
schedule: schedule:
# 13:00 UTC -> 7pm IST on every Wednesday - cron: '30 4 15 * *'
- cron: '30 4 * * 3'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@@ -1,10 +1,7 @@
<template> <template>
<div class="border rounded-md p-4"> <div class="flex flex-col border rounded-md p-4 h-full">
<div class="flex space-x-4"> <div class="flex space-x-4 mb-2">
<img <img :src="job.company_logo" class="size-8 rounded-full object-contain" />
:src="job.company_logo"
class="size-10 rounded-full object-contain"
/>
<div class="flex flex-col space-y-1 flex-1"> <div class="flex flex-col space-y-1 flex-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-lg font-semibold text-ink-gray-9"> <span class="text-lg font-semibold text-ink-gray-9">
@@ -16,7 +13,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="space-x-4 mt-2"> <div class="space-x-4 mt-auto">
<Badge> <Badge>
{{ job.location }} {{ job.location }}
</Badge> </Badge>

View File

@@ -12,12 +12,13 @@
</Button> </Button>
</router-link> </router-link>
</header> </header>
<div class="p-5 lg:w-3/4 mx-auto"> <div
<div v-if="participants.data?.length"
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5" class="mx-auto w-full max-w-4xl pt-6 pb-10"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="flex flex-col md:flex-row justify-between mb-4 px-3">
{{ __('All Certified Participants') }} <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }}
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<FormControl <FormControl
@@ -40,57 +41,80 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="participants.data?.length"> <div class="divide-y">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> <template v-for="participant in participants.data">
<router-link <router-link
v-for="participant in participants.data"
:to="{ :to="{
name: 'ProfileCertificates', name: 'ProfileCertificates',
params: { username: participant.username }, params: {
username: participant.username,
},
}" }"
class="flex sm:rounded px-3 py-2 sm:h-15 hover:bg-surface-gray-2"
> >
<div <div class="flex items-center w-full space-x-3">
class="flex items-center space-x-2 border rounded-md hover:bg-surface-menu-bar p-2 text-ink-gray-7"
>
<Avatar <Avatar
:image="participant.user_image" :image="participant.user_image"
class="size-8 rounded-full object-contain"
:label="participant.full_name" :label="participant.full_name"
size="2xl" size="2xl"
/> />
<div class="flex flex-col space-y-2"> <div class="flex flex-col md:flex-row w-full">
<div class="font-medium"> <div class="flex-1">
{{ participant.full_name }} <div class="text-base font-medium text-ink-gray-8">
{{ participant.full_name }}
</div>
<div
v-if="participant.headline"
class="mt-1.5 text-base text-ink-gray-5"
>
{{ participant.headline }}
</div>
</div> </div>
<div <div
v-if="participant.headline" class="flex items-center space-x-3 md:space-x-24 text-sm md:text-base mt-1.5"
class="headline text-sm text-ink-gray-7"
> >
{{ participant.headline }} <div class="text-ink-gray-5">
{{ participant.certificate_count }}
{{
participant.certificate_count > 1
? __('certificates')
: __('certificate')
}}
</div>
<span class="text-ink-gray-4 md:hidden">·</span>
<div class="text-ink-gray-5">
{{ dayjs(participant.issue_date).format('DD MMM YYYY') }}
</div>
</div> </div>
</div> </div>
</div> </div>
</router-link> </router-link>
</div> </template>
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="participants.next()">
{{ __('Load More') }}
</Button>
</div>
</div> </div>
<div <div
v-else-if="!participants.list.loading" v-if="!participants.list.loading && participants.hasNextPage"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48" class="flex justify-center mt-5"
> >
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" /> <Button @click="participants.next()">
<div class="text-lg font-medium mb-1"> {{ __('Load More') }}
{{ __('No participants found') }} </Button>
</div> </div>
<div class="leading-5 w-2/5 text-center"> </div>
{{ __('There are no participants matching this criteria.') }} <div
</div> v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No certified members') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'No certified members found. Please check again later or get certified yourself.'
)
}}
</div> </div>
</div> </div>
</template> </template>
@@ -99,13 +123,13 @@ import {
Avatar, Avatar,
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
Select, Select,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { updateDocumentTitle } from '@/utils'
import { BookOpen, GraduationCap } from 'lucide-vue-next' import { BookOpen, GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
@@ -113,6 +137,8 @@ const currentCategory = ref('')
const filters = ref({}) const filters = ref({})
const nameFilter = ref('') const nameFilter = ref('')
const { brand } = sessionStore() const { brand } = sessionStore()
const memberCount = ref(0)
const dayjs = inject('$dayjs')
onMounted(() => { onMounted(() => {
updateParticipants() updateParticipants()
@@ -126,6 +152,12 @@ const participants = createListResource({
pageLength: 30, pageLength: 30,
}) })
const count = call('lms.lms.api.get_count_of_certified_members').then(
(data) => {
memberCount.value = data
}
)
const categories = createListResource({ const categories = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories', url: 'lms.lms.api.get_certification_categories',
@@ -161,14 +193,14 @@ const updateFilters = () => {
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ {
label: __('Certified Participants'), label: __('Certified Members'),
route: { name: 'CertifiedParticipants' }, route: { name: 'CertifiedParticipants' },
}, },
]) ])
usePageMeta(() => { usePageMeta(() => {
return { return {
title: __('Certified Participants'), title: __('Certified Members'),
icon: brand.favicon, icon: brand.favicon,
} }
}) })

View File

@@ -479,7 +479,7 @@ export function getSidebarLinks() {
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'], activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
}, },
{ {
label: 'Certified Participants', label: 'Certified Members',
icon: 'GraduationCap', icon: 'GraduationCap',
to: 'CertifiedParticipants', to: 'CertifiedParticipants',
activeFor: ['CertifiedParticipants'], activeFor: ['CertifiedParticipants'],

View File

@@ -19,6 +19,8 @@ from frappe.utils import (
format_date, format_date,
date_diff, date_diff,
) )
from frappe.query_builder import DocType
from pypika.functions import DistinctOptionFunction
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress from lms.lms.doctype.course_lesson.course_lesson import save_progress
@@ -411,29 +413,50 @@ def get_certified_participants(filters=None, start=0, page_length=30):
or_filters["course_title"] = ["like", f"%{category}%"] or_filters["course_title"] = ["like", f"%{category}%"]
or_filters["batch_title"] = ["like", f"%{category}%"] or_filters["batch_title"] = ["like", f"%{category}%"]
participants = frappe.get_all( participants = frappe.db.get_all(
"LMS Certificate", "LMS Certificate",
filters=filters, filters=filters,
or_filters=or_filters, or_filters=or_filters,
fields=["member"], fields=["member", "issue_date"],
group_by="member", group_by="member",
order_by="creation desc", order_by="issue_date desc",
start=start, start=start,
page_length=page_length, page_length=page_length,
) )
for participant in participants: for participant in participants:
count = frappe.db.count("LMS Certificate", {"member": participant.member})
details = frappe.db.get_value( details = frappe.db.get_value(
"User", "User",
participant.member, participant.member,
["full_name", "user_image", "username", "country", "headline"], ["full_name", "user_image", "username", "country", "headline"],
as_dict=1, as_dict=1,
) )
details["certificate_count"] = count
participant.update(details) participant.update(details)
return participants return participants
class CountDistinct(DistinctOptionFunction):
def __init__(self, field):
super().__init__("COUNT", field, distinct=True)
@frappe.whitelist(allow_guest=True)
def get_count_of_certified_members():
Certificate = DocType("LMS Certificate")
query = (
frappe.qb.from_(Certificate)
.select(CountDistinct(Certificate.member).as_("total"))
.where(Certificate.published == 1)
)
result = query.run(as_dict=True)
return result[0]["total"] if result else 0
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_certification_categories(): def get_certification_categories():
categories = [] categories = []
@@ -655,13 +678,13 @@ def get_categories(doctype, filters):
@frappe.whitelist() @frappe.whitelist()
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index. """Get members for the given search term and start index.
Args: start (int): Start index for the query. Args: start (int): Start index for the query.
<<<<<<< HEAD <<<<<<< HEAD
search (str): Search term to filter the results. search (str): Search term to filter the results.
======= =======
search (str): Search term to filter the results. search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577 >>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members. Returns: List of members.
""" """
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}

View File

@@ -29,7 +29,6 @@ def get_context():
def get_meta(app_path, title, favicon, description): def get_meta(app_path, title, favicon, description):
meta = frappe._dict() meta = frappe._dict()
if app_path: if app_path:
meta = get_meta_from_document(app_path) meta = get_meta_from_document(app_path)
@@ -286,4 +285,11 @@ def get_meta_from_document(app_path):
"link": "/programs", "link": "/programs",
} }
if app_path == "certified-participants":
return {
"title": _("Certified Participants"),
"keywords": "All Certified Participants, Certified Participants, Learn, Certification",
"link": "/certified-participants",
}
return {} return {}