feat: badge list and form

This commit is contained in:
Jannat Patel
2025-07-07 16:44:48 +05:30
parent 84067cb027
commit 023fd272b1
14 changed files with 753 additions and 185 deletions

View File

@@ -19,6 +19,9 @@ 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']
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']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
@@ -99,6 +102,7 @@ declare module 'vue' {
Tags: typeof import('./src/components/Tags.vue')['default']
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']

View File

@@ -1,128 +1,140 @@
<template>
<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="() => togglePopover()"
:disabled="attrs.readonly"
>
<div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap 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">
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
<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"
/>
<div>
<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: 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="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
:disabled="attrs.readonly"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
<div class="flex items-center">
<slot name="prefix" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap 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>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
<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
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
{{ 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 },
]"
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
{{ 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 },
]"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
</div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
</div>
</slot>
</li>
</ComboboxOption>
</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>
<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>
</template>
</Popover>
</Combobox>
</template>
</Popover>
</Combobox>
</div>
</template>
<script setup>
@@ -149,6 +161,10 @@ const props = defineProps({
type: String,
default: 'md',
},
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'subtle',

View File

@@ -1,5 +1,8 @@
<template>
<div class="flex w-full flex-col gap-1.5">
<div v-if="label" class="text-xs text-ink-gray-5">
{{ __(label) }}
</div>
<codemirror
v-model="code"
:extensions="extensions"
@@ -9,6 +12,9 @@
:style="{ height: height, maxHeight: maxHeight }"
:disabled="readonly"
@blur="emitEditorValue"
:class="{
'border border-outline-gray-1': showBorder,
}"
/>
<Button
v-if="showSaveButton"
@@ -40,6 +46,7 @@ const props = withDefaults(
showLineNumbers?: boolean
completions?: Function | null
label?: string
showBorder?: boolean
required?: boolean
readonly?: boolean
}>(),

View File

@@ -5,7 +5,7 @@
height: height,
}"
>
<span class="text-xs text-ink-gray-7" v-if="label">
<span class="text-xs text-ink-gray-7 mb-1" v-if="label">
{{ label }}
</span>
<div

View File

@@ -0,0 +1,76 @@
<template>
<div class="mb-4">
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
{{ __(label) }}
<span class="text-ink-red-3">*</span>
</div>
<FileUploader
v-if="!modelValue"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file: File) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ __('Appears on the course card in the course list') }}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div
v-if="description"
class="mt-2 text-ink-gray-5 text-sm leading-5"
>
{{ __(description) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { validateFile } from '@/utils'
import { Button, FileUploader } from 'frappe-ui'
import { Image } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
description?: string
}>(),
{
modelValue: '',
label: '',
description: '',
}
)
const saveImage = (file: any) => {
emit('update:modelValue', file.file_url)
}
const removeImage = () => {
emit('update:modelValue', '')
}
</script>

View File

