feat: badge assignment from settings

This commit is contained in:
Jannat Patel
2025-07-08 14:12:46 +05:30
parent 023fd272b1
commit efb317191c
11 changed files with 467 additions and 47 deletions

View File

@@ -19,6 +19,8 @@ declare module 'vue' {
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
Bagdes: typeof import('./src/components/Settings/Bagdes.vue')['default']

View File

@@ -12,7 +12,7 @@
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<div class="border rounded-md w-fit py-7 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
@@ -20,7 +20,7 @@
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ __('Appears on the course card in the course list') }}
{{ __(description) }}
</div>
</div>
</div>

View File

@@ -0,0 +1,142 @@
<template>
<Dialog
v-model="show"
:options="{
title:
props.badgeAssignmentID === 'new'
? __('Assign a Badge')
: __('Edit Badge Assignment'),
size: 'sm',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveBadgeAssignment(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<Link
doctype="User"
v-model="badgeAssignment.member"
:label="__('Member')"
:required="true"
/>
<Link
doctype="LMS Badge"
v-model="badgeAssignment.badge"
:label="__('Badge')"
:required="true"
/>
<div>
<label class="text-xs text-ink-gray-5 mb-1">
{{ __('Issued On') }}
<span class="text-ink-red-3">*</span>
</label>
<DatePicker
v-model="badgeAssignment.issued_on"
:placeholder="__('Select Date')"
:required="true"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog, DatePicker, toast } from 'frappe-ui'
import type {
BadgeAssignments,
BadgeAssignment,
} from '@/components/Settings/types'
import { ref, watch } from 'vue'
import { cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel<boolean>({ required: true, default: false })
const defaultBadgeAssignment = {
name: '',
badge: '',
member: '',
issued_on: '',
member_name: '',
member_username: '',
member_image: '',
}
const badgeAssignments = defineModel<BadgeAssignments>('badgeAssignments')
const badgeAssignment = ref<BadgeAssignment>(defaultBadgeAssignment)
const props = defineProps<{
badgeAssignmentID: string
badge: string | null
}>()
watch(
() => props.badgeAssignmentID,
(newID) => {
if (newID === 'new') {
badgeAssignment.value = {
...defaultBadgeAssignment,
badge: props.badge || '',
}
} else {
const assignment = badgeAssignments.value?.data?.find(
(assignment) => assignment.name === newID
)
if (assignment) {
badgeAssignment.value = { ...assignment }
}
}
}
)
const saveBadgeAssignment = (close: () => void) => {
if (props.badgeAssignmentID === 'new') {
createBadgeAssignment(close)
} else {
updateBadgeAssignment(close)
}
}
const updateBadgeAssignment = async (close: () => void) => {
badgeAssignments.value?.setValue.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment updated successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to update badge assignment: ') + cleanError(error)
)
},
}
)
}
const createBadgeAssignment = (close: () => void) => {
badgeAssignments.value?.insert.submit(
{
...badgeAssignment.value,
},
{
onSuccess: () => {
toast.success(__('Badge assignment created successfully'))
close()
},
onError: (error) => {
toast.error(
__('Failed to create badge assignment: ') + cleanError(error)
)
},
}
)
}
</script>

View File

@@ -0,0 +1,192 @@
<template>
<div class="text-base">
<div class="flex items-center justify-between space-x-2 mb-5">
<div class="flex items-center space-x-2">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
@click="
() => {
show = false
}
"
/>
<div class="text-xl font-semibold text-ink-gray-9">
{{ props.badgeName }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="assignments.data?.length">
<ListView
:rows="assignments.data"
:columns="columns"
rowKey="name"
:options="{
showTooltip: false,
onRowClick: (row: BadgeAssignment) => {
openForm(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 columns">
<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 assignments.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="deleteBadgeAssignment(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="flex flex-col items-center justify-center mt-44">
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No Assignments') }}
</div>
<div
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
>
{{ __('This badge has not been assigned to any students yet') }}
</div>
</div>
</div>
<BadgeAssignmentForm
v-model="showForm"
:badgeAssignmentID="currentAssignmentID"
:badge="props.badgeName"
v-model:badgeAssignments="assignments"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { ChevronLeft, GraduationCap, Plus, Trash2 } from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import type { BadgeAssignment } from '@/components/Settings/types'
import BadgeAssignmentForm from '@/components/Settings/BadgeAssignmentForm.vue'
const show = defineModel<boolean>()
const dayjs = inject('$dayjs') as any
const showForm = ref(false)
const currentAssignmentID = ref<string>('')
const props = defineProps<{
badgeName: string | null
}>()
const assignments = createListResource({
doctype: 'LMS Badge Assignment',
fields: [
'name',
'member',
'member_name',
'member_username',
'member_image',
'issued_on',
'badge',
],
filters: {
badge: props.badgeName,
},
order_by: 'issued_on desc',
transform(data: BadgeAssignment[]) {
return data.map((item: BadgeAssignment) => {
return {
...item,
issued_on: item.issued_on
? dayjs(item.issued_on).format('DD MMM YYYY')
: null,
}
})
},
auto: true,
})
const openForm = (assignmentID: string) => {
currentAssignmentID.value = assignmentID
showForm.value = true
}
const deleteBadgeAssignment = (
selections: Set<string>,
unselectAll: () => void
) => {
Array.from(selections).forEach(async (assignment: string) => {
await assignments.delete.submit(assignment)
})
unselectAll()
toast.success(__('Badge assignments deleted successfully'))
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
width: '80%',
},
{
label: __('Issued On'),
key: 'issued_on',
icon: 'calendar',
align: 'right',
},
]
})
</script>

