chore: resolved conflicts
This commit is contained in:
159
frontend/src/components/AdminBatchDashboard.vue
Normal file
159
frontend/src/components/AdminBatchDashboard.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="">
|
||||
<div class="w-full flex items-center justify-between pb-4">
|
||||
<div class="font-medium text-ink-gray-7">
|
||||
{{ __('Statistics') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Certified'),
|
||||
value: certificationCount.data || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Courses'),
|
||||
value: batch?.data?.courses?.length || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border"
|
||||
:config="{
|
||||
data: chartData || [],
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
swapXY: true,
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const chartData = ref<null | any[]>(null)
|
||||
const showProgressChart = ref(false)
|
||||
const assessmentCount = ref(0)
|
||||
|
||||
const props = defineProps<{
|
||||
batch: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const students = createResource({
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data: any[]) {
|
||||
chartData.value = getChartData()
|
||||
showProgressChart.value =
|
||||
data.length &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.value)
|
||||
},
|
||||
})
|
||||
|
||||
const getChartData = () => {
|
||||
let tasks: any[] = []
|
||||
let data: { task: any; value: any }[] = []
|
||||
|
||||
students.data.forEach((row: any) => {
|
||||
tasks = countAssessments(row, tasks)
|
||||
tasks = countCourses(row, tasks)
|
||||
})
|
||||
|
||||
tasks.forEach((task) => {
|
||||
data.push({
|
||||
task: task.label,
|
||||
value: task.value,
|
||||
})
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const countAssessments = (
|
||||
row: { assessments: { [x: string]: { result: string } } },
|
||||
tasks: any[]
|
||||
) => {
|
||||
Object.keys(row.assessments).forEach((assessment) => {
|
||||
if (row.assessments[assessment].result === 'Pass') {
|
||||
tasks.filter((task) => task.label === assessment).length
|
||||
? tasks.filter((task) => task.label === assessment)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: assessment,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
const countCourses = (
|
||||
row: { courses: { [x: string]: number } },
|
||||
tasks: any[]
|
||||
) => {
|
||||
Object.keys(row.courses).forEach((course) => {
|
||||
if (row.courses[course] === 100) {
|
||||
tasks.filter((task) => task.label === course).length
|
||||
? tasks.filter((task) => task.label === course)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: course,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: {
|
||||
batch_name: props.batch?.data?.name,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(students, () => {
|
||||
if (students.data?.length) {
|
||||
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -317,54 +317,68 @@ const addNotifications = () => {
|
||||
}
|
||||
|
||||
const addQuizzes = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.splice(4, 0, {
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
activeFor: [
|
||||
'Quizzes',
|
||||
'QuizForm',
|
||||
'QuizSubmissionList',
|
||||
'QuizSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
if (!isInstructor.value && !isModerator.value) return
|
||||
|
||||
const quizzesLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Quizzes'
|
||||
)
|
||||
if (quizzesLinkExists) return
|
||||
|
||||
sidebarLinks.value.splice(4, 0, {
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
activeFor: ['Quizzes', 'QuizForm', 'QuizSubmissionList', 'QuizSubmission'],
|
||||
})
|
||||
}
|
||||
|
||||
const addAssignments = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.splice(5, 0, {
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
activeFor: [
|
||||
'Assignments',
|
||||
'AssignmentForm',
|
||||
'AssignmentSubmissionList',
|
||||
'AssignmentSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
if (!isInstructor.value && !isModerator.value) return
|
||||
|
||||
const assignmentsLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Assignments'
|
||||
)
|
||||
if (assignmentsLinkExists) return
|
||||
|
||||
sidebarLinks.value.splice(5, 0, {
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
activeFor: [
|
||||
'Assignments',
|
||||
'AssignmentForm',
|
||||
'AssignmentSubmissionList',
|
||||
'AssignmentSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const addProgrammingExercises = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.splice(3, 0, {
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
activeFor: [
|
||||
'ProgrammingExercises',
|
||||
'ProgrammingExerciseForm',
|
||||
'ProgrammingExerciseSubmissions',
|
||||
'ProgrammingExerciseSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
if (!isInstructor.value && !isModerator.value) return
|
||||
const programmingExercisesLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Programming Exercises'
|
||||
)
|
||||
if (programmingExercisesLinkExists) return
|
||||
|
||||
sidebarLinks.value.splice(3, 0, {
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
activeFor: [
|
||||
'ProgrammingExercises',
|
||||
'ProgrammingExerciseForm',
|
||||
'ProgrammingExerciseSubmissions',
|
||||
'ProgrammingExerciseSubmission',
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const addPrograms = async () => {
|
||||
const programsLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Programs'
|
||||
)
|
||||
if (programsLinkExists) return
|
||||
|
||||
let canAddProgram = await checkIfCanAddProgram()
|
||||
if (!canAddProgram) return
|
||||
let activeFor = ['Programs', 'ProgramDetail']
|
||||
@@ -379,15 +393,21 @@ const addPrograms = async () => {
|
||||
}
|
||||
|
||||
const addContactUsDetails = () => {
|
||||
if (settingsStore.contactUsEmail?.data || settingsStore.contactUsURL?.data) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Contact Us',
|
||||
icon: settingsStore.contactUsURL?.data ? 'Headset' : 'Mail',
|
||||
to: settingsStore.contactUsURL?.data
|
||||
? settingsStore.contactUsURL.data
|
||||
: settingsStore.contactUsEmail?.data,
|
||||
})
|
||||
}
|
||||
if (!settingsStore.contactUsEmail?.data && !settingsStore.contactUsURL?.data)
|
||||
return
|
||||
|
||||
const contactUsLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Contact Us'
|
||||
)
|
||||
if (contactUsLinkExists) return
|
||||
|
||||
sidebarLinks.value.push({
|
||||
label: 'Contact Us',
|
||||
icon: settingsStore.contactUsURL?.data ? 'Headset' : 'Mail',
|
||||
to: settingsStore.contactUsURL?.data
|
||||
? settingsStore.contactUsURL.data
|
||||
: settingsStore.contactUsEmail?.data,
|
||||
})
|
||||
}
|
||||
|
||||
const checkIfCanAddProgram = async () => {
|
||||
@@ -399,6 +419,10 @@ const checkIfCanAddProgram = async () => {
|
||||
}
|
||||
|
||||
const addHome = () => {
|
||||
const homeLinkExists = sidebarLinks.value.some(
|
||||
(link) => link.label === 'Home'
|
||||
)
|
||||
if (homeLinkExists) return
|
||||
sidebarLinks.value.unshift({
|
||||
label: 'Home',
|
||||
icon: 'Home',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
<div class="font-medium text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||
|
||||
@@ -1,70 +1,8 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<div class="w-full flex items-center justify-between pb-4">
|
||||
<div class="font-medium text-ink-gray-7">
|
||||
{{ __('Statistics') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Certified'),
|
||||
value: certificationCount.data || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Courses'),
|
||||
value: batch.data.courses?.length || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
:config="{
|
||||
data: chartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
swapXY: true,
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-7 font-medium">
|
||||
{{ __('Students') }}
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ students.data?.length }} {{ __('Students') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
@@ -76,6 +14,7 @@
|
||||
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
class="max-h-[75vh]"
|
||||
:columns="getStudentColumns()"
|
||||
:rows="students.data"
|
||||
row-key="name"
|
||||
@@ -151,7 +90,7 @@
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +109,6 @@
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
AxisChart,
|
||||
Button,
|
||||
createResource,
|
||||
FeatherIcon,
|
||||
@@ -181,30 +119,17 @@ import {
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
NumberChart,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||
import ApexChart from 'vue3-apexcharts'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
const showStudentProgressModal = ref(false)
|
||||
const selectedStudent = ref(null)
|
||||
const chartData = ref(null)
|
||||
const showProgressChart = ref(false)
|
||||
const assessmentCount = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
@@ -220,12 +145,6 @@ const students = createResource({
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
chartData.value = getChartData()
|
||||
showProgressChart.value =
|
||||
data.length &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.value)
|
||||
},
|
||||
})
|
||||
|
||||
const getStudentColumns = () => {
|
||||
@@ -288,67 +207,4 @@ const removeStudents = (selections, unselectAll) => {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const getChartData = () => {
|
||||
let tasks = []
|
||||
let data = []
|
||||
|
||||
students.data.forEach((row) => {
|
||||
tasks = countAssessments(row, tasks)
|
||||
tasks = countCourses(row, tasks)
|
||||
})
|
||||
|
||||
tasks.forEach((task) => {
|
||||
data.push({
|
||||
task: task.label,
|
||||
value: task.value,
|
||||
})
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const countAssessments = (row, tasks) => {
|
||||
Object.keys(row.assessments).forEach((assessment) => {
|
||||
if (row.assessments[assessment].result === 'Pass') {
|
||||
tasks.filter((task) => task.label === assessment).length
|
||||
? tasks.filter((task) => task.label === assessment)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: assessment,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
const countCourses = (row, tasks) => {
|
||||
Object.keys(row.courses).forEach((course) => {
|
||||
if (row.courses[course] === 100) {
|
||||
tasks.filter((task) => task.label === course).length
|
||||
? tasks.filter((task) => task.label === course)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: course,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
watch(students, () => {
|
||||
if (students.data?.length) {
|
||||
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
||||
}
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: {
|
||||
batch_name: props.batch?.data?.name,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
@click="() => togglePopover()"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="overflow-x-auto border rounded-md">
|
||||
<div class="overflow-x-auto overflow-y-visible border rounded-md">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
@@ -27,7 +27,7 @@
|
||||
<input
|
||||
v-if="showKey(key)"
|
||||
v-model="row[key]"
|
||||
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
|
||||
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -43,20 +43,27 @@
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||
>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: menuTopPosition,
|
||||
left: menuLeftPosition,
|
||||
}"
|
||||
class="top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,17 +80,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const rows = defineModel<Cell[][]>()
|
||||
const rows = defineModel<Record<string, string>[]>()
|
||||
const menuRef = ref(null)
|
||||
const menuOpenIndex = ref<number | null>(null)
|
||||
const menuTopPosition = ref<string>('')
|
||||
const menuLeftPosition = ref('0px')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Cell[][]): void
|
||||
(e: 'update:modelValue', value: Record<string, string>[]): void
|
||||
}>()
|
||||
|
||||
type Cell = {
|
||||
@@ -93,19 +102,19 @@ type Cell = {
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: Cell[][]
|
||||
modelValue?: Record<string, string>[]
|
||||
columns?: string[]
|
||||
label?: string
|
||||
}>(),
|
||||
{
|
||||
columns: [],
|
||||
columns: () => [] as string[],
|
||||
}
|
||||
)
|
||||
|
||||
const columns = ref(props.columns)
|
||||
|
||||
watch(rows, () => {
|
||||
if (rows.value?.length < 1) {
|
||||
if (rows.value && rows.value.length < 1) {
|
||||
addRow()
|
||||
}
|
||||
})
|
||||
@@ -119,12 +128,25 @@ const addRow = () => {
|
||||
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
||||
})
|
||||
rows.value.push(newRow)
|
||||
focusNewRowInput()
|
||||
emit('update:modelValue', rows.value)
|
||||
}
|
||||
|
||||
const focusNewRowInput = () => {
|
||||
nextTick(() => {
|
||||
const rowElements = document.querySelectorAll('.overflow-x-auto .grid')[
|
||||
rows.value!.length
|
||||
]
|
||||
const firstInput = rowElements.querySelector('input')
|
||||
if (firstInput) {
|
||||
;(firstInput as HTMLInputElement).focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteRow = (index: number) => {
|
||||
rows.value.splice(index, 1)
|
||||
emit('update:modelValue', rows.value)
|
||||
rows.value?.splice(index, 1)
|
||||
emit('update:modelValue', rows.value ?? [])
|
||||
}
|
||||
|
||||
const getGridTemplateColumns = () => {
|
||||
@@ -132,8 +154,10 @@ const getGridTemplateColumns = () => {
|
||||
}
|
||||
|
||||
const toggleMenu = (index: number, event: MouseEvent) => {
|
||||
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
||||
menuTopPosition.value = `${event.clientY + 10}px`
|
||||
const rect = (event.target as HTMLElement).getBoundingClientRect()
|
||||
menuOpenIndex.value = index
|
||||
menuTopPosition.value = rect.bottom + 'px'
|
||||
menuLeftPosition.value = rect.right + 'px'
|
||||
}
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
|
||||
@@ -67,6 +67,7 @@ import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@@ -103,6 +104,7 @@ const value = computed({
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
@@ -121,6 +123,16 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => settingsStore.isSettingsOpen,
|
||||
(isOpen, wasOpen) => {
|
||||
if (wasOpen && !isOpen) {
|
||||
reload('')
|
||||
}
|
||||
},
|
||||
{ debounce: 200 }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen, close }">
|
||||
@@ -58,7 +56,7 @@
|
||||
<div class="h-10"></div>
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||
class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -180,6 +178,7 @@ const filterOptions = createResource({
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
setFocus()
|
||||
return filterOptions.data || []
|
||||
})
|
||||
|
||||
@@ -225,25 +224,6 @@ const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasPermission() && !props.zoomAccount"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3 text-xs"
|
||||
>
|
||||
<AlertCircle class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { escapeHTML } from '@/utils'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
@@ -121,35 +122,48 @@ watch(show, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
}
|
||||
|
||||
const saveAssignment = () => {
|
||||
validateTitle()
|
||||
if (props.assignmentID == 'new') {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
createAssignment()
|
||||
} else {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
updateAssignment()
|
||||
}
|
||||
}
|
||||
|
||||
const createAssignment = () => {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateAssignment = () => {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const assignmentOptions = computed(() => {
|
||||
return [
|
||||
{ label: 'PDF', value: 'PDF' },
|
||||
|
||||
@@ -139,7 +139,8 @@ function submitEvaluation(close) {
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
console.log(err.messages?.[0] || err)
|
||||
toast.warning(__(err.messages?.[0] || err), { duration: 10000 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Members', close)
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
142
frontend/src/components/Settings/Coupons/CouponDetails.vue
Normal file
142
frontend/src/components/Settings/Coupons/CouponDetails.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-base h-full">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ data?.name ? __('Edit Coupon') : __('New Coupon') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="data.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
v-model="data.code"
|
||||
:label="__('Coupon Code')"
|
||||
:required="true"
|
||||
@input="() => (data.code = data.code.toUpperCase())"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.discount_type"
|
||||
:label="__('Discount Type')"
|
||||
:required="true"
|
||||
type="select"
|
||||
:options="['Percentage', 'Fixed Amount']"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.expires_on"
|
||||
:label="__('Expires On')"
|
||||
type="date"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-if="data.discount_type === 'Percentage'"
|
||||
v-model="data.percentage_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Percentage')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="data.fixed_amount_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="data.usage_limit"
|
||||
:label="__('Usage Limit')"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.redemptions_count"
|
||||
:label="__('Redemptions Count')"
|
||||
type="number"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="py-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-2">
|
||||
{{ __('Applicable For') }}
|
||||
</div>
|
||||
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto space-x-2 ml-auto">
|
||||
<Button variant="solid" @click="saveCoupon()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
import CouponItems from '@/components/Settings/Coupons/CouponItems.vue'
|
||||
|
||||
const couponItems = ref<any>(null)
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps<{
|
||||
coupons: Coupons
|
||||
data: Coupon
|
||||
}>()
|
||||
|
||||
const saveCoupon = () => {
|
||||
if (props.data?.name) {
|
||||
editCoupon()
|
||||
} else {
|
||||
createCoupon()
|
||||
}
|
||||
}
|
||||
|
||||
const editCoupon = () => {
|
||||
props.coupons.setValue.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
if (couponItems.value) {
|
||||
couponItems.value.saveItems()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const createCoupon = () => {
|
||||
if (couponItems.value) {
|
||||
let rows = couponItems.value.saveItems()
|
||||
props.data.applicable_items = rows
|
||||
}
|
||||
props.coupons.insert.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
toast.success(__('Coupon created successfully'))
|
||||
emit('updateStep', 'details', { ...data })
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
140
frontend/src/components/Settings/Coupons/CouponItems.vue
Normal file
140
frontend/src/components/Settings/Coupons/CouponItems.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative overflow-x-auto border rounded-md">
|
||||
<table class="w-full text-sm text-left text-ink-gray-5">
|
||||
<thead class="text-xs text-ink-gray-7 uppercase bg-surface-gray-2">
|
||||
<tr>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
{{ __('Document Type') }}
|
||||
</td>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
{{ __('Document Name') }}
|
||||
</td>
|
||||
<td scope="col" class="px-6 py-2 w-16"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
class="bg-white dark:bg-gray-800 dark:border-gray-700 border-gray-200"
|
||||
>
|
||||
<td class="px-6 py-2">
|
||||
<FormControl
|
||||
type="select"
|
||||
v-model="row.reference_doctype"
|
||||
:options="[
|
||||
{ label: 'Course', value: 'LMS Course' },
|
||||
{ label: 'Batch', value: 'LMS Batch' },
|
||||
]"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-2">
|
||||
<Link
|
||||
:doctype="row.reference_doctype"
|
||||
v-model="row.reference_name"
|
||||
class="bg-white"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-2">
|
||||
<Button variant="ghost" @click="removeRow(row)">
|
||||
<template #icon>
|
||||
<X class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Button @click="addRow()">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add Row') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ApplicableItem, Coupon, Coupons } from './types'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Button, createListResource, FormControl } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const rows = ref<
|
||||
{
|
||||
reference_doctype: string
|
||||
reference_name: string | null
|
||||
name: string | null
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const props = defineProps<{
|
||||
data: Coupon
|
||||
coupons: Coupons
|
||||
}>()
|
||||
|
||||
const applicableItems = createListResource({
|
||||
doctype: 'LMS Coupon Item',
|
||||
fields: [
|
||||
'reference_doctype',
|
||||
'reference_name',
|
||||
'name',
|
||||
'parent',
|
||||
'parenttype',
|
||||
'parentfield',
|
||||
],
|
||||
parent: 'LMS Coupon',
|
||||
onSuccess(data: ApplicableItem[]) {
|
||||
rows.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
rows.value.push({
|
||||
reference_doctype: 'LMS Course',
|
||||
reference_name: null,
|
||||
name: null,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
if (props.data?.name) {
|
||||
applicableItems.update({
|
||||
filters: {
|
||||
parent: props.data.name,
|
||||
},
|
||||
})
|
||||
applicableItems.reload()
|
||||
} else {
|
||||
addRow()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const saveItems = (parent = null) => {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
const removeRow = (rowToRemove: any) => {
|
||||
rows.value = rows.value.filter((row) => row !== rowToRemove)
|
||||
if (rowToRemove.name) {
|
||||
applicableItems.delete.submit(rowToRemove.name, {
|
||||
onSuccess() {
|
||||
props.coupons.reload()
|
||||
applicableItems.reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
saveItems,
|
||||
})
|
||||
</script>
|
||||
203
frontend/src/components/Settings/Coupons/CouponList.vue
Normal file
203
frontend/src/components/Settings/Coupons/CouponList.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm()">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="coupons.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="coupons.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: true,
|
||||
onRowClick: (row: Coupon) => {
|
||||
openForm(row)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in coupons.data" :key="row.name">
|
||||
<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 == 'expires_on'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div v-else-if="column.key == 'discount'">
|
||||
<div v-if="row['discount_type'] == 'Percentage'">
|
||||
{{ row['percentage_discount'] }}%
|
||||
</div>
|
||||
<div v-else-if="row['discount_type'] == 'Fixed Amount'">
|
||||
{{ row['fixed_amount_discount'] }}/-
|
||||
</div>
|
||||
</div>
|
||||
<div v-else 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="confirmDeletion(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-center text-ink-gray-6 italic mt-40">
|
||||
{{ __('No coupons created yet.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, getCurrentInstance, inject, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
|
||||
const dayjs = inject('$dayjs') as typeof import('dayjs')
|
||||
const app = getCurrentInstance()
|
||||
const $dialog = app?.appContext.config.globalProperties.$dialog
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
coupons: Coupons
|
||||
}>()
|
||||
|
||||
const openForm = (coupon: Coupon = {} as Coupon) => {
|
||||
emit('updateStep', 'details', { ...coupon })
|
||||
}
|
||||
|
||||
const confirmDeletion = (selections: any[], unselectAll: () => void) => {
|
||||
if (selections.length === 0) {
|
||||
toast.info(__('No coupons selected for deletion'))
|
||||
return
|
||||
}
|
||||
$dialog({
|
||||
title: __('Delete this coupon?'),
|
||||
message: __(
|
||||
'This will permanently delete the coupon and the code will no longer be valid.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }: { close: () => void }) {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Coupon',
|
||||
documents: Array.from(selections),
|
||||
}).then((data: any) => {
|
||||
toast.success(__('Coupon(s) deleted successfully'))
|
||||
coupons.reload()
|
||||
unselectAll()
|
||||
close()
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function trashCoupon(name, close) {
|
||||
call('frappe.client.delete', { doctype: 'LMS Coupon', name }).then(() => {
|
||||
toast.success(__('Coupon deleted successfully'))
|
||||
coupons.reload()
|
||||
if (typeof close === 'function') close()
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Code'),
|
||||
key: 'code',
|
||||
icon: 'tag',
|
||||
width: '150px',
|
||||
},
|
||||
{
|
||||
label: __('Discount'),
|
||||
key: 'discount',
|
||||
align: 'center',
|
||||
width: '80px',
|
||||
icon: 'dollar-sign',
|
||||
},
|
||||
{
|
||||
label: __('Expires On'),
|
||||
key: 'expires_on',
|
||||
width: '120px',
|
||||
icon: 'calendar',
|
||||
},
|
||||
{
|
||||
label: __('Usage Limit'),
|
||||
key: 'usage_limit',
|
||||
align: 'center',
|
||||
width: '100px',
|
||||
icon: 'hash',
|
||||
},
|
||||
{
|
||||
label: __('Redemption Count'),
|
||||
key: 'redemption_count',
|
||||
align: 'center',
|
||||
width: '100px',
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
label: __('Enabled'),
|
||||
key: 'enabled',
|
||||
align: 'center',
|
||||
icon: 'check-square',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
53
frontend/src/components/Settings/Coupons/Coupons.vue
Normal file
53
frontend/src/components/Settings/Coupons/Coupons.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<CouponList
|
||||
v-if="step === 'list'"
|
||||
:label="props.label"
|
||||
:description="props.description"
|
||||
:coupons="coupons"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<CouponDetails
|
||||
v-else-if="step == 'details'"
|
||||
:coupons="coupons"
|
||||
:data="data"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import CouponList from '@/components/Settings/Coupons/CouponList.vue'
|
||||
import CouponDetails from '@/components/Settings/Coupons/CouponDetails.vue'
|
||||
import type { Coupon } from './types'
|
||||
|
||||
const step = ref('list')
|
||||
const data = ref<Coupon | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const updateStep = (newStep: 'list' | 'new' | 'edit', newData: Coupon) => {
|
||||
step.value = newStep
|
||||
if (newData) {
|
||||
data.value = newData
|
||||
}
|
||||
}
|
||||
|
||||
const coupons = createListResource({
|
||||
doctype: 'LMS Coupon',
|
||||
fields: [
|
||||
'name',
|
||||
'code',
|
||||
'discount_type',
|
||||
'percentage_discount',
|
||||
'fixed_amount_discount',
|
||||
'expires_on',
|
||||
'usage_limit',
|
||||
'redemption_count',
|
||||
'enabled',
|
||||
],
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
30
frontend/src/components/Settings/Coupons/types.ts
Normal file
30
frontend/src/components/Settings/Coupons/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface Coupon {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
code: string;
|
||||
discount_type: 'Percentage' | 'Fixed Amount';
|
||||
percentage_discount?: number;
|
||||
fixed_amount_discount?: number;
|
||||
expires_on?: string;
|
||||
description?: string;
|
||||
usage_limit?: number;
|
||||
redemptions_count: number;
|
||||
applicable_items: ApplicableItem[];
|
||||
}
|
||||
|
||||
export type ApplicableItem = {
|
||||
reference_doctype: "LMS Course" | "LMS Batch";
|
||||
reference_name: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
parenttype: "LMS Coupon";
|
||||
parentfield: "applicable_items";
|
||||
}
|
||||
|
||||
export interface Coupons {
|
||||
data: Coupon[];
|
||||
update: (args: { filters: any[] }) => void;
|
||||
insert: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void };
|
||||
setValue: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void };
|
||||
reload: () => void;
|
||||
}
|
||||
@@ -147,6 +147,8 @@ const columns = computed(() => {
|
||||
} else {
|
||||
if (field.type == 'checkbox') {
|
||||
field.value = props.data[field.name] ? true : false
|
||||
} else {
|
||||
field.value = props.data[field.name]
|
||||
}
|
||||
currentColumn.push(field)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<div
|
||||
v-if="activeTab && data.doc"
|
||||
:key="activeTab.label"
|
||||
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal overflow-x-auto"
|
||||
class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto"
|
||||
>
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
@@ -79,7 +79,8 @@ import Categories from '@/components/Settings/Categories.vue'
|
||||
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
|
||||
import Transactions from '@/components/Settings/Transactions.vue'
|
||||
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
|
||||
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
|
||||
@@ -286,6 +287,12 @@ const tabsStructure = computed(() => {
|
||||
template: markRaw(Transactions),
|
||||
description: 'View all your payment transactions',
|
||||
},
|
||||
{
|
||||
label: 'Coupons',
|
||||
icon: 'Ticket',
|
||||
template: markRaw(Coupons),
|
||||
description: 'Manage discount coupons for courses and batches',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Transaction Details'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="transactionData" class="text-base">
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<FormControl
|
||||
:label="__('Payment Received')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_received"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Payment For Certificate')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_for_certificate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Member')"
|
||||
doctype="User"
|
||||
v-model="transactionData.member"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="transactionData.billing_name"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Source')"
|
||||
v-model="transactionData.source"
|
||||
doctype="LMS Source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Payment Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Payment For Document Type')"
|
||||
v-model="transactionData.payment_for_document_type"
|
||||
doctype="DocType"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Payment For Document')"
|
||||
v-model="transactionData.payment_for_document"
|
||||
:doctype="transactionData.payment_for_document_type"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Address')"
|
||||
v-model="transactionData.address"
|
||||
doctype="Address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Currency')"
|
||||
v-model="transactionData.currency"
|
||||
doctype="Currency"
|
||||
/>
|
||||
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
|
||||
<FormControl
|
||||
:label="__('Order ID')"
|
||||
v-model="transactionData.order_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
<FormControl
|
||||
:label="__('Payment ID')"
|
||||
v-model="transactionData.payment_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="space-x-2 pb-5 float-right">
|
||||
<Button @click="openDetails(close)">
|
||||
{{ __('Open the ') }}
|
||||
{{
|
||||
transaction.payment_for_document_type == 'LMS Course'
|
||||
? __('Course')
|
||||
: __('Batch')
|
||||
}}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveTransaction(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog, FormControl, Button } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const transactions = defineModel<any>('transactions')
|
||||
const router = useRouter()
|
||||
const showModal = defineModel('show')
|
||||
const transactionData = ref<{ [key: string]: any } | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.transaction,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const saveTransaction = (close: () => void) => {
|
||||
transactions.value.setValue
|
||||
.submit({
|
||||
...transactionData.value,
|
||||
})
|
||||
.then(() => {
|
||||
close()
|
||||
})
|
||||
}
|
||||
|
||||
const openDetails = (close: Function) => {
|
||||
if (props.transaction) {
|
||||
const docType = props.transaction.payment_for_document_type
|
||||
const docName = props.transaction.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
close()
|
||||
showModal.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full text-base">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ __('Transaction Details') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="transactionData" class="overflow-y-auto">
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
:label="__('Payment Received')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_received"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Payment For Certificate')"
|
||||
type="checkbox"
|
||||
v-model="transactionData.payment_for_certificate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Member')"
|
||||
doctype="User"
|
||||
v-model="transactionData.member"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="transactionData.billing_name"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Source')"
|
||||
v-model="transactionData.source"
|
||||
doctype="LMS Source"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Payment For Document Type')"
|
||||
v-model="transactionData.payment_for_document_type"
|
||||
doctype="DocType"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Payment For Document')"
|
||||
v-model="transactionData.payment_for_document"
|
||||
:doctype="transactionData.payment_for_document_type"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Payment Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Currency')"
|
||||
v-model="transactionData.currency"
|
||||
doctype="Currency"
|
||||
/>
|
||||
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
|
||||
<FormControl
|
||||
v-if="transactionData.amount_with_gst"
|
||||
:label="__('Amount with GST')"
|
||||
v-model="transactionData.amount_with_gst"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="transactionData.coupon">
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Coupon Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon_code"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Discount Amount')"
|
||||
v-model="transactionData.discount_amount"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Original Amount')"
|
||||
v-model="transactionData.original_amount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
{{ __('Billing Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Address')"
|
||||
v-model="transactionData.address"
|
||||
doctype="Address"
|
||||
/>
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
<FormControl
|
||||
:label="__('Payment ID')"
|
||||
v-model="transactionData.payment_id"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Order ID')"
|
||||
v-model="transactionData.order_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2 mt-auto ml-auto">
|
||||
<Button @click="openDetails()">
|
||||
{{ __('Open the ') }}
|
||||
{{
|
||||
data.payment_for_document_type == 'LMS Course'
|
||||
? __('Course')
|
||||
: __('Batch')
|
||||
}}
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveTransaction()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const transactionData = ref<{ [key: string]: any } | null>(null)
|
||||
const emit = defineEmits(['updateStep'])
|
||||
const show = defineModel('show')
|
||||
|
||||
const props = defineProps<{
|
||||
transactions: any
|
||||
data: any
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const saveTransaction = (close: () => void) => {
|
||||
props.transactions.value.setValue
|
||||
.submit({
|
||||
...transactionData.value,
|
||||
})
|
||||
.then(() => {
|
||||
close()
|
||||
})
|
||||
}
|
||||
|
||||
const openDetails = () => {
|
||||
if (props.data) {
|
||||
const docType = props.data.payment_for_document_type
|
||||
const docName = props.data.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
}
|
||||
show.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -96,17 +96,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TransactionDetails
|
||||
v-model="showForm"
|
||||
:transaction="currentTransaction"
|
||||
v-model:transactions="transactions"
|
||||
v-model:show="show"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
@@ -118,50 +111,19 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import TransactionDetails from './TransactionDetails.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const showForm = ref(false)
|
||||
const currentTransaction = ref<{ [key: string]: any } | null>(null)
|
||||
const show = defineModel('show')
|
||||
const billingName = ref(null)
|
||||
const paymentReceived = ref(false)
|
||||
const paymentForCertificate = ref(false)
|
||||
const member = ref(null)
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const transactions = createListResource({
|
||||
doctype: 'LMS Payment',
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'billing_name',
|
||||
'source',
|
||||
'payment_for_document_type',
|
||||
'payment_for_document',
|
||||
'payment_received',
|
||||
'payment_for_certificate',
|
||||
'currency',
|
||||
'amount',
|
||||
'order_id',
|
||||
'payment_id',
|
||||
'gstin',
|
||||
'pan',
|
||||
'address',
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
})
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
transactions: any
|
||||
}>()
|
||||
|
||||
watch(
|
||||
[billingName, member, paymentReceived, paymentForCertificate],
|
||||
@@ -171,7 +133,7 @@ watch(
|
||||
newPaymentReceived,
|
||||
newPaymentForCertificate,
|
||||
]) => {
|
||||
transactions.update({
|
||||
props.transactions.update({
|
||||
filters: [
|
||||
newBillingName ? [['billing_name', 'like', `%${newBillingName}%`]] : [],
|
||||
newMember ? [['member', '=', newMember]] : [],
|
||||
@@ -183,14 +145,13 @@ watch(
|
||||
: [],
|
||||
].flat(),
|
||||
})
|
||||
transactions.reload()
|
||||
props.transactions.reload()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const openForm = (transaction: { [key: string]: any }) => {
|
||||
currentTransaction.value = transaction
|
||||
showForm.value = true
|
||||
emit('updateStep', 'details', { ...transaction })
|
||||
}
|
||||
|
||||
const getCurrencySymbol = (currency: string) => {
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<TransactionList
|
||||
v-if="step === 'list'"
|
||||
:label="props.label"
|
||||
:description="props.description"
|
||||
:transactions="transactions"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<TransactionDetails
|
||||
v-else-if="step == 'details'"
|
||||
:transactions="transactions"
|
||||
:data="data"
|
||||
v-model:show="show"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import TransactionList from '@/components/Settings/Transactions/TransactionList.vue'
|
||||
import TransactionDetails from '@/components/Settings/Transactions/TransactionDetails.vue'
|
||||
|
||||
const step = ref('list')
|
||||
const data = ref<any | null>(null)
|
||||
const show = defineModel('show')
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
|
||||
step.value = newStep
|
||||
if (newData) {
|
||||
data.value = newData
|
||||
}
|
||||
}
|
||||
|
||||
const transactions = createListResource({
|
||||
doctype: 'LMS Payment',
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'billing_name',
|
||||
'source',
|
||||
'payment_for_document_type',
|
||||
'payment_for_document',
|
||||
'payment_received',
|
||||
'payment_for_certificate',
|
||||
'currency',
|
||||
'amount',
|
||||
'amount_with_gst',
|
||||
'coupon',
|
||||
'coupon_code',
|
||||
'discount_amount',
|
||||
'original_amount',
|
||||
'order_id',
|
||||
'payment_id',
|
||||
'gstin',
|
||||
'pan',
|
||||
'address',
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user