@@ -41,7 +41,7 @@
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value, close) => openSettings('Members', close)"
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
:required="true"
/>
<FormControl
@@ -86,6 +86,12 @@ interface ZoomAccounts {
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: ZoomAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
@@ -137,7 +143,7 @@ watch(show, (val) => {
}
})
const saveAccount = (close) => {
const saveAccount = (close: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
@@ -145,7 +151,7 @@ const saveAccount = (close) => {
}
}
const createAccount = (close) => {
const createAccount = (close: () => void) => {
zoomAccounts.value?.insert.submit(
{
account_name: account.name,
@@ -167,7 +173,7 @@ const createAccount = (close) => {
)
}
const updateAccount = async (close) => {
const updateAccount = async (close: () => void) => {
if (props.accountID != account.name) {
await renameDoc()
}
@@ -182,11 +188,12 @@ const renameDoc = async () => {
})
}
const setValue = (close) => {
const setValue = (close: () => void) => {
zoomAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
@@ -194,7 +201,7 @@ const setValue = (close) => {
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err) {
onError(err: any) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')

View File

@@ -0,0 +1,222 @@
<template>
<Dialog
v-model="show"
:options="{
title: badge ? __('Edit Badge') : __('Create a new Badge'),
size: '3xl',
}"
>
<template #body-content>
<div class="grid grid-cols-2 gap-x-5">
<div class="space-y-4">
<FormControl
v-model="badge.enabled"
:label="__('Enabled')"
type="checkbox"
/>
<FormControl
v-model="badge.title"
:label="__('Title')"
type="text"
:required="true"
/>
<Autocomplete
@update:modelValue="(opt) => (badge.reference_doctype = opt.value)"
:modelValue="badge.reference_doctype"
:options="referenceDoctypeOptions"
:required="true"
:label="__('Assign For')"
/>
<FormControl
v-model="badge.description"
:label="__('Description')"
:required="true"
type="textarea"
/>
<Uploader
v-model="badge.image"
label="Badge Image"
description="An image that represents the badge."
/>
</div>
<div class="space-y-4">
<FormControl
v-model="badge.grant_only_once"
:label="__('Grant Only Once')"
type="checkbox"
/>
<FormControl
v-model="badge.event"
:label="__('Event')"
type="select"
:options="eventOptions"
:required="true"
/>
<FormControl
v-model="badge.user_field"
:label="__('Assign To')"
type="select"
:options="userFieldOptions"
:required="true"
/>
<CodeEditor
v-model="badge.condition"
:label="__('Condition')"
type="JavaScript"
:required="true"
:showBorder="true"
height="82px"
/>
</div>
<div class="space-y-4"></div>
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<Button variant="solid" @click="saveBadge(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { cleanError } from '@/utils'
import type { Badges, Badge } from '@/components/Settings/types'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
import Uploader from '@/components/Controls/Uploader.vue'
const defaultBadge = {
name: '',
title: '',
enabled: true,
description: '',
image: '',
grant_only_once: false,
event: 'New',
reference_doctype: 'LMS Course',
condition: null,
user_field: 'member',
field_to_check: '',
}
const show = defineModel<boolean | undefined>()
const badges = defineModel<Badges>('badges')
const badge = ref<Badge>(defaultBadge)
const props = defineProps<{
badgeName: string | null
}>()
watch(
() => props.badgeName,
(val) => {
if (val != 'new') {
badges.value?.data.forEach((bdg: Badge) => {
if (bdg.name === val) {
badge.value = bdg
}
})
} else {
badge.value = { ...defaultBadge }
}
}
)
const saveBadge = (close: () => void) => {
if (props.badgeName == 'new') {
createBadge(close)
} else {
updateBadge(close)
}
}
const updateBadge = async (close: () => void) => {
console.log(props.badgeName, badge.value?.title)
if (props.badgeName != badge.value?.title) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Badge',
old_name: props.badgeName,
new_name: badge.value?.title,
})
}
const setValue = (close: () => void) => {
badges.value?.setValue.submit(
{
...badge.value,
name: badge.value.title,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err: any) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')
)
},
}
)
}
const createBadge = (close: () => void) => {
badges.value?.insert.submit(
{
...badge.value,
name: badge.value.name,
},
{
onSuccess() {
badges.value?.reload()
close()
toast.success(__('Badge created successfully'))
},
onError(err) {
close()
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
},
}
)
}
const referenceDoctypeOptions = computed(() => {
return [
{ label: __('Course'), value: 'LMS Course' },
{ label: __('Batch'), value: 'LMS Batch' },
{ label: __('User'), value: 'Member' },
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
{
label: __('Programming Exercise Submission'),
value: 'LMS Programming Exercise Submission',
},
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
]
})
const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign']
return options.map((event) => ({ label: __(event), value: event }))
})
const userFieldOptions = computed(() => {
return [
{ label: __('Member'), value: 'member' },
{ label: __('Owner'), value: 'owner' },
]
})
</script>

View File