View File

@@ -21,7 +21,7 @@
:required="true"
/>
<Autocomplete
@update:modelValue="(opt) => (badge.reference_doctype = opt.value)"
@update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
:modelValue="badge.reference_doctype"
:options="referenceDoctypeOptions"
:required="true"
@@ -98,12 +98,12 @@ const defaultBadge = {
image: '',
grant_only_once: false,
event: 'New',
reference_doctype: 'LMS Course',
condition: null,
reference_doctype: '',
condition: '',
user_field: 'member',
field_to_check: '',
}
const show = defineModel<boolean | undefined>()
const show = defineModel<boolean>({ required: true, default: false })
const badges = defineModel<Badges>('badges')
const badge = ref<Badge>(defaultBadge)
@@ -135,7 +135,6 @@ const saveBadge = (close: () => void) => {
}
const updateBadge = async (close: () => void) => {
console.log(props.badgeName, badge.value?.title)
if (props.badgeName != badge.value?.title) {
await renameDoc()
}

View File

@@ -1,5 +1,10 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<BadgeAssignments
v-if="showAssignments"
v-model="showAssignments"
:badgeName="showAssignmentsFor"
/>
<div v-else class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
@@ -23,6 +28,7 @@
row-key="name"
:options="{
showTooltip: false,
selectable: false,
}"
>
<ListHeader
@@ -51,7 +57,11 @@
</Badge>
</div>
<div v-else-if="column.key == 'reference_doctype'">
{{ doctypeLabel[row[column.key]] || row[column.key] }}
{{
doctypeLabel[
row[column.key] as keyof typeof doctypeLabel
] || row[column.key]
}}
</div>
<FormControl
v-else-if="column.key == 'grant_only_once'"
@@ -65,15 +75,12 @@
>
{{ row[column.key] }}
</div>
<!-- <Button v-else variant="ghost">
<Ellipsis class="size-4 stroke-1.5 text-ink-gray-9" />
</Button> -->
<Dropdown
v-else
:options="getMoreOptions(row.name)"
:button="{
icon: 'more-horizontal',
onblur: (e) => {
onblur: (e: Event) => {
e.stopPropagation()
},
}"
@@ -83,19 +90,6 @@
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
@@ -119,14 +113,18 @@ import {
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { Plus } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import BadgeForm from '@/components/Settings/BadgeForm.vue'
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
const showForm = ref<boolean>(false)
const selectedBadge = ref<string | null>(null)
const showAssignments = ref<boolean>(false)
const showAssignmentsFor = ref<string | null>(null)
const props = defineProps<{
label: string
@@ -165,7 +163,15 @@ const getMoreOptions = (badgeName: string) => {
label: __('Assignments'),
icon: 'download',
onClick() {
console.log('assignments')
showAssignmentsFor.value = badgeName
showAssignments.value = true
},
},
{
label: __('Delete'),
icon: 'trash-2',
onClick() {
deleteBadge(badgeName)
},
},
]
@@ -176,6 +182,18 @@ const openForm = (badgeName: string) => {
showForm.value = true
}
const deleteBadge = (badgeName: string) => {
badges.delete
.submit(badgeName)
.then(() => {
badges.reload()
toast.success(__('Badge deleted successfully'))
})
.catch((err: any) => {
toast.error(cleanError(err.messages[0]) || __('Error deleting badge'))
})
}
const doctypeLabel = computed(() => {
return {
'LMS Course': __('Course'),
@@ -218,7 +236,7 @@ const columns = computed(() => {
key: 'grant_only_once',
icon: 'check',
align: 'center',
width: '15%',
width: '20%',
},
{
key: 'action',

View File

@@ -224,7 +224,8 @@ const tabsStructure = computed(() => {
},
{
label: 'Zoom Accounts',
description: 'Manage the Zoom accounts for your learning system',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
@@ -250,7 +251,7 @@ const tabsStructure = computed(() => {
],
},
{
label: 'Customise',
label: 'Customize',
hideLabel: false,
items: [
{

View File

@@ -5,9 +5,9 @@
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div> -->
</div>
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
@@ -35,10 +35,10 @@
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
<FeatherIcon
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
@@ -48,6 +48,16 @@
<ListRow :row="row" v-for="row in zoomAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
@@ -87,10 +97,12 @@
</template>
<script setup lang="ts">
import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
@@ -122,6 +134,7 @@ const zoomAccounts = createListResource({
'enabled',
'member',
'member_name',
'member_image',
'account_id',
'client_id',
'client_secret',
@@ -170,18 +183,21 @@ const removeAccount = (selections, unselectAll) => {
const columns = computed(() => {
return [
{
label: __('Account'),
key: 'name',
},
{
label: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})

View File

@@ -24,7 +24,7 @@ export interface Badge {
grant_only_once: boolean;
event: string;
reference_doctype: string;
condition: string | null;
condition: string;
user_field: string;
field_to_check: string;
};
@@ -44,4 +44,31 @@ export interface Badges {
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}
export interface BadgeAssignment {
name: string;
member: string;
member_name: string;
member_username: string;
member_image: string;
badge: string;
issued_on: string;
}
export interface BadgeAssignments {
data: BadgeAssignment[],
reload: () => void
insert: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: BadgeAssignment,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}