Merge branch 'develop' into patch-2

This commit is contained in:
Jannat Patel
2025-11-21 10:48:55 +05:30
committed by GitHub
130 changed files with 19017 additions and 9373 deletions
+5
View File
@@ -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);
+8 -4
View File
@@ -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']
+2 -1
View File
@@ -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",
@@ -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>
+72 -48
View File
@@ -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 -1
View File
@@ -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()">
+6 -150
View File
@@ -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) }}
+49 -25
View File
@@ -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, () => {
+1 -1
View File
@@ -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`]
+12
View File
@@ -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>
+11 -4
View File
@@ -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()
+1 -1
View File
@@ -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 -1
View File
@@ -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 })
},
})
}
+37 -7
View File
@@ -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,
},
}
+2 -1
View File
@@ -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
}
"
/>
+3 -3
View File
@@ -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">
+2 -2
View File
@@ -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()">
+5 -5
View File
@@ -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]
}
})
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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 -1
View File
@@ -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">
+18 -3
View File
@@ -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,
+1 -1
View File
@@ -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>
+13
View File
@@ -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 {
+5 -6
View File
@@ -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) => {
+99 -45
View File
@@ -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) => {
+30 -1
View File
@@ -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'),
+15 -6
View File
@@ -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 {
+8 -5
View File
@@ -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">
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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>
+6 -3
View File
@@ -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
View 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 -1
View File
@@ -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,
+12 -3
View File
@@ -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
}
-1
View 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()
+3 -4
View File
@@ -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,
+39 -18
View File
@@ -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: [
+20 -3
View File
@@ -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
}
+6 -1
View File
@@ -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
+3 -3
View File
@@ -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 }}
+8 -6
View File
@@ -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,
+2 -2
View File
@@ -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">
+2 -2
View File
@@ -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 {
+6
View File
@@ -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,
+6
View File
@@ -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',
-11
View File
@@ -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,
}
})
+8
View File
@@ -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,
}
})
+593 -592
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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(
+14 -2
View File
@@ -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
}
+8
View 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
View 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
View 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"))
@@ -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,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": []
}
@@ -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
+42 -10
View File
@@ -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",
+1 -1
View File
@@ -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
}
+7 -4
View File
@@ -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
}
+51 -7
View File
@@ -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
+246 -103
View File
@@ -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):
+600 -334
View File
File diff suppressed because it is too large Load Diff
+529 -263
View File
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