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

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);

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']

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",

View File

@@ -0,0 +1,159 @@
<template>
<div v-if="batch?.data" class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChart
class="border rounded-md"
:config="{ title: __('Students'), value: students.data?.length || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch?.data?.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
/>
</div>
<AxisChart
v-if="showProgressChart"
class="border"
:config="{
data: chartData || [],
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div>
</template>
<script setup lang="ts">
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
import { ref, watch } from 'vue'
const chartData = ref<null | any[]>(null)
const showProgressChart = ref(false)
const assessmentCount = ref(0)
const props = defineProps<{
batch: { [key: string]: any } | null
}>()
const students = createResource({
url: 'lms.lms.utils.get_batch_students',
params: {
batch: props.batch?.data?.name,
},
auto: true,
onSuccess(data: any[]) {
chartData.value = getChartData()
showProgressChart.value =
data.length &&
(props.batch?.data?.courses?.length || assessmentCount.value)
},
})
const getChartData = () => {
let tasks: any[] = []
let data: { task: any; value: any }[] = []
students.data.forEach((row: any) => {
tasks = countAssessments(row, tasks)
tasks = countCourses(row, tasks)
})
tasks.forEach((task) => {
data.push({
task: task.label,
value: task.value,
})
})
return data
}
const countAssessments = (
row: { assessments: { [x: string]: { result: string } } },
tasks: any[]
) => {
Object.keys(row.assessments).forEach((assessment) => {
if (row.assessments[assessment].result === 'Pass') {
tasks.filter((task) => task.label === assessment).length
? tasks.filter((task) => task.label === assessment)[0].value++
: tasks.push({
value: 1,
label: assessment,
})
}
})
return tasks
}
const countCourses = (
row: { courses: { [x: string]: number } },
tasks: any[]
) => {
Object.keys(row.courses).forEach((course) => {
if (row.courses[course] === 100) {
tasks.filter((task) => task.label === course).length
? tasks.filter((task) => task.label === course)[0].value++
: tasks.push({
value: 1,
label: course,
})
}
})
return tasks
}
const certificationCount = createResource({
url: 'frappe.client.get_count',
params: {
doctype: 'LMS Certificate',
filters: {
batch_name: props.batch?.data?.name,
},
},
auto: true,
})
watch(students, () => {
if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
}
})
</script>

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',

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()">

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>

View File

