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'] AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default'] AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.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'] BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default'] Badges: typeof import('./src/components/Settings/Badges.vue')['default']
Bagdes: typeof import('./src/components/Settings/Bagdes.vue')['default'] Bagdes: typeof import('./src/components/Settings/Bagdes.vue')['default']

View File

@@ -12,7 +12,7 @@
> >
<template v-slot="{ file, progress, uploading, openFileSelector }"> <template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center"> <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" /> <Image class="size-5 stroke-1 text-ink-gray-7" />
</div> </div>
<div class="ml-4"> <div class="ml-4">
@@ -20,7 +20,7 @@
{{ __('Upload') }} {{ __('Upload') }}
</Button> </Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5"> <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> </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" :required="true"
/> />
<Autocomplete <Autocomplete
@update:modelValue="(opt) => (badge.reference_doctype = opt.value)" @update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
:modelValue="badge.reference_doctype" :modelValue="badge.reference_doctype"
:options="referenceDoctypeOptions" :options="referenceDoctypeOptions"
:required="true" :required="true"
@@ -98,12 +98,12 @@ const defaultBadge = {
image: '', image: '',
grant_only_once: false, grant_only_once: false,
event: 'New', event: 'New',
reference_doctype: 'LMS Course', reference_doctype: '',
condition: null, condition: '',
user_field: 'member', user_field: 'member',
field_to_check: '', field_to_check: '',
} }
const show = defineModel<boolean | undefined>() const show = defineModel<boolean>({ required: true, default: false })
const badges = defineModel<Badges>('badges') const badges = defineModel<Badges>('badges')
const badge = ref<Badge>(defaultBadge) const badge = ref<Badge>(defaultBadge)
@@ -135,7 +135,6 @@ const saveBadge = (close: () => void) => {
} }
const updateBadge = async (close: () => void) => { const updateBadge = async (close: () => void) => {
console.log(props.badgeName, badge.value?.title)
if (props.badgeName != badge.value?.title) { if (props.badgeName != badge.value?.title) {
await renameDoc() await renameDoc()
} }

View File

@@ -1,5 +1,10 @@
<template> <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 items-center justify-between mb-5">
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9"> <div class="text-xl font-semibold text-ink-gray-9">
@@ -23,6 +28,7 @@
row-key="name" row-key="name"
:options="{ :options="{
showTooltip: false, showTooltip: false,
selectable: false,
}" }"
> >
<ListHeader <ListHeader
@@ -51,7 +57,11 @@
</Badge> </Badge>
</div> </div>
<div v-else-if="column.key == 'reference_doctype'"> <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> </div>
<FormControl <FormControl
v-else-if="column.key == 'grant_only_once'" v-else-if="column.key == 'grant_only_once'"
@@ -65,15 +75,12 @@
> >
{{ row[column.key] }} {{ row[column.key] }}
</div> </div>
<!-- <Button v-else variant="ghost">
<Ellipsis class="size-4 stroke-1.5 text-ink-gray-9" />
</Button> -->
<Dropdown <Dropdown
v-else v-else
:options="getMoreOptions(row.name)" :options="getMoreOptions(row.name)"
:button="{ :button="{
icon: 'more-horizontal', icon: 'more-horizontal',
onblur: (e) => { onblur: (e: Event) => {
e.stopPropagation() e.stopPropagation()
}, },
}" }"
@@ -83,19 +90,6 @@
</template> </template>
</ListRow> </ListRow>
</ListRows> </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> </ListView>
</div> </div>
</div> </div>
@@ -119,14 +113,18 @@ import {
ListRows, ListRows,
ListRow, ListRow,
ListRowItem, ListRowItem,
ListSelectBanner, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, ref } from 'vue' 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 BadgeForm from '@/components/Settings/BadgeForm.vue'
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
const showForm = ref<boolean>(false) const showForm = ref<boolean>(false)
const selectedBadge = ref<string | null>(null) const selectedBadge = ref<string | null>(null)
const showAssignments = ref<boolean>(false)
const showAssignmentsFor = ref<string | null>(null)
const props = defineProps<{ const props = defineProps<{
label: string label: string
@@ -165,7 +163,15 @@ const getMoreOptions = (badgeName: string) => {
label: __('Assignments'), label: __('Assignments'),
icon: 'download', icon: 'download',
onClick() { 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 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(() => { const doctypeLabel = computed(() => {
return { return {
'LMS Course': __('Course'), 'LMS Course': __('Course'),
@@ -218,7 +236,7 @@ const columns = computed(() => {
key: 'grant_only_once', key: 'grant_only_once',
icon: 'check', icon: 'check',
align: 'center', align: 'center',
width: '15%', width: '20%',
}, },
{ {
key: 'action', key: 'action',

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export interface Badge {
grant_only_once: boolean; grant_only_once: boolean;
event: string; event: string;
reference_doctype: string; reference_doctype: string;
condition: string | null; condition: string;
user_field: string; user_field: string;
field_to_check: string; field_to_check: string;
}; };
@@ -45,3 +45,30 @@ export interface Badges {
) => 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
},
}

View File

@@ -7,8 +7,10 @@
"field_order": [ "field_order": [
"member", "member",
"member_name", "member_name",
"issued_on", "member_username",
"member_image",
"column_break_ugix", "column_break_ugix",
"issued_on",
"badge", "badge",
"badge_image", "badge_image",
"badge_description" "badge_description"
@@ -65,12 +67,25 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Member Name", "label": "Member Name",
"read_only": 1 "read_only": 1
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-01-06 12:32:28.450028", "modified": "2025-07-07 20:37:22.449149",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Badge Assignment", "name": "LMS Badge Assignment",
"owner": "Administrator", "owner": "Administrator",
@@ -122,6 +137,7 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",

View File

@@ -11,6 +11,7 @@
"account_name", "account_name",
"member", "member",
"member_name", "member_name",
"member_image",
"column_break_fxxg", "column_break_fxxg",
"account_id", "account_id",
"client_id", "client_id",
@@ -71,12 +72,18 @@
{ {
"fieldname": "column_break_fxxg", "fieldname": "column_break_fxxg",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-05-26 18:09:09.392368", "modified": "2025-07-08 12:20:48.314056",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Zoom Settings", "name": "LMS Zoom Settings",