feat: zoom settings on frontend
This commit is contained in:
92
frontend/src/components/Settings/BrandSettings.vue
Normal file
92
frontend/src/components/Settings/BrandSettings.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between min-h-0">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Button, Badge } from 'frappe-ui'
|
||||
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||
import { watch, ref } from 'vue'
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Website Settings',
|
||||
name: 'Website Settings',
|
||||
fieldname: values.fields,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
let fieldsToSave = {}
|
||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||
} else {
|
||||
fieldsToSave[f.name] = f.value
|
||||
}
|
||||
})
|
||||
saveSettings.submit(
|
||||
{
|
||||
fields: fieldsToSave,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
watch(props.data, (newData) => {
|
||||
if (newData && !isDirty.value) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
216
frontend/src/components/Settings/Categories.vue
Normal file
216
frontend/src/components/Settings/Categories.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<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-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div
|
||||
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
|
||||
v-if="saving"
|
||||
>
|
||||
<LoadingIndicator class="size-2" />
|
||||
<span class="text-xs">
|
||||
{{ __('saving...') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button @click="() => showCategoryForm()">
|
||||
<template #prefix>
|
||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ showForm ? __('Close') : __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="flex items-center justify-between my-4 space-x-2"
|
||||
>
|
||||
<FormControl
|
||||
ref="categoryInput"
|
||||
v-model="category"
|
||||
:placeholder="__('Category Name')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button @click="addCategory()" variant="subtle">
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="divide-y space-y-2">
|
||||
<div
|
||||
v-for="(cat, index) in categories.data"
|
||||
:key="cat.name"
|
||||
class="pt-2"
|
||||
>
|
||||
<div
|
||||
v-if="editing?.name !== cat.name"
|
||||
class="flex items-center justify-between group text-sm"
|
||||
>
|
||||
<div @dblclick="allowEdit(cat, index)">
|
||||
{{ cat.category }}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
theme="red"
|
||||
class="invisible group-hover:visible"
|
||||
@click="deleteCategory(cat.name)"
|
||||
>
|
||||
<template #icon>
|
||||
<Trash2 class="size-4 stroke-1.5 text-ink-red-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else
|
||||
:ref="(el) => (editInputRef[index] = el)"
|
||||
v-model="editedValue"
|
||||
type="text"
|
||||
class="w-full"
|
||||
@keyup.enter="saveChanges(cat.name, editedValue)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
LoadingIndicator,
|
||||
createListResource,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
const showForm = ref(false)
|
||||
const category = ref(null)
|
||||
const categoryInput = ref(null)
|
||||
const saving = ref(false)
|
||||
const editing = ref(null)
|
||||
const editedValue = ref('')
|
||||
const editInputRef = ref([])
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const categories = createListResource({
|
||||
doctype: 'LMS Category',
|
||||
fields: ['name', 'category'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const addCategory = () => {
|
||||
categories.insert.submit(
|
||||
{
|
||||
category: category.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
categories.reload()
|
||||
category.value = null
|
||||
showForm.value = false
|
||||
toast.success(__('Category added successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(__(cleanError(err.messages[0]) || 'Unable to add category'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const showCategoryForm = () => {
|
||||
showForm.value = !showForm.value
|
||||
setTimeout(() => {
|
||||
categoryInput.value.$el.querySelector('input').focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const updateCategory = createResource({
|
||||
url: 'frappe.client.rename_doc',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Category',
|
||||
old_name: values.name,
|
||||
new_name: values.category,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const update = (name, value) => {
|
||||
saving.value = true
|
||||
updateCategory.submit(
|
||||
{
|
||||
name: name,
|
||||
category: value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
saving.value = false
|
||||
categories.reload()
|
||||
editing.value = null
|
||||
editedValue.value = ''
|
||||
toast.success(__('Category updated successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
saving.value = false
|
||||
editing.value = null
|
||||
editedValue.value = ''
|
||||
toast.error(
|
||||
__(cleanError(err.messages[0]) || 'Unable to update category')
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteCategory = (name) => {
|
||||
saving.value = true
|
||||
categories.delete.submit(name, {
|
||||
onSuccess() {
|
||||
saving.value = false
|
||||
categories.reload()
|
||||
toast.success(__('Category deleted successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
saving.value = false
|
||||
toast.error(
|
||||
__(cleanError(err.messages[0]) || 'Unable to delete category')
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const saveChanges = (name, value) => {
|
||||
saving.value = true
|
||||
update(name, value)
|
||||
}
|
||||
|
||||
const allowEdit = (cat, index) => {
|
||||
editing.value = cat
|
||||
editedValue.value = cat.category
|
||||
setTimeout(() => {
|
||||
editInputRef.value[index].$el.querySelector('input').focus()
|
||||
}, 0)
|
||||
}
|
||||
</script>
|
||||
160
frontend/src/components/Settings/EmailTemplates.vue
Normal file
160
frontend/src/components/Settings/EmailTemplates.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<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-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<Button @click="openTemplateForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="emailTemplates.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
onRowClick: (row) => {
|
||||
openTemplateForm(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 }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in emailTemplates.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<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="removeTemplate(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<EmailTemplateModal
|
||||
v-model="showForm"
|
||||
v-model:emailTemplates="emailTemplates"
|
||||
:templateID="selectedTemplate"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const showForm = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const selectedTemplate = ref(null)
|
||||
|
||||
const emailTemplates = createListResource({
|
||||
doctype: 'Email Template',
|
||||
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
cache: 'email-templates',
|
||||
})
|
||||
|
||||
const removeTemplate = (selections, unselectAll) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'Email Template',
|
||||
documents: Array.from(selections),
|
||||
})
|
||||
.then(() => {
|
||||
emailTemplates.reload()
|
||||
toast.success(__('Email Templates deleted successfully'))
|
||||
unselectAll()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error deleting email templates')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const openTemplateForm = (templateID) => {
|
||||
if (readOnlyMode) {
|
||||
return
|
||||
}
|
||||
selectedTemplate.value = templateID
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Name',
|
||||
key: 'name',
|
||||
width: '20rem',
|
||||
},
|
||||
{
|
||||
label: 'Subject',
|
||||
key: 'subject',
|
||||
width: '25rem',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
132
frontend/src/components/Settings/Evaluators.vue
Normal file
132
frontend/src/components/Settings/Evaluators.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<!-- <div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<FormControl
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
:debounce="300"
|
||||
/>
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
||||
<X v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ showForm ? __('Close') : __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form to add new member -->
|
||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
||||
<FormControl
|
||||
v-model="email"
|
||||
:placeholder="__('Email')"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button @click="addEvaluator()" variant="subtle">
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="evaluator in evaluators.data"
|
||||
@click="openProfile(evaluator.username)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Avatar
|
||||
:image="evaluator.user_image"
|
||||
:label="evaluator.full_name"
|
||||
size="lg"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-base font-semibold text-ink-gray-9">
|
||||
{{ evaluator.full_name }}
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ evaluator.evaluator }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const show = defineModel('show')
|
||||
const search = ref('')
|
||||
const showForm = ref(false)
|
||||
const email = ref('')
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
const evaluators = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
makeParams: () => {
|
||||
return {
|
||||
doctype: 'Course Evaluator',
|
||||
fields: ['evaluator', 'full_name', 'user_image', 'username'],
|
||||
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const addEvaluator = () => {
|
||||
call('lms.lms.api.add_an_evaluator', {
|
||||
email: email.value,
|
||||
}).then((data) => {
|
||||
showForm.value = false
|
||||
email.value = ''
|
||||
evaluators.reload()
|
||||
})
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
evaluators.reload()
|
||||
})
|
||||
|
||||
const openProfile = (username) => {
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: username,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
224
frontend/src/components/Settings/Members.vue
Normal file
224
frontend/src/components/Settings/Members.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<!-- <div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<FormControl
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
:debounce="300"
|
||||
/>
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
||||
<X v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ showForm ? __('Close') : __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form to add new member -->
|
||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:placeholder="__('Email')"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:placeholder="__('First Name')"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button @click="addMember()" variant="subtle">
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 pb-10 overflow-auto">
|
||||
<!-- Member list -->
|
||||
<div class="overflow-y-scroll">
|
||||
<ul class="divide-y">
|
||||
<li
|
||||
v-for="member in memberList"
|
||||
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
@click="openProfile(member.username)"
|
||||
class="flex items-center space-x-3 col-span-2"
|
||||
>
|
||||
<Avatar
|
||||
:image="member.user_image"
|
||||
:label="member.full_name"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="flex">
|
||||
<div class="text-ink-gray-9">
|
||||
{{ member.full_name }}
|
||||
</div>
|
||||
<div
|
||||
class="px-1"
|
||||
v-if="member.role && getRole(member.role) !== 'Student'"
|
||||
>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:ref_for="true"
|
||||
theme="blue"
|
||||
size="sm"
|
||||
label="Badge"
|
||||
>
|
||||
{{ getRole(member.role) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ member.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-center text-ink-gray-7 text-sm"
|
||||
>
|
||||
<div v-if="member.last_active">
|
||||
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
|
||||
</div>
|
||||
<div v-else>-</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="memberList.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="members.reload()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { RefreshCw, Plus, X } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import type { User } from '@/components/Settings/types'
|
||||
|
||||
const router = useRouter()
|
||||
const show = defineModel('show')
|
||||
const search = ref('')
|
||||
const start = ref(0)
|
||||
const memberList = ref([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
const members = createResource({
|
||||
url: 'lms.lms.api.get_members',
|
||||
makeParams: () => {
|
||||
return {
|
||||
search: search.value,
|
||||
start: start.value,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
memberList.value = memberList.value.concat(data)
|
||||
start.value = start.value + 20
|
||||
hasNextPage.value = data.length === 20
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openProfile = (username) => {
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const newMember = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
show.value = false
|
||||
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const addMember = () => {
|
||||
newMember.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
memberList.value = []
|
||||
start.value = 0
|
||||
members.reload()
|
||||
})
|
||||
|
||||
const getRole = (role) => {
|
||||
const map = {
|
||||
'LMS Student': 'Student',
|
||||
'Course Creator': 'Instructor',
|
||||
Moderator: 'Moderator',
|
||||
'Batch Evaluator': 'Evaluator',
|
||||
}
|
||||
return map[role]
|
||||
}
|
||||
</script>
|
||||
128
frontend/src/components/Settings/PaymentSettings.vue
Normal file
128
frontend/src/components/Settings/PaymentSettings.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ label }}
|
||||
</div>
|
||||
<!-- <Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/> -->
|
||||
</div>
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="flex flex-col divide-y">
|
||||
<SettingFields :fields="fields" :data="data.doc" />
|
||||
<SettingFields
|
||||
v-if="paymentGateway.data"
|
||||
:fields="paymentGateway.data.fields"
|
||||
:data="paymentGateway.data.data"
|
||||
class="pt-5 my-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||
import { createResource, Badge, Button } from 'frappe-ui'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const paymentGateway = createResource({
|
||||
url: 'lms.lms.api.get_payment_gateway_details',
|
||||
makeParams(values) {
|
||||
return {
|
||||
payment_gateway: props.data.doc.payment_gateway,
|
||||
}
|
||||
},
|
||||
transform(data) {
|
||||
arrangeFields(data.fields)
|
||||
return data
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const arrangeFields = (fields) => {
|
||||
fields = fields.sort((a, b) => {
|
||||
if (a.type === 'Upload' && b.type !== 'Upload') {
|
||||
return 1
|
||||
} else if (a.type !== 'Upload' && b.type === 'Upload') {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
fields.splice(3, 0, {
|
||||
type: 'Column Break',
|
||||
})
|
||||
}
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
let fields = {}
|
||||
Object.keys(paymentGateway.data.data).forEach((key) => {
|
||||
if (
|
||||
paymentGateway.data.data[key] &&
|
||||
typeof paymentGateway.data.data[key] === 'object'
|
||||
) {
|
||||
fields[key] = paymentGateway.data.data[key].file_url
|
||||
} else {
|
||||
fields[key] = paymentGateway.data.data[key]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
doctype: paymentGateway.data.doctype,
|
||||
name: paymentGateway.data.docname,
|
||||
fieldname: fields,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
paymentGateway.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type != 'Column Break') {
|
||||
props.data.doc[f.name] = f.value
|
||||
}
|
||||
})
|
||||
props.data.save.submit()
|
||||
saveSettings.submit()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data.doc.payment_gateway,
|
||||
() => {
|
||||
paymentGateway.reload()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
79
frontend/src/components/Settings/SettingDetails.vue
Normal file
79
frontend/src/components/Settings/SettingDetails.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div class="flex itemsc-center justify-between">
|
||||
<div class="text-xl font-semibold leading-none mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingFields :fields="fields" :data="data.doc" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Badge, toast } from 'frappe-ui'
|
||||
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type == 'Upload') {
|
||||
props.data.doc[f.name] = f.value ? f.value.file_url : null
|
||||
} else if (f.type != 'Column Break') {
|
||||
props.data.doc[f.name] = f.value
|
||||
}
|
||||
})
|
||||
props.data.save.submit(
|
||||
{},
|
||||
{
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.CodeMirror pre.CodeMirror-line,
|
||||
.CodeMirror pre.CodeMirror-line-like {
|
||||
font-family: revert;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
155
frontend/src/components/Settings/SettingFields.vue
Normal file
155
frontend/src/components/Settings/SettingFields.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div
|
||||
class="my-5"
|
||||
:class="{ 'flex justify-between w-full': columns.length > 1 }"
|
||||
>
|
||||
<div v-for="(column, index) in columns" :key="index">
|
||||
<div
|
||||
class="flex flex-col space-y-5"
|
||||
:class="columns.length > 1 ? 'w-[21rem]' : 'w-1/2'"
|
||||
>
|
||||
<div v-for="field in column">
|
||||
<Link
|
||||
v-if="field.type == 'Link'"
|
||||
v-model="data[field.name]"
|
||||
:doctype="field.doctype"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
<CodeEditor
|
||||
:label="__(field.label)"
|
||||
type="HTML"
|
||||
description="The HTML you add here will be shown on your sign up page."
|
||||
v-model="data[field.name]"
|
||||
height="250px"
|
||||
class="shrink-0"
|
||||
:showLineNumbers="true"
|
||||
>
|
||||
</CodeEditor>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.type == 'Upload'" class="space-y-2">
|
||||
<div class="text-sm text-ink-gray-5 mb-1">
|
||||
{{ __(field.label) }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!data[field.name]"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => (data[field.name] = file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2 px-20 py-5"
|
||||
>
|
||||
<img
|
||||
:src="data[field.name]?.file_url || data[field.name]"
|
||||
class="size-6 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap">
|
||||
<span class="break-all text-ink-gray-9">
|
||||
{{
|
||||
data[field.name]?.file_name ||
|
||||
data[field.name].split('/').pop()
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="data[field.name]?.file_size"
|
||||
class="text-sm text-ink-gray-5 mt-1"
|
||||
>
|
||||
{{ getFileSize(data[field.name]?.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
v-else-if="field.type == 'checkbox'"
|
||||
size="sm"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-else
|
||||
:key="field.name"
|
||||
v-model="data[field.name]"
|
||||
:label="__(field.label)"
|
||||
:type="field.type"
|
||||
:rows="field.rows"
|
||||
:options="field.options"
|
||||
:description="field.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = []
|
||||
let currentColumn = []
|
||||
|
||||
props.fields.forEach((field) => {
|
||||
if (field.type === 'Column Break') {
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
currentColumn = []
|
||||
}
|
||||
} else {
|
||||
if (field.type == 'checkbox') {
|
||||
field.value = props.data[field.name] ? true : false
|
||||
} else {
|
||||
field.value = props.data[field.name]
|
||||
}
|
||||
currentColumn.push(field)
|
||||
}
|
||||
})
|
||||
|
||||
if (currentColumn.length > 0) {
|
||||
cols.push(currentColumn)
|
||||
}
|
||||
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
401
frontend/src/components/Settings/Settings.vue
Normal file
401
frontend/src/components/Settings/Settings.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '5xl' }">
|
||||
<template #body>
|
||||
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
|
||||
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Settings') }}
|
||||
</h1>
|
||||
<div v-for="tab in tabs" :key="tab.label">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<span>{{ __(tab.label) }}</span>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<SidebarLink
|
||||
v-for="item in tab.items"
|
||||
:link="item"
|
||||
:key="item.label"
|
||||
class="w-full"
|
||||
:class="
|
||||
activeTab?.label == item.label
|
||||
? 'bg-surface-selected shadow-sm'
|
||||
: 'hover:bg-surface-gray-2'
|
||||
"
|
||||
@click="activeTab = item"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTab && data.doc"
|
||||
: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'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
/>
|
||||
<PaymentSettings
|
||||
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
: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"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
:data="data"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import Members from '@/components/Settings/Members.vue'
|
||||
import Evaluators from '@/components/Settings/Evaluators.vue'
|
||||
import Categories from '@/components/Settings/Categories.vue'
|
||||
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'
|
||||
|
||||
const show = defineModel()
|
||||
const doctype = ref('LMS Settings')
|
||||
const activeTab = ref(null)
|
||||
const settingsStore = useSettings()
|
||||
|
||||
const data = createDocumentResource({
|
||||
doctype: doctype.value,
|
||||
name: doctype.value,
|
||||
fields: ['*'],
|
||||
cache: doctype.value,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
auto: true,
|
||||
cache: 'brand',
|
||||
})
|
||||
|
||||
const tabsStructure = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'General',
|
||||
icon: 'Wrench',
|
||||
fields: [
|
||||
{
|
||||
label: 'Enable Learning Paths',
|
||||
name: 'enable_learning_paths',
|
||||
description:
|
||||
'This will ensure students follow the assigned programs in order.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Allow Guest Access',
|
||||
name: 'allow_guest_access',
|
||||
description:
|
||||
'If enabled, users can access the course and batch lists without logging in.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Send calendar invite for evaluations',
|
||||
name: 'send_calendar_invite_for_evaluations',
|
||||
description:
|
||||
'If enabled, it sends google calendar invite to the student for evaluations.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Batch Confirmation Template',
|
||||
name: 'batch_confirmation_template',
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
{
|
||||
label: 'Certification Template',
|
||||
name: 'certification_template',
|
||||
doctype: 'Email Template',
|
||||
type: 'Link',
|
||||
},
|
||||
{
|
||||
label: 'Unsplash Access Key',
|
||||
name: 'unsplash_access_key',
|
||||
description:
|
||||
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
|
||||
type: 'password',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
icon: 'DollarSign',
|
||||
description:
|
||||
'Configure the payment gateway and other payment related settings',
|
||||
fields: [
|
||||
{
|
||||
label: 'Default Currency',
|
||||
name: 'default_currency',
|
||||
type: 'Link',
|
||||
doctype: 'Currency',
|
||||
},
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Apply GST for India',
|
||||
name: 'apply_gst',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Lists',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description: 'Manage the members of your learning system',
|
||||
icon: 'UserRoundPlus',
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: 'Manage the evaluators of your learning system',
|
||||
icon: 'UserCheck',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
label: 'Zoom Accounts',
|
||||
description: 'Manage the Zoom accounts for your learning system',
|
||||
icon: 'Video',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customise',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
fields: [
|
||||
{
|
||||
label: 'Brand Name',
|
||||
name: 'app_name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
label: 'Logo',
|
||||
name: 'banner_image',
|
||||
type: 'Upload',
|
||||
},
|
||||
{
|
||||
label: 'Favicon',
|
||||
name: 'favicon',
|
||||
type: 'Upload',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Sidebar',
|
||||
icon: 'PanelLeftIcon',
|
||||
description: 'Choose the items you want to show in the sidebar',
|
||||
fields: [
|
||||
{
|
||||
label: 'Courses',
|
||||
name: 'courses',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Batches',
|
||||
name: 'batches',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Certified Participants',
|
||||
name: 'certified_participants',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
type: 'Column Break',
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
name: 'jobs',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Statistics',
|
||||
name: 'statistics',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
name: 'notifications',
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Signup',
|
||||
icon: 'LogIn',
|
||||
fields: [
|
||||
{
|
||||
label: 'Identify User Category',
|
||||
name: 'user_category',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'Enable this option to identify the user category during signup.',
|
||||
},
|
||||
{
|
||||
label: 'Disable signup',
|
||||
name: 'disable_signup',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'New users will have to be manually registered by Admins.',
|
||||
},
|
||||
{
|
||||
label: 'Signup Consent HTML',
|
||||
name: 'custom_signup_content',
|
||||
type: 'Code',
|
||||
mode: 'htmlmixed',
|
||||
rows: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SEO',
|
||||
icon: 'Search',
|
||||
fields: [
|
||||
{
|
||||
label: 'Meta Description',
|
||||
name: 'meta_description',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
description:
|
||||
"This description will be shown on lists and pages that don't have meta description",
|
||||
},
|
||||
{
|
||||
label: 'Meta Keywords',
|
||||
name: 'meta_keywords',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
description:
|
||||
'Keywords for search engines to find your website. Separated by commas.',
|
||||
},
|
||||
{
|
||||
label: 'Meta Image',
|
||||
name: 'meta_image',
|
||||
type: 'Upload',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
return tabsStructure.value.map((tab) => {
|
||||
return {
|
||||
...tab,
|
||||
items: tab.items.filter((item) => {
|
||||
return !item.condition || item.condition()
|
||||
}),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(show, async () => {
|
||||
if (show.value) {
|
||||
const currentTab = await tabs.value
|
||||
.flatMap((tab) => tab.items)
|
||||
.find((item) => item.label === settingsStore.activeTab)
|
||||
activeTab.value = currentTab || tabs.value[0].items[0]
|
||||
} else {
|
||||
activeTab.value = null
|
||||
settingsStore.isSettingsOpen = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
149
frontend/src/components/Settings/ZoomSettings.vue
Normal file
149
frontend/src/components/Settings/ZoomSettings.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<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-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="zoomAccounts.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
onRowClick: (row) => {
|
||||
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 }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in zoomAccounts.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<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="removeTemplate(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<ZoomAccountModal v-model="showForm" v-model:zoomAccounts="zoomAccounts" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
createListResource,
|
||||
Button,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
|
||||
|
||||
const user = inject<User | null>('$user')
|
||||
const showForm = ref(false)
|
||||
const currentAccount = ref<string | null>(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
description: String,
|
||||
})
|
||||
|
||||
const zoomAccounts = createListResource({
|
||||
doctype: 'LMS Zoom Settings',
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'account_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
],
|
||||
cache: ['zoomAccounts'],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchZoomAccounts()
|
||||
})
|
||||
|
||||
const fetchZoomAccounts = () => {
|
||||
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
|
||||
|
||||
if (user?.data?.is_evaluator) {
|
||||
zoomAccounts.update({
|
||||
filters: {
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
zoomAccounts.reload()
|
||||
}
|
||||
|
||||
const openForm = (accountID: string) => {
|
||||
currentAccount.value = accountID
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Account'),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
16
frontend/src/components/Settings/types.ts
Normal file
16
frontend/src/components/Settings/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface User {
|
||||
data: {
|
||||
email: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
user_image: string
|
||||
full_name: string
|
||||
user_type: ['System User', 'Website User']
|
||||
username: string
|
||||
is_moderator: boolean
|
||||
is_system_manager: boolean
|
||||
is_evaluator: boolean
|
||||
is_instructor: boolean
|
||||
is_fc_site: boolean
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user