@@ -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) }}

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, () => {

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`]

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],

View File

@@ -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()
}

View File

@@ -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>

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()

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>

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>

View File

@@ -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(() => {

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 })
},
})
}

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
},

View File

@@ -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,
},
}

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',

View File

@@ -22,6 +22,7 @@
:onCreate="
(value, close) => {
openSettings('Members', close)
show = false
}
"
/>

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">

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()">

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}'

View File

@@ -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]
}
})

View File

@@ -0,0 +1,142 @@
<template>
<div class="flex flex-col text-base h-full">
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="emit('updateStep', 'list')"
/>
<div class="text-xl font-semibold text-ink-gray-9">
{{ data?.name ? __('Edit Coupon') : __('New Coupon') }}
</div>
</div>
<div class="space-y-4 overflow-y-auto">
<div>
<FormControl
v-model="data.enabled"
:label="__('Enabled')"
type="checkbox"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<FormControl
v-model="data.code"
:label="__('Coupon Code')"
:required="true"
@input="() => (data.code = data.code.toUpperCase())"
/>
<FormControl
v-model="data.discount_type"
:label="__('Discount Type')"
:required="true"
type="select"
:options="['Percentage', 'Fixed Amount']"
/>
<FormControl
v-model="data.expires_on"
:label="__('Expires On')"
type="date"
/>
<FormControl
v-if="data.discount_type === 'Percentage'"
v-model="data.percentage_discount"
:required="true"
:label="__('Discount Percentage')"
type="number"
/>
<FormControl
v-else
v-model="data.fixed_amount_discount"
:required="true"
:label="__('Discount Amount')"
type="number"
/>
<FormControl
v-model="data.usage_limit"
:label="__('Usage Limit')"
type="number"
/>
<FormControl
v-model="data.redemptions_count"
:label="__('Redemptions Count')"
type="number"
:disabled="true"
/>
</div>
<div class="py-8">
<div class="font-semibold text-ink-gray-9 mb-2">
{{ __('Applicable For') }}
</div>
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
</div>
</div>
<div class="mt-auto space-x-2 ml-auto">
<Button variant="solid" @click="saveCoupon()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, FormControl, toast } from 'frappe-ui'
import { ref } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
import type { Coupon, Coupons } from './types'
import CouponItems from '@/components/Settings/Coupons/CouponItems.vue'
const couponItems = ref<any>(null)
const emit = defineEmits(['updateStep'])
const props = defineProps<{
coupons: Coupons
data: Coupon
}>()
const saveCoupon = () => {
if (props.data?.name) {
editCoupon()
} else {
createCoupon()
}
}
const editCoupon = () => {
props.coupons.setValue.submit(
{
...props.data,
},
{
onSuccess(data: Coupon) {
if (couponItems.value) {
couponItems.value.saveItems()
}
},
}
)
}
const createCoupon = () => {
if (couponItems.value) {
let rows = couponItems.value.saveItems()
props.data.applicable_items = rows
}
props.coupons.insert.submit(
{
...props.data,
},
{
onSuccess(data: Coupon) {
toast.success(__('Coupon created successfully'))
emit('updateStep', 'details', { ...data })
},
onError(err: any) {
toast.error(err.messages?.[0] || err.message || err)
console.error(err)
},
}
)
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<div>
<div class="relative overflow-x-auto border rounded-md">
<table class="w-full text-sm text-left text-ink-gray-5">
<thead class="text-xs text-ink-gray-7 uppercase bg-surface-gray-2">
<tr>
<td scope="col" class="px-6 py-2">
{{ __('Document Type') }}
</td>
<td scope="col" class="px-6 py-2">
{{ __('Document Name') }}
</td>
<td scope="col" class="px-6 py-2 w-16"></td>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
class="bg-white dark:bg-gray-800 dark:border-gray-700 border-gray-200"
>
<td class="px-6 py-2">
<FormControl
type="select"
v-model="row.reference_doctype"
:options="[
{ label: 'Course', value: 'LMS Course' },
{ label: 'Batch', value: 'LMS Batch' },
]"
/>
</td>
<td class="px-6 py-2">
<Link
:doctype="row.reference_doctype"
v-model="row.reference_name"
class="bg-white"
/>
</td>
<td class="px-6 py-2">
<Button variant="ghost" @click="removeRow(row)">
<template #icon>
<X class="size-4 stroke-1.5" />
</template>
</Button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4">
<Button @click="addRow()">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Add Row') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import type { ApplicableItem, Coupon, Coupons } from './types'
import { ref, watch } from 'vue'
import { Button, createListResource, FormControl } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
const rows = ref<
{
reference_doctype: string
reference_name: string | null
name: string | null
}[]
>([])
const props = defineProps<{
data: Coupon
coupons: Coupons
}>()
const applicableItems = createListResource({
doctype: 'LMS Coupon Item',
fields: [
'reference_doctype',
'reference_name',
'name',
'parent',
'parenttype',
'parentfield',
],
parent: 'LMS Coupon',
onSuccess(data: ApplicableItem[]) {
rows.value = data
},
})
const addRow = () => {
rows.value.push({
reference_doctype: 'LMS Course',
reference_name: null,
name: null,
})
}
watch(
() => props.data,
() => {
if (props.data?.name) {
applicableItems.update({
filters: {
parent: props.data.name,
},
})
applicableItems.reload()
} else {
addRow()
}
},
{ immediate: true }
)
const saveItems = (parent = null) => {
return rows.value
}
const removeRow = (rowToRemove: any) => {
rows.value = rows.value.filter((row) => row !== rowToRemove)
if (rowToRemove.name) {
applicableItems.delete.submit(rowToRemove.name, {
onSuccess() {
props.coupons.reload()
applicableItems.reload()
},
})
}
}
defineExpose({
saveItems,
})
</script>

View File

@@ -0,0 +1,203 @@
<template>
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<Button @click="openForm()">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
<div v-if="coupons.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="coupons.data"
row-key="name"
:options="{
showTooltip: false,
selectable: true,
onRowClick: (row: Coupon) => {
openForm(row)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in coupons.data" :key="row.name">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else-if="column.key == 'expires_on'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div v-else-if="column.key == 'discount'">
<div v-if="row['discount_type'] == 'Percentage'">
{{ row['percentage_discount'] }}%
</div>
<div v-else-if="row['discount_type'] == 'Fixed Amount'">
{{ row['fixed_amount_discount'] }}/-
</div>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="confirmDeletion(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-center text-ink-gray-6 italic mt-40">
{{ __('No coupons created yet.') }}
</div>
</div>
</template>
<script setup lang="ts">
import {
Badge,
Button,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, getCurrentInstance, inject, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import type { Coupon, Coupons } from './types'
const dayjs = inject('$dayjs') as typeof import('dayjs')
const app = getCurrentInstance()
const $dialog = app?.appContext.config.globalProperties.$dialog
const emit = defineEmits(['updateStep'])
const props = defineProps<{
label: string
description: string
coupons: Coupons
}>()
const openForm = (coupon: Coupon = {} as Coupon) => {
emit('updateStep', 'details', { ...coupon })
}
const confirmDeletion = (selections: any[], unselectAll: () => void) => {
if (selections.length === 0) {
toast.info(__('No coupons selected for deletion'))
return
}
$dialog({
title: __('Delete this coupon?'),
message: __(
'This will permanently delete the coupon and the code will no longer be valid.'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick({ close }: { close: () => void }) {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Coupon',
documents: Array.from(selections),
}).then((data: any) => {
toast.success(__('Coupon(s) deleted successfully'))
coupons.reload()
unselectAll()
close()
})
},
},
],
})
}
function trashCoupon(name, close) {
call('frappe.client.delete', { doctype: 'LMS Coupon', name }).then(() => {
toast.success(__('Coupon deleted successfully'))
coupons.reload()
if (typeof close === 'function') close()
})
}
const columns = computed(() => {
return [
{
label: __('Code'),
key: 'code',
icon: 'tag',
width: '150px',
},
{
label: __('Discount'),
key: 'discount',
align: 'center',
width: '80px',
icon: 'dollar-sign',
},
{
label: __('Expires On'),
key: 'expires_on',
width: '120px',
icon: 'calendar',
},
{
label: __('Usage Limit'),
key: 'usage_limit',
align: 'center',
width: '100px',
icon: 'hash',
},
{
label: __('Redemption Count'),
key: 'redemption_count',
align: 'center',
width: '100px',
icon: 'users',
},
{
label: __('Enabled'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})
</script>

View File

@@ -0,0 +1,53 @@
<template>
<CouponList
v-if="step === 'list'"
:label="props.label"
:description="props.description"
:coupons="coupons"
@updateStep="updateStep"
/>
<CouponDetails
v-else-if="step == 'details'"
:coupons="coupons"
:data="data"
@updateStep="updateStep"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { createListResource } from 'frappe-ui'
import CouponList from '@/components/Settings/Coupons/CouponList.vue'
import CouponDetails from '@/components/Settings/Coupons/CouponDetails.vue'
import type { Coupon } from './types'
const step = ref('list')
const data = ref<Coupon | null>(null)
const props = defineProps<{
label: string
description: string
}>()
const updateStep = (newStep: 'list' | 'new' | 'edit', newData: Coupon) => {
step.value = newStep
if (newData) {
data.value = newData
}
}
const coupons = createListResource({
doctype: 'LMS Coupon',
fields: [
'name',
'code',
'discount_type',
'percentage_discount',
'fixed_amount_discount',
'expires_on',
'usage_limit',
'redemption_count',
'enabled',
],
auto: true,
})
</script>

View File

@@ -0,0 +1,30 @@
export interface Coupon {
name: string;
enabled: boolean;
code: string;
discount_type: 'Percentage' | 'Fixed Amount';
percentage_discount?: number;
fixed_amount_discount?: number;
expires_on?: string;
description?: string;
usage_limit?: number;
redemptions_count: number;
applicable_items: ApplicableItem[];
}
export type ApplicableItem = {
reference_doctype: "LMS Course" | "LMS Batch";
reference_name: string;
name: string;
parent: string;
parenttype: "LMS Coupon";
parentfield: "applicable_items";
}
export interface Coupons {
data: Coupon[];
update: (args: { filters: any[] }) => void;
insert: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void };
setValue: { submit: (params: Coupon, options: { onSuccess: (data: Coupon) => void; onError?: (err: any) => void }) => void };
reload: () => void;
}

View File

@@ -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)
}

View File

@@ -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',
},
],
},
{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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>

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')

View File

@@ -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">

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,

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>

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 {

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) => {

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) => {

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'),

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 {

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">

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>

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>

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">

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>

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,

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
}

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()

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,

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 = [
{

View File

@@ -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: [

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)

View File

@@ -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)
}

View File

@@ -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
}

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

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 }}

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,

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">

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 {

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,

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',

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,
}
})

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,
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
__version__ = "2.39.1"
__version__ = "2.40.0"

View File

@@ -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"
}
}

View File

@@ -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(

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

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

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) {
// },
// });

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"
}

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"))

View File

@@ -0,0 +1,20 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestLMSCoupon(IntegrationTestCase):
"""
Integration tests for LMSCoupon.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -0,0 +1,43 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-10-11 21:45:00",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"reference_doctype",
"reference_name"
],
"fields": [
{
"fieldname": "reference_doctype",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Reference DocType",
"options": "\nLMS Course\nLMS Batch",
"reqd": 1
},
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Reference Name",
"options": "reference_doctype",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-12 17:27:14.123811",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Coupon Item",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSCouponItem(Document):
pass

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",

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):

View File

@@ -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
}

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
}

View File

@@ -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
}

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

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):

File diff suppressed because it is too large Load Diff

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