Merge branch 'develop' into patch-2
This commit is contained in:
@@ -27,6 +27,10 @@ describe("Batch Creation", () => {
|
||||
cy.get("input[placeholder='Jane']").type(randomName);
|
||||
cy.get("button").contains("Add").click();
|
||||
|
||||
// Open Settings
|
||||
cy.get("span").contains("Learning").click();
|
||||
cy.get("span").contains("Settings").click();
|
||||
|
||||
// Add evaluator
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
@@ -155,6 +159,7 @@ describe("Batch Creation", () => {
|
||||
cy.get("button:visible").contains("Manage Batch").click();
|
||||
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Students").click();
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
|
||||
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
||||
|
||||
Submodule frappe-ui updated: 310089f4a4...8d5956c0c6
12
frontend/components.d.ts
vendored
12
frontend/components.d.ts
vendored
@@ -8,9 +8,9 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default']
|
||||
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
||||
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
|
||||
AppHeader: typeof import('./src/components/AppHeader.vue')['default']
|
||||
Apps: typeof import('./src/components/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
|
||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||
@@ -43,6 +43,10 @@ declare module 'vue' {
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
||||
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
|
||||
CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default']
|
||||
CouponList: typeof import('./src/components/Settings/Coupons/CouponList.vue')['default']
|
||||
Coupons: typeof import('./src/components/Settings/Coupons/Coupons.vue')['default']
|
||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||
@@ -73,7 +77,6 @@ declare module 'vue' {
|
||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
|
||||
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||
@@ -109,8 +112,9 @@ declare module 'vue' {
|
||||
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
||||
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||
TransactionDetails: typeof import('./src/components/Settings/TransactionDetails.vue')['default']
|
||||
Transactions: typeof import('./src/components/Settings/Transactions.vue')['default']
|
||||
TransactionDetails: typeof import('./src/components/Settings/Transactions/TransactionDetails.vue')['default']
|
||||
TransactionList: typeof import('./src/components/Settings/Transactions/TransactionList.vue')['default']
|
||||
Transactions: typeof import('./src/components/Settings/Transactions/Transactions.vue')['default']
|
||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"@editorjs/table": "^2.4.2",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"@vueuse/router": "^12.7.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"apexcharts": "^4.3.0",
|
||||
@@ -32,7 +33,7 @@
|
||||
"dayjs": "^1.11.6",
|
||||
"dompurify": "^3.2.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.201",
|
||||
"frappe-ui": "^0.1.214",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
|
||||
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, () => {
|
||||
|
||||
@@ -107,7 +107,7 @@ async function setLanguageExtension() {
|
||||
if (!languageImport) return
|
||||
|
||||
const module = await languageImport()
|
||||
languageExtension.value = (module as any)[props.language]()
|
||||
languageExtension.value = (module as any)[props.language]?.()
|
||||
|
||||
if (props.completions) {
|
||||
const languageData = (module as any)[`${props.language}Language`]
|
||||
|
||||
@@ -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,11 +1,12 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="text-ink-gray-7">
|
||||
<span v-if="instructors?.length == 1">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[0].full_name }}
|
||||
</router-link>
|
||||
@@ -16,6 +17,7 @@
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
@@ -25,6 +27,7 @@
|
||||
name: 'Profile',
|
||||
params: { username: instructors[1].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[1].first_name }}
|
||||
</router-link>
|
||||
@@ -35,6 +38,7 @@
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Popover :show="iosInstallMessage" placement="top">
|
||||
<Popover :show="iosInstallMessage" placement="top-start">
|
||||
<template #body>
|
||||
<div
|
||||
class="fixed bottom-[4rem] left-1/2 -translate-x-1/2 z-20 w-[90%] flex flex-col gap-3 rounded bg-blue-100 py-5 drop-shadow-xl"
|
||||
class="fixed top-[20rem] translate-x-1/3 z-20 flex flex-col gap-3 rounded bg-surface-white py-5 drop-shadow-xl"
|
||||
>
|
||||
<div
|
||||
class="mb-1 flex flex-row items-center justify-between px-3 text-center"
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<div class="px-3 text-xs text-gray-800">
|
||||
<span class="flex flex-col gap-2">
|
||||
<span>
|
||||
<span class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'Get the app on your iPhone for easy access & a better experience'
|
||||
@@ -76,7 +76,14 @@ const isIos = () => {
|
||||
const isInStandaloneMode = () =>
|
||||
'standalone' in window.navigator && window.navigator.standalone
|
||||
|
||||
if (isIos() && !isInStandaloneMode()) iosInstallMessage.value = true
|
||||
if (
|
||||
isIos() &&
|
||||
!isInStandaloneMode() &&
|
||||
localStorage.getItem('learningIosInstallPromptShown') !== 'true'
|
||||
) {
|
||||
iosInstallMessage.value = true
|
||||
localStorage.setItem('learningIosInstallPromptShown', 'true')
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
|
||||
>
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<div class="flex flex-col space-y-2 flex-1">
|
||||
<div class="flex flex-col space-y-2 flex-1 break-all">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ job.company_name }}
|
||||
</div>
|
||||
|
||||
@@ -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')
|
||||
@@ -113,33 +114,54 @@ watch(
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const saveAssignment = () => {
|
||||
if (props.assignmentID == 'new') {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
watch(show, (newVal) => {
|
||||
if (newVal && props.assignmentID === 'new') {
|
||||
assignment.title = ''
|
||||
assignment.type = ''
|
||||
assignment.question = ''
|
||||
}
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
}
|
||||
|
||||
const saveAssignment = () => {
|
||||
validateTitle()
|
||||
if (props.assignmentID == 'new') {
|
||||
createAssignment()
|
||||
} else {
|
||||
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(() => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Avatar :image="student.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
@@ -36,7 +36,9 @@
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
@@ -86,7 +88,9 @@
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ chapter.scorm_package.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +66,11 @@
|
||||
</template>
|
||||
{{ __('View Certificate') }}
|
||||
</Button>
|
||||
<Button v-else @click="openCallLink(event.venue)" class="w-full">
|
||||
<Button
|
||||
v-else-if="userIsEvaluator()"
|
||||
@click="openCallLink(event.venue)"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -83,21 +87,31 @@
|
||||
class="flex flex-col space-y-4 p-5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Rating v-model="evaluation.rating" :label="__('Rating')" />
|
||||
<Rating
|
||||
v-model="evaluation.rating"
|
||||
:label="__('Rating')"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="statusOptions"
|
||||
v-model="evaluation.status"
|
||||
:label="__('Status')"
|
||||
class="w-1/2"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="evaluation.summary"
|
||||
:label="__('Summary')"
|
||||
:rows="7"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Button variant="solid" @click="saveEvaluation()">
|
||||
<Button
|
||||
v-if="userIsEvaluator()"
|
||||
variant="solid"
|
||||
@click="saveEvaluation()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -106,11 +120,13 @@
|
||||
type="checkbox"
|
||||
v-model="certificate.published"
|
||||
:label="__('Published')"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Link
|
||||
v-model="certificate.template"
|
||||
:label="__('Template')"
|
||||
doctype="Print Format"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:filters="{
|
||||
doc_type: 'LMS Certificate',
|
||||
}"
|
||||
@@ -118,14 +134,20 @@
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="certificate.issue_date"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:label="__('Issue Date')"
|
||||
/>
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="certificate.expiry_date"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:label="__('Expiry Date')"
|
||||
/>
|
||||
<Button variant="solid" @click="saveCertificate()">
|
||||
<Button
|
||||
v-if="userIsEvaluator()"
|
||||
variant="solid"
|
||||
@click="saveCertificate()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -163,9 +185,12 @@ import Rating from '@/components/Controls/Rating.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const tabIndex = ref(0)
|
||||
const showCertification = ref(false)
|
||||
const evaluation = reactive({})
|
||||
const certificate = reactive({})
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
@@ -174,9 +199,15 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const evaluation = reactive({})
|
||||
watch(user, () => {
|
||||
if (userIsEvaluator()) {
|
||||
defaultTemplate.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const certificate = reactive({})
|
||||
const userIsEvaluator = () => {
|
||||
return user.data && user.data.name == props.event.evaluator
|
||||
}
|
||||
|
||||
const defaultTemplate = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
@@ -190,7 +221,6 @@ const defaultTemplate = createResource({
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
certificate.template = data.value
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p>
|
||||
<p class="text-ink-gray-9">
|
||||
{{
|
||||
__(
|
||||
'Submit your resume to proceed with your application for this position. Upon submission, it will be shared with the job poster.'
|
||||
@@ -29,6 +29,7 @@
|
||||
<FileUploader
|
||||
:fileTypes="['.pdf']"
|
||||
:validateFile="validateFile"
|
||||
:uploadArgs="{ private: 1 }"
|
||||
@success="
|
||||
(file) => {
|
||||
resume = file
|
||||
@@ -51,7 +52,7 @@
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ resume.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
@@ -95,7 +96,7 @@ const jobApplication = createResource({
|
||||
doc: {
|
||||
doctype: 'LMS Job Application',
|
||||
user: user.data?.name,
|
||||
resume: resume.value?.file_name,
|
||||
resume: resume.value?.file_url,
|
||||
job: props.job,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ import {
|
||||
Button,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, watch, reactive, ref, inject } from 'vue'
|
||||
import { watch, reactive, ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
|
||||
@@ -141,6 +141,7 @@ const existingQuestion = reactive({
|
||||
question: '',
|
||||
marks: 1,
|
||||
})
|
||||
|
||||
const question = reactive({
|
||||
question: '',
|
||||
type: 'Choices',
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Members', close)
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div v-if="user.data" class="px-5 py-3">
|
||||
<div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
@@ -21,7 +21,7 @@
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-4">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="mb-4 leading-6">
|
||||
<div class="mb-4 leading-6 text-ink-gray-7">
|
||||
{{ __(text) }}
|
||||
</div>
|
||||
<Button variant="solid" class="w-full" @click="redirect()">
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
<div class="mb-2">
|
||||
<span class=""> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold">
|
||||
<span class="text-ink-gray-9"> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold text-ink-gray-9">
|
||||
{{ formatTimer(timer) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -165,14 +165,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="ml-2"
|
||||
class="ml-2 text-ink-gray-9"
|
||||
v-html="questionDetails.data[`option_${index}`]"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="questionDetails.data[`explanation_${index}`]"
|
||||
class="mt-2 text-xs"
|
||||
class="mt-2 text-xs text-ink-gray-7"
|
||||
v-show="showAnswers.length"
|
||||
>
|
||||
{{ questionDetails.data[`explanation_${index}`] }}
|
||||
@@ -260,7 +260,7 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||
|
||||
@@ -69,9 +69,12 @@ const update = () => {
|
||||
let imageFields = ['favicon', 'banner_image']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||
fieldsToSave[f.name] =
|
||||
branding.data[f.name] && branding.data[f.name].file_url
|
||||
? branding.data[f.name].file_url
|
||||
: null
|
||||
} else {
|
||||
fieldsToSave[f.name] = f.value
|
||||
fieldsToSave[f.name] = branding.data[f.name]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
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"
|
||||
class="flex flex-1 flex-col p-8 bg-surface-modal"
|
||||
>
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
@@ -78,7 +78,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'
|
||||
|
||||
@@ -233,6 +234,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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="heatmap.data">
|
||||
<div class="text-lg font-semibold mb-2">
|
||||
<div class="text-lg font-semibold mb-2 text-ink-gray-9">
|
||||
{{ heatmap.data.total_activities }}
|
||||
{{
|
||||
heatmap.data.total_activities > 1 ? __('activities') : __('activity')
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="upcoming_evals.data?.length">
|
||||
<div class="grid gap-4" :class="forHome ? 'grid-cols-2' : 'grid-cols-3'">
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="forHome ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-3'"
|
||||
>
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border text-ink-gray-7 rounded-md p-3">
|
||||
<div class="flex justify-between mb-3">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div v-if="user.data?.is_moderator || isStudent" class="">
|
||||
<div v-if="isAdmin || isStudent" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
v-if="user.data?.is_moderator && batch.data?.certification"
|
||||
v-if="isAdmin && batch.data?.certification"
|
||||
@click="openCertificateDialog = true"
|
||||
>
|
||||
{{ __('Generate Certificates') }}
|
||||
@@ -67,6 +67,9 @@
|
||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard'">
|
||||
<AdminBatchDashboard :batch="batch" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Students'">
|
||||
<BatchStudents :batch="batch" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Classes'">
|
||||
@@ -235,6 +238,7 @@ import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
import BatchCourses from '@/components/BatchCourses.vue'
|
||||
import LiveClass from '@/components/LiveClass.vue'
|
||||
import BatchStudents from '@/components/BatchStudents.vue'
|
||||
import AdminBatchDashboard from '@/components/AdminBatchDashboard.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
import Announcements from '@/components/Annoucements.vue'
|
||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||
@@ -260,6 +264,13 @@ const tabs = computed(() => {
|
||||
icon: LayoutDashboard,
|
||||
})
|
||||
|
||||
if (isAdmin.value) {
|
||||
batchTabs.push({
|
||||
label: 'Students',
|
||||
icon: ClipboardPen,
|
||||
})
|
||||
}
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
@@ -270,7 +281,7 @@ const tabs = computed(() => {
|
||||
icon: Laptop,
|
||||
})
|
||||
|
||||
if (user.data?.is_moderator) {
|
||||
if (isAdmin.value) {
|
||||
batchTabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
@@ -367,6 +378,10 @@ const canMakeAnnouncement = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<div v-if="batch.data.courses.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,6 +344,7 @@ import {
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
escapeHTML,
|
||||
} from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -500,7 +501,19 @@ const imageResource = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
Object.keys(batch).forEach((key) => {
|
||||
if (
|
||||
!['description', 'batch_details'].includes(key) &&
|
||||
typeof batch[key] === 'string'
|
||||
) {
|
||||
batch[key] = escapeHTML(batch[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveBatch = () => {
|
||||
validateFields()
|
||||
if (batchDetail.data) {
|
||||
editBatchDetails()
|
||||
} else {
|
||||
|
||||
@@ -244,12 +244,11 @@ const setQueryParams = () => {
|
||||
}
|
||||
})
|
||||
|
||||
let queryString = ''
|
||||
if (queries.toString()) {
|
||||
queryString = `?${queries.toString()}`
|
||||
}
|
||||
|
||||
history.replaceState({}, '', `${location.pathname}${queryString}`)
|
||||
history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`${location.pathname}${queries.size > 0 ? `?${queries.toString()}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const updateCategories = (data) => {
|
||||
|
||||
@@ -13,54 +13,89 @@
|
||||
class="pt-5 pb-10 mx-5"
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row justify-between">
|
||||
<div
|
||||
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 font-medium lg:w-1/3"
|
||||
>
|
||||
<div class="flex items-baseline justify-between space-y-2">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Payment for ') }} {{ type }}:
|
||||
<div class="flex flex-col lg:order-last mb-10 lg:mt-10 lg:w-1/4">
|
||||
<div class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('Payment for ') }} {{ type }}:
|
||||
</div>
|
||||
<div class="leading-5 text-ink-gray-9">
|
||||
{{ orderSummary.data.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{ orderSummary.data.title }}
|
||||
<div
|
||||
v-if="
|
||||
orderSummary.data.gst_applied ||
|
||||
orderSummary.data.discount_amount
|
||||
"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('Original Amount') }}:
|
||||
</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ orderSummary.data.original_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="orderSummary.data.discount_amount" class="space-y-1">
|
||||
<div class="text-ink-gray-5">{{ __('Discount') }}:</div>
|
||||
<div>- {{ orderSummary.data.discount_amount_formatted }}</div>
|
||||
</div>
|
||||
<div v-if="orderSummary.data.gst_applied" class="space-y-1">
|
||||
<div class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('GST Amount') }}:
|
||||
</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ orderSummary.data.gst_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 border-t border-outline-gray-3 pt-4 mt-2">
|
||||
<div class="uppercase text-ink-gray-5 text-xs">
|
||||
{{ __('Total') }}:
|
||||
</div>
|
||||
<div class="font-bold text-ink-gray-9">
|
||||
{{ orderSummary.data.total_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Original Amount') }}
|
||||
</div>
|
||||
<div class="">
|
||||
{{ orderSummary.data.original_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between mt-2"
|
||||
>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('GST Amount') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ orderSummary.data.gst_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-outline-gray-3 pt-4 mt-2"
|
||||
>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Total') }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ orderSummary.data.total_amount_formatted }}
|
||||
|
||||
<div class="bg-surface-gray-2 rounded-md p-4 space-y-2 my-5">
|
||||
<span class="text-ink-gray-5 uppercase text-xs">
|
||||
{{ __('Enter a Coupon Code') }}:
|
||||
</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="appliedCoupon"
|
||||
:disabled="orderSummary.data.discount_amount > 0"
|
||||
@input="appliedCoupon = $event.target.value.toUpperCase()"
|
||||
@keydown.enter="applyCouponCode"
|
||||
placeholder="COUPON2025"
|
||||
autocomplete="off"
|
||||
class="flex-1 [&_input]:bg-white"
|
||||
/>
|
||||
<Button
|
||||
v-if="!orderSummary.data.discount_amount"
|
||||
@click="applyCouponCode"
|
||||
variant="outline"
|
||||
>
|
||||
{{ __('Apply') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="orderSummary.data.discount_amount"
|
||||
@click="removeCoupon"
|
||||
variant="outline"
|
||||
>
|
||||
<template #icon>
|
||||
<X class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 lg:mr-10">
|
||||
<div class="mb-5">
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Address') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +147,7 @@
|
||||
/>
|
||||
<FormControl
|
||||
v-if="billingDetails.country == 'India'"
|
||||
:label="__('Pan Number')"
|
||||
:label="__('PAN Number')"
|
||||
v-model="billingDetails.pan"
|
||||
/>
|
||||
</div>
|
||||
@@ -157,11 +192,13 @@ import {
|
||||
Breadcrumbs,
|
||||
usePageMeta,
|
||||
toast,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, computed } from 'vue'
|
||||
import { reactive, inject, onMounted, computed, ref } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
@@ -205,6 +242,7 @@ const orderSummary = createResource({
|
||||
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||
docname: props.name,
|
||||
country: billingDetails.country,
|
||||
coupon: appliedCoupon.value,
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
@@ -212,6 +250,7 @@ const orderSummary = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const appliedCoupon = ref(null)
|
||||
const billingDetails = reactive({})
|
||||
|
||||
const setBillingDetails = (data) => {
|
||||
@@ -231,17 +270,21 @@ const setBillingDetails = (data) => {
|
||||
const paymentLink = createResource({
|
||||
url: 'lms.lms.payments.get_payment_link',
|
||||
makeParams(values) {
|
||||
return {
|
||||
let data = {
|
||||
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||
docname: props.name,
|
||||
title: orderSummary.data.title,
|
||||
amount: orderSummary.data.original_amount,
|
||||
total_amount: orderSummary.data.amount,
|
||||
discount_amount: orderSummary.data.discount_amount || 0,
|
||||
gst_amount: orderSummary.data.gst_applied || 0,
|
||||
currency: orderSummary.data.currency,
|
||||
address: billingDetails,
|
||||
redirect_to: redirectTo.value,
|
||||
payment_for_certificate: props.type == 'certificate',
|
||||
coupon_code: appliedCoupon.value,
|
||||
coupon: orderSummary.data.coupon,
|
||||
}
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
@@ -265,6 +308,19 @@ const generatePaymentLink = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function applyCouponCode() {
|
||||
if (!appliedCoupon.value) {
|
||||
toast.error(__('Please enter a coupon code'))
|
||||
return
|
||||
}
|
||||
orderSummary.reload()
|
||||
}
|
||||
|
||||
function removeCoupon() {
|
||||
appliedCoupon.value = null
|
||||
orderSummary.reload()
|
||||
}
|
||||
|
||||
const validateAddress = () => {
|
||||
let mandatoryFields = [
|
||||
'billing_name',
|
||||
@@ -329,8 +385,6 @@ const validateAddress = () => {
|
||||
!states.includes(billingDetails.state)
|
||||
)
|
||||
return 'Please enter a valid state with correct spelling and the first letter capitalized.'
|
||||
|
||||
console.log('validation address')
|
||||
}
|
||||
|
||||
const showError = (err) => {
|
||||
|
||||
@@ -124,7 +124,7 @@ const memberCount = ref(0)
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
onMounted(() => {
|
||||
getMemberCount()
|
||||
setFiltersFromQuery()
|
||||
updateParticipants()
|
||||
})
|
||||
|
||||
@@ -158,6 +158,8 @@ const categories = createListResource({
|
||||
const updateParticipants = () => {
|
||||
updateFilters()
|
||||
getMemberCount()
|
||||
setQueryParams()
|
||||
|
||||
participants.update({
|
||||
filters: filters.value,
|
||||
})
|
||||
@@ -178,6 +180,33 @@ const updateFilters = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const setQueryParams = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
let filterKeys = {
|
||||
category: currentCategory.value,
|
||||
name: nameFilter.value,
|
||||
}
|
||||
|
||||
Object.keys(filterKeys).forEach((key) => {
|
||||
if (filterKeys[key]) {
|
||||
queries.set(key, filterKeys[key])
|
||||
} else {
|
||||
queries.delete(key)
|
||||
}
|
||||
})
|
||||
history.replaceState(
|
||||
{},
|
||||
'',
|
||||
`${location.pathname}${queries.size > 0 ? `?${queries.toString()}` : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const setFiltersFromQuery = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
nameFilter.value = queries.get('name') || ''
|
||||
currentCategory.value = queries.get('category') || ''
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Certified Members'),
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</header>
|
||||
<div class="mt-5 mb-5">
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
@@ -178,7 +178,7 @@
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('About the Course') }}
|
||||
</div>
|
||||
<FormControl
|
||||
@@ -234,7 +234,7 @@
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mt-5">
|
||||
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
|
||||
{{ __('Pricing and Certification') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
@@ -294,7 +294,7 @@
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-10 pb-5 space-y-5">
|
||||
<div class="text-lg font-semibold mt-5">
|
||||
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
@@ -329,7 +329,6 @@
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
call,
|
||||
TextEditor,
|
||||
Button,
|
||||
createResource,
|
||||
@@ -358,6 +357,7 @@ import {
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
escapeHTML,
|
||||
} from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
@@ -538,7 +538,16 @@ const imageResource = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
Object.keys(course).forEach((key) => {
|
||||
if (key != 'description' && typeof course[key] === 'string') {
|
||||
course[key] = escapeHTML(course[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitCourse = () => {
|
||||
validateFields()
|
||||
if (courseResource.data) {
|
||||
editCourse()
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div v-if="createdCourses.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg">
|
||||
<span class="font-semibold text-lg text-ink-gray-9">
|
||||
{{ __('Courses Created') }}
|
||||
</span>
|
||||
<router-link
|
||||
@@ -94,10 +94,10 @@
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div
|
||||
v-for="evaluation in evals?.data"
|
||||
class="border rounded-md p-3 flex flex-col h-full cursor-pointer"
|
||||
class="border hover:border-outline-gray-3 rounded-md p-3 flex flex-col h-full cursor-pointer"
|
||||
@click="redirectToProfile()"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
|
||||
{{ evaluation.course_title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm">
|
||||
@@ -128,8 +128,11 @@
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div v-for="cls in liveClasses?.data" class="border rounded-md p-3">
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
<div
|
||||
v-for="cls in liveClasses?.data"
|
||||
class="border hover:border-outline-gray-3 rounded-md p-3"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
<span> 🔥 </span>
|
||||
<span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ streakInfo.data?.current_streak }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}}
|
||||
{{ __(' you are on a') }}
|
||||
</div>
|
||||
<div class="font-semibold text-xl">
|
||||
<div class="font-semibold text-xl text-ink-gray-9">
|
||||
{{ streakInfo.data?.current_streak }} {{ __('day streak') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="text-ink-gray-6">
|
||||
{{ __('Current Streak') }}
|
||||
</div>
|
||||
<div class="font-semibold text-lg">
|
||||
<div class="font-semibold text-lg text-ink-gray-9">
|
||||
{{ streakInfo.data?.current_streak }} {{ __('days') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="text-ink-gray-6">
|
||||
{{ __('Longest Streak') }}
|
||||
</div>
|
||||
<div class="font-semibold text-lg">
|
||||
<div class="font-semibold text-lg text-ink-gray-9">
|
||||
{{ streakInfo.data?.longest_streak }} {{ __('days') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,15 +64,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-5 mt-10">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10">
|
||||
<UpcomingEvaluations :forHome="true" />
|
||||
<div v-if="myLiveClasses.data?.length">
|
||||
<div class="font-semibold text-lg mb-3 text-ink-gray-9">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div v-for="cls in myLiveClasses.data" class="border rounded-md p-2">
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
<div
|
||||
v-for="cls in myLiveClasses.data"
|
||||
class="border rounded-md hover:border-outline-gray-3 p-2"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
|
||||
|
||||
320
frontend/src/pages/JobApplications.vue
Normal file
320
frontend/src/pages/JobApplications.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="[
|
||||
{ label: __('Jobs'), route: { name: 'Jobs' } },
|
||||
{
|
||||
label: applications.data?.[0]?.job_title,
|
||||
route: { name: 'JobDetail', params: { job: props.job } },
|
||||
},
|
||||
{ label: __('Applications') },
|
||||
]"
|
||||
/>
|
||||
</header>
|
||||
<div class="max-w-4xl mx-auto pt-5 p-4">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-lg font-semibold text-ink-gray-9 mb-2">
|
||||
{{ applications.data?.length || 0 }}
|
||||
{{
|
||||
applications.data?.length === 1
|
||||
? __('Application')
|
||||
: __('Applications')
|
||||
}}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
v-if="applications.data?.length"
|
||||
:columns="applicationColumns"
|
||||
:rows="applicantRows"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in applicationColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon?.toString()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ column, item }"
|
||||
v-for="row in applicantRows"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key === 'full_name'"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
:image="row['user_image']"
|
||||
:label="row['full_name']"
|
||||
/>
|
||||
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'actions'"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<Dropdown :options="getActionOptions(row)">
|
||||
<Button variant="ghost">
|
||||
<FeatherIcon name="more-horizontal" class="w-4 h-4" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'applied_on'"
|
||||
class="text-sm text-ink-gray-6"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<EmptyState v-else-if="!applications.loading" type="Job Applications" />
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model="showEmailModal"
|
||||
:options="{
|
||||
title: __('Send Email to {0}').format(selectedApplicant?.full_name),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Send'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => sendEmail(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="emailForm.subject"
|
||||
:label="__('Subject')"
|
||||
:placeholder="__('Enter email subject')"
|
||||
required
|
||||
/>
|
||||
<FormControl
|
||||
v-model="emailForm.replyTo"
|
||||
:label="__('Reply To')"
|
||||
:placeholder="__('Enter reply to email')"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm text-ink-gray-5 mb-1">
|
||||
{{ __('Message') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="emailForm.message"
|
||||
@change="(val) => (emailForm.message = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
createResource,
|
||||
createListResource,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
|
||||
import { inject, ref, computed, reactive } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
const showEmailModal = ref(false)
|
||||
const selectedApplicant = ref(null)
|
||||
const emailForm = reactive({
|
||||
subject: '',
|
||||
message: '',
|
||||
replyTo: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
job: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const applications = createListResource({
|
||||
doctype: 'LMS Job Application',
|
||||
fields: [
|
||||
'name',
|
||||
'user.user_image as user_image',
|
||||
'user.full_name as full_name',
|
||||
'user.email as email',
|
||||
'creation',
|
||||
'resume',
|
||||
'job.job_title as job_title',
|
||||
],
|
||||
filters: {
|
||||
job: props.job,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const emailResource = createResource({
|
||||
url: 'frappe.core.doctype.communication.email.make',
|
||||
makeParams(values) {
|
||||
return {
|
||||
recipients: selectedApplicant.value.email,
|
||||
cc: emailForm.replyTo,
|
||||
subject: emailForm.subject,
|
||||
content: emailForm.message,
|
||||
doctype: 'LMS Job Application',
|
||||
name: selectedApplicant.value.name,
|
||||
send_email: 1,
|
||||
now: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const openEmailModal = (applicant) => {
|
||||
selectedApplicant.value = applicant
|
||||
emailForm.subject = `Job Application for ${applications.data?.[0]?.job_title} - ${applicant.full_name}`
|
||||
emailForm.replyTo = ''
|
||||
emailForm.message = ''
|
||||
showEmailModal.value = true
|
||||
}
|
||||
|
||||
const sendEmail = (close) => {
|
||||
emailResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!emailForm.subject) {
|
||||
return __('Subject is required')
|
||||
}
|
||||
if (!emailForm.message) {
|
||||
return __('Message is required')
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Email sent successfully'))
|
||||
close()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const downloadResume = (resumeUrl) => {
|
||||
console.log(resumeUrl)
|
||||
window.open(resumeUrl, '_blank')
|
||||
}
|
||||
|
||||
const getActionOptions = (row) => {
|
||||
const options = []
|
||||
if (row.resume) {
|
||||
options.push({
|
||||
label: __('View Resume'),
|
||||
icon: 'download',
|
||||
onClick: () => downloadResume(row.resume),
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label: __('Send Email'),
|
||||
icon: 'mail',
|
||||
onClick: () => openEmailModal(row),
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
const applicationColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Full Name'),
|
||||
key: 'full_name',
|
||||
width: 2,
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Email'),
|
||||
key: 'email',
|
||||
width: 2,
|
||||
icon: 'at-sign',
|
||||
},
|
||||
{
|
||||
label: __('Applied On'),
|
||||
key: 'applied_on',
|
||||
width: 1,
|
||||
icon: 'calendar',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
key: 'actions',
|
||||
width: 1,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const applicantRows = computed(() => {
|
||||
if (!applications.data) return []
|
||||
return applications.data.map((application) => ({
|
||||
...application,
|
||||
full_name: application.full_name,
|
||||
applied_on: dayjs(application.creation).fromNow(),
|
||||
}))
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: `Applications - ${applications.data?.[0]?.job_title}`,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -20,6 +20,17 @@
|
||||
v-if="user.data?.name && !readOnlyMode"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<router-link
|
||||
v-if="canManageJob && applicationCount.data > 0"
|
||||
:to="{
|
||||
name: 'JobApplications',
|
||||
params: { job: job.data?.name },
|
||||
}"
|
||||
>
|
||||
<Button variant="subtle">
|
||||
{{ __('View Applications') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="user.data.name == job.data?.owner"
|
||||
:to="{
|
||||
@@ -146,7 +157,7 @@ import {
|
||||
createResource,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { inject, ref } from 'vue'
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||
import {
|
||||
@@ -159,6 +170,7 @@ import {
|
||||
FileText,
|
||||
ClipboardType,
|
||||
BriefcaseBusiness,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
@@ -226,6 +238,13 @@ const redirectToWebsite = (url) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const canManageJob = computed(() => {
|
||||
if (!user.data?.name || !job.data) return false
|
||||
return (
|
||||
user.data.name === job.data.owner || user.data.roles?.includes('Moderator')
|
||||
)
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: job.data?.job_title,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="container border-b mb-4 pb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('Job Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="container border-b mb-4 pb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('Company Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
@@ -158,7 +158,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFileSize, validateFile } from '@/utils'
|
||||
import { escapeHTML, getFileSize, validateFile } from '@/utils'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
@@ -248,6 +248,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const saveJob = () => {
|
||||
validateJobFields()
|
||||
if (jobDetail.data) {
|
||||
editJobDetails()
|
||||
} else {
|
||||
@@ -293,6 +294,14 @@ const editJobDetails = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const validateJobFields = () => {
|
||||
Object.keys(job).forEach((key) => {
|
||||
if (key != 'description' && typeof job[key] === 'string') {
|
||||
job[key] = escapeHTML(job[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
job.image = file
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ const renderEditor = (holder) => {
|
||||
return new EditorJS({
|
||||
holder: holder,
|
||||
tools: getEditorTools(true),
|
||||
autofocus: true,
|
||||
defaultBlock: 'markdown',
|
||||
onChange: async (api, event) => {
|
||||
enablePlyr()
|
||||
|
||||
@@ -30,14 +30,14 @@
|
||||
<div class="notification text-ink-gray-7" v-html="log.subject"></div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
<a
|
||||
v-if="log.link"
|
||||
:to="log.link"
|
||||
:href="log.link"
|
||||
@click="(e) => handleMarkAsRead(e, log.name)"
|
||||
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
||||
>
|
||||
{{ __('View') }}
|
||||
</Link>
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
TabButtons,
|
||||
Button,
|
||||
usePageMeta,
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
<NoPermission v-if="!$user.data" />
|
||||
<div v-else-if="profile.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
class="sticky group top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<Button v-if="isSessionUser()" class="invisible group-hover:visible">
|
||||
<template #icon>
|
||||
<RefreshCcw
|
||||
class="w-4 h-4 stroke-1.5 text-ink-gray-7"
|
||||
@click="reloadUser()"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</header>
|
||||
<div class="group relative h-[130px] w-full">
|
||||
<img
|
||||
@@ -92,18 +100,19 @@
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Edit } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { Edit, RefreshCcw } from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
import EditProfile from '@/components/Modals/EditProfile.vue'
|
||||
import EditCoverImage from '@/components/Modals/EditCoverImage.vue'
|
||||
|
||||
@@ -124,18 +133,14 @@ const props = defineProps({
|
||||
|
||||
onMounted(() => {
|
||||
if ($user.data) profile.reload()
|
||||
|
||||
setActiveTab()
|
||||
})
|
||||
|
||||
const profile = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
url: 'lms.lms.api.get_profile_details',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'User',
|
||||
filters: {
|
||||
username: props.username,
|
||||
},
|
||||
username: props.username,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -191,23 +196,39 @@ const editProfile = () => {
|
||||
}
|
||||
|
||||
const isSessionUser = () => {
|
||||
return $user.data?.email === profile.data?.email
|
||||
return $user.data?.email === profile.data?.name
|
||||
}
|
||||
|
||||
const currentUserHasHigherAccess = () => {
|
||||
return $user.data?.is_evaluator || $user.data?.is_moderator
|
||||
}
|
||||
|
||||
const isEvaluatorOrModerator = () => {
|
||||
return (
|
||||
profile.data?.roles?.includes('Batch Evaluator') ||
|
||||
profile.data?.roles?.includes('Moderator')
|
||||
)
|
||||
}
|
||||
|
||||
const getTabButtons = () => {
|
||||
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
||||
if (
|
||||
isSessionUser() &&
|
||||
($user.data?.is_evaluator || $user.data?.is_moderator)
|
||||
) {
|
||||
|
||||
if (currentUserHasHigherAccess() && isEvaluatorOrModerator()) {
|
||||
buttons.push({ label: 'Slots' })
|
||||
buttons.push({ label: 'Schedule' })
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
const reloadUser = () => {
|
||||
call('frappe.sessions.clear').then(() => {
|
||||
$user.reload().then(() => {
|
||||
profile.reload()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ const props = defineProps({
|
||||
const evaluations = createListResource({
|
||||
doctype: 'LMS Certificate Request',
|
||||
filters: {
|
||||
evaluator: user.data?.name,
|
||||
evaluator: props.profile.data?.name,
|
||||
status: ['!=', 'Cancelled'],
|
||||
},
|
||||
fields: [
|
||||
|
||||
@@ -43,18 +43,22 @@
|
||||
:options="days"
|
||||
v-model="slot.day"
|
||||
@focusout.stop="update(slot.name, 'day', slot.day)"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.start_time"
|
||||
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.end_time"
|
||||
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<X
|
||||
v-if="isSessionUser()"
|
||||
@click="deleteRow(slot.name)"
|
||||
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
|
||||
/>
|
||||
@@ -69,20 +73,23 @@
|
||||
:options="days"
|
||||
v-model="newSlot.day"
|
||||
@focusout.stop="add()"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.start_time"
|
||||
@focusout.stop="add()"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.end_time"
|
||||
@focusout.stop="add()"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="showSlotsTemplate = 1">
|
||||
<Button v-if="isSessionUser()" @click="showSlotsTemplate = 1">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
@@ -98,6 +105,7 @@
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
v-model="from"
|
||||
:disabled="!isSessionUser()"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
@@ -111,6 +119,7 @@
|
||||
type="date"
|
||||
:label="__('To')"
|
||||
v-model="to"
|
||||
:disabled="!isSessionUser()"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
@@ -122,7 +131,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="isSessionUser()">
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('My calendar') }}
|
||||
</h2>
|
||||
@@ -157,11 +166,19 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (user.data?.name !== props.profile.data?.name) {
|
||||
if (user.data?.name !== props.profile.data?.name && !hasHigherAccess()) {
|
||||
window.location.href = `/user/${props.profile.data?.username}`
|
||||
}
|
||||
})
|
||||
|
||||
const hasHigherAccess = () => {
|
||||
return user.data?.is_evaluator || user.data?.is_moderator
|
||||
}
|
||||
|
||||
const isSessionUser = () => {
|
||||
return user.data?.email === props.profile.data?.name
|
||||
}
|
||||
|
||||
const showSlotsTemplate = ref(0)
|
||||
const from = ref(null)
|
||||
const to = ref(null)
|
||||
|
||||
@@ -105,6 +105,8 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { escapeHTML } from '@/utils'
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
@@ -113,14 +115,13 @@ import {
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
ProgrammingExercise,
|
||||
ProgrammingExercises,
|
||||
TestCase,
|
||||
} from '@/types/programming-exercise'
|
||||
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
|
||||
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const exercises = defineModel<ProgrammingExercises>('exercises')
|
||||
@@ -194,7 +195,12 @@ const fetchTestCases = () => {
|
||||
testCases.reload()
|
||||
}
|
||||
|
||||
const validateTitle = () => {
|
||||
exercise.value.title = escapeHTML(exercise.value.title.trim())
|
||||
}
|
||||
|
||||
const saveExercise = (close: () => void) => {
|
||||
validateTitle()
|
||||
if (props.exerciseID == 'new') createNewExercise(close)
|
||||
else updateExercise(close)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
|
||||
<div class="border-r py-5 px-8 h-full">
|
||||
<div class="font-semibold mb-2">
|
||||
<div class="font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __('Problem Statement') }}
|
||||
</div>
|
||||
<div
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
|
||||
<div class="font-semibold">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ exercise.doc?.language }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
@@ -89,7 +89,9 @@
|
||||
class="py-3"
|
||||
>
|
||||
<div class="flex items-center mb-3">
|
||||
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ __('Test {0}').format(index + 1) }} -
|
||||
</span>
|
||||
<span
|
||||
class="font-semibold ml-2 mr-1"
|
||||
:class="
|
||||
@@ -112,13 +114,13 @@
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Input') }}
|
||||
</div>
|
||||
<div>{{ testCase.input }}</div>
|
||||
<div class="text-ink-gray-9">{{ testCase.input }}</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Your Output') }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ testCase.output }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +128,9 @@
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Expected Output') }}
|
||||
</div>
|
||||
<div>{{ testCase.expected_output }}</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ testCase.expected_output }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,6 +157,7 @@ import { Play, X, Check, Settings } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { openSettings } from '@/utils'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const code = ref<string | null>('')
|
||||
@@ -162,7 +167,8 @@ const errorMessage = ref<string | null>(null)
|
||||
const testCaseSection = ref<HTMLElement | null>(null)
|
||||
const testCases = ref<TestCase[]>([])
|
||||
const boilerplate = ref<string>('')
|
||||
const { brand, livecodeURL } = sessionStore()
|
||||
const { brand } = sessionStore()
|
||||
const { livecodeURL } = useSettings()
|
||||
const router = useRouter()
|
||||
const fromLesson = ref(false)
|
||||
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||
@@ -260,8 +266,7 @@ const checkIfUserIsPermitted = (doc: any = null) => {
|
||||
!user.data.is_evaluator
|
||||
) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||
name: 'Courses',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||
import { Programs, Program } from '@/types/programs'
|
||||
import { openSettings } from '@/utils'
|
||||
import { escapeHTML, openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import ProgramProgressSummary from '@/pages/Programs/ProgramProgressSummary.vue'
|
||||
@@ -362,7 +362,12 @@ const fetchMembers = () => {
|
||||
programMembers.reload()
|
||||
}
|
||||
|
||||
const validateTitle = () => {
|
||||
program.value.name = escapeHTML(program.value.name.trim())
|
||||
}
|
||||
|
||||
const saveProgram = (close: () => void) => {
|
||||
validateTitle()
|
||||
if (props.programName === 'new') createNewProgram(close)
|
||||
else updateProgram(close)
|
||||
dirty.value = false
|
||||
|
||||
@@ -25,17 +25,17 @@
|
||||
@click="openForm(program.name)"
|
||||
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer space-y-2"
|
||||
>
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ program.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="flex items-center space-x-1 text-ink-gray-7">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<span>
|
||||
{{ program.course_count }}
|
||||
{{ program.course_count == 1 ? __('Course') : __('Courses') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="flex items-center space-x-1 text-ink-gray-7">
|
||||
<User class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<span>
|
||||
{{ program.member_count || 0 }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div v-if="!readOnlyMode" class="space-x-2">
|
||||
<div v-if="!readOnlyMode" class="flex items-center space-x-2">
|
||||
<Badge v-if="quizDetails.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
@@ -231,6 +231,7 @@ import {
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { escapeHTML } from '@/utils'
|
||||
import Question from '@/components/Modals/Question.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
@@ -254,11 +255,7 @@ const props = defineProps({
|
||||
const questions = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
props.quizID == 'new' &&
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor
|
||||
) {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
router.push({ name: 'Courses' })
|
||||
}
|
||||
if (props.quizID !== 'new') {
|
||||
@@ -298,7 +295,12 @@ const quizDetails = createDocumentResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
quizDetails.doc.title = escapeHTML(quizDetails.doc.title.trim())
|
||||
}
|
||||
|
||||
const submitQuiz = () => {
|
||||
validateTitle()
|
||||
quizDetails.setValue.submit(
|
||||
{
|
||||
...quizDetails.doc,
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
<span class="font-semibold"> {{ __('Question') }}: </span>
|
||||
<span class="leading-5" v-html="row.question"> </span>
|
||||
</div>
|
||||
<div class="">
|
||||
<span class="font-semibold"> {{ __('Answer') }} </span>
|
||||
<div class="text-ink-gray-9">
|
||||
<span class="font-semibold"> {{ __('Answer') }}: </span>
|
||||
<span class="leading-5" v-html="row.answer"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="text-xl font-semibold mb-5">
|
||||
<div class="text-xl font-semibold mb-5 text-ink-gray-9">
|
||||
{{ submissions.data[0].quiz_title }}
|
||||
</div>
|
||||
<ListView
|
||||
@@ -40,7 +40,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else />
|
||||
<EmptyState v-else type="Quiz Submissions" />
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
|
||||
@@ -138,6 +138,7 @@ import { useRouter } from 'vue-router'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { escapeHTML } from '@/utils'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
@@ -191,7 +192,12 @@ const quizzes = createListResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
title.value = escapeHTML(title.value.trim())
|
||||
}
|
||||
|
||||
const insertQuiz = (close) => {
|
||||
validateTitle()
|
||||
quizzes.insert.submit(
|
||||
{
|
||||
title: title.value,
|
||||
|
||||
@@ -112,6 +112,12 @@ const routes = [
|
||||
component: () => import('@/pages/JobDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/job-openings/:job/applications',
|
||||
name: 'JobApplications',
|
||||
component: () => import('@/pages/JobApplications.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName/edit',
|
||||
name: 'CourseForm',
|
||||
|
||||
@@ -54,16 +54,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const livecodeURL = createResource({
|
||||
url: 'frappe.client.get_single_value',
|
||||
params: {
|
||||
doctype: 'LMS Settings',
|
||||
field: 'livecode_url',
|
||||
},
|
||||
cache: 'livecodeURL',
|
||||
auto: user.value ? true : false,
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
@@ -71,6 +61,5 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
logout,
|
||||
brand,
|
||||
branding,
|
||||
livecodeURL,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,6 +41,13 @@ export const useSettings = defineStore('settings', () => {
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const livecodeURL = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'livecode_url' },
|
||||
auto: true,
|
||||
cache: ['livecodeURL'],
|
||||
})
|
||||
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
@@ -49,5 +56,6 @@ export const useSettings = defineStore('settings', () => {
|
||||
contactUsEmail,
|
||||
contactUsURL,
|
||||
sidebarSettings,
|
||||
livecodeURL,
|
||||
}
|
||||
})
|
||||
|
||||
1185
frontend/yarn.lock
1185
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
__version__ = "2.39.1"
|
||||
__version__ = "2.40.0"
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-02-20 20:10:46.943871",
|
||||
"modified": "2025-11-01 14:03:02.903943",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Job",
|
||||
"name": "LMS Job Application",
|
||||
@@ -82,6 +82,7 @@
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -90,8 +91,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "user"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ class LMSJobApplication(Document):
|
||||
self.validate_duplicate()
|
||||
|
||||
def after_insert(self):
|
||||
job_owner = frappe.get_value("Job Opportunity", self.job, "owner")
|
||||
if job_owner:
|
||||
frappe.share.add_docshare("LMS Job Application", self.name, job_owner, read=1)
|
||||
|
||||
outgoing_email_account = frappe.get_cached_value(
|
||||
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
|
||||
)
|
||||
@@ -33,7 +37,7 @@ class LMSJobApplication(Document):
|
||||
resume = frappe.get_doc(
|
||||
"File",
|
||||
{
|
||||
"file_name": self.resume,
|
||||
"file_url": self.resume,
|
||||
},
|
||||
)
|
||||
frappe.sendmail(
|
||||
|
||||
@@ -1384,6 +1384,7 @@ def save_role(user, role, value):
|
||||
doc.save(ignore_permissions=True)
|
||||
else:
|
||||
frappe.db.delete("Has Role", {"parent": user, "role": role})
|
||||
frappe.clear_cache(user=user)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1492,9 +1493,7 @@ def update_meta_info(type, route, meta_tags):
|
||||
else:
|
||||
new_tag = frappe.new_doc("Website Meta Tag")
|
||||
new_tag.update(tag_properties)
|
||||
print(new_tag)
|
||||
new_tag.insert()
|
||||
print(new_tag.as_dict())
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1672,3 +1671,16 @@ def get_pwa_manifest():
|
||||
}
|
||||
|
||||
return Response(json.dumps(manifest), status=200, content_type="application/manifest+json")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_profile_details(username):
|
||||
details = frappe.db.get_value(
|
||||
"User",
|
||||
{"username": username},
|
||||
["full_name", "name", "username", "user_image", "bio", "headline", "cover_image"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
details.roles = frappe.get_roles(details.name)
|
||||
return details
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from lms.lms.api import update_course_statistics
|
||||
from lms.lms.utils import get_course_progress
|
||||
from lms.lms.utils import get_course_progress, get_lesson_count
|
||||
|
||||
|
||||
class CourseChapter(Document):
|
||||
def on_update(self):
|
||||
self.recalculate_course_progress()
|
||||
update_course_statistics()
|
||||
self.update_lesson_count()
|
||||
frappe.enqueue(method=self.recalculate_course_progress, queue="short", timeout=300, is_async=True)
|
||||
|
||||
def recalculate_course_progress(self):
|
||||
"""Recalculate course progress if a new lesson is added or removed"""
|
||||
previous_lessons = self.get_doc_before_save() and self.get_doc_before_save().as_dict().lessons
|
||||
current_lessons = self.lessons
|
||||
|
||||
@@ -22,3 +23,7 @@ class CourseChapter(Document):
|
||||
for enrollment in enrolled_members:
|
||||
new_progress = get_course_progress(self.course, enrollment.member)
|
||||
frappe.db.set_value("LMS Enrollment", enrollment.name, "progress", new_progress)
|
||||
|
||||
def update_lesson_count(self):
|
||||
"""Update lesson count in the course"""
|
||||
frappe.db.set_value("LMS Course", self.course, "lessons", get_lesson_count(self.course))
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-04 12:04:11.007945",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-11-10 11:41:51.802016",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "Course Evaluator",
|
||||
"naming_rule": "By fieldname",
|
||||
@@ -131,5 +131,6 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "full_name"
|
||||
"title_field": "full_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -70,10 +70,11 @@
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-24 09:36:31.464508",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-11-10 11:40:38.157448",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Assignment",
|
||||
"naming_rule": "Expression (old style)",
|
||||
@@ -113,9 +114,11 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
}
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-07 20:37:22.449149",
|
||||
"modified": "2025-11-10 11:39:42.233779",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Badge Assignment",
|
||||
@@ -135,6 +135,30 @@
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Batch Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Course Creator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
@@ -142,5 +166,6 @@
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "member"
|
||||
"title_field": "member",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -131,10 +131,11 @@
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-11 11:20:06.233491",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-11-10 11:41:38.999620",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Certificate Evaluation",
|
||||
"owner": "Administrator",
|
||||
@@ -164,6 +165,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [
|
||||
@@ -184,5 +186,6 @@
|
||||
"title": "In Progress"
|
||||
}
|
||||
],
|
||||
"title_field": "member_name"
|
||||
}
|
||||
"title_field": "member_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-13 14:30:57.897102",
|
||||
"modified": "2025-11-10 11:40:50.679211",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Certificate Request",
|
||||
@@ -228,5 +228,6 @@
|
||||
"title": "Cancelled"
|
||||
}
|
||||
],
|
||||
"title_field": "member_name"
|
||||
"title_field": "member_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
0
lms/lms/doctype/lms_coupon/__init__.py
Normal file
0
lms/lms/doctype/lms_coupon/__init__.py
Normal file
8
lms/lms/doctype/lms_coupon/lms_coupon.js
Normal file
8
lms/lms/doctype/lms_coupon/lms_coupon.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("LMS Coupon", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
190
lms/lms/doctype/lms_coupon/lms_coupon.json
Normal file
190
lms/lms/doctype/lms_coupon/lms_coupon.json
Normal file
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "hash",
|
||||
"creation": "2025-10-11 21:39:11.456420",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enabled",
|
||||
"section_break_spfj",
|
||||
"code",
|
||||
"expires_on",
|
||||
"column_break_mptc",
|
||||
"discount_type",
|
||||
"percentage_discount",
|
||||
"fixed_amount_discount",
|
||||
"section_break_ixxu",
|
||||
"usage_limit",
|
||||
"column_break_dcvj",
|
||||
"redemption_count",
|
||||
"section_break_ophm",
|
||||
"applicable_items"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "code",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Code",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "discount_type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Discount Type",
|
||||
"options": "Percentage\nFixed Amount",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "expires_on",
|
||||
"fieldtype": "Date",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Expires On"
|
||||
},
|
||||
{
|
||||
"fieldname": "usage_limit",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Usage Limit"
|
||||
},
|
||||
{
|
||||
"fieldname": "applicable_items",
|
||||
"fieldtype": "Table",
|
||||
"label": "Applicable Items",
|
||||
"options": "LMS Coupon Item",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_mptc",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ixxu",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dcvj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ophm",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.discount_type=='Percentage'",
|
||||
"fieldname": "percentage_discount",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Percentage Discount",
|
||||
"mandatory_depends_on": "eval:doc.discount_type=='Percentage'"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.discount_type=='Fixed Amount'",
|
||||
"fieldname": "fixed_amount_discount",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Fixed Amount Discount",
|
||||
"mandatory_depends_on": "eval:doc.discount_type=='Fixed Amount'"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "redemption_count",
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Redemption Count",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_spfj",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-27 19:52:11.835042",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Coupon",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Administrator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Batch Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Course Creator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Moderator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "code"
|
||||
}
|
||||
31
lms/lms/doctype/lms_coupon/lms_coupon.py
Normal file
31
lms/lms/doctype/lms_coupon/lms_coupon.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) 2025, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, nowdate
|
||||
|
||||
|
||||
class LMSCoupon(Document):
|
||||
def validate(self):
|
||||
self.convert_to_uppercase()
|
||||
self.validate_expiry_date()
|
||||
self.validate_applicable_items()
|
||||
self.validate_usage_limit()
|
||||
|
||||
def convert_to_uppercase(self):
|
||||
if self.code:
|
||||
self.code = self.code.strip().upper()
|
||||
|
||||
def validate_expiry_date(self):
|
||||
if self.expires_on and str(self.expires_on) < nowdate():
|
||||
frappe.throw(_("Expiry date cannot be in the past"))
|
||||
|
||||
def validate_applicable_items(self):
|
||||
if not self.get("applicable_items") or len(self.get("applicable_items")) == 0:
|
||||
frappe.throw(_("At least one applicable item is required"))
|
||||
|
||||
def validate_usage_limit(self):
|
||||
if self.usage_limit is not None and cint(self.usage_limit) < 0:
|
||||
frappe.throw(_("Usage limit cannot be negative"))
|
||||
20
lms/lms/doctype/lms_coupon/test_lms_coupon.py
Normal file
20
lms/lms/doctype/lms_coupon/test_lms_coupon.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2025, Frappe and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class IntegrationTestLMSCoupon(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for LMSCoupon.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
0
lms/lms/doctype/lms_coupon_item/__init__.py
Normal file
0
lms/lms/doctype/lms_coupon_item/__init__.py
Normal file
43
lms/lms/doctype/lms_coupon_item/lms_coupon_item.json
Normal file
43
lms/lms/doctype/lms_coupon_item/lms_coupon_item.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-10-11 21:45:00",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"reference_doctype",
|
||||
"reference_name"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Reference DocType",
|
||||
"options": "\nLMS Course\nLMS Batch",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Reference Name",
|
||||
"options": "reference_doctype",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-12 17:27:14.123811",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Coupon Item",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
9
lms/lms/doctype/lms_coupon_item/lms_coupon_item.py
Normal file
9
lms/lms/doctype/lms_coupon_item/lms_coupon_item.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2025, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class LMSCouponItem(Document):
|
||||
pass
|
||||
@@ -16,14 +16,18 @@
|
||||
"payment_received",
|
||||
"payment_for_certificate",
|
||||
"payment_details_section",
|
||||
"currency",
|
||||
"original_amount",
|
||||
"discount_amount",
|
||||
"amount",
|
||||
"amount_with_gst",
|
||||
"column_break_yxpl",
|
||||
"order_id",
|
||||
"payment_id",
|
||||
"currency",
|
||||
"coupon",
|
||||
"coupon_code",
|
||||
"billing_details_section",
|
||||
"address",
|
||||
"payment_id",
|
||||
"order_id",
|
||||
"column_break_monu",
|
||||
"gstin",
|
||||
"pan"
|
||||
@@ -47,6 +51,19 @@
|
||||
"options": "currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "coupon",
|
||||
"fieldtype": "Link",
|
||||
"label": "Coupon",
|
||||
"options": "LMS Coupon"
|
||||
},
|
||||
{
|
||||
"depends_on": "coupon",
|
||||
"fieldname": "discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Discount Amount",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
@@ -117,12 +134,6 @@
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.currency == \"INR\";",
|
||||
"fieldname": "amount_with_gst",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount with GST"
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_for_document_type",
|
||||
"fieldtype": "Select",
|
||||
@@ -149,6 +160,27 @@
|
||||
"fieldtype": "Check",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Payment for Certificate"
|
||||
},
|
||||
{
|
||||
"depends_on": "coupon",
|
||||
"fetch_from": "coupon.code",
|
||||
"fieldname": "coupon_code",
|
||||
"fieldtype": "Data",
|
||||
"label": "Coupon Code",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.currency == \"INR\"",
|
||||
"fieldname": "amount_with_gst",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount with GST"
|
||||
},
|
||||
{
|
||||
"depends_on": "coupon",
|
||||
"fieldname": "original_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Original Amount",
|
||||
"options": "currency"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -162,7 +194,7 @@
|
||||
"link_fieldname": "payment"
|
||||
}
|
||||
],
|
||||
"modified": "2025-09-23 11:04:00.462274",
|
||||
"modified": "2025-11-12 12:39:52.466297",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Payment",
|
||||
|
||||
@@ -5,7 +5,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, nowdate
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
|
||||
class LMSPayment(Document):
|
||||
|
||||
@@ -193,11 +193,12 @@
|
||||
"label": "Possible Answer 4"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-10-07 09:41:17.862774",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-11-10 11:40:18.568547",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Question",
|
||||
"naming_rule": "Expression",
|
||||
@@ -240,8 +241,10 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "question"
|
||||
}
|
||||
"title_field": "question",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
"unique": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2023-10-26 17:25:09.144367",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-11-10 11:39:57.251861",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Source",
|
||||
"naming_rule": "By fieldname",
|
||||
@@ -62,8 +63,10 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "source"
|
||||
}
|
||||
"title_field": "source",
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-08 12:20:48.314056",
|
||||
"modified": "2025-11-10 11:39:13.146961",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Zoom Settings",
|
||||
@@ -131,5 +131,6 @@
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
|
||||
@@ -23,23 +23,38 @@ def get_payment_link(
|
||||
docname,
|
||||
title,
|
||||
amount,
|
||||
total_amount,
|
||||
discount_amount,
|
||||
gst_amount,
|
||||
currency,
|
||||
address,
|
||||
redirect_to,
|
||||
payment_for_certificate,
|
||||
coupon_code=None,
|
||||
coupon=None,
|
||||
):
|
||||
payment_gateway = get_payment_gateway()
|
||||
address = frappe._dict(address)
|
||||
amount_with_gst = total_amount if total_amount != amount else 0
|
||||
original_amount = amount
|
||||
amount -= discount_amount
|
||||
amount_with_gst = get_amount_with_gst(amount, gst_amount)
|
||||
|
||||
payment = record_payment(
|
||||
address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
|
||||
address,
|
||||
doctype,
|
||||
docname,
|
||||
amount,
|
||||
original_amount,
|
||||
currency,
|
||||
amount_with_gst,
|
||||
discount_amount,
|
||||
payment_for_certificate,
|
||||
coupon_code,
|
||||
coupon,
|
||||
)
|
||||
controller = get_controller(payment_gateway)
|
||||
|
||||
payment_details = {
|
||||
"amount": total_amount,
|
||||
"amount": amount_with_gst if amount_with_gst else amount,
|
||||
"title": f"Payment for {doctype} {title} {docname}",
|
||||
"description": f"{address.billing_name}'s payment for {title}",
|
||||
"reference_doctype": doctype,
|
||||
@@ -51,23 +66,41 @@ def get_payment_link(
|
||||
"redirect_to": redirect_to,
|
||||
"payment": payment.name,
|
||||
}
|
||||
if payment_gateway == "Razorpay":
|
||||
order = controller.create_order(**payment_details)
|
||||
payment_details.update({"order_id": order.get("id")})
|
||||
|
||||
create_order(payment_gateway, payment_details, controller)
|
||||
url = controller.get_payment_url(**payment_details)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def create_order(payment_gateway, payment_details, controller):
|
||||
if payment_gateway != "Razorpay":
|
||||
return
|
||||
|
||||
order = controller.create_order(**payment_details)
|
||||
payment_details.update({"order_id": order.get("id")})
|
||||
|
||||
|
||||
def get_amount_with_gst(amount, gst_amount):
|
||||
amount_with_gst = 0
|
||||
if gst_amount:
|
||||
amount_with_gst = amount + gst_amount
|
||||
|
||||
return amount_with_gst
|
||||
|
||||
|
||||
def record_payment(
|
||||
address,
|
||||
doctype,
|
||||
docname,
|
||||
amount,
|
||||
original_amount,
|
||||
currency,
|
||||
amount_with_gst=0,
|
||||
discount_amount=0,
|
||||
payment_for_certificate=0,
|
||||
coupon_code=None,
|
||||
coupon=None,
|
||||
):
|
||||
address = frappe._dict(address)
|
||||
address_name = save_address(address)
|
||||
@@ -80,6 +113,7 @@ def record_payment(
|
||||
"address": address_name,
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"discount_amount": discount_amount,
|
||||
"amount_with_gst": amount_with_gst,
|
||||
"gstin": address.gstin,
|
||||
"pan": address.pan,
|
||||
@@ -89,6 +123,16 @@ def record_payment(
|
||||
"payment_for_certificate": payment_for_certificate,
|
||||
}
|
||||
)
|
||||
if coupon_code:
|
||||
payment_doc.update(
|
||||
{
|
||||
"coupon": coupon,
|
||||
"coupon_code": coupon_code,
|
||||
"discount_amount": discount_amount,
|
||||
"original_amount": original_amount,
|
||||
}
|
||||
)
|
||||
|
||||
payment_doc.save(ignore_permissions=True)
|
||||
return payment_doc
|
||||
|
||||
|
||||
349
lms/lms/utils.py
349
lms/lms/utils.py
@@ -1587,6 +1587,28 @@ def get_batch_students(batch):
|
||||
"LMS Batch Enrollment", filters={"batch": batch}, fields=["member", "name"]
|
||||
)
|
||||
|
||||
for student in students_list:
|
||||
details = get_batch_student_details(student)
|
||||
calculate_student_progress(batch, details)
|
||||
students.append(details)
|
||||
students = sorted(students, key=lambda x: x.progress, reverse=True)
|
||||
return students
|
||||
|
||||
|
||||
def get_batch_student_details(student):
|
||||
details = frappe.db.get_value(
|
||||
"User",
|
||||
student.member,
|
||||
["full_name", "email", "username", "last_active", "user_image"],
|
||||
as_dict=True,
|
||||
)
|
||||
details.last_active = format_datetime(details.last_active, "dd MMM YY")
|
||||
details.name = student.name
|
||||
details.assessments = frappe._dict()
|
||||
return details
|
||||
|
||||
|
||||
def calculate_student_progress(batch, details):
|
||||
batch_courses = frappe.get_all("Batch Course", {"parent": batch}, ["course", "title"])
|
||||
assessments = frappe.get_all(
|
||||
"LMS Assessment",
|
||||
@@ -1594,53 +1616,55 @@ def get_batch_students(batch):
|
||||
fields=["name", "assessment_type", "assessment_name"],
|
||||
)
|
||||
|
||||
for student in students_list:
|
||||
courses_completed = 0
|
||||
assessments_completed = 0
|
||||
detail = frappe.db.get_value(
|
||||
"User",
|
||||
student.member,
|
||||
["full_name", "email", "username", "last_active", "user_image"],
|
||||
as_dict=True,
|
||||
calculate_course_progress(batch_courses, details)
|
||||
calculate_assessment_progress(assessments, details)
|
||||
|
||||
if len(batch_courses) + len(assessments):
|
||||
details.progress = flt(
|
||||
(
|
||||
(details.average_course_progress * len(batch_courses))
|
||||
+ (details.average_assessments_progress * len(assessments))
|
||||
)
|
||||
/ (len(batch_courses) + len(assessments)),
|
||||
2,
|
||||
)
|
||||
detail.last_active = format_datetime(detail.last_active, "dd MMM YY")
|
||||
detail.name = student.name
|
||||
detail.courses = frappe._dict()
|
||||
detail.assessments = frappe._dict()
|
||||
else:
|
||||
details.progress = 0
|
||||
|
||||
""" Iterate through courses and track their progress """
|
||||
for course in batch_courses:
|
||||
progress = frappe.db.get_value(
|
||||
"LMS Enrollment", {"course": course.course, "member": student.member}, "progress"
|
||||
)
|
||||
detail.courses[course.title] = progress
|
||||
if progress == 100:
|
||||
courses_completed += 1
|
||||
|
||||
""" Iterate through assessments and track their progress """
|
||||
for assessment in assessments:
|
||||
title = frappe.db.get_value(assessment.assessment_type, assessment.assessment_name, "title")
|
||||
assessment_info = has_submitted_assessment(
|
||||
assessment.assessment_name, assessment.assessment_type, student.member
|
||||
)
|
||||
detail.assessments[title] = assessment_info
|
||||
def calculate_course_progress(batch_courses, details):
|
||||
course_progress = []
|
||||
details.courses = frappe._dict()
|
||||
|
||||
if assessment_info.result == "Pass":
|
||||
assessments_completed += 1
|
||||
for course in batch_courses:
|
||||
progress = frappe.db.get_value(
|
||||
"LMS Enrollment", {"course": course.course, "member": details.email}, "progress"
|
||||
)
|
||||
details.courses[course.title] = progress
|
||||
course_progress.append(progress)
|
||||
|
||||
detail.courses_completed = courses_completed
|
||||
detail.assessments_completed = assessments_completed
|
||||
if len(batch_courses) + len(assessments):
|
||||
detail.progress = flt(
|
||||
((courses_completed + assessments_completed) / (len(batch_courses) + len(assessments)) * 100),
|
||||
2,
|
||||
)
|
||||
else:
|
||||
detail.progress = 0
|
||||
details.average_course_progress = (
|
||||
flt(sum(course_progress) / len(batch_courses), 2) if len(batch_courses) else 0
|
||||
)
|
||||
|
||||
students.append(detail)
|
||||
students = sorted(students, key=lambda x: x.progress, reverse=True)
|
||||
return students
|
||||
|
||||
def calculate_assessment_progress(assessments, details):
|
||||
assessments_completed = 0
|
||||
details.assessments = frappe._dict()
|
||||
|
||||
for assessment in assessments:
|
||||
title = frappe.db.get_value(assessment.assessment_type, assessment.assessment_name, "title")
|
||||
assessment_info = has_submitted_assessment(
|
||||
assessment.assessment_name, assessment.assessment_type, details.email
|
||||
)
|
||||
details.assessments[title] = assessment_info
|
||||
|
||||
if assessment_info.result == "Pass":
|
||||
assessments_completed += 1
|
||||
|
||||
details.average_assessments_progress = (
|
||||
flt((assessments_completed / len(assessments) * 100), 2) if len(assessments) else 0
|
||||
)
|
||||
|
||||
|
||||
def has_submitted_assessment(assessment, assessment_type, member=None):
|
||||
@@ -1752,51 +1776,140 @@ def get_discussion_replies(topic):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_order_summary(doctype, docname, country=None):
|
||||
if doctype == "LMS Course":
|
||||
details = frappe.db.get_value(
|
||||
"LMS Course",
|
||||
docname,
|
||||
[
|
||||
"title",
|
||||
"name",
|
||||
"paid_course",
|
||||
"paid_certificate",
|
||||
"course_price as amount",
|
||||
"currency",
|
||||
"amount_usd",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not details.paid_course and not details.paid_certificate:
|
||||
raise frappe.throw(_("This course is free."))
|
||||
|
||||
else:
|
||||
details = frappe.db.get_value(
|
||||
"LMS Batch",
|
||||
docname,
|
||||
["title", "name", "paid_batch", "amount", "currency", "amount_usd"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not details.paid_batch:
|
||||
raise frappe.throw(_("To join this batch, please contact the Administrator."))
|
||||
def get_order_summary(doctype, docname, coupon=None, country=None):
|
||||
details = get_paid_course_details(docname) if doctype == "LMS Course" else get_paid_batch_details(docname)
|
||||
|
||||
details.amount, details.currency = check_multicurrency(
|
||||
details.amount, details.currency, country, details.amount_usd
|
||||
)
|
||||
|
||||
details.original_amount = details.amount
|
||||
details.original_amount_formatted = fmt_money(details.amount, 0, details.currency)
|
||||
|
||||
if details.currency == "INR":
|
||||
details.amount, details.gst_applied = apply_gst(details.amount, country)
|
||||
details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
|
||||
adjust_amount_for_coupon(details, coupon, doctype, docname)
|
||||
get_gst_details(details, country)
|
||||
|
||||
details.total_amount = details.amount
|
||||
details.total_amount_formatted = fmt_money(details.amount, 0, details.currency)
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def get_paid_course_details(docname):
|
||||
details = frappe.db.get_value(
|
||||
"LMS Course",
|
||||
docname,
|
||||
[
|
||||
"title",
|
||||
"name",
|
||||
"paid_course",
|
||||
"paid_certificate",
|
||||
"course_price as amount",
|
||||
"currency",
|
||||
"amount_usd",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not details.paid_course and not details.paid_certificate:
|
||||
raise frappe.throw(_("This course is free."))
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def get_paid_batch_details(docname):
|
||||
details = frappe.db.get_value(
|
||||
"LMS Batch",
|
||||
docname,
|
||||
["title", "name", "paid_batch", "amount", "currency", "amount_usd"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not details.paid_batch:
|
||||
raise frappe.throw(_("To join this batch, please contact the Administrator."))
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def adjust_amount_for_coupon(details, coupon, doctype, docname):
|
||||
if not coupon:
|
||||
return
|
||||
discount_amount, subtotal, coupon_name = apply_coupon(doctype, docname, coupon, details.amount)
|
||||
details.amount = subtotal
|
||||
details.discount_amount = discount_amount
|
||||
details.discount_amount_formatted = fmt_money(discount_amount, 0, details.currency)
|
||||
details.coupon = coupon_name
|
||||
|
||||
|
||||
def get_gst_details(details, country):
|
||||
if details.currency != "INR":
|
||||
return
|
||||
|
||||
details.amount, details.gst_applied = apply_gst(details.amount, country)
|
||||
details.gst_amount_formatted = fmt_money(details.gst_applied, 0, details.currency)
|
||||
|
||||
|
||||
def apply_coupon(doctype, docname, code, base_amount):
|
||||
coupon_name = frappe.db.exists("LMS Coupon", {"code": code, "enabled": 1})
|
||||
if not coupon_name:
|
||||
frappe.throw(_("The coupon code '{0}' is invalid.").format(code))
|
||||
|
||||
coupon = frappe.db.get_value(
|
||||
"LMS Coupon",
|
||||
coupon_name,
|
||||
[
|
||||
"expires_on",
|
||||
"usage_limit",
|
||||
"redemption_count",
|
||||
"discount_type",
|
||||
"percentage_discount",
|
||||
"fixed_amount_discount",
|
||||
"name",
|
||||
"code",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
validate_coupon(code, coupon)
|
||||
validate_coupon_applicability(doctype, docname, coupon_name)
|
||||
|
||||
discount_amount = calculate_discount_amount(base_amount, coupon)
|
||||
subtotal = max(flt(base_amount) - flt(discount_amount), 0)
|
||||
|
||||
return discount_amount, subtotal, coupon_name
|
||||
|
||||
|
||||
def validate_coupon(code, coupon):
|
||||
if coupon.expires_on and getdate(coupon.expires_on) < getdate():
|
||||
frappe.throw(_("This coupon has expired."))
|
||||
|
||||
if coupon.usage_limit and cint(coupon.redemption_count) >= cint(coupon.usage_limit):
|
||||
frappe.throw(_("This coupon has reached its maximum usage limit."))
|
||||
|
||||
|
||||
def validate_coupon_applicability(doctype, docname, coupon_name):
|
||||
applicable_item = frappe.db.exists(
|
||||
"LMS Coupon Item", {"parent": coupon_name, "reference_doctype": doctype, "reference_name": docname}
|
||||
)
|
||||
if not applicable_item:
|
||||
frappe.throw(
|
||||
_("This coupon is not applicable to this {0}.").format(
|
||||
"Course" if doctype == "LMS Course" else "Batch"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_discount_amount(base_amount, coupon):
|
||||
discount_amount = 0
|
||||
|
||||
if coupon.discount_type == "Percentage":
|
||||
discount_amount = (base_amount * coupon.percentage_discount) / 100
|
||||
elif coupon.discount_type == "Fixed Amount":
|
||||
discount_amount = base_amount - coupon.fixed_amount_discount
|
||||
|
||||
return discount_amount
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_lesson_creation_details(course, chapter, lesson):
|
||||
chapter_name = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": chapter}, "chapter")
|
||||
@@ -1843,49 +1956,79 @@ def publish_notifications(doc, method):
|
||||
|
||||
|
||||
def update_payment_record(doctype, docname):
|
||||
request = frappe.get_all(
|
||||
request = get_integration_requests(doctype, docname)
|
||||
|
||||
if len(request):
|
||||
data = request[0].data
|
||||
data = frappe._dict(json.loads(data))
|
||||
payment_doc = get_payment_doc(data.payment)
|
||||
|
||||
update_payment_details(data)
|
||||
update_coupon_redemption(payment_doc)
|
||||
|
||||
if payment_doc.payment_for_certificate:
|
||||
update_certificate_purchase(docname, data.payment)
|
||||
elif doctype == "LMS Course":
|
||||
enroll_in_course(docname, data.payment)
|
||||
else:
|
||||
enroll_in_batch(docname, data.payment)
|
||||
|
||||
|
||||
def get_integration_requests(doctype, docname):
|
||||
return frappe.get_all(
|
||||
"Integration Request",
|
||||
{
|
||||
"reference_doctype": doctype,
|
||||
"reference_docname": docname,
|
||||
"owner": frappe.session.user,
|
||||
},
|
||||
["data"],
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
if len(request):
|
||||
data = frappe.db.get_value("Integration Request", request[0].name, "data")
|
||||
data = frappe._dict(json.loads(data))
|
||||
|
||||
payment_gateway = data.get("payment_gateway")
|
||||
if payment_gateway == "Razorpay":
|
||||
payment_id = "razorpay_payment_id"
|
||||
elif "Stripe" in payment_gateway:
|
||||
payment_id = "stripe_token_id"
|
||||
else:
|
||||
payment_id = "order_id"
|
||||
def get_payment_doc(payment_name):
|
||||
return frappe.db.get_value(
|
||||
"LMS Payment", payment_name, ["name", "coupon", "payment_for_certificate"], as_dict=True
|
||||
)
|
||||
|
||||
|
||||
def update_payment_details(data):
|
||||
payment_id = get_payment_id(data)
|
||||
|
||||
frappe.db.set_value(
|
||||
"LMS Payment",
|
||||
data.payment,
|
||||
{
|
||||
"payment_received": 1,
|
||||
"payment_id": data.get(payment_id),
|
||||
"order_id": data.get("order_id"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_payment_id(data):
|
||||
payment_gateway = data.get("payment_gateway")
|
||||
if payment_gateway == "Razorpay":
|
||||
payment_id = "razorpay_payment_id"
|
||||
elif "Stripe" in payment_gateway:
|
||||
payment_id = "stripe_token_id"
|
||||
else:
|
||||
payment_id = "order_id"
|
||||
return payment_id
|
||||
|
||||
|
||||
def update_coupon_redemption(payment_doc):
|
||||
if payment_doc.coupon:
|
||||
redemption_count = frappe.db.get_value("LMS Coupon", payment_doc.coupon, "redemption_count") or 0
|
||||
|
||||
frappe.db.set_value(
|
||||
"LMS Payment",
|
||||
data.payment,
|
||||
{
|
||||
"payment_received": 1,
|
||||
"payment_id": data.get(payment_id),
|
||||
"order_id": data.get("order_id"),
|
||||
},
|
||||
"LMS Coupon",
|
||||
payment_doc.coupon,
|
||||
"redemption_count",
|
||||
redemption_count + 1,
|
||||
)
|
||||
payment_for_certificate = frappe.db.get_value("LMS Payment", data.payment, "payment_for_certificate")
|
||||
|
||||
try:
|
||||
if payment_for_certificate:
|
||||
update_certificate_purchase(docname, data.payment)
|
||||
elif doctype == "LMS Course":
|
||||
enroll_in_course(docname, data.payment)
|
||||
else:
|
||||
enroll_in_batch(docname, data.payment)
|
||||
except Exception as e:
|
||||
frappe.log_error(frappe.get_traceback(), _("Enrollment Failed, {0}").format(e))
|
||||
|
||||
|
||||
def enroll_in_course(course, payment_name):
|
||||
|
||||
934
lms/locale/ar.po
934
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
792
lms/locale/bs.po
792
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user