@@ -0,0 +1,229 @@
<template>
<div 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">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="badges.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="badges.data"
row-key="name"
:options="{
showTooltip: false,
}"
>
<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 badges.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else-if="column.key == 'reference_doctype'">
{{ doctypeLabel[row[column.key]] || row[column.key] }}
</div>
<FormControl
v-else-if="column.key == 'grant_only_once'"
v-model="row[column.key]"
type="checkbox"
:disabled="true"
/>
<div
v-else-if="column.key != 'action'"
class="leading-5 text-sm"
>
{{ 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) => {
e.stopPropagation()
},
}"
placement="right"
/>
</ListRowItem>
</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>
<BadgeForm
v-model="showForm"
:badgeName="selectedBadge"
v-model:badges="badges"
/>
</template>
<script setup lang="ts">
import {
Badge,
Button,
createListResource,
Dropdown,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import BadgeForm from '@/components/Settings/BadgeForm.vue'
const showForm = ref<boolean>(false)
const selectedBadge = ref<string | null>(null)
const props = defineProps<{
label: string
description: string
}>()
const badges = createListResource({
doctype: 'LMS Badge',
fields: [
'name',
'title',
'enabled',
'description',
'image',
'grant_only_once',
'event',
'reference_doctype',
'condition',
'user_field',
'field_to_check',
],
order_by: 'creation desc',
auto: true,
})
const getMoreOptions = (badgeName: string) => {
return [
{
label: __('Edit'),
icon: 'edit',
onClick() {
openForm(badgeName)
},
},
{
label: __('Assignments'),
icon: 'download',
onClick() {
console.log('assignments')
},
},
]
}
const openForm = (badgeName: string) => {
selectedBadge.value = badgeName
showForm.value = true
}
const doctypeLabel = computed(() => {
return {
'LMS Course': __('Course'),
'LMS Batch': __('Batch'),
'LMS Enrollment': __('Course Enrollment'),
'LMS Batch Enrollment': __('Batch Enrollment'),
'LMS Quiz Submission': __('Quiz Submission'),
'LMS Assignment Submission': __('Assignment Submission'),
'LMS Programming Exercise Submission': __(
'Programming Exercise Submission'
),
}
})
const columns = computed(() => {
return [
{
label: __('Badge'),
key: 'title',
icon: 'award',
align: 'left',
width: '25%',
},
{
label: __('Assigned For'),
key: 'reference_doctype',
icon: 'info',
align: 'left',
width: '35%',
},
{
label: __('Status'),
key: 'enabled',
icon: 'check-square',
align: 'left',
width: '15%',
},
{
label: __('Grant Only Once'),
key: 'grant_only_once',
icon: 'check',
align: 'center',
width: '15%',
},
{
key: 'action',
align: 'right',
},
]
})
</script>

View File

@@ -17,7 +17,7 @@
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" />
<SettingFields :fields="fields" :data="branding.data" />
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
@@ -38,10 +38,6 @@ const props = defineProps({
type: Array,
required: true,
},
data: {
type: Object,
required: true,
},
label: {
type: String,
required: true,
@@ -51,6 +47,12 @@ const props = defineProps({
},
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const saveSettings = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
@@ -86,7 +88,7 @@ const update = () => {
)
}
watch(props.data, (newData) => {
watch(branding, (newData) => {
if (newData && !isDirty.value) {
isDirty.value = true
}

View File

@@ -34,30 +34,9 @@
:key="activeTab.label"
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
>
<Members
v-if="activeTab.label === 'Members'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Evaluators
v-else-if="activeTab.label === 'Evaluators'"
:label="activeTab.label"
:description="activeTab.description"
v-model:show="show"
/>
<Categories
v-else-if="activeTab.label === 'Categories'"
:label="activeTab.label"
:description="activeTab.description"
/>
<EmailTemplates
v-else-if="activeTab.label === 'Email Templates'"
:label="activeTab.label"
:description="activeTab.description"
/>
<ZoomSettings
v-else-if="activeTab.label === 'Zoom Accounts'"
<component
v-if="activeTab.template"
:is="activeTab.template"
:label="activeTab.label"
:description="activeTab.description"
/>
@@ -68,13 +47,6 @@
:data="data"
:fields="activeTab.fields"
/>
<BrandSettings
v-else-if="activeTab.label === 'Branding'"
:label="activeTab.label"
:description="activeTab.description"
:fields="activeTab.fields"
:data="branding"
/>
<SettingDetails
v-else
:fields="activeTab.fields"
@@ -88,8 +60,8 @@
</Dialog>
</template>
<script setup>
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue'
import { Dialog, createDocumentResource } from 'frappe-ui'
import { computed, markRaw, ref, watch } from 'vue'
import { useSettings } from '@/stores/settings'
import SettingDetails from '@/components/Settings/SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue'
@@ -100,6 +72,7 @@ import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
import BrandSettings from '@/components/Settings/BrandSettings.vue'
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
const doctype = ref('LMS Settings')
@@ -114,12 +87,6 @@ const data = createDocumentResource({
auto: true,
})
const branding = createResource({
url: 'lms.lms.api.get_branding',
auto: true,
cache: 'brand',
})
const tabsStructure = computed(() => {
return [
{
@@ -245,6 +212,7 @@ const tabsStructure = computed(() => {
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
@@ -252,21 +220,32 @@ const tabsStructure = computed(() => {
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
},
{
label: 'Categories',
description: 'Double click to edit the category',
icon: 'Network',
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description: 'Manage the Zoom accounts for your learning system',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
'Create badges and assign them to students to acknowledge their achievements',
icon: 'Award',
template: markRaw(Badges),
},
{
label: 'Categories',
description: 'Double click to edit the category',
icon: 'Network',
template: markRaw(Categories),
},
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
template: markRaw(EmailTemplates),
},
],
},
@@ -277,6 +256,7 @@ const tabsStructure = computed(() => {
{
label: 'Branding',
icon: 'Blocks',
template: markRaw(BrandSettings),
fields: [
{
label: 'Brand Name',

View File

@@ -49,7 +49,7 @@
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="blue">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">

View File

@@ -13,4 +13,35 @@ export interface User {
is_instructor: boolean
is_fc_site: boolean
}
}
export interface Badge {
name: string;
title: string;
enabled: boolean;
description: string;
image: string;
grant_only_once: boolean;
event: string;
reference_doctype: string;
condition: string | null;
user_field: string;
field_to_check: string;
};
export interface Badges {
data: Badge[],
reload: () => void
insert: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
setValue: {
submit: (
data: Badge,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
},
}

View File

@@ -9,11 +9,11 @@
"enabled",
"title",
"description",
"reference_doctype",
"event",
"image",
"column_break_wgum",
"grant_only_once",
"event",
"reference_doctype",
"user_field",
"field_to_check",
"condition"
@@ -91,6 +91,7 @@
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [
{
@@ -98,7 +99,7 @@
"link_fieldname": "badge"
}
],
"modified": "2024-05-27 17:25:55.399830",
"modified": "2025-07-04 13:02:19.048994",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Badge",
@@ -127,9 +128,10 @@
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}
}

View File

@@ -27,17 +27,9 @@ class LMSBadge(Document):
def rule_condition_satisfied(self, doc):
doc_before_save = doc.get_doc_before_save()
if self.event == "Manual Assignment":
return False
if self.event == "New" and doc_before_save != None:
return False
if self.event == "Value Change":
field_to_check = self.field_to_check
if not field_to_check:
return False
if self.condition:
return eval_condition(doc, self.condition)