feat: evaluation feedback record
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.56",
|
"frappe-ui": "^0.1.67",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="block text-xs text-gray-600" v-if="props.label">
|
||||||
|
{{ props.label }}
|
||||||
|
</label>
|
||||||
<div class="flex text-center">
|
<div class="flex text-center">
|
||||||
<div v-for="index in 5">
|
<div v-for="index in 5">
|
||||||
|
{{ rating }}
|
||||||
<Star
|
<Star
|
||||||
:class="index <= rating ? 'fill-orange-500' : ''"
|
:class="index <= rating ? 'fill-orange-500' : ''"
|
||||||
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
|
class="h-6 w-6 fill-gray-400 text-gray-50 mr-1 cursor-pointer"
|
||||||
@@ -8,6 +13,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -23,11 +29,15 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
let rating = ref(props.modelValue)
|
let rating = ref(props.modelValue)
|
||||||
|
console.log(props.modelValue)
|
||||||
let emitChange = (value) => {
|
let emitChange = (value) => {
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
}
|
}
|
||||||
|
|||||||
156
frontend/src/components/Modals/Event.vue
Normal file
156
frontend/src/components/Modals/Event.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '2xl',
|
||||||
|
}">
|
||||||
|
<template #body>
|
||||||
|
<div class="flex text-base">
|
||||||
|
<div class="flex flex-col w-1/2 p-5">
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ event.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ event.course_title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<Calendar class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ dayjs(event.date).format("DD MMM YYYY") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(event.start_time) }} - {{ formatTime(event.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<User class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ event.member }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button @click="openCallLink(event.venue)" class="mt-auto">
|
||||||
|
<template #prefix>
|
||||||
|
<Video class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
<span>
|
||||||
|
{{ __("Join Meeting") }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-4 border-l w-1/2 p-5">
|
||||||
|
{{ evaluation.rating }}
|
||||||
|
<Rating v-model="evaluation.rating" :label="__('Rating')"/>
|
||||||
|
<FormControl type="select" :options='[{
|
||||||
|
value: "Pending",
|
||||||
|
label: __("Pending")
|
||||||
|
}, {
|
||||||
|
value: "In Progress",
|
||||||
|
label: __("In Progress")
|
||||||
|
}, {
|
||||||
|
value: "Pass",
|
||||||
|
label: __("Pass")
|
||||||
|
}, {
|
||||||
|
value: "Fail",
|
||||||
|
label: __("Fail")
|
||||||
|
}]'
|
||||||
|
v-model="evaluation.status" :label="__('Status')" />
|
||||||
|
<FormControl type="textarea" v-model="evaluation.summary" :label="__('Summary')" />
|
||||||
|
<Button variant="solid" @click="saveEvaluation()">
|
||||||
|
{{ __("Save") }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Button, FormControl, createResource } from 'frappe-ui';
|
||||||
|
import { User, Calendar, Clock, Video, BookOpen } from "lucide-vue-next"
|
||||||
|
import { inject, reactive, watch } from "vue"
|
||||||
|
import { formatTime, showToast } from "@/utils"
|
||||||
|
import Rating from "@/components/Controls/Rating.vue"
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const dayjs = inject("$dayjs")
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
event: {
|
||||||
|
type: [Object, null],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluation = reactive({
|
||||||
|
rating: 0,
|
||||||
|
status: "Pending",
|
||||||
|
summary: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCallLink = (link) => {
|
||||||
|
window.open(link, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluationResource = createResource({
|
||||||
|
url: "lms.lms.api.save_evaluation_details",
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
member: props.event.member,
|
||||||
|
course: props.event.course,
|
||||||
|
date: props.event.date,
|
||||||
|
start_time: props.event.start_time,
|
||||||
|
end_time: props.event.end_time,
|
||||||
|
status: evaluation.status,
|
||||||
|
rating: evaluation.rating,
|
||||||
|
summary: evaluation.summary,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluationDetails = createResource({
|
||||||
|
url: "frappe.client.get",
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: "LMS Certificate Evaluation",
|
||||||
|
filters: {
|
||||||
|
member: props.event.member,
|
||||||
|
course: props.event.course,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
for (const key in data) {
|
||||||
|
if (key in evaluation)
|
||||||
|
evaluation[key] = data[key]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveEvaluation = () => {
|
||||||
|
evaluationResource.submit({}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
show.value = false
|
||||||
|
showToast( __("Success"), __("Evaluation saved successfully"), "check")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
evaluation.rating = 0
|
||||||
|
evaluation.status = "Pending"
|
||||||
|
evaluation.summary = ""
|
||||||
|
evaluationDetails.reload()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div>
|
<div>
|
||||||
{{ __('Please login to access this page.') }}
|
{{ __('Please login to access this page.') }}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
|
<Button @click="redirectToLogin()" class="mt-4">
|
||||||
{{ __('Login') }}
|
{{ __('Login') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const coverImage = createResource({
|
|||||||
|
|
||||||
const setActiveTab = () => {
|
const setActiveTab = () => {
|
||||||
let fragments = route.path.split('/')
|
let fragments = route.path.split('/')
|
||||||
let sections = ['certificates', 'roles', 'evaluations']
|
let sections = ['certificates', 'roles', 'slots', 'schedule']
|
||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
if (fragments.includes(section)) {
|
if (fragments.includes(section)) {
|
||||||
activeTab.value = convertToTitleCase(section)
|
activeTab.value = convertToTitleCase(section)
|
||||||
@@ -158,10 +158,11 @@ const setActiveTab = () => {
|
|||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (activeTab.value) {
|
if (activeTab.value) {
|
||||||
let route = {
|
let route = {
|
||||||
About: { name: 'ProfileAbout' },
|
"About": { name: 'ProfileAbout' },
|
||||||
Certificates: { name: 'ProfileCertificates' },
|
"Certificates": { name: 'ProfileCertificates' },
|
||||||
Roles: { name: 'ProfileRoles' },
|
"Roles": { name: 'ProfileRoles' },
|
||||||
Evaluations: { name: 'ProfileEvaluator' },
|
"Slots": { name: 'ProfileEvaluator' },
|
||||||
|
"Schedule": { name: 'ProfileEvaluationSchedule' },
|
||||||
}[activeTab.value]
|
}[activeTab.value]
|
||||||
router.push(route)
|
router.push(route)
|
||||||
}
|
}
|
||||||
@@ -185,8 +186,10 @@ const isSessionUser = () => {
|
|||||||
const getTabButtons = () => {
|
const getTabButtons = () => {
|
||||||
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||||
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
||||||
if (isSessionUser() && $user.data?.is_evaluator)
|
if (isSessionUser() && ($user.data?.is_evaluator || $user.data?.is_moderator)) {
|
||||||
buttons.push({ label: 'Evaluations' })
|
buttons.push({ label: 'Slots' })
|
||||||
|
buttons.push({ label: 'Schedule' })
|
||||||
|
}
|
||||||
|
|
||||||
return buttons
|
return buttons
|
||||||
}
|
}
|
||||||
|
|||||||
92
frontend/src/pages/ProfileEvaluationSchedule.vue
Normal file
92
frontend/src/pages/ProfileEvaluationSchedule.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-7 mb-20">
|
||||||
|
<div class="flex h-screen flex-col overflow-hidden">
|
||||||
|
<Calendar
|
||||||
|
v-if="evaluations.data?.length"
|
||||||
|
:config="{
|
||||||
|
defaultMode: 'Month',
|
||||||
|
disableModes: ['Day', 'Week'],
|
||||||
|
redundantCellHeight: 100,
|
||||||
|
enableShortcuts: true,
|
||||||
|
}"
|
||||||
|
:events="evaluations.data"
|
||||||
|
@click="(event) => openEvent(event)"
|
||||||
|
>
|
||||||
|
<template #header="{currentMonthYear,
|
||||||
|
decrement,
|
||||||
|
increment,
|
||||||
|
}">
|
||||||
|
<div class="mb-2 flex justify-between">
|
||||||
|
<span class="text-lg font-semibold">
|
||||||
|
{{ currentMonthYear }}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-x-1">
|
||||||
|
<Button
|
||||||
|
@click="decrement()"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-4 w-4"
|
||||||
|
icon="chevron-left"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="increment()"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-4 w-4"
|
||||||
|
icon="chevron-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Calendar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Event v-model="showEvent" :event="currentEvent"/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Calendar, createListResource, Button } from "frappe-ui"
|
||||||
|
import { inject, ref } from "vue"
|
||||||
|
import Event from "@/components/Modals/Event.vue"
|
||||||
|
|
||||||
|
const user = inject("$user")
|
||||||
|
const currentEvent = ref(null)
|
||||||
|
const showEvent = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
profile: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluations = createListResource({
|
||||||
|
doctype: "LMS Certificate Request",
|
||||||
|
filters: {
|
||||||
|
"evaluator": user.data?.name
|
||||||
|
},
|
||||||
|
fields: ["name", "member_name", "member", "course", "course_title", "date", "start_time", "end_time", "google_meet_link"],
|
||||||
|
auto: true,
|
||||||
|
cache: ["schedule", user.data?.name],
|
||||||
|
transform(data) {
|
||||||
|
return data.map((d) => {
|
||||||
|
return {
|
||||||
|
title: `${d.member_name}'s Evaluation`,
|
||||||
|
participant: d.member_name,
|
||||||
|
id: d.name,
|
||||||
|
venue: d.google_meet_link,
|
||||||
|
fromDate: `${d.date} ${d.start_time}`,
|
||||||
|
toDate: `${d.date} ${d.end_time}`,
|
||||||
|
color: "green",
|
||||||
|
start_time: d.start_time,
|
||||||
|
end_time: d.end_time,
|
||||||
|
course: d.course,
|
||||||
|
course_title: d.course_title,
|
||||||
|
member: d.member,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const openEvent = (event) => {
|
||||||
|
currentEvent.value = event.calendarEvent
|
||||||
|
showEvent.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -79,9 +79,14 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ProfileEvaluator',
|
name: 'ProfileEvaluator',
|
||||||
path: 'evaluations',
|
path: 'slots',
|
||||||
component: () => import('@/pages/ProfileEvaluator.vue'),
|
component: () => import('@/pages/ProfileEvaluator.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'ProfileEvaluationSchedule',
|
||||||
|
path: 'schedule',
|
||||||
|
component: () => import('@/pages/ProfileEvaluationSchedule.vue'),
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
1489
frontend/yarn.lock
1489
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -610,3 +610,42 @@ def check_app_permission():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def save_evaluation_details(
|
||||||
|
member: str,
|
||||||
|
course: str,
|
||||||
|
date: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
status: str,
|
||||||
|
rating: int,
|
||||||
|
summary: str) -> None:
|
||||||
|
"""
|
||||||
|
Save evaluation details for a member against a course.
|
||||||
|
"""
|
||||||
|
evaluation = frappe.db.exists("LMS Certificate Evaluation", {
|
||||||
|
"member": member,
|
||||||
|
"course": course
|
||||||
|
})
|
||||||
|
|
||||||
|
details = {
|
||||||
|
"date": date,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"status": status,
|
||||||
|
"rating": rating,
|
||||||
|
"summary": summary
|
||||||
|
}
|
||||||
|
|
||||||
|
if evaluation:
|
||||||
|
doc = frappe.db.set_value("LMS Certificate Evaluation", evaluation, details)
|
||||||
|
else:
|
||||||
|
doc = frappe.new_doc("LMS Certificate Evaluation")
|
||||||
|
details.update({
|
||||||
|
"member": member,
|
||||||
|
"course": course
|
||||||
|
})
|
||||||
|
doc.update(details)
|
||||||
|
doc.insert()
|
||||||
@@ -38,7 +38,6 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "member",
|
"fieldname": "member",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_standard_filter": 1,
|
|
||||||
"label": "Member",
|
"label": "Member",
|
||||||
"options": "User",
|
"options": "User",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
@@ -46,6 +45,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "evaluator",
|
"fieldname": "evaluator",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
|
"in_standard_filter": 1,
|
||||||
"label": "Evaluator",
|
"label": "Evaluator",
|
||||||
"options": "User",
|
"options": "User",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-09-05 16:28:54.043488",
|
"modified": "2024-09-06 18:39:53.551920",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Certificate Request",
|
"name": "LMS Certificate Request",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "frappe_lms",
|
"name": "frappe_lms",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Easy to use, open-source, Learning Management System",
|
"description": "Easy to use, open-source, Learning Management System",
|
||||||
"workspaces1": [
|
"workspaces": [
|
||||||
"frappe-ui",
|
"frappe-ui",
|
||||||
"frontend"
|
"frontend"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user