Merge branch 'develop' of https://github.com/frappe/lms into feature/coupons
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -107,7 +107,7 @@ async function setLanguageExtension() {
|
||||
if (!languageImport) return
|
||||
|
||||
const module = await languageImport()
|
||||
languageExtension.value = (module as any)[props.language]()
|
||||
languageExtension.value = (module as any)[props.language]?.()
|
||||
|
||||
if (props.completions) {
|
||||
const languageData = (module as any)[`${props.language}Language`]
|
||||
|
||||
@@ -67,6 +67,7 @@ import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@@ -103,6 +104,7 @@ const value = computed({
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
@@ -121,6 +123,16 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => settingsStore.isSettingsOpen,
|
||||
(isOpen, wasOpen) => {
|
||||
if (wasOpen && !isOpen) {
|
||||
reload('')
|
||||
}
|
||||
},
|
||||
{ debounce: 200 }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen, close }">
|
||||
@@ -58,7 +56,7 @@
|
||||
<div class="h-10"></div>
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||
class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -180,6 +178,7 @@ const filterOptions = createResource({
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
setFocus()
|
||||
return filterOptions.data || []
|
||||
})
|
||||
|
||||
@@ -225,25 +224,6 @@ const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { escapeHTML } from '@/utils'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
@@ -113,33 +114,54 @@ watch(
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const saveAssignment = () => {
|
||||
if (props.assignmentID == 'new') {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
watch(show, (newVal) => {
|
||||
if (newVal && props.assignmentID === 'new') {
|
||||
assignment.title = ''
|
||||
assignment.type = ''
|
||||
assignment.question = ''
|
||||
}
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
}
|
||||
|
||||
const saveAssignment = () => {
|
||||
validateTitle()
|
||||
if (props.assignmentID == 'new') {
|
||||
createAssignment()
|
||||
} else {
|
||||
updateAssignment()
|
||||
}
|
||||
}
|
||||
|
||||
const createAssignment = () => {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateAssignment = () => {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const assignmentOptions = computed(() => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Avatar :image="student.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
@@ -36,7 +36,9 @@
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
@@ -86,7 +88,9 @@
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<FileUploader
|
||||
:fileTypes="['.pdf']"
|
||||
:validateFile="validateFile"
|
||||
:uploadArgs="{ private: 1 }"
|
||||
@success="
|
||||
(file) => {
|
||||
resume = file
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="heatmap.data">
|
||||
<div class="text-lg font-semibold mb-2">
|
||||
<div class="text-lg font-semibold mb-2 text-ink-gray-9">
|
||||
{{ heatmap.data.total_activities }}
|
||||
{{
|
||||
heatmap.data.total_activities > 1 ? __('activities') : __('activity')
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="upcoming_evals.data?.length">
|
||||
<div class="grid gap-4" :class="forHome ? 'grid-cols-2' : 'grid-cols-3'">
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="forHome ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-3'"
|
||||
>
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border text-ink-gray-7 rounded-md p-3">
|
||||
<div class="flex justify-between mb-3">
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<div v-if="batch.data.courses.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,6 +344,7 @@ import {
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
escapeHTML,
|
||||
} from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -500,7 +501,16 @@ const imageResource = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
Object.keys(batch).forEach((key) => {
|
||||
if (key != 'description' && typeof batch[key] === 'string') {
|
||||
batch[key] = escapeHTML(batch[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveBatch = () => {
|
||||
validateFields()
|
||||
if (batchDetail.data) {
|
||||
editBatchDetails()
|
||||
} else {
|
||||
|
||||
@@ -357,6 +357,7 @@ import {
|
||||
getMetaInfo,
|
||||
updateMetaInfo,
|
||||
validateFile,
|
||||
escapeHTML,
|
||||
} from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
@@ -537,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 {
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</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">
|
||||
|
||||
320
frontend/src/pages/JobApplications.vue
Normal file
320
frontend/src/pages/JobApplications.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs
|
||||
class="h-7"
|
||||
:items="[
|
||||
{ label: __('Jobs'), route: { name: 'Jobs' } },
|
||||
{
|
||||
label: applications.data?.[0]?.job_title,
|
||||
route: { name: 'JobDetail', params: { job: props.job } },
|
||||
},
|
||||
{ label: __('Applications') },
|
||||
]"
|
||||
/>
|
||||
</header>
|
||||
<div class="max-w-4xl mx-auto pt-5 p-4">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-lg font-semibold text-ink-gray-9 mb-2">
|
||||
{{ applications.data?.length || 0 }}
|
||||
{{
|
||||
applications.data?.length === 1
|
||||
? __('Application')
|
||||
: __('Applications')
|
||||
}}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
v-if="applications.data?.length"
|
||||
:columns="applicationColumns"
|
||||
:rows="applicantRows"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in applicationColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon?.toString()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-slot="{ column, item }"
|
||||
v-for="row in applicantRows"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRowItem :item="item">
|
||||
<div
|
||||
v-if="column.key === 'full_name'"
|
||||
class="flex items-center space-x-3"
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
:image="row['user_image']"
|
||||
:label="row['full_name']"
|
||||
/>
|
||||
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'actions'"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<Dropdown :options="getActionOptions(row)">
|
||||
<Button variant="ghost">
|
||||
<FeatherIcon name="more-horizontal" class="w-4 h-4" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key === 'applied_on'"
|
||||
class="text-sm text-ink-gray-6"
|
||||
>
|
||||
{{ item }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ item }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<EmptyState v-else-if="!applications.loading" type="Job Applications" />
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model="showEmailModal"
|
||||
:options="{
|
||||
title: __('Send Email to {0}').format(selectedApplicant?.full_name),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Send'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => sendEmail(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="emailForm.subject"
|
||||
:label="__('Subject')"
|
||||
:placeholder="__('Enter email subject')"
|
||||
required
|
||||
/>
|
||||
<FormControl
|
||||
v-model="emailForm.replyTo"
|
||||
:label="__('Reply To')"
|
||||
:placeholder="__('Enter reply to email')"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-sm text-ink-gray-5 mb-1">
|
||||
{{ __('Message') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="emailForm.message"
|
||||
@change="(val) => (emailForm.message = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Breadcrumbs,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
createResource,
|
||||
createListResource,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
|
||||
import { inject, ref, computed, reactive } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
const showEmailModal = ref(false)
|
||||
const selectedApplicant = ref(null)
|
||||
const emailForm = reactive({
|
||||
subject: '',
|
||||
message: '',
|
||||
replyTo: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
job: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const applications = createListResource({
|
||||
doctype: 'LMS Job Application',
|
||||
fields: [
|
||||
'name',
|
||||
'user.user_image as user_image',
|
||||
'user.full_name as full_name',
|
||||
'user.email as email',
|
||||
'creation',
|
||||
'resume',
|
||||
'job.job_title as job_title',
|
||||
],
|
||||
filters: {
|
||||
job: props.job,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const emailResource = createResource({
|
||||
url: 'frappe.core.doctype.communication.email.make',
|
||||
makeParams(values) {
|
||||
return {
|
||||
recipients: selectedApplicant.value.email,
|
||||
cc: emailForm.replyTo,
|
||||
subject: emailForm.subject,
|
||||
content: emailForm.message,
|
||||
doctype: 'LMS Job Application',
|
||||
name: selectedApplicant.value.name,
|
||||
send_email: 1,
|
||||
now: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const openEmailModal = (applicant) => {
|
||||
selectedApplicant.value = applicant
|
||||
emailForm.subject = `Job Application for ${applications.data?.[0]?.job_title} - ${applicant.full_name}`
|
||||
emailForm.replyTo = ''
|
||||
emailForm.message = ''
|
||||
showEmailModal.value = true
|
||||
}
|
||||
|
||||
const sendEmail = (close) => {
|
||||
emailResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!emailForm.subject) {
|
||||
return __('Subject is required')
|
||||
}
|
||||
if (!emailForm.message) {
|
||||
return __('Message is required')
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Email sent successfully'))
|
||||
close()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const downloadResume = (resumeUrl) => {
|
||||
console.log(resumeUrl)
|
||||
window.open(resumeUrl, '_blank')
|
||||
}
|
||||
|
||||
const getActionOptions = (row) => {
|
||||
const options = []
|
||||
if (row.resume) {
|
||||
options.push({
|
||||
label: __('View Resume'),
|
||||
icon: 'download',
|
||||
onClick: () => downloadResume(row.resume),
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label: __('Send Email'),
|
||||
icon: 'mail',
|
||||
onClick: () => openEmailModal(row),
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
const applicationColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Full Name'),
|
||||
key: 'full_name',
|
||||
width: 2,
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Email'),
|
||||
key: 'email',
|
||||
width: 2,
|
||||
icon: 'at-sign',
|
||||
},
|
||||
{
|
||||
label: __('Applied On'),
|
||||
key: 'applied_on',
|
||||
width: 1,
|
||||
icon: 'calendar',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
key: 'actions',
|
||||
width: 1,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const applicantRows = computed(() => {
|
||||
if (!applications.data) return []
|
||||
return applications.data.map((application) => ({
|
||||
...application,
|
||||
full_name: application.full_name,
|
||||
applied_on: dayjs(application.creation).fromNow(),
|
||||
}))
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: `Applications - ${applications.data?.[0]?.job_title}`,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -20,6 +20,17 @@
|
||||
v-if="user.data?.name && !readOnlyMode"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<router-link
|
||||
v-if="canManageJob && applicationCount.data > 0"
|
||||
:to="{
|
||||
name: 'JobApplications',
|
||||
params: { job: job.data?.name },
|
||||
}"
|
||||
>
|
||||
<Button variant="subtle">
|
||||
{{ __('View Applications') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="user.data.name == job.data?.owner"
|
||||
:to="{
|
||||
@@ -146,7 +157,7 @@ import {
|
||||
createResource,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { inject, ref } from 'vue'
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||
import {
|
||||
@@ -159,6 +170,7 @@ import {
|
||||
FileText,
|
||||
ClipboardType,
|
||||
BriefcaseBusiness,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
@@ -226,6 +238,13 @@ const redirectToWebsite = (url) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const canManageJob = computed(() => {
|
||||
if (!user.data?.name || !job.data) return false
|
||||
return (
|
||||
user.data.name === job.data.owner || user.data.roles?.includes('Moderator')
|
||||
)
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: job.data?.job_title,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="container border-b mb-4 pb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('Job Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="container border-b mb-4 pb-5">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('Company Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
|
||||
@@ -30,14 +30,14 @@
|
||||
<div class="notification text-ink-gray-7" v-html="log.subject"></div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link
|
||||
<a
|
||||
v-if="log.link"
|
||||
:to="log.link"
|
||||
:href="log.link"
|
||||
@click="(e) => handleMarkAsRead(e, log.name)"
|
||||
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
||||
>
|
||||
{{ __('View') }}
|
||||
</Link>
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
v-if="!log.read"
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
TabButtons,
|
||||
Button,
|
||||
usePageMeta,
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
<NoPermission v-if="!$user.data" />
|
||||
<div v-else-if="profile.data">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
class="sticky group top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<Button v-if="isSessionUser()" class="invisible group-hover:visible">
|
||||
<template #icon>
|
||||
<RefreshCcw
|
||||
class="w-4 h-4 stroke-1.5 text-ink-gray-7"
|
||||
@click="reloadUser()"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</header>
|
||||
<div class="group relative h-[130px] w-full">
|
||||
<img
|
||||
@@ -92,18 +100,19 @@
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
createResource,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Edit } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { Edit, RefreshCcw } from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import NoPermission from '@/components/NoPermission.vue'
|
||||
import EditProfile from '@/components/Modals/EditProfile.vue'
|
||||
import EditCoverImage from '@/components/Modals/EditCoverImage.vue'
|
||||
|
||||
@@ -124,18 +133,14 @@ const props = defineProps({
|
||||
|
||||
onMounted(() => {
|
||||
if ($user.data) profile.reload()
|
||||
|
||||
setActiveTab()
|
||||
})
|
||||
|
||||
const profile = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
url: 'lms.lms.api.get_profile_details',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'User',
|
||||
filters: {
|
||||
username: props.username,
|
||||
},
|
||||
username: props.username,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -191,23 +196,39 @@ const editProfile = () => {
|
||||
}
|
||||
|
||||
const isSessionUser = () => {
|
||||
return $user.data?.email === profile.data?.email
|
||||
return $user.data?.email === profile.data?.name
|
||||
}
|
||||
|
||||
const currentUserHasHigherAccess = () => {
|
||||
return $user.data?.is_evaluator || $user.data?.is_moderator
|
||||
}
|
||||
|
||||
const isEvaluatorOrModerator = () => {
|
||||
return (
|
||||
profile.data?.roles?.includes('Batch Evaluator') ||
|
||||
profile.data?.roles?.includes('Moderator')
|
||||
)
|
||||
}
|
||||
|
||||
const getTabButtons = () => {
|
||||
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
||||
if (
|
||||
isSessionUser() &&
|
||||
($user.data?.is_evaluator || $user.data?.is_moderator)
|
||||
) {
|
||||
|
||||
if (currentUserHasHigherAccess() && isEvaluatorOrModerator()) {
|
||||
buttons.push({ label: 'Slots' })
|
||||
buttons.push({ label: 'Schedule' })
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
const reloadUser = () => {
|
||||
call('frappe.sessions.clear').then(() => {
|
||||
$user.reload().then(() => {
|
||||
profile.reload()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ const props = defineProps({
|
||||
const evaluations = createListResource({
|
||||
doctype: 'LMS Certificate Request',
|
||||
filters: {
|
||||
evaluator: user.data?.name,
|
||||
evaluator: props.profile.data?.name,
|
||||
status: ['!=', 'Cancelled'],
|
||||
},
|
||||
fields: [
|
||||
|
||||
@@ -43,18 +43,22 @@
|
||||
:options="days"
|
||||
v-model="slot.day"
|
||||
@focusout.stop="update(slot.name, 'day', slot.day)"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.start_time"
|
||||
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="slot.end_time"
|
||||
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<X
|
||||
v-if="isSessionUser()"
|
||||
@click="deleteRow(slot.name)"
|
||||
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
|
||||
/>
|
||||
@@ -69,20 +73,23 @@
|
||||
:options="days"
|
||||
v-model="newSlot.day"
|
||||
@focusout.stop="add()"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.start_time"
|
||||
@focusout.stop="add()"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
<FormControl
|
||||
type="time"
|
||||
v-model="newSlot.end_time"
|
||||
@focusout.stop="add()"
|
||||
:disabled="!isSessionUser()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="showSlotsTemplate = 1">
|
||||
<Button v-if="isSessionUser()" @click="showSlotsTemplate = 1">
|
||||
<template #prefix>
|
||||
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
@@ -98,6 +105,7 @@
|
||||
type="date"
|
||||
:label="__('From')"
|
||||
v-model="from"
|
||||
:disabled="!isSessionUser()"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
@@ -111,6 +119,7 @@
|
||||
type="date"
|
||||
:label="__('To')"
|
||||
v-model="to"
|
||||
:disabled="!isSessionUser()"
|
||||
@blur="
|
||||
() => {
|
||||
updateUnavailability.submit({
|
||||
@@ -122,7 +131,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="isSessionUser()">
|
||||
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('My calendar') }}
|
||||
</h2>
|
||||
@@ -157,11 +166,19 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (user.data?.name !== props.profile.data?.name) {
|
||||
if (user.data?.name !== props.profile.data?.name && !hasHigherAccess()) {
|
||||
window.location.href = `/user/${props.profile.data?.username}`
|
||||
}
|
||||
})
|
||||
|
||||
const hasHigherAccess = () => {
|
||||
return user.data?.is_evaluator || user.data?.is_moderator
|
||||
}
|
||||
|
||||
const isSessionUser = () => {
|
||||
return user.data?.email === props.profile.data?.name
|
||||
}
|
||||
|
||||
const showSlotsTemplate = ref(0)
|
||||
const from = ref(null)
|
||||
const to = ref(null)
|
||||
|
||||
@@ -105,6 +105,8 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { escapeHTML } from '@/utils'
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
@@ -113,14 +115,13 @@ import {
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
ProgrammingExercise,
|
||||
ProgrammingExercises,
|
||||
TestCase,
|
||||
} from '@/types/programming-exercise'
|
||||
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
|
||||
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const exercises = defineModel<ProgrammingExercises>('exercises')
|
||||
@@ -194,7 +195,12 @@ const fetchTestCases = () => {
|
||||
testCases.reload()
|
||||
}
|
||||
|
||||
const validateTitle = () => {
|
||||
exercise.value.title = escapeHTML(exercise.value.title.trim())
|
||||
}
|
||||
|
||||
const saveExercise = (close: () => void) => {
|
||||
validateTitle()
|
||||
if (props.exerciseID == 'new') createNewExercise(close)
|
||||
else updateExercise(close)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
|
||||
<div class="border-r py-5 px-8 h-full">
|
||||
<div class="font-semibold mb-2">
|
||||
<div class="font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __('Problem Statement') }}
|
||||
</div>
|
||||
<div
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
|
||||
<div class="font-semibold">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ exercise.doc?.language }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
@@ -89,7 +89,9 @@
|
||||
class="py-3"
|
||||
>
|
||||
<div class="flex items-center mb-3">
|
||||
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ __('Test {0}').format(index + 1) }} -
|
||||
</span>
|
||||
<span
|
||||
class="font-semibold ml-2 mr-1"
|
||||
:class="
|
||||
@@ -112,13 +114,13 @@
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Input') }}
|
||||
</div>
|
||||
<div>{{ testCase.input }}</div>
|
||||
<div class="text-ink-gray-9">{{ testCase.input }}</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Your Output') }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ testCase.output }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +128,9 @@
|
||||
<div class="text-xs text-ink-gray-7">
|
||||
{{ __('Expected Output') }}
|
||||
</div>
|
||||
<div>{{ testCase.expected_output }}</div>
|
||||
<div class="text-ink-gray-9">
|
||||
{{ testCase.expected_output }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,6 +157,7 @@ import { Play, X, Check, Settings } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { openSettings } from '@/utils'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const code = ref<string | null>('')
|
||||
@@ -162,7 +167,8 @@ const errorMessage = ref<string | null>(null)
|
||||
const testCaseSection = ref<HTMLElement | null>(null)
|
||||
const testCases = ref<TestCase[]>([])
|
||||
const boilerplate = ref<string>('')
|
||||
const { brand, livecodeURL } = sessionStore()
|
||||
const { brand } = sessionStore()
|
||||
const { livecodeURL } = useSettings()
|
||||
const router = useRouter()
|
||||
const fromLesson = ref(false)
|
||||
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||
@@ -260,8 +266,7 @@ const checkIfUserIsPermitted = (doc: any = null) => {
|
||||
!user.data.is_evaluator
|
||||
) {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||
name: 'Courses',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Trash2, TrendingUp } from 'lucide-vue-next'
|
||||
import { Programs, Program } from '@/types/programs'
|
||||
import { openSettings } from '@/utils'
|
||||
import { escapeHTML, openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import ProgramProgressSummary from '@/pages/Programs/ProgramProgressSummary.vue'
|
||||
@@ -362,7 +362,12 @@ const fetchMembers = () => {
|
||||
programMembers.reload()
|
||||
}
|
||||
|
||||
const validateTitle = () => {
|
||||
program.value.name = escapeHTML(program.value.name.trim())
|
||||
}
|
||||
|
||||
const saveProgram = (close: () => void) => {
|
||||
validateTitle()
|
||||
if (props.programName === 'new') createNewProgram(close)
|
||||
else updateProgram(close)
|
||||
dirty.value = false
|
||||
|
||||
@@ -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()
|
||||
@@ -294,7 +295,12 @@ const quizDetails = createDocumentResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
quizDetails.doc.title = escapeHTML(quizDetails.doc.title.trim())
|
||||
}
|
||||
|
||||
const submitQuiz = () => {
|
||||
validateTitle()
|
||||
quizDetails.setValue.submit(
|
||||
{
|
||||
...quizDetails.doc,
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
<span class="font-semibold"> {{ __('Question') }}: </span>
|
||||
<span class="leading-5" v-html="row.question"> </span>
|
||||
</div>
|
||||
<div class="">
|
||||
<span class="font-semibold"> {{ __('Answer') }} </span>
|
||||
<div class="text-ink-gray-9">
|
||||
<span class="font-semibold"> {{ __('Answer') }}: </span>
|
||||
<span class="leading-5" v-html="row.answer"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div v-if="submissions.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||
<div class="text-xl font-semibold mb-5">
|
||||
<div class="text-xl font-semibold mb-5 text-ink-gray-9">
|
||||
{{ submissions.data[0].quiz_title }}
|
||||
</div>
|
||||
<ListView
|
||||
@@ -40,7 +40,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else />
|
||||
<EmptyState v-else type="Quiz Submissions" />
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
|
||||
@@ -138,6 +138,7 @@ import { useRouter } from 'vue-router'
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { escapeHTML } from '@/utils'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
|
||||
const { brand } = sessionStore()
|
||||
@@ -191,7 +192,12 @@ const quizzes = createListResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
title.value = escapeHTML(title.value.trim())
|
||||
}
|
||||
|
||||
const insertQuiz = (close) => {
|
||||
validateTitle()
|
||||
quizzes.insert.submit(
|
||||
{
|
||||
title: title.value,
|
||||
|
||||
@@ -112,6 +112,12 @@ const routes = [
|
||||
component: () => import('@/pages/JobDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/job-openings/:job/applications',
|
||||
name: 'JobApplications',
|
||||
component: () => import('@/pages/JobApplications.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName/edit',
|
||||
name: 'CourseForm',
|
||||
|
||||
@@ -54,16 +54,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const livecodeURL = createResource({
|
||||
url: 'frappe.client.get_single_value',
|
||||
params: {
|
||||
doctype: 'LMS Settings',
|
||||
field: 'livecode_url',
|
||||
},
|
||||
cache: 'livecodeURL',
|
||||
auto: user.value ? true : false,
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
@@ -71,6 +61,5 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
logout,
|
||||
brand,
|
||||
branding,
|
||||
livecodeURL,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,6 +41,13 @@ export const useSettings = defineStore('settings', () => {
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const livecodeURL = createResource({
|
||||
url: 'lms.lms.api.get_lms_setting',
|
||||
params: { field: 'livecode_url' },
|
||||
auto: true,
|
||||
cache: ['livecodeURL'],
|
||||
})
|
||||
|
||||
return {
|
||||
isSettingsOpen,
|
||||
activeTab,
|
||||
@@ -49,5 +56,6 @@ export const useSettings = defineStore('settings', () => {
|
||||
contactUsEmail,
|
||||
contactUsURL,
|
||||
sidebarSettings,
|
||||
livecodeURL,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user