Merge branch 'frappe:develop' into feat/scorm-progress
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
v-if="user.data?.is_moderator"
|
||||
v-if="user.data?.is_moderator && batch.data?.certification"
|
||||
@click="openCertificateDialog = true"
|
||||
>
|
||||
{{ __('Generate Certificates') }}
|
||||
@@ -193,8 +193,9 @@
|
||||
<BulkCertificates v-model="openCertificateDialog" :batch="batch.data" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import {
|
||||
@@ -270,7 +271,7 @@ const isStudent = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const tabIndex = useRouteQuery('tab', 0)
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
batchTabs.push({
|
||||
@@ -313,7 +314,7 @@ const tabs = computed(() => {
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/batches`
|
||||
window.location.href = `/login?redirect-to=/lms/batches/${props.batchName}`
|
||||
}
|
||||
|
||||
const openAnnouncementModal = () => {
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
type="checkbox"
|
||||
:label="__('Allow self enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.certification"
|
||||
type="checkbox"
|
||||
:label="__('Certification')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,6 +298,7 @@ const batch = reactive({
|
||||
medium: '',
|
||||
category: '',
|
||||
allow_self_enrollment: false,
|
||||
certification: false,
|
||||
image: null,
|
||||
paid_batch: false,
|
||||
currency: '',
|
||||
@@ -362,7 +368,12 @@ const batchDetail = createResource({
|
||||
batch[key] = `${hours}:${minutes}`
|
||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
})
|
||||
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'paid_batch',
|
||||
'allow_self_enrollment',
|
||||
'certification',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batch[key] = batch[key] ? true : false
|
||||
|
||||
@@ -26,13 +26,19 @@
|
||||
{{ __('All Batches') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-2"
|
||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||
>
|
||||
<TabButtons
|
||||
v-if="user.data"
|
||||
:buttons="batchTabs"
|
||||
v-model="currentTab"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
type="checkbox"
|
||||
@change="updateBatches()"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
v-model="title"
|
||||
@@ -111,6 +117,7 @@ const pageLength = ref(20)
|
||||
const categories = ref([])
|
||||
const currentCategory = ref(null)
|
||||
const title = ref('')
|
||||
const certification = ref(false)
|
||||
const filters = ref({})
|
||||
const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming')
|
||||
const orderBy = ref('start_date')
|
||||
@@ -130,6 +137,7 @@ const setFiltersFromQuery = () => {
|
||||
let queries = new URLSearchParams(location.search)
|
||||
title.value = queries.get('title') || ''
|
||||
currentCategory.value = queries.get('category') || null
|
||||
certification.value = queries.get('certification') || false
|
||||
}
|
||||
|
||||
const batches = createListResource({
|
||||
@@ -161,6 +169,7 @@ const updateBatches = () => {
|
||||
const updateFilters = () => {
|
||||
updateCategoryFilter()
|
||||
updateTitleFilter()
|
||||
updateCertificationFilter()
|
||||
updateTabFilter()
|
||||
updateStudentFilter()
|
||||
setQueryParams()
|
||||
@@ -182,6 +191,14 @@ const updateTitleFilter = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const updateCertificationFilter = () => {
|
||||
if (certification.value) {
|
||||
filters.value['certification'] = 1
|
||||
} else {
|
||||
delete filters.value['certification']
|
||||
}
|
||||
}
|
||||
|
||||
const updateTabFilter = () => {
|
||||
orderBy.value = 'start_date'
|
||||
if (!user.data) {
|
||||
@@ -222,6 +239,7 @@ const setQueryParams = () => {
|
||||
let filterKeys = {
|
||||
title: title.value,
|
||||
category: currentCategory.value,
|
||||
certification: certification.value,
|
||||
}
|
||||
|
||||
Object.keys(filterKeys).forEach((key) => {
|
||||
|
||||
@@ -12,20 +12,15 @@
|
||||
v-if="access.data?.access && orderSummary.data"
|
||||
class="pt-5 pb-10 mx-5"
|
||||
>
|
||||
<!-- <div class="mb-5">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Address') }}
|
||||
</div>
|
||||
</div> -->
|
||||
<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 text-sm font-medium lg:w-1/4"
|
||||
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-center justify-between space-x-2">
|
||||
<div class="flex items-baseline justify-between space-y-2">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Ordered Item') }}
|
||||
{{ __('Payment for ') }} {{ type }}:
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="leading-5">
|
||||
{{ orderSummary.data.title }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +121,7 @@
|
||||
<p class="text-ink-gray-5">
|
||||
{{
|
||||
__(
|
||||
'Make sure to enter the right billing name as the same will be used in your invoice.'
|
||||
'Make sure to enter the correct billing name as the same will be used in your invoice.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@@ -140,10 +135,10 @@
|
||||
<div v-else-if="access.data?.message">
|
||||
<NotPermitted
|
||||
:text="access.data.message"
|
||||
:buttonLabel="
|
||||
type == 'course' ? 'Checkout Courses' : 'Checkout Batches'
|
||||
:buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
|
||||
:buttonLink="
|
||||
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
|
||||
"
|
||||
:buttonLink="type == 'course' ? '/lms/courses' : '/lms/batches'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!user.data?.name">
|
||||
@@ -163,7 +158,7 @@ import {
|
||||
Breadcrumbs,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, ref } from 'vue'
|
||||
import { reactive, inject, onMounted, computed } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
import { showToast } from '@/utils/'
|
||||
@@ -193,7 +188,7 @@ const props = defineProps({
|
||||
const access = createResource({
|
||||
url: 'lms.lms.api.validate_billing_access',
|
||||
params: {
|
||||
type: props.type,
|
||||
billing_type: props.type,
|
||||
name: props.name,
|
||||
},
|
||||
onSuccess(data) {
|
||||
@@ -206,7 +201,7 @@ const orderSummary = createResource({
|
||||
url: 'lms.lms.utils.get_order_summary',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||
docname: props.name,
|
||||
country: billingDetails.country,
|
||||
}
|
||||
@@ -236,22 +231,26 @@ const paymentLink = createResource({
|
||||
url: 'lms.lms.payments.get_payment_link',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
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,
|
||||
currency: orderSummary.data.currency,
|
||||
address: billingDetails,
|
||||
redirect_to: redirectTo.value,
|
||||
payment_for_certificate: props.type == 'certificate',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const generatePaymentLink = () => {
|
||||
console.log('called')
|
||||
paymentLink.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
console.log('validation start')
|
||||
if (!billingDetails.source) {
|
||||
return __('Please let us know where you heard about us from.')
|
||||
}
|
||||
@@ -330,6 +329,8 @@ 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) => {
|
||||
@@ -347,4 +348,14 @@ const changeCurrency = (country) => {
|
||||
billingDetails.country = country
|
||||
orderSummary.reload()
|
||||
}
|
||||
|
||||
const redirectTo = computed(() => {
|
||||
if (props.type == 'course') {
|
||||
return `/lms/courses/${props.name}`
|
||||
} else if (props.type == 'batch') {
|
||||
return `/lms/batches/${props.name}`
|
||||
} else if (props.type == 'certificate') {
|
||||
return `/lms/courses/${props.name}/certification`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
117
frontend/src/pages/CourseCertification.vue
Normal file
117
frontend/src/pages/CourseCertification.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<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" />
|
||||
</header>
|
||||
<div class="p-5">
|
||||
<div v-if="certificate.data && Object.keys(certificate.data).length">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-1">
|
||||
{{ __('Certification') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-9 text-sm">
|
||||
{{
|
||||
__(
|
||||
'You are already certified for this course. Click on the card below to open your certificate.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="border p-3 w-fit min-w-60 rounded-md space-y-2 hover:bg-surface-gray-1 cursor-pointer mt-5"
|
||||
@click="openCertificate(certificate.data)"
|
||||
>
|
||||
<div class="text-ink-gray-9 font-semibold">
|
||||
{{ courseTitle }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7 font-medium">
|
||||
{{ __('Issued On') }}:
|
||||
{{ dayjs(certificate.data.issue_date).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<UpcomingEvaluations v-if="courses.length" :courses="courses" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Breadcrumbs, call, createResource } from 'frappe-ui'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const courseTitle = ref(null)
|
||||
const evaluator = ref(null)
|
||||
const courses = ref([])
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchCourseDetails()
|
||||
})
|
||||
|
||||
const certificate = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: {
|
||||
member: user.data?.name,
|
||||
course: props.courseName,
|
||||
},
|
||||
fieldname: ['name', 'template', 'issue_date'],
|
||||
},
|
||||
auto: true,
|
||||
cache: [user.data?.name, props.courseName],
|
||||
})
|
||||
|
||||
const fetchCourseDetails = () => {
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Course',
|
||||
filters: { name: props.courseName },
|
||||
fieldname: ['title', 'evaluator'],
|
||||
}).then((data) => {
|
||||
courseTitle.value = data.title
|
||||
evaluator.value = data.evaluator
|
||||
populateCourses()
|
||||
})
|
||||
}
|
||||
|
||||
const populateCourses = () => {
|
||||
courses.value = [
|
||||
{
|
||||
course: props.courseName,
|
||||
title: courseTitle.value,
|
||||
evaluator: evaluator.value,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const openCertificate = (certificate) => {
|
||||
window.open(
|
||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||
certificate.name
|
||||
}&format=${encodeURIComponent(certificate.template)}`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Courses'),
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
{
|
||||
label: courseTitle.value,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
},
|
||||
{
|
||||
label: __('Certification'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||
<div class="grid grid-cols-2 gap-10 mb-4">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-4"
|
||||
@@ -188,43 +188,48 @@
|
||||
v-model="course.featured"
|
||||
:label="__('Featured')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.disable_self_learning"
|
||||
:label="__('Disable Self Enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Pricing') }}
|
||||
<div class="container border-t space-y-4">
|
||||
<div class="text-lg font-semibold mt-5">
|
||||
{{ __('Pricing and Certification') }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="grid grid-cols-3">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_course"
|
||||
:label="__('Paid Course')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_certificate"
|
||||
:label="__('Paid Certificate')"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.course_price"
|
||||
:label="__('Course Price')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl v-model="course.course_price" :label="__('Amount')" />
|
||||
<Link
|
||||
doctype="Currency"
|
||||
v-model="course.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
<Link
|
||||
v-if="course.paid_certificate"
|
||||
doctype="Course Evaluator"
|
||||
v-model="course.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,8 +301,10 @@ const course = reactive({
|
||||
disable_self_learning: false,
|
||||
enable_certification: false,
|
||||
paid_course: false,
|
||||
paid_certificate: false,
|
||||
course_price: '',
|
||||
currency: '',
|
||||
evaluator: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -391,6 +398,7 @@ const courseResource = createResource({
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
'paid_certifiate',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
|
||||
@@ -4,6 +4,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 class="h-7" :items="breadcrumbs" />
|
||||
<CertificationLinks :courseName="courseName" />
|
||||
</header>
|
||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
||||
<div
|
||||
@@ -197,13 +198,14 @@ import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonContent from '@/components/LessonContent.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
@@ -139,7 +139,7 @@ const renderEditor = (holder) => {
|
||||
const lesson = reactive({
|
||||
title: '',
|
||||
include_in_preview: false,
|
||||
body: 'Test',
|
||||
body: '',
|
||||
instructor_notes: '',
|
||||
content: '',
|
||||
})
|
||||
@@ -294,7 +294,7 @@ const convertToJSON = (lessonData) => {
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: video,
|
||||
file_type: 'video',
|
||||
file_type: video.split('.').pop(),
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ Audio')) {
|
||||
@@ -303,7 +303,7 @@ const convertToJSON = (lessonData) => {
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: audio,
|
||||
file_type: 'audio',
|
||||
file_type: audio.split('.').pop(),
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ PDF')) {
|
||||
|
||||
Reference in New Issue
Block a user