Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed8baf3327 | ||
|
|
15dd4c4350 | ||
|
|
c986089e77 | ||
|
|
17dc77f061 | ||
|
|
189f353de0 | ||
|
|
845e7174f0 | ||
|
|
8c6e4ad3ee | ||
|
|
5dfddc890c | ||
|
|
1ebabc23d3 | ||
|
|
1bf8c1c763 | ||
|
|
c5a59b6370 | ||
|
|
4a5a777478 | ||
|
|
4fd7dcd5b2 | ||
|
|
55920d9e3f | ||
|
|
6d0c3c9cd8 | ||
|
|
7b20c3fe03 | ||
|
|
efbe35c836 | ||
|
|
e591cd74ab | ||
|
|
669b9c73be | ||
|
|
52e1dd6d33 | ||
|
|
828e195b81 | ||
|
|
145342bb72 | ||
|
|
58abfd004d | ||
|
|
9dc8322270 | ||
|
|
4f0a6a7d57 | ||
|
|
2fb8ae00b9 | ||
|
|
63da1e384d | ||
|
|
34685ebdb2 | ||
|
|
215ae941e1 | ||
|
|
9d1211e872 | ||
|
|
cd4f2b1039 | ||
|
|
9881b7b498 | ||
|
|
28a687f6bf | ||
|
|
bd43ed0e88 | ||
|
|
17b59ce4e5 | ||
|
|
7acc1864c8 | ||
|
|
5a6fdfcbc3 | ||
|
|
23d465d4a1 | ||
|
|
27ae014fcb | ||
|
|
b4c7338b76 | ||
|
|
0d1464c5e9 | ||
|
|
f4421d362c | ||
|
|
5c8378f2d4 | ||
|
|
8401e86acb | ||
|
|
e16101813c | ||
|
|
bbd3ac6451 | ||
|
|
c6a26e5260 | ||
|
|
a87fda6b84 | ||
|
|
b42c635cdb | ||
|
|
a9c6b71e19 | ||
|
|
282441e0e7 | ||
|
|
6020d5f5c2 | ||
|
|
9a395cbda0 | ||
|
|
61e41180dd | ||
|
|
26bde996ac | ||
|
|
6f78ac06c2 | ||
|
|
8e498f4fbe | ||
|
|
8105e606c9 | ||
|
|
7df6e5fe64 | ||
|
|
909c9b446b | ||
|
|
29639d59c3 | ||
|
|
a13dac6dd4 | ||
|
|
31257e588f | ||
|
|
52ab419040 | ||
|
|
7dbc35977f | ||
|
|
ce9aafadd9 | ||
|
|
13da79488f | ||
|
|
2c999e2037 | ||
|
|
c096c176e3 | ||
|
|
8fe0b62bb3 | ||
|
|
e3b53efd2c | ||
|
|
2ecb93e925 | ||
|
|
5d14d6f1aa | ||
|
|
4869bba7bb | ||
|
|
ecc12d783a | ||
|
|
54b7f811f7 | ||
|
|
e45b33a809 |
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.png" />
|
<link rel="icon" href="{{ favicon or '/assets/lms/frontend/favicon.png' }}" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Frappe Learning</title>
|
<title>Frappe Learning</title>
|
||||||
<meta name="title" content="{{ meta.title }}" />
|
<meta name="title" content="{{ meta.title }}" />
|
||||||
|
|||||||
239
frontend/src/components/BatchFeedback.vue
Normal file
239
frontend/src/components/BatchFeedback.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="user.data?.is_student">
|
||||||
|
<div
|
||||||
|
v-if="feedbackList.data?.length"
|
||||||
|
class="bg-blue-100 text-blue-700 p-2 rounded-md mb-5"
|
||||||
|
>
|
||||||
|
{{ __('Thank you for providing your feedback!') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex justify-between items-center mb-5">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('Help Us Improve') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="submitFeedback()">
|
||||||
|
{{ __('Submit') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Rating
|
||||||
|
v-for="key in ratingKeys"
|
||||||
|
v-model="feedback[key]"
|
||||||
|
:label="__(convertToTitleCase(key))"
|
||||||
|
:readonly="readOnly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="feedback.feedback"
|
||||||
|
type="textarea"
|
||||||
|
:label="__('Feedback')"
|
||||||
|
:rows="7"
|
||||||
|
:readonly="readOnly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="feedbackList.data?.length">
|
||||||
|
<div class="text-lg font-semibold mb-5">
|
||||||
|
{{ __('Average of Feedback Received') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-10">
|
||||||
|
<Rating
|
||||||
|
v-for="key in ratingKeys"
|
||||||
|
v-model="average[key]"
|
||||||
|
:label="__(convertToTitleCase(key))"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-lg font-semibold mb-5">
|
||||||
|
{{ __('All Feedback') }}
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
:columns="feedbackColumns"
|
||||||
|
:rows="feedbackList.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
rowHeight: 'h-16',
|
||||||
|
selectable: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
></ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow
|
||||||
|
:row="row"
|
||||||
|
v-for="row in feedbackList.data"
|
||||||
|
class="group cursor-pointer"
|
||||||
|
>
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key]"
|
||||||
|
:align="column.align"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'member_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['member_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="ratingKeys.includes(column.key)">
|
||||||
|
<Rating v-model="row[column.key]" :readonly="true" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-center text-gray-700 mt-5">
|
||||||
|
{{ __('No feedback received yet.') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { convertToTitleCase } from '@/utils'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
Rating,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const ratingKeys = ['content', 'instructors', 'value']
|
||||||
|
const readOnly = ref(false)
|
||||||
|
const average = reactive({})
|
||||||
|
const feedback = reactive({})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
let filters = {
|
||||||
|
batch: props.batch,
|
||||||
|
}
|
||||||
|
if (user.data?.is_student) {
|
||||||
|
filters['member'] = user.data?.name
|
||||||
|
}
|
||||||
|
feedbackList.update({
|
||||||
|
filters: filters,
|
||||||
|
})
|
||||||
|
feedbackList.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedbackList = createListResource({
|
||||||
|
doctype: 'LMS Batch Feedback',
|
||||||
|
filters: {
|
||||||
|
batch: props.batch,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'content',
|
||||||
|
'instructors',
|
||||||
|
'value',
|
||||||
|
'feedback',
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
],
|
||||||
|
cache: ['feedbackList', props.batch, user.data?.name],
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => feedbackList.data,
|
||||||
|
() => {
|
||||||
|
if (feedbackList.data.length) {
|
||||||
|
let data = feedbackList.data
|
||||||
|
readOnly.value = true
|
||||||
|
|
||||||
|
ratingKeys.forEach((key) => {
|
||||||
|
average[key] = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
data.forEach((row) => {
|
||||||
|
Object.keys(row).forEach((key) => {
|
||||||
|
if (ratingKeys.includes(key)) row[key] = row[key] * 5
|
||||||
|
feedback[key] = row[key]
|
||||||
|
})
|
||||||
|
ratingKeys.forEach((key) => {
|
||||||
|
average[key] += row[key]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Object.keys(average).forEach((key) => {
|
||||||
|
average[key] = average[key] / data.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const submitFeedback = () => {
|
||||||
|
ratingKeys.forEach((key) => {
|
||||||
|
feedback[key] = feedback[key] / 5
|
||||||
|
})
|
||||||
|
feedbackList.insert.submit(
|
||||||
|
{
|
||||||
|
member: user.data?.name,
|
||||||
|
batch: props.batch,
|
||||||
|
...feedback,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
feedbackList.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedbackColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Member',
|
||||||
|
key: 'member_name',
|
||||||
|
width: '10rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Feedback',
|
||||||
|
key: 'feedback',
|
||||||
|
width: '15rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content',
|
||||||
|
key: 'content',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Instructors',
|
||||||
|
key: 'instructors',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Value',
|
||||||
|
key: 'value',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -383,7 +383,7 @@ const getChartOptions = (categories) => {
|
|||||||
},
|
},
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
formatter: function (value) {
|
formatter: function (value) {
|
||||||
return value.length > 30 ? `${value.substring(0, 30)}...` : value // Trim long labels
|
return value.length > 30 ? `${value.substring(0, 30)}...` : value
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextEditor
|
<TextEditor
|
||||||
|
v-if="renderEditor"
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
:content="newReply"
|
:content="newReply"
|
||||||
:mentions="mentionUsers"
|
:mentions="mentionUsers"
|
||||||
@@ -94,7 +95,7 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
|||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted, computed } from 'vue'
|
import { ref, inject, onMounted } from 'vue'
|
||||||
import { createToast } from '../utils'
|
import { createToast } from '../utils'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
@@ -102,6 +103,8 @@ const newReply = ref('')
|
|||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const allUsers = inject('$allUsers')
|
const allUsers = inject('$allUsers')
|
||||||
|
const mentionUsers = ref([])
|
||||||
|
const renderEditor = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: {
|
topic: {
|
||||||
@@ -124,6 +127,7 @@ onMounted(() => {
|
|||||||
socket.on('delete_message', (data) => {
|
socket.on('delete_message', (data) => {
|
||||||
replies.reload()
|
replies.reload()
|
||||||
})
|
})
|
||||||
|
fetchMentionUsers()
|
||||||
})
|
})
|
||||||
|
|
||||||
const replies = createResource({
|
const replies = createResource({
|
||||||
@@ -150,15 +154,26 @@ const newReplyResource = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const mentionUsers = computed(() => {
|
const fetchMentionUsers = () => {
|
||||||
let users = Object.values(allUsers.data).map((user) => {
|
if (user.data?.is_student) {
|
||||||
return {
|
renderEditor.value = true
|
||||||
value: user.name,
|
} else {
|
||||||
label: user.full_name,
|
allUsers.reload(
|
||||||
}
|
{},
|
||||||
})
|
{
|
||||||
return users
|
onSuccess(data) {
|
||||||
})
|
mentionUsers.value = Object.values(data).map((user) => {
|
||||||
|
return {
|
||||||
|
value: user.name,
|
||||||
|
label: user.full_name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
renderEditor.value = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const postReply = () => {
|
const postReply = () => {
|
||||||
newReplyResource.submit(
|
newReplyResource.submit(
|
||||||
|
|||||||
@@ -1,71 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
|
<div class="flex space-x-4 border rounded-md p-2">
|
||||||
<div class="flex w-3/5 md:w-2/5">
|
<Avatar :image="job.company_logo" :label="job.job_title" size="2xl" />
|
||||||
<img
|
<div class="flex flex-col space-y-2 flex-1">
|
||||||
:src="job.company_logo"
|
<div class="flex items-center justify-between">
|
||||||
class="w-12 h-12 rounded-lg object-contain mr-4"
|
<span class="font-semibold">
|
||||||
:alt="job.company_name"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium mb-1">
|
|
||||||
{{ job.job_title }}
|
{{ job.job_title }}
|
||||||
</div>
|
|
||||||
<div class="text-gray-700">
|
|
||||||
{{ job.company_name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end w-1/5 text-gray-700">
|
|
||||||
{{ job.location.replace(',', '').split(' ')[0] }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex justify-end w-1/5 text-gray-700 text-right hidden md:block"
|
|
||||||
>
|
|
||||||
{{ job.type }}
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end w-1/5 text-sm text-gray-700 text-right">
|
|
||||||
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="text-xl font-semibold mb-2">
|
|
||||||
{{ job.job_title }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ __("posted by") }}
|
|
||||||
<span class="font-medium">
|
|
||||||
{{ job.company_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<img
|
|
||||||
:src="job.company_logo"
|
|
||||||
class="w-12 h-12 rounded-lg object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between mt-8">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Badge :label="job.type" theme="green" size="lg" class="mr-4"/>
|
|
||||||
<Badge :label="job.location" theme="gray" size="lg">
|
|
||||||
<template #prefix>
|
|
||||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">
|
|
||||||
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Building2 class="w-4 h-4 stroke-1.5 text-gray-600" />
|
||||||
|
<span>
|
||||||
|
{{ job.company_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<MapPin class="w-4 h-4 stroke-1.5 text-gray-600" />
|
||||||
|
<span>
|
||||||
|
{{ job.location }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Shapes class="w-4 h-4 stroke-1.5 text-gray-600" />
|
||||||
|
<span>
|
||||||
|
{{ job.type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Calendar class="w-4 h-4 stroke-1.5 text-gray-600" />
|
||||||
|
<span> {{ __('posted') }} {{ dayjs(job.creation).fromNow() }} </span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { MapPin } from 'lucide-vue-next'
|
import { Building2, Calendar, MapPin, Shapes } from 'lucide-vue-next'
|
||||||
import { Badge } from 'frappe-ui'
|
|
||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
|
import { Avatar } from 'frappe-ui'
|
||||||
|
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -17,12 +17,6 @@
|
|||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<FormControl
|
|
||||||
type="select"
|
|
||||||
v-model="details.course"
|
|
||||||
:label="__('Course')"
|
|
||||||
:options="getCourses()"
|
|
||||||
/>
|
|
||||||
<Link
|
<Link
|
||||||
v-model="details.evaluator"
|
v-model="details.evaluator"
|
||||||
:label="__('Evaluator')"
|
:label="__('Evaluator')"
|
||||||
@@ -38,6 +32,12 @@
|
|||||||
v-model="details.expiry_date"
|
v-model="details.expiry_date"
|
||||||
:label="__('Expiry Date')"
|
:label="__('Expiry Date')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
v-model="details.course"
|
||||||
|
:label="__('Course')"
|
||||||
|
:options="getCourses()"
|
||||||
|
/>
|
||||||
<Link
|
<Link
|
||||||
v-model="details.template"
|
v-model="details.template"
|
||||||
:label="__('Template')"
|
:label="__('Template')"
|
||||||
@@ -94,7 +94,7 @@ const createCertificate = createResource({
|
|||||||
template: details.template,
|
template: details.template,
|
||||||
published: details.published,
|
published: details.published,
|
||||||
course: values.course,
|
course: values.course,
|
||||||
batch: values.batch,
|
batch_name: values.batch,
|
||||||
member: values.member,
|
member: values.member,
|
||||||
evaluator: details.evaluator,
|
evaluator: details.evaluator,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, watch, defineModel } from 'vue'
|
import { reactive, watch, defineModel } from 'vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { getFileSize, showToast } from '@/utils'
|
import { getFileSize, showToast, escapeHTML } from '@/utils'
|
||||||
|
|
||||||
const reloadProfile = defineModel('reloadProfile')
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
|
|
||||||
@@ -131,6 +131,7 @@ const imageResource = createResource({
|
|||||||
const updateProfile = createResource({
|
const updateProfile = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
|
profile.bio = escapeHTML(profile.bio)
|
||||||
return {
|
return {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
name: props.profile.data.name,
|
name: props.profile.data.name,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -8,15 +7,8 @@ import dayjs from '@/utils/dayjs'
|
|||||||
import { createDialog } from '@/utils/dialogs'
|
import { createDialog } from '@/utils/dialogs'
|
||||||
import translationPlugin from './translation'
|
import translationPlugin from './translation'
|
||||||
import { usersStore } from './stores/user'
|
import { usersStore } from './stores/user'
|
||||||
import { sessionStore } from './stores/session'
|
|
||||||
import { initSocket } from './socket'
|
import { initSocket } from './socket'
|
||||||
import {
|
import { FrappeUI, setConfig, frappeRequest, pageMetaPlugin } from 'frappe-ui'
|
||||||
FrappeUI,
|
|
||||||
setConfig,
|
|
||||||
frappeRequest,
|
|
||||||
resourcesPlugin,
|
|
||||||
pageMetaPlugin,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
|
|
||||||
let pinia = createPinia()
|
let pinia = createPinia()
|
||||||
let app = createApp(App)
|
let app = createApp(App)
|
||||||
@@ -32,8 +24,6 @@ app.provide('$socket', initSocket())
|
|||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
const { userResource, allUsers } = usersStore()
|
const { userResource, allUsers } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
|
||||||
|
|
||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
app.provide('$allUsers', allUsers)
|
app.provide('$allUsers', allUsers)
|
||||||
app.config.globalProperties.$user = userResource
|
app.config.globalProperties.$user = userResource
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
<div v-if="batch.data" class="grid grid-cols-[75%,25%] h-screen">
|
||||||
<div class="border-r">
|
<div class="border-r">
|
||||||
<Tabs
|
<Tabs
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<div v-else-if="tab.label == 'Dashboard'">
|
<div v-else-if="tab.label == 'Dashboard'">
|
||||||
<BatchStudents :batch="batch.data" />
|
<BatchStudents :batch="batch.data" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Live Class'">
|
<div v-else-if="tab.label == 'Classes'">
|
||||||
<LiveClass :batch="batch.data.name" />
|
<LiveClass :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Assessments'">
|
<div v-else-if="tab.label == 'Assessments'">
|
||||||
@@ -81,9 +81,12 @@
|
|||||||
:title="__('Discussions')"
|
:title="__('Discussions')"
|
||||||
:key="batch.data.name"
|
:key="batch.data.name"
|
||||||
:singleThread="true"
|
:singleThread="true"
|
||||||
:scrollToBottom="true"
|
:scrollToBottom="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="tab.label == 'Feedback'">
|
||||||
|
<BatchFeedback :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -190,12 +193,11 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Laptop,
|
Laptop,
|
||||||
BookOpenCheck,
|
BookOpenCheck,
|
||||||
Contact2,
|
|
||||||
Mail,
|
Mail,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Globe,
|
Globe,
|
||||||
ShieldCheck,
|
ClipboardPen,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatTime, updateDocumentTitle } from '@/utils'
|
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||||
@@ -208,6 +210,7 @@ import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
|||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
||||||
|
import BatchFeedback from '@/components/BatchFeedback.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showAnnouncementModal = ref(false)
|
const showAnnouncementModal = ref(false)
|
||||||
@@ -271,7 +274,7 @@ const tabs = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
batchTabs.push({
|
batchTabs.push({
|
||||||
label: 'Live Class',
|
label: 'Classes',
|
||||||
icon: Laptop,
|
icon: Laptop,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -291,6 +294,11 @@ const tabs = computed(() => {
|
|||||||
label: 'Discussions',
|
label: 'Discussions',
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Feedback',
|
||||||
|
icon: ClipboardPen,
|
||||||
|
})
|
||||||
return batchTabs
|
return batchTabs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,254 +1,296 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<header
|
||||||
<header
|
class="sticky flex items-center justify-between top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<router-link
|
||||||
|
v-if="user.data?.is_moderator"
|
||||||
|
:to="{
|
||||||
|
name: 'BatchForm',
|
||||||
|
params: { batchName: 'new' },
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<Breadcrumbs
|
<Button variant="solid">
|
||||||
class="h-7"
|
<template #prefix>
|
||||||
:items="[{ label: __('Batches'), route: { name: 'Batches' } }]"
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
/>
|
</template>
|
||||||
<div class="flex space-x-2">
|
{{ __('New') }}
|
||||||
<div class="w-44">
|
</Button>
|
||||||
<Select
|
</router-link>
|
||||||
v-if="categories.data?.length"
|
</header>
|
||||||
v-model="currentCategory"
|
<div class="p-5 pb-10">
|
||||||
:options="categories.data"
|
<div
|
||||||
:placeholder="__('Category')"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||||
|
>
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('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"
|
||||||
|
>
|
||||||
|
<TabButtons
|
||||||
|
v-if="user.data"
|
||||||
|
:buttons="batchTabs"
|
||||||
|
v-model="currentTab"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<FormControl
|
||||||
|
v-model="title"
|
||||||
|
:placeholder="__('Search by Title')"
|
||||||
|
type="text"
|
||||||
|
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||||
|
@input="updateBatches()"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40">
|
||||||
<router-link
|
<Select
|
||||||
v-if="user.data?.is_moderator"
|
v-if="categories.length"
|
||||||
:to="{
|
v-model="currentCategory"
|
||||||
name: 'BatchForm',
|
:options="categories"
|
||||||
params: { batchName: 'new' },
|
:placeholder="__('Category')"
|
||||||
}"
|
@change="updateBatches()"
|
||||||
>
|
/>
|
||||||
<Button variant="solid">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
{{ __('New') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div v-if="batches.data" class="pb-5">
|
|
||||||
<div
|
|
||||||
v-if="batches.data.length == 0 && batches.list.loading"
|
|
||||||
class="p-5 text-base text-gray-700"
|
|
||||||
>
|
|
||||||
{{ __('Loading Batches...') }}
|
|
||||||
</div>
|
|
||||||
<Tabs
|
|
||||||
v-if="hasBatches"
|
|
||||||
v-model="tabIndex"
|
|
||||||
:tabs="makeTabs"
|
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
|
||||||
>
|
|
||||||
<template #tab="{ tab, selected }">
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
class="group -mb-px flex items-center gap-2 border-b border-transparent py-2.5 text-base text-gray-600 duration-300 ease-in-out hover:border-gray-400 hover:text-gray-900"
|
|
||||||
:class="{ 'text-gray-900': selected }"
|
|
||||||
>
|
|
||||||
<component v-if="tab.icon" :is="tab.icon" class="h-5" />
|
|
||||||
{{ __(tab.label) }}
|
|
||||||
<Badge
|
|
||||||
:class="
|
|
||||||
selected
|
|
||||||
? 'text-gray-800 border border-gray-800'
|
|
||||||
: 'border border-gray-500'
|
|
||||||
"
|
|
||||||
variant="subtle"
|
|
||||||
theme="gray"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{{ tab.count }}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<template #default="{ tab }">
|
|
||||||
<div
|
|
||||||
v-if="tab.batches && tab.batches.value.length"
|
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
v-for="batch in tab.batches.value"
|
|
||||||
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
|
|
||||||
>
|
|
||||||
<BatchCard :batch="batch" />
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div v-else class="p-5 italic text-gray-500">
|
|
||||||
{{ __('No {0} batches').format(tab.label.toLowerCase()) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Tabs>
|
|
||||||
<div
|
|
||||||
v-else-if="
|
|
||||||
!batches.loading &&
|
|
||||||
!hasBatches &&
|
|
||||||
(user.data?.is_instructor || user.data?.is_moderator)
|
|
||||||
"
|
|
||||||
class="grid grid-cols-3 p-5"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'BatchForm',
|
|
||||||
params: {
|
|
||||||
batchName: 'new',
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="bg-gray-50 py-32 px-5 rounded-md">
|
|
||||||
<div class="flex flex-col items-center text-center space-y-2">
|
|
||||||
<Plus
|
|
||||||
class="size-10 stroke-1 text-gray-800 p-1 rounded-full border bg-white"
|
|
||||||
/>
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ __('Create a Batch') }}
|
|
||||||
</div>
|
|
||||||
<span class="text-gray-700 text-sm leading-4">
|
|
||||||
{{ __('You can link courses and assessments to it.') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="!batches.loading && !hasBatches"
|
|
||||||
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
|
||||||
>
|
|
||||||
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
|
||||||
<div class="text-xl font-medium">
|
|
||||||
{{ __('No batches found') }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'There are no batches available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="batches.data?.length"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
v-for="batch in batches.data"
|
||||||
|
:to="{ name: 'BatchDetail', params: { batchName: batch.name } }"
|
||||||
|
>
|
||||||
|
<BatchCard :batch="batch" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!batches.list.loading"
|
||||||
|
class="flex flex-col items-center justify-center text-sm text-gray-600 italic mt-48"
|
||||||
|
>
|
||||||
|
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||||
|
<div class="text-lg font-medium mb-1">
|
||||||
|
{{ __('No batches found') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5 w-2/5 text-center">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'There are no batches matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!batches.list.loading && batches.hasNextPage"
|
||||||
|
class="flex justify-center mt-5"
|
||||||
|
>
|
||||||
|
<Button @click="batches.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
createResource,
|
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
Tabs,
|
createListResource,
|
||||||
Badge,
|
FormControl,
|
||||||
Select,
|
Select,
|
||||||
|
TabButtons,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
import { BookOpen, Plus } from 'lucide-vue-next'
|
import { BookOpen, Plus } from 'lucide-vue-next'
|
||||||
import BatchCard from '@/components/BatchCard.vue'
|
|
||||||
import { inject, ref, computed, onMounted, watch } from 'vue'
|
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
import BatchCard from '@/components/BatchCard.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const start = ref(0)
|
||||||
|
const pageLength = ref(20)
|
||||||
|
const categories = ref([])
|
||||||
const currentCategory = ref(null)
|
const currentCategory = ref(null)
|
||||||
const hasBatches = ref(false)
|
const title = ref('')
|
||||||
|
const filters = ref({})
|
||||||
|
const currentTab = ref(user.data?.is_student ? 'All' : 'Upcoming')
|
||||||
|
const orderBy = ref('start_date')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
let queries = new URLSearchParams(location.search)
|
setFiltersFromQuery()
|
||||||
if (queries.has('category')) {
|
updateBatches()
|
||||||
currentCategory.value = queries.get('category')
|
categories.value = [
|
||||||
}
|
{
|
||||||
})
|
|
||||||
|
|
||||||
const batches = createResource({
|
|
||||||
doctype: 'LMS Batch',
|
|
||||||
url: 'lms.lms.utils.get_batches',
|
|
||||||
cache: ['batches', user.data?.email],
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = createResource({
|
|
||||||
url: 'lms.lms.api.get_categories',
|
|
||||||
makeParams() {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Batch',
|
|
||||||
filters: {
|
|
||||||
published: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cache: ['batchCategories'],
|
|
||||||
auto: true,
|
|
||||||
transform(data) {
|
|
||||||
data.unshift({
|
|
||||||
label: '',
|
label: '',
|
||||||
value: null,
|
value: null,
|
||||||
})
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const setFiltersFromQuery = () => {
|
||||||
|
let queries = new URLSearchParams(location.search)
|
||||||
|
title.value = queries.get('title') || ''
|
||||||
|
currentCategory.value = queries.get('category') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const batches = createListResource({
|
||||||
|
doctype: 'LMS Batch',
|
||||||
|
url: 'lms.lms.utils.get_batches',
|
||||||
|
cache: ['batches', user.data?.name],
|
||||||
|
pageLength: pageLength.value,
|
||||||
|
start: start.value,
|
||||||
|
onSuccess(data) {
|
||||||
|
let allCategories = data.map((batch) => batch.category)
|
||||||
|
allCategories = allCategories.filter(
|
||||||
|
(category, index) => allCategories.indexOf(category) === index && category
|
||||||
|
)
|
||||||
|
if (categories.value.length <= allCategories.length) {
|
||||||
|
updateCategories(data)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
const updateBatches = () => {
|
||||||
let tabs
|
updateFilters()
|
||||||
|
batches.update({
|
||||||
|
filters: filters.value,
|
||||||
|
orderBy: orderBy.value,
|
||||||
|
})
|
||||||
|
batches.reload()
|
||||||
|
}
|
||||||
|
|
||||||
const makeTabs = computed(() => {
|
const updateFilters = () => {
|
||||||
tabs = []
|
updateCategoryFilter()
|
||||||
addToTabs('Upcoming')
|
updateTitleFilter()
|
||||||
|
updateTabFilter()
|
||||||
|
updateStudentFilter()
|
||||||
|
setQueryParams()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategoryFilter = () => {
|
||||||
|
if (currentCategory.value) {
|
||||||
|
filters.value['category'] = currentCategory.value
|
||||||
|
} else {
|
||||||
|
delete filters.value['category']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTitleFilter = () => {
|
||||||
|
if (title.value) {
|
||||||
|
filters.value['title'] = ['like', `%${title.value}%`]
|
||||||
|
} else {
|
||||||
|
delete filters.value['title']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTabFilter = () => {
|
||||||
|
orderBy.value = 'start_date'
|
||||||
|
if (!user.data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentTab.value == 'Enrolled' && user.data?.is_student) {
|
||||||
|
filters.value['enrolled'] = 1
|
||||||
|
delete filters.value['start_date']
|
||||||
|
delete filters.value['published']
|
||||||
|
orderBy.value = 'start_date desc'
|
||||||
|
} else if (user.data?.is_student) {
|
||||||
|
delete filters.value['enrolled']
|
||||||
|
} else {
|
||||||
|
delete filters.value['start_date']
|
||||||
|
delete filters.value['published']
|
||||||
|
orderBy.value = 'start_date desc'
|
||||||
|
if (currentTab.value == 'Upcoming') {
|
||||||
|
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
|
||||||
|
filters.value['published'] = 1
|
||||||
|
orderBy.value = 'start_date'
|
||||||
|
} else if (currentTab.value == 'Archived') {
|
||||||
|
filters.value['start_date'] = ['<=', dayjs().format('YYYY-MM-DD')]
|
||||||
|
} else if (currentTab.value == 'Unpublished') {
|
||||||
|
filters.value['published'] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStudentFilter = () => {
|
||||||
|
if (!user.data || (user.data?.is_student && currentTab.value != 'Enrolled')) {
|
||||||
|
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
|
||||||
|
filters.value['published'] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setQueryParams = () => {
|
||||||
|
let queries = new URLSearchParams(location.search)
|
||||||
|
let filterKeys = {
|
||||||
|
title: title.value,
|
||||||
|
category: currentCategory.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(filterKeys).forEach((key) => {
|
||||||
|
if (filterKeys[key]) {
|
||||||
|
queries.set(key, filterKeys[key])
|
||||||
|
} else {
|
||||||
|
queries.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
history.replaceState({}, '', `${location.pathname}?${queries.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategories = (data) => {
|
||||||
|
data.forEach((batch) => {
|
||||||
|
if (
|
||||||
|
batch.category &&
|
||||||
|
!categories.value.find((category) => category.value === batch.category)
|
||||||
|
)
|
||||||
|
categories.value.push({
|
||||||
|
label: batch.category,
|
||||||
|
value: batch.category,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentTab, () => {
|
||||||
|
updateBatches()
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchType = computed(() => {
|
||||||
|
let types = [
|
||||||
|
{ label: __(''), value: null },
|
||||||
|
{ label: __('Upcoming'), value: 'Upcoming' },
|
||||||
|
{ label: __('Archived'), value: 'Archived' },
|
||||||
|
]
|
||||||
if (user.data?.is_moderator) {
|
if (user.data?.is_moderator) {
|
||||||
addToTabs('Archived')
|
types.push({ label: __('Unpublished'), value: 'Unpublished' })
|
||||||
addToTabs('Private')
|
|
||||||
}
|
}
|
||||||
|
return types
|
||||||
|
})
|
||||||
|
|
||||||
if (user.data) {
|
const batchTabs = computed(() => {
|
||||||
addToTabs('Enrolled')
|
let tabs = [
|
||||||
|
{
|
||||||
|
label: __('All'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (user.data?.is_student) {
|
||||||
|
tabs.push({ label: __('Enrolled') })
|
||||||
|
} else {
|
||||||
|
tabs.push({ label: __('Upcoming') })
|
||||||
|
tabs.push({ label: __('Archived') })
|
||||||
|
tabs.push({ label: __('Unpublished') })
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabs
|
return tabs
|
||||||
})
|
})
|
||||||
|
|
||||||
const getBatches = (type) => {
|
const breadcrumbs = computed(() => [
|
||||||
if (currentCategory.value && currentCategory.value != '') {
|
{
|
||||||
return batches.data[type].filter(
|
label: __('Batches'),
|
||||||
(batch) => batch.category == currentCategory.value
|
route: { name: 'Batches' },
|
||||||
)
|
},
|
||||||
}
|
])
|
||||||
return batches.data[type]
|
|
||||||
}
|
|
||||||
|
|
||||||
const addToTabs = (label) => {
|
|
||||||
let batches = getBatches(label.toLowerCase().split(' ').join('_'))
|
|
||||||
tabs.push({
|
|
||||||
label,
|
|
||||||
batches: computed(() => batches),
|
|
||||||
count: computed(() => batches.length),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(batches, () => {
|
|
||||||
Object.keys(batches.data).forEach((key) => {
|
|
||||||
if (batches.data[key].length) {
|
|
||||||
hasBatches.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => currentCategory.value,
|
|
||||||
() => {
|
|
||||||
let queries = new URLSearchParams(location.search)
|
|
||||||
if (currentCategory.value) {
|
|
||||||
queries.set('category', currentCategory.value)
|
|
||||||
} else {
|
|
||||||
queries.delete('category')
|
|
||||||
}
|
|
||||||
history.pushState(null, '', `${location.pathname}?${queries.toString()}`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => {
|
||||||
return {
|
return {
|
||||||
title: 'Batches',
|
title: 'Batches',
|
||||||
description: 'All batches divided by categories',
|
description: 'All upcoming batches.',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +1,175 @@
|
|||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
class="sticky flex items-center justify-between top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
type="text"
|
|
||||||
placeholder="Search"
|
|
||||||
v-model="searchQuery"
|
|
||||||
@input="participants.reload()"
|
|
||||||
class="w-40"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
|
||||||
</template>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
<div class="p-5 lg:w-3/4 mx-auto">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
|
|
||||||
<div
|
<div
|
||||||
v-if="participants.data?.length"
|
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5"
|
||||||
v-for="participant in participantsList"
|
|
||||||
>
|
>
|
||||||
<router-link
|
<div class="text-lg font-semibold">
|
||||||
:to="{
|
{{ __('All Certified Participants') }}
|
||||||
name: 'Profile',
|
</div>
|
||||||
params: { username: participant.username },
|
<div class="grid grid-cols-2 gap-2">
|
||||||
}"
|
<FormControl
|
||||||
>
|
v-model="nameFilter"
|
||||||
<div class="flex shadow rounded-md h-full p-2">
|
:placeholder="__('Search by Name')"
|
||||||
<UserAvatar :user="participant" size="3xl" class="mr-2" />
|
type="text"
|
||||||
<div>
|
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||||
<router-link
|
@input="updateParticipants()"
|
||||||
:to="{
|
/>
|
||||||
name: 'Profile',
|
<div
|
||||||
params: { username: participant.username },
|
v-if="categories.data?.length"
|
||||||
}"
|
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||||
>
|
>
|
||||||
<div class="text-lg font-semibold mb-2">
|
<Select
|
||||||
|
v-model="currentCategory"
|
||||||
|
:options="categories.data"
|
||||||
|
:placeholder="__('Category')"
|
||||||
|
@change="updateParticipants()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="participants.data?.length">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
<router-link
|
||||||
|
v-for="participant in participants.data"
|
||||||
|
:to="{
|
||||||
|
name: 'ProfileCertificates',
|
||||||
|
params: { username: participant.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-2 border rounded-md hover:bg-gray-50 p-2"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:image="participant.user_image"
|
||||||
|
:label="participant.full_name"
|
||||||
|
size="2xl"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="font-medium">
|
||||||
{{ participant.full_name }}
|
{{ participant.full_name }}
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
<div
|
||||||
<div class="leading-5" v-for="course in participant.courses">
|
v-if="participant.headline"
|
||||||
{{ course }}
|
class="headline text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
{{ participant.headline }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</router-link>
|
||||||
</router-link>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!participants.list.loading && participants.hasNextPage"
|
||||||
|
class="flex justify-center mt-5"
|
||||||
|
>
|
||||||
|
<Button @click="participants.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!participants.list.loading"
|
||||||
|
class="flex flex-col items-center justify-center text-sm text-gray-600 italic mt-48"
|
||||||
|
>
|
||||||
|
<BookOpen class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||||
|
<div class="text-lg font-medium mb-1">
|
||||||
|
{{ __('No participants found') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5 w-2/5 text-center">
|
||||||
|
{{ __('There are no participants matching this criteria.') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
|
import {
|
||||||
import { ref, computed } from 'vue'
|
Avatar,
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
Breadcrumbs,
|
||||||
import { Search } from 'lucide-vue-next'
|
Button,
|
||||||
|
createListResource,
|
||||||
|
FormControl,
|
||||||
|
Select,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
import { BookOpen } from 'lucide-vue-next'
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const currentCategory = ref('')
|
||||||
|
const filters = ref({})
|
||||||
|
const nameFilter = ref('')
|
||||||
|
|
||||||
const participants = createResource({
|
onMounted(() => {
|
||||||
|
updateParticipants()
|
||||||
|
})
|
||||||
|
|
||||||
|
const participants = createListResource({
|
||||||
|
doctype: 'LMS Certificate',
|
||||||
url: 'lms.lms.api.get_certified_participants',
|
url: 'lms.lms.api.get_certified_participants',
|
||||||
method: 'GET',
|
cache: ['certified_participants'],
|
||||||
cache: 'certified-participants',
|
start: 0,
|
||||||
auto: true,
|
pageLength: 30,
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const categories = createListResource({
|
||||||
return [{ label: 'Certified Participants', to: '/certified-participants' }]
|
doctype: 'LMS Certificate',
|
||||||
|
url: 'lms.lms.api.get_certification_categories',
|
||||||
|
cache: ['certification_categories'],
|
||||||
|
auto: true,
|
||||||
|
transform(data) {
|
||||||
|
data.unshift({ label: __(''), value: '' })
|
||||||
|
return data
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateParticipants = () => {
|
||||||
|
updateFilters()
|
||||||
|
participants.update({
|
||||||
|
filters: filters.value,
|
||||||
|
})
|
||||||
|
participants.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFilters = () => {
|
||||||
|
if (currentCategory.value) {
|
||||||
|
filters.value.category = currentCategory.value
|
||||||
|
} else {
|
||||||
|
delete filters.value.category
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameFilter.value) {
|
||||||
|
filters.value.member_name = ['like', `%${nameFilter.value}%`]
|
||||||
|
} else {
|
||||||
|
delete filters.value.member_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{
|
||||||
|
label: __('Certified Participants'),
|
||||||
|
route: { name: 'CertifiedParticipants' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => {
|
||||||
return {
|
return {
|
||||||
title: 'Certified Participants',
|
title: 'Certified Participants',
|
||||||
description: 'All participants that have been certified.',
|
description: 'All participants that have been certified.',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const participantsList = computed(() => {
|
|
||||||
if (searchQuery.value) {
|
|
||||||
return participants.data.filter((participant) => {
|
|
||||||
return participant.full_name
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.value.toLowerCase())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return participants.data
|
|
||||||
})
|
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.headline {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
<template #default="{ tab }">
|
<template #default="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.courses && tab.courses.value.length"
|
v-if="tab.courses && tab.courses.value.length"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 my-5 mx-5"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 gap-7 my-5 mx-5"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="course in tab.courses.value"
|
v-for="course in tab.courses.value"
|
||||||
|
|||||||
@@ -7,47 +7,63 @@
|
|||||||
class="h-7"
|
class="h-7"
|
||||||
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
|
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex space-x-2">
|
<router-link
|
||||||
<div class="w-40 md:w-44">
|
v-if="user.data?.name"
|
||||||
<FormControl
|
:to="{
|
||||||
v-model="jobType"
|
name: 'JobCreation',
|
||||||
type="select"
|
params: {
|
||||||
:options="jobTypes"
|
jobName: 'new',
|
||||||
:placeholder="__('Type')"
|
},
|
||||||
/>
|
}"
|
||||||
</div>
|
>
|
||||||
<div class="w-28 md:w-36">
|
<Button variant="solid">
|
||||||
<FormControl type="text" placeholder="Search" v-model="searchQuery">
|
<template #prefix>
|
||||||
<template #prefix>
|
<Plus class="h-4 w-4" />
|
||||||
<Search class="w-4 h-4 stroke-1.5 text-gray-600" name="search" />
|
</template>
|
||||||
</template>
|
{{ __('New Job') }}
|
||||||
</FormControl>
|
</Button>
|
||||||
</div>
|
</router-link>
|
||||||
<router-link
|
|
||||||
v-if="user.data?.name"
|
|
||||||
:to="{
|
|
||||||
name: 'JobCreation',
|
|
||||||
params: {
|
|
||||||
jobName: 'new',
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button variant="solid">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
{{ __('New Job') }}
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div v-if="jobsList?.length">
|
<div>
|
||||||
<div class="lg:w-3/4 mx-auto p-5">
|
<div class="lg:w-3/4 mx-auto p-5">
|
||||||
<div class="text-xl font-semibold mb-5">
|
<div
|
||||||
{{ __('Find the perfect job for you') }}
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||||
|
>
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ __('Find the perfect job for you') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
:placeholder="__('Search')"
|
||||||
|
v-model="searchQuery"
|
||||||
|
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||||
|
@input="updateJobs"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search
|
||||||
|
class="w-4 h-4 stroke-1.5 text-gray-600"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl
|
||||||
|
v-model="jobType"
|
||||||
|
type="select"
|
||||||
|
:options="jobTypes"
|
||||||
|
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
|
||||||
|
:placeholder="__('Type')"
|
||||||
|
@change="updateJobs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="job in jobsList" class="divide-y">
|
|
||||||
|
<div
|
||||||
|
v-if="jobs.data?.length"
|
||||||
|
class="grid grid-cols-1 lg:grid-cols-2 gap-5"
|
||||||
|
>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-for="job in jobs.data"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'JobDetail',
|
name: 'JobDetail',
|
||||||
params: { job: job.name },
|
params: { job: job.name },
|
||||||
@@ -57,15 +73,15 @@
|
|||||||
<JobCard :job="job" />
|
<JobCard :job="job" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
|
||||||
|
{{ __('No jobs posted') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
|
|
||||||
{{ __('No jobs posted') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Breadcrumbs, createResource, FormControl } from 'frappe-ui'
|
import { Button, Breadcrumbs, createListResource, FormControl } from 'frappe-ui'
|
||||||
import { Plus, Search } from 'lucide-vue-next'
|
import { Plus, Search } from 'lucide-vue-next'
|
||||||
import { inject, computed, ref, onMounted } from 'vue'
|
import { inject, computed, ref, onMounted } from 'vue'
|
||||||
import JobCard from '@/components/JobCard.vue'
|
import JobCard from '@/components/JobCard.vue'
|
||||||
@@ -74,43 +90,59 @@ import { updateDocumentTitle } from '@/utils'
|
|||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const jobType = ref(null)
|
const jobType = ref(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const filters = ref({})
|
||||||
|
const orFilters = ref({})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
let queries = new URLSearchParams(location.search)
|
let queries = new URLSearchParams(location.search)
|
||||||
if (queries.has('type')) {
|
if (queries.has('type')) {
|
||||||
jobType.value = queries.get('type')
|
jobType.value = queries.get('type')
|
||||||
}
|
}
|
||||||
|
updateJobs()
|
||||||
})
|
})
|
||||||
|
|
||||||
const jobs = createResource({
|
const jobs = createListResource({
|
||||||
url: 'lms.lms.api.get_job_opportunities',
|
doctype: 'Job Opportunity',
|
||||||
cache: ['jobs'],
|
fields: [
|
||||||
auto: true,
|
'name',
|
||||||
|
'job_title',
|
||||||
|
'company_name',
|
||||||
|
'company_logo',
|
||||||
|
'location',
|
||||||
|
'type',
|
||||||
|
'creation',
|
||||||
|
],
|
||||||
|
start: 0,
|
||||||
|
pageLength: 20,
|
||||||
|
cache: ['jobOpportunities'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
const updateJobs = () => {
|
||||||
return {
|
updateFilters()
|
||||||
title: 'Jobs',
|
jobs.update({
|
||||||
description: 'An open job board for the community',
|
filters: filters.value,
|
||||||
|
orFilters: orFilters.value,
|
||||||
|
})
|
||||||
|
jobs.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFilters = () => {
|
||||||
|
if (jobType.value) {
|
||||||
|
filters.value.type = jobType.value
|
||||||
|
} else {
|
||||||
|
delete filters.value.type
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const jobsList = computed(() => {
|
|
||||||
let jobData = jobs.data
|
|
||||||
if (jobType.value && jobType.value != '') {
|
|
||||||
jobData = jobData.filter((job) => job.type == jobType.value)
|
|
||||||
}
|
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
let query = searchQuery.value.toLowerCase()
|
orFilters.value = {
|
||||||
jobData = jobData.filter(
|
job_title: ['like', `%${searchQuery.value}%`],
|
||||||
(job) =>
|
company_name: ['like', `%${searchQuery.value}%`],
|
||||||
job.job_title.toLowerCase().includes(query) ||
|
location: ['like', `%${searchQuery.value}%`],
|
||||||
job.company_name.toLowerCase().includes(query) ||
|
}
|
||||||
job.location.toLowerCase().includes(query)
|
} else {
|
||||||
)
|
orFilters.value = {}
|
||||||
}
|
}
|
||||||
return jobData
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const jobTypes = computed(() => {
|
const jobTypes = computed(() => {
|
||||||
return [
|
return [
|
||||||
@@ -121,6 +153,12 @@ const jobTypes = computed(() => {
|
|||||||
{ label: __('Freelance'), value: 'Freelance' },
|
{ label: __('Freelance'), value: 'Freelance' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: 'Jobs',
|
||||||
|
description: 'An open job board for the community',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
updateDocumentTitle(pageMeta)
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -475,7 +475,8 @@ updateDocumentTitle(pageMeta)
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-tool__caption {
|
.embed-tool__caption,
|
||||||
|
.cdx-simple-image__caption {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,4 +586,8 @@ iframe {
|
|||||||
border-top: 3px solid theme('colors.gray.700');
|
border-top: 3px solid theme('colors.gray.700');
|
||||||
border-bottom: 3px solid theme('colors.gray.700');
|
border-bottom: 3px solid theme('colors.gray.700');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tc-table {
|
||||||
|
border-left: 1px solid #e8e8eb;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -619,4 +619,8 @@ iframe {
|
|||||||
border-top: 3px solid theme('colors.gray.700');
|
border-top: 3px solid theme('colors.gray.700');
|
||||||
border-bottom: 3px solid theme('colors.gray.700');
|
border-bottom: 3px solid theme('colors.gray.700');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tc-table {
|
||||||
|
border-left: 1px solid #e8e8eb;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
class="flex items-center py-2 justify-between"
|
class="flex items-center py-2 justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
|
<Avatar :image="log.user_image" :label="log.full_name" class="mr-2" />
|
||||||
<div class="notification" v-html="log.subject"></div>
|
<div class="notification" v-html="log.subject"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
createListResource,
|
createListResource,
|
||||||
createResource,
|
createResource,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
@@ -66,14 +67,12 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed, inject, ref, onMounted } from 'vue'
|
import { computed, inject, ref, onMounted } from 'vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const allUsers = inject('$allUsers')
|
|
||||||
const activeTab = ref('Unread')
|
const activeTab = ref('Unread')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -93,24 +92,22 @@ const notifications = computed(() => {
|
|||||||
|
|
||||||
const unReadNotifications = createListResource({
|
const unReadNotifications = createListResource({
|
||||||
doctype: 'Notification Log',
|
doctype: 'Notification Log',
|
||||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
url: 'lms.lms.api.get_notifications',
|
||||||
filters: {
|
filters: {
|
||||||
for_user: user.data?.name,
|
for_user: user.data?.name,
|
||||||
read: 0,
|
read: 0,
|
||||||
},
|
},
|
||||||
orderBy: 'creation desc',
|
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: 'Unread Notifications',
|
cache: 'Unread Notifications',
|
||||||
})
|
})
|
||||||
|
|
||||||
const readNotifications = createListResource({
|
const readNotifications = createListResource({
|
||||||
doctype: 'Notification Log',
|
doctype: 'Notification Log',
|
||||||
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
url: 'lms.lms.api.get_notifications',
|
||||||
filters: {
|
filters: {
|
||||||
for_user: user.data?.name,
|
for_user: user.data?.name,
|
||||||
read: 1,
|
read: 1,
|
||||||
},
|
},
|
||||||
orderBy: 'creation desc',
|
|
||||||
auto: true,
|
auto: true,
|
||||||
cache: 'Read Notifications',
|
cache: 'Read Notifications',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
<div
|
<div
|
||||||
v-for="certificate in certificates.data"
|
v-for="certificate in certificates.data"
|
||||||
:key="certificate.name"
|
:key="certificate.name"
|
||||||
class="bg-white shadow rounded-lg p-3 cursor-pointer"
|
class="flex flex-col bg-white shadow rounded-lg p-3 cursor-pointer hover:bg-gray-50"
|
||||||
@click="openCertificate(certificate)"
|
@click="openCertificate(certificate)"
|
||||||
>
|
>
|
||||||
<div class="font-medium leading-5">
|
<div class="font-medium leading-5 mb-2">
|
||||||
{{ certificate.course_title }}
|
{{ certificate.course_title || certificate.batch_title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="text-sm text-gray-700 font-medium mt-auto">
|
||||||
<span class="text-xs text-gray-700"> {{ __('issued on') }}: </span>
|
<span> {{ __('Issued on') }}: </span>
|
||||||
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
|
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource } from 'frappe-ui'
|
import { createListResource } from 'frappe-ui'
|
||||||
import { inject } from 'vue'
|
import { inject, onMounted } from 'vue'
|
||||||
|
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -33,12 +33,19 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const certificates = createResource({
|
onMounted(() => {
|
||||||
url: 'lms.lms.api.get_certificates',
|
if (props.profile.data?.name) {
|
||||||
params: {
|
certificates.reload()
|
||||||
member: props.profile.data.name,
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const certificates = createListResource({
|
||||||
|
doctype: 'LMS Certificate',
|
||||||
|
filters: {
|
||||||
|
member: props.profile.data?.name,
|
||||||
},
|
},
|
||||||
auto: true,
|
fields: ['name', 'course_title', 'batch_title', 'issue_date'],
|
||||||
|
cache: ['certificates', props.profile.data?.name],
|
||||||
})
|
})
|
||||||
|
|
||||||
const openCertificate = (certificate) => {
|
const openCertificate = (certificate) => {
|
||||||
|
|||||||
@@ -217,21 +217,13 @@ let router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const { userResource, allUsers } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
await userResource.promise
|
await userResource.promise
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
isLoggedIn &&
|
|
||||||
(to.name == 'Lesson' ||
|
|
||||||
to.name == 'Batch' ||
|
|
||||||
to.name == 'Notifications')
|
|
||||||
) {
|
|
||||||
await allUsers.promise
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import router from '@/router'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export const sessionStore = defineStore('lms-session', () => {
|
export const sessionStore = defineStore('lms-session', () => {
|
||||||
let { userResource, allUsers } = usersStore()
|
let { userResource } = usersStore()
|
||||||
|
|
||||||
function sessionUser() {
|
function sessionUser() {
|
||||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||||
@@ -17,9 +17,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user = ref(sessionUser())
|
let user = ref(sessionUser())
|
||||||
if (user.value) {
|
|
||||||
allUsers.reload()
|
|
||||||
}
|
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
|
||||||
const login = createResource({
|
const login = createResource({
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { createResource } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { sessionStore } from './session'
|
||||||
|
|
||||||
export const useSettings = defineStore('settings', () => {
|
export const useSettings = defineStore('settings', () => {
|
||||||
|
const { isLoggedIn } = sessionStore()
|
||||||
const isSettingsOpen = ref(false)
|
const isSettingsOpen = ref(false)
|
||||||
const activeTab = ref(null)
|
const activeTab = ref(null)
|
||||||
const learningPaths = createResource({
|
const learningPaths = createResource({
|
||||||
@@ -13,13 +15,13 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
field: 'enable_learning_paths',
|
field: 'enable_learning_paths',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: isLoggedIn ? true : false,
|
||||||
cache: ['learningPaths'],
|
cache: ['learningPaths'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const onboardingDetails = createResource({
|
const onboardingDetails = createResource({
|
||||||
url: 'lms.lms.utils.is_onboarding_complete',
|
url: 'lms.lms.utils.is_onboarding_complete',
|
||||||
auto: true,
|
auto: isLoggedIn ? true : false,
|
||||||
cache: ['onboardingDetails'],
|
cache: ['onboardingDetails'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,10 @@ export function getEditorTools() {
|
|||||||
upload: Upload,
|
upload: Upload,
|
||||||
markdown: Markdown,
|
markdown: Markdown,
|
||||||
image: SimpleImage,
|
image: SimpleImage,
|
||||||
table: Table,
|
table: {
|
||||||
|
class: Table,
|
||||||
|
inlineToolbar: true,
|
||||||
|
},
|
||||||
paragraph: {
|
paragraph: {
|
||||||
class: Paragraph,
|
class: Paragraph,
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
@@ -179,6 +182,7 @@ export function getEditorTools() {
|
|||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
class: NestedList,
|
class: NestedList,
|
||||||
|
inlineToolbar: true,
|
||||||
config: {
|
config: {
|
||||||
defaultStyle: 'ordered',
|
defaultStyle: 'ordered',
|
||||||
},
|
},
|
||||||
@@ -529,3 +533,21 @@ export const validateFile = (file) => {
|
|||||||
return __('Only image file is allowed.')
|
return __('Only image file is allowed.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const escapeHTML = (text) => {
|
||||||
|
if (!text) return ''
|
||||||
|
let escape_html_mapping = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
'`': '`',
|
||||||
|
'=': '=',
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(text).replace(
|
||||||
|
/[&<>"'`=]/g,
|
||||||
|
(char) => escape_html_mapping[char] || char
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ module.exports = {
|
|||||||
strokeWidth: {
|
strokeWidth: {
|
||||||
1.5: '1.5',
|
1.5: '1.5',
|
||||||
},
|
},
|
||||||
|
screens: {
|
||||||
|
'2xl': '1536px',
|
||||||
|
'3xl': '1920px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.19.0"
|
__version__ = "2.21.0"
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
frappe.ui.form.on("Job Opportunity", {
|
frappe.ui.form.on("Job Opportunity", {
|
||||||
refresh: (frm) => {
|
refresh: (frm) => {
|
||||||
if (frm.doc.name)
|
if (frm.doc.name)
|
||||||
frm.add_web_link(`/job-openings/${frm.doc.name}`, "See on Website");
|
frm.add_web_link(
|
||||||
|
`/lms/job-openings/${frm.doc.name}`,
|
||||||
|
"See on Website"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"actions": [],
|
"actions": [],
|
||||||
"allow_import": 1,
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "format: JOB-{#####}",
|
|
||||||
"creation": "2022-02-07 12:01:41.074418",
|
"creation": "2022-02-07 12:01:41.074418",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
@@ -117,11 +116,10 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-02-07 23:02:06.102120",
|
"modified": "2025-01-17 12:38:57.134919",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Job",
|
"module": "Job",
|
||||||
"name": "Job Opportunity",
|
"name": "Job Opportunity",
|
||||||
"naming_rule": "Expression",
|
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ from frappe import _
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import get_link_to_form, add_months, getdate
|
from frappe.utils import get_link_to_form, add_months, getdate
|
||||||
from frappe.utils.user import get_system_managers
|
from frappe.utils.user import get_system_managers
|
||||||
|
from lms.lms.utils import validate_image, generate_slug
|
||||||
from lms.lms.utils import validate_image
|
|
||||||
|
|
||||||
|
|
||||||
class JobOpportunity(Document):
|
class JobOpportunity(Document):
|
||||||
@@ -18,6 +17,10 @@ class JobOpportunity(Document):
|
|||||||
def validate_urls(self):
|
def validate_urls(self):
|
||||||
frappe.utils.validate_url(self.company_website, True)
|
frappe.utils.validate_url(self.company_website, True)
|
||||||
|
|
||||||
|
def autoname(self):
|
||||||
|
if not self.name:
|
||||||
|
self.name = generate_slug(f"{self.job_title}-${self.company_name}", "LMS Course")
|
||||||
|
|
||||||
|
|
||||||
def update_job_openings():
|
def update_job_openings():
|
||||||
old_jobs = frappe.get_all(
|
old_jobs = frappe.get_all(
|
||||||
|
|||||||
107
lms/lms/api.py
107
lms/lms/api.py
@@ -361,34 +361,59 @@ def get_evaluator_details(evaluator):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_certified_participants():
|
def get_certified_participants(filters=None, start=0, page_length=30, search=None):
|
||||||
LMSCertificate = DocType("LMS Certificate")
|
or_filters = {}
|
||||||
participants = (
|
if not filters:
|
||||||
frappe.qb.from_(LMSCertificate)
|
filters = {}
|
||||||
.select(LMSCertificate.member)
|
|
||||||
.distinct()
|
filters.update({"published": 1})
|
||||||
.where(LMSCertificate.published == 1)
|
|
||||||
.orderby(LMSCertificate.creation, order=frappe.qb.desc)
|
category = filters.get("category")
|
||||||
.run(as_dict=1)
|
if category:
|
||||||
|
del filters["category"]
|
||||||
|
or_filters["course_title"] = ["like", f"%{category}%"]
|
||||||
|
or_filters["batch_title"] = ["like", f"%{category}%"]
|
||||||
|
|
||||||
|
participants = frappe.get_all(
|
||||||
|
"LMS Certificate",
|
||||||
|
filters=filters,
|
||||||
|
or_filters=or_filters,
|
||||||
|
fields=["member"],
|
||||||
|
group_by="member",
|
||||||
|
order_by="creation desc",
|
||||||
|
start=start,
|
||||||
|
page_length=page_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
participant_details = []
|
|
||||||
for participant in participants:
|
for participant in participants:
|
||||||
details = frappe.db.get_value(
|
details = frappe.db.get_value(
|
||||||
"User",
|
"User",
|
||||||
participant.member,
|
participant.member,
|
||||||
["name", "full_name", "username", "user_image"],
|
["full_name", "user_image", "username", "country", "headline"],
|
||||||
as_dict=True,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
course_names = frappe.get_all(
|
participant.update(details)
|
||||||
"LMS Certificate", {"member": participant.member}, pluck="course"
|
|
||||||
)
|
return participants
|
||||||
courses = []
|
|
||||||
for course in course_names:
|
|
||||||
courses.append(frappe.db.get_value("LMS Course", course, "title"))
|
@frappe.whitelist()
|
||||||
details["courses"] = courses
|
def get_certification_categories():
|
||||||
participant_details.append(details)
|
categories = []
|
||||||
return participant_details
|
docs = frappe.get_all(
|
||||||
|
"LMS Certificate",
|
||||||
|
filters={
|
||||||
|
"published": 1,
|
||||||
|
},
|
||||||
|
fields=["course_title", "batch_title"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
category = doc.course_title if doc.course_title else doc.batch_title
|
||||||
|
if category not in categories:
|
||||||
|
categories.append(category)
|
||||||
|
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -407,19 +432,9 @@ def get_assigned_badges(member):
|
|||||||
return assigned_badges
|
return assigned_badges
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_certificates(member):
|
|
||||||
"""Get certificates for a member."""
|
|
||||||
return frappe.get_all(
|
|
||||||
"LMS Certificate",
|
|
||||||
filters={"member": member},
|
|
||||||
fields=["name", "course", "course_title", "issue_date", "template"],
|
|
||||||
order_by="creation desc",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_all_users():
|
def get_all_users():
|
||||||
|
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
|
||||||
users = frappe.get_all(
|
users = frappe.get_all(
|
||||||
"User",
|
"User",
|
||||||
{
|
{
|
||||||
@@ -603,9 +618,13 @@ def get_categories(doctype, filters):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_members(start=0, search=""):
|
def get_members(start=0, search=""):
|
||||||
"""Get members for the given search term and start index.
|
"""Get members for the given search term and start index.
|
||||||
Args: start (int): Start index for the query.
|
Args: start (int): Start index for the query.
|
||||||
search (str): Search term to filter the results.
|
<<<<<<< HEAD
|
||||||
Returns: List of members.
|
search (str): Search term to filter the results.
|
||||||
|
=======
|
||||||
|
search (str): Search term to filter the results.
|
||||||
|
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
|
||||||
|
Returns: List of members.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
|
||||||
@@ -1171,3 +1190,21 @@ def prepare_heatmap_data(start_date, number_of_days, date_count):
|
|||||||
def get_week_difference(start_date, current_date):
|
def get_week_difference(start_date, current_date):
|
||||||
diff_in_days = date_diff(current_date, start_date)
|
diff_in_days = date_diff(current_date, start_date)
|
||||||
return diff_in_days // 7
|
return diff_in_days // 7
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_notifications(filters):
|
||||||
|
notifications = frappe.get_all(
|
||||||
|
"Notification Log",
|
||||||
|
filters,
|
||||||
|
["subject", "from_user", "link", "read", "name"],
|
||||||
|
order_by="creation desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
for notification in notifications:
|
||||||
|
from_user_details = frappe.db.get_value(
|
||||||
|
"User", notification.from_user, ["full_name", "user_image"], as_dict=1
|
||||||
|
)
|
||||||
|
notification.update(from_user_details)
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
|||||||
@@ -72,16 +72,6 @@ class CourseLesson(Document):
|
|||||||
exercises = [value for name, value in macros if name == "Exercise"]
|
exercises = [value for name, value in macros if name == "Exercise"]
|
||||||
return [frappe.get_doc("LMS Exercise", name) for name in exercises]
|
return [frappe.get_doc("LMS Exercise", name) for name in exercises]
|
||||||
|
|
||||||
def get_progress(self):
|
|
||||||
return frappe.db.get_value(
|
|
||||||
"LMS Course Progress", {"lesson": self.name, "owner": frappe.session.user}, "status"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_slugified_class(self):
|
|
||||||
if self.get_progress():
|
|
||||||
return ("").join([s for s in self.get_progress().lower().split()])
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def save_progress(lesson, course):
|
def save_progress(lesson, course):
|
||||||
|
|||||||
@@ -72,9 +72,12 @@ class LMSAssignmentSubmission(Document):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_status(self):
|
def validate_status(self):
|
||||||
doc_before_save = self.get_doc_before_save()
|
if not self.is_new():
|
||||||
if doc_before_save.status != self.status or doc_before_save.comments != self.comments:
|
doc_before_save = self.get_doc_before_save()
|
||||||
self.trigger_update_notification()
|
if (
|
||||||
|
doc_before_save.status != self.status or doc_before_save.comments != self.comments
|
||||||
|
):
|
||||||
|
self.trigger_update_notification()
|
||||||
|
|
||||||
def trigger_update_notification(self):
|
def trigger_update_notification(self):
|
||||||
notification = frappe._dict(
|
notification = frappe._dict(
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ frappe.ui.form.on("LMS Batch", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
refresh: (frm) => {
|
refresh: (frm) => {
|
||||||
frm.add_web_link(`/batches/details/${frm.doc.name}`, "See on website");
|
frm.add_web_link(
|
||||||
|
`/lms/batches/details/${frm.doc.name}`,
|
||||||
|
"See on website"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"actions": [],
|
"actions": [],
|
||||||
"allow_import": 1,
|
"allow_import": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"autoname": "format: CLS-{#####}",
|
|
||||||
"creation": "2022-11-09 16:14:05.876933",
|
"creation": "2022-11-09 16:14:05.876933",
|
||||||
"default_view": "List",
|
"default_view": "List",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -330,11 +329,10 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-11-18 16:28:41.336928",
|
"modified": "2025-01-17 10:23:10.580311",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Batch",
|
"name": "LMS Batch",
|
||||||
"naming_rule": "Expression (old style)",
|
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from lms.lms.utils import (
|
|||||||
get_quiz_details,
|
get_quiz_details,
|
||||||
get_assignment_details,
|
get_assignment_details,
|
||||||
update_payment_record,
|
update_payment_record,
|
||||||
|
generate_slug,
|
||||||
)
|
)
|
||||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ class LMSBatch(Document):
|
|||||||
self.validate_evaluation_end_date()
|
self.validate_evaluation_end_date()
|
||||||
self.add_students_to_live_class()
|
self.add_students_to_live_class()
|
||||||
|
|
||||||
|
def autoname(self):
|
||||||
|
if not self.name:
|
||||||
|
self.name = generate_slug(self.title, "LMS Batch")
|
||||||
|
|
||||||
def validate_batch_end_date(self):
|
def validate_batch_end_date(self):
|
||||||
if self.end_date < self.start_date:
|
if self.end_date < self.start_date:
|
||||||
frappe.throw(_("Batch end date cannot be before the batch start date"))
|
frappe.throw(_("Batch end date cannot be before the batch start date"))
|
||||||
|
|||||||
0
lms/lms/doctype/lms_batch_feedback/__init__.py
Normal file
0
lms/lms/doctype/lms_batch_feedback/__init__.py
Normal file
8
lms/lms/doctype/lms_batch_feedback/lms_batch_feedback.js
Normal file
8
lms/lms/doctype/lms_batch_feedback/lms_batch_feedback.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("LMS Batch Feedback", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
112
lms/lms/doctype/lms_batch_feedback/lms_batch_feedback.json
Normal file
112
lms/lms/doctype/lms_batch_feedback/lms_batch_feedback.json
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2025-01-07 18:53:22.279844",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"member",
|
||||||
|
"member_name",
|
||||||
|
"member_image",
|
||||||
|
"batch",
|
||||||
|
"column_break_swst",
|
||||||
|
"content",
|
||||||
|
"instructors",
|
||||||
|
"value",
|
||||||
|
"feedback"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "member",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Member",
|
||||||
|
"options": "User",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "batch",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Batch",
|
||||||
|
"options": "LMS Batch",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "feedback",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Feedback",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_swst",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "content",
|
||||||
|
"fieldtype": "Rating",
|
||||||
|
"label": "Content"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "instructors",
|
||||||
|
"fieldtype": "Rating",
|
||||||
|
"label": "Instructors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "value",
|
||||||
|
"fieldtype": "Rating",
|
||||||
|
"label": "Value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.full_name",
|
||||||
|
"fieldname": "member_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Member Name",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.user_image",
|
||||||
|
"fieldname": "member_image",
|
||||||
|
"fieldtype": "Attach Image",
|
||||||
|
"label": "Member Image",
|
||||||
|
"read_only": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-01-13 19:02:58.259908",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "LMS",
|
||||||
|
"name": "LMS Batch Feedback",
|
||||||
|
"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,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"if_owner": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "LMS Student",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
9
lms/lms/doctype/lms_batch_feedback/lms_batch_feedback.py
Normal file
9
lms/lms/doctype/lms_batch_feedback/lms_batch_feedback.py
Normal 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 LMSBatchFeedback(Document):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Copyright (c) 2025, Frappe and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# 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 UnitTestLMSBatchFeedback(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for LMSBatchFeedback.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestLMSBatchFeedback(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for LMSBatchFeedback.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -6,20 +6,21 @@
|
|||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"course",
|
|
||||||
"course_title",
|
|
||||||
"member",
|
"member",
|
||||||
"member_name",
|
"member_name",
|
||||||
"column_break_vwbn",
|
|
||||||
"issue_date",
|
|
||||||
"template",
|
|
||||||
"published",
|
|
||||||
"section_break_scyf",
|
|
||||||
"evaluator",
|
"evaluator",
|
||||||
"evaluator_name",
|
"evaluator_name",
|
||||||
"column_break_slaw",
|
"column_break_vwbn",
|
||||||
|
"issue_date",
|
||||||
"expiry_date",
|
"expiry_date",
|
||||||
"batch_name"
|
"template",
|
||||||
|
"published",
|
||||||
|
"section_break_unwn",
|
||||||
|
"course",
|
||||||
|
"course_title",
|
||||||
|
"column_break_ywee",
|
||||||
|
"batch_name",
|
||||||
|
"batch_title"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -32,11 +33,9 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "course",
|
"fieldname": "course",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Course",
|
"label": "Course",
|
||||||
"options": "LMS Course",
|
"options": "LMS Course"
|
||||||
"reqd": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "expiry_date",
|
"fieldname": "expiry_date",
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "member",
|
"fieldname": "member",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Member",
|
"label": "Member",
|
||||||
"options": "User",
|
"options": "User",
|
||||||
@@ -56,6 +54,8 @@
|
|||||||
"fetch_from": "member.full_name",
|
"fetch_from": "member.full_name",
|
||||||
"fieldname": "member_name",
|
"fieldname": "member_name",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
"label": "Member Name",
|
"label": "Member Name",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
@@ -90,14 +90,6 @@
|
|||||||
"fieldname": "column_break_vwbn",
|
"fieldname": "column_break_vwbn",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "section_break_scyf",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_slaw",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "evaluator",
|
"fieldname": "evaluator",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
@@ -108,13 +100,29 @@
|
|||||||
"fetch_from": "evaluator.full_name",
|
"fetch_from": "evaluator.full_name",
|
||||||
"fieldname": "evaluator_name",
|
"fieldname": "evaluator_name",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
"label": "Evaluator Name",
|
"label": "Evaluator Name",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_unwn",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ywee",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "batch_name.title",
|
||||||
|
"fieldname": "batch_title",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Batch Title",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-09-11 11:37:20.419956",
|
"modified": "2025-01-17 11:57:02.859109",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Certificate",
|
"name": "LMS Certificate",
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ from frappe.model.document import Document
|
|||||||
from frappe.utils import add_years, nowdate
|
from frappe.utils import add_years, nowdate
|
||||||
from lms.lms.utils import is_certified
|
from lms.lms.utils import is_certified
|
||||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||||
|
from frappe.model.naming import make_autoname
|
||||||
|
|
||||||
|
|
||||||
class LMSCertificate(Document):
|
class LMSCertificate(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_duplicate_certificate()
|
self.validate_duplicate_certificate()
|
||||||
|
|
||||||
|
def autoname(self):
|
||||||
|
self.name = make_autoname("hash", self.doctype)
|
||||||
|
|
||||||
def after_insert(self):
|
def after_insert(self):
|
||||||
if not frappe.flags.in_test:
|
if not frappe.flags.in_test:
|
||||||
outgoing_email_account = frappe.get_cached_value(
|
outgoing_email_account = frappe.get_cached_value(
|
||||||
@@ -48,16 +52,46 @@ class LMSCertificate(Document):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_duplicate_certificate(self):
|
def validate_duplicate_certificate(self):
|
||||||
certificates = frappe.get_all(
|
self.validate_course_duplicates()
|
||||||
"LMS Certificate",
|
self.validate_batch_duplicates()
|
||||||
{"member": self.member, "course": self.course, "name": ["!=", self.name]},
|
|
||||||
)
|
def validate_course_duplicates(self):
|
||||||
if len(certificates):
|
if self.course:
|
||||||
full_name = frappe.db.get_value("User", self.member, "full_name")
|
course_duplicates = frappe.get_all(
|
||||||
course_name = frappe.db.get_value("LMS Course", self.course, "title")
|
"LMS Certificate",
|
||||||
frappe.throw(
|
filters={
|
||||||
_("{0} is already certified for the course {1}").format(full_name, course_name)
|
"member": self.member,
|
||||||
|
"name": ["!=", self.name],
|
||||||
|
"course": self.course,
|
||||||
|
},
|
||||||
|
fields=["name", "course", "course_title"],
|
||||||
)
|
)
|
||||||
|
if len(course_duplicates):
|
||||||
|
full_name = frappe.db.get_value("User", self.member, "full_name")
|
||||||
|
frappe.throw(
|
||||||
|
_("{0} is already certified for the course {1}").format(
|
||||||
|
full_name, course_duplicates[0].course_title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_batch_duplicates(self):
|
||||||
|
if self.batch_name:
|
||||||
|
batch_duplicates = frappe.get_all(
|
||||||
|
"LMS Certificate",
|
||||||
|
filters={
|
||||||
|
"member": self.member,
|
||||||
|
"name": ["!=", self.name],
|
||||||
|
"batch_name": self.batch_name,
|
||||||
|
},
|
||||||
|
fields=["name", "batch_name", "batch_title"],
|
||||||
|
)
|
||||||
|
if len(batch_duplicates):
|
||||||
|
full_name = frappe.db.get_value("User", self.member, "full_name")
|
||||||
|
frappe.throw(
|
||||||
|
_("{0} is already certified for the batch {1}").format(
|
||||||
|
full_name, batch_duplicates[0].batch_title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
frappe.share.add_docshare(
|
frappe.share.add_docshare(
|
||||||
|
|||||||
@@ -93,10 +93,7 @@ class LMSCourse(Document):
|
|||||||
|
|
||||||
def autoname(self):
|
def autoname(self):
|
||||||
if not self.name:
|
if not self.name:
|
||||||
title = self.title
|
self.name = generate_slug(self.title, "LMS Course")
|
||||||
if self.title == "New Course":
|
|
||||||
title = self.title + str(random.randint(0, 99))
|
|
||||||
self.name = generate_slug(title, "LMS Course")
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Course#{self.name}>"
|
return f"<Course#{self.name}>"
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Course",
|
"label": "Course",
|
||||||
"options": "LMS Course",
|
"options": "LMS Course",
|
||||||
"read_only": 1
|
"read_only": 1,
|
||||||
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fetch_from": "lesson.chapter",
|
"fetch_from": "lesson.chapter",
|
||||||
@@ -30,14 +31,16 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Chapter",
|
"label": "Chapter",
|
||||||
"options": "Course Chapter",
|
"options": "Course Chapter",
|
||||||
"read_only": 1
|
"read_only": 1,
|
||||||
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "lesson",
|
"fieldname": "lesson",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Lesson",
|
"label": "Lesson",
|
||||||
"options": "Course Lesson"
|
"options": "Course Lesson",
|
||||||
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "status",
|
"fieldname": "status",
|
||||||
@@ -45,7 +48,8 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"options": "Complete\nPartially Complete\nIncomplete"
|
"options": "Complete\nPartially Complete\nIncomplete",
|
||||||
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_3",
|
"fieldname": "column_break_3",
|
||||||
@@ -55,7 +59,8 @@
|
|||||||
"fieldname": "member",
|
"fieldname": "member",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Member",
|
"label": "Member",
|
||||||
"options": "User"
|
"options": "User",
|
||||||
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fetch_from": "member.full_name",
|
"fetch_from": "member.full_name",
|
||||||
@@ -67,7 +72,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-02-27 11:43:08.326886",
|
"modified": "2025-01-17 15:54:34.040621",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course Progress",
|
"name": "LMS Course Progress",
|
||||||
|
|||||||
166
lms/lms/utils.py
166
lms/lms/utils.py
@@ -450,24 +450,6 @@ def get_signup_optin_checks():
|
|||||||
return (", ").join(links)
|
return (", ").join(links)
|
||||||
|
|
||||||
|
|
||||||
def get_popular_courses():
|
|
||||||
courses = frappe.get_all("LMS Course", {"published": 1, "upcoming": 0})
|
|
||||||
course_membership = []
|
|
||||||
|
|
||||||
for course in courses:
|
|
||||||
course_membership.append(
|
|
||||||
{
|
|
||||||
"course": course.name,
|
|
||||||
"members": cint(frappe.db.count("LMS Enrollment", {"course": course.name})),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
course_membership = sorted(
|
|
||||||
course_membership, key=lambda x: x.get("members"), reverse=True
|
|
||||||
)
|
|
||||||
return course_membership[:3]
|
|
||||||
|
|
||||||
|
|
||||||
def format_amount(amount, currency):
|
def format_amount(amount, currency):
|
||||||
amount_reduced = amount / 1000
|
amount_reduced = amount / 1000
|
||||||
if amount_reduced < 1:
|
if amount_reduced < 1:
|
||||||
@@ -935,7 +917,7 @@ def check_multicurrency(amount, currency, country=None, amount_usd=None):
|
|||||||
|
|
||||||
# Conversion logic starts here. Exchange rate is fetched and amount is converted.
|
# Conversion logic starts here. Exchange rate is fetched and amount is converted.
|
||||||
exchange_rate = get_current_exchange_rate(currency, "USD")
|
exchange_rate = get_current_exchange_rate(currency, "USD")
|
||||||
amount = amount * exchange_rate
|
amount = flt(amount * exchange_rate, 2)
|
||||||
currency = "USD"
|
currency = "USD"
|
||||||
|
|
||||||
# Check if the amount should be rounded and then apply rounding
|
# Check if the amount should be rounded and then apply rounding
|
||||||
@@ -1210,59 +1192,6 @@ def get_neighbour_lesson(course, chapter, lesson):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
|
||||||
def get_batches():
|
|
||||||
batches = []
|
|
||||||
filters = {}
|
|
||||||
if frappe.session.user == "Guest":
|
|
||||||
filters.update({"start_date": [">=", getdate()], "published": 1})
|
|
||||||
batch_list = frappe.get_all("LMS Batch", filters)
|
|
||||||
|
|
||||||
for batch in batch_list:
|
|
||||||
batches.append(get_batch_card_details(batch.name))
|
|
||||||
|
|
||||||
batches = categorize_batches(batches)
|
|
||||||
return batches
|
|
||||||
|
|
||||||
|
|
||||||
def get_batch_card_details(batchname):
|
|
||||||
batch = frappe.db.get_value(
|
|
||||||
"LMS Batch",
|
|
||||||
batchname,
|
|
||||||
[
|
|
||||||
"name",
|
|
||||||
"title",
|
|
||||||
"description",
|
|
||||||
"seat_count",
|
|
||||||
"paid_batch",
|
|
||||||
"amount",
|
|
||||||
"amount_usd",
|
|
||||||
"currency",
|
|
||||||
"start_date",
|
|
||||||
"end_date",
|
|
||||||
"start_time",
|
|
||||||
"end_time",
|
|
||||||
"timezone",
|
|
||||||
"published",
|
|
||||||
],
|
|
||||||
as_dict=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
batch.instructors = get_instructors(batchname)
|
|
||||||
students_count = frappe.db.count("Batch Student", {"parent": batchname})
|
|
||||||
|
|
||||||
if batch.seat_count:
|
|
||||||
batch.seats_left = batch.seat_count - students_count
|
|
||||||
|
|
||||||
if batch.paid_batch and batch.start_date >= getdate():
|
|
||||||
batch.amount, batch.currency = check_multicurrency(
|
|
||||||
batch.amount, batch.currency, None, batch.amount_usd
|
|
||||||
)
|
|
||||||
batch.price = fmt_money(batch.amount, 0, batch.currency)
|
|
||||||
|
|
||||||
return batch
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_batch_details(batch):
|
def get_batch_details(batch):
|
||||||
batch_details = frappe.db.get_value(
|
batch_details = frappe.db.get_value(
|
||||||
@@ -1901,3 +1830,96 @@ def enroll_in_program_course(program, course):
|
|||||||
)
|
)
|
||||||
enrollment.save()
|
enrollment.save()
|
||||||
return enrollment
|
return enrollment
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist(allow_guest=True)
|
||||||
|
def get_batches(filters=None, start=0, page_length=20, order_by="start_date"):
|
||||||
|
if not filters:
|
||||||
|
filters = {}
|
||||||
|
|
||||||
|
if filters.get("enrolled"):
|
||||||
|
enrolled_batches = frappe.get_all(
|
||||||
|
"Batch Student", {"student": frappe.session.user}, pluck="parent"
|
||||||
|
)
|
||||||
|
filters.update({"name": ["in", enrolled_batches]})
|
||||||
|
del filters["enrolled"]
|
||||||
|
|
||||||
|
batches = frappe.get_all(
|
||||||
|
"LMS Batch",
|
||||||
|
filters=filters,
|
||||||
|
fields=[
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"seat_count",
|
||||||
|
"paid_batch",
|
||||||
|
"amount",
|
||||||
|
"amount_usd",
|
||||||
|
"currency",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"timezone",
|
||||||
|
"published",
|
||||||
|
"category",
|
||||||
|
],
|
||||||
|
order_by=order_by,
|
||||||
|
start=start,
|
||||||
|
page_length=page_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
batches = filter_batches_based_on_start_time(batches, filters)
|
||||||
|
batches = get_batch_card_details(batches)
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def filter_batches_based_on_start_time(batches, filters):
|
||||||
|
batchType = get_batch_type(filters)
|
||||||
|
if batchType == "upcoming":
|
||||||
|
batches_to_remove = [
|
||||||
|
batch
|
||||||
|
for batch in batches
|
||||||
|
if getdate(batch.start_date) == getdate()
|
||||||
|
and get_time_str(batch.start_time) < nowtime()
|
||||||
|
]
|
||||||
|
batches = [batch for batch in batches if batch not in batches_to_remove]
|
||||||
|
elif batchType == "archived":
|
||||||
|
batches_to_remove = [
|
||||||
|
batch
|
||||||
|
for batch in batches
|
||||||
|
if getdate(batch.start_date) == getdate()
|
||||||
|
and get_time_str(batch.start_time) >= nowtime()
|
||||||
|
]
|
||||||
|
batches = [batch for batch in batches if batch not in batches_to_remove]
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def get_batch_type(filters):
|
||||||
|
start_date_filter = filters.get("start_date")
|
||||||
|
batchType = None
|
||||||
|
if start_date_filter:
|
||||||
|
sign = start_date_filter[0]
|
||||||
|
if ">" in sign:
|
||||||
|
batchType = "upcoming"
|
||||||
|
elif "<" in sign:
|
||||||
|
batchType = "archived"
|
||||||
|
|
||||||
|
return batchType
|
||||||
|
|
||||||
|
|
||||||
|
def get_batch_card_details(batches):
|
||||||
|
for batch in batches:
|
||||||
|
batch.instructors = get_instructors(batch.name)
|
||||||
|
students_count = frappe.db.count("Batch Student", {"parent": batch.name})
|
||||||
|
|
||||||
|
if batch.seat_count:
|
||||||
|
batch.seats_left = batch.seat_count - students_count
|
||||||
|
|
||||||
|
if batch.paid_batch and batch.start_date >= getdate():
|
||||||
|
batch.amount, batch.currency = check_multicurrency(
|
||||||
|
batch.amount, batch.currency, None, batch.amount_usd
|
||||||
|
)
|
||||||
|
batch.price = fmt_money(batch.amount, 0, batch.currency)
|
||||||
|
|
||||||
|
return batches
|
||||||
|
|||||||
@@ -1,31 +1,9 @@
|
|||||||
{% set enrolled = get_enrolled_courses().in_progress + get_enrolled_courses().completed %}
|
{% set enrolled = get_enrolled_courses().in_progress + get_enrolled_courses().completed %}
|
||||||
|
|
||||||
|
|
||||||
{% if enrolled | length %}
|
{% if enrolled | length %}
|
||||||
<div class="cards-parent">
|
<div class="cards-parent">
|
||||||
{% for course in enrolled %}
|
{% for course in enrolled %}
|
||||||
{{ widgets.CourseCard(course=course) }}
|
{{ widgets.CourseCard(course=course) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
{% set site_name = frappe.db.get_single_value("System Settings", "app_name") %}
|
|
||||||
<div class="empty-state p-5">
|
|
||||||
<div style="text-align: left; flex: 1;">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="empty-state-heading">{{ _("You haven't enrolled for any courses") }}</div>
|
|
||||||
<div class="course-meta mb-6">{{ _("Here are a few courses we recommend for you to get started with {0}").format(site_name) }}</div>
|
|
||||||
</div>
|
|
||||||
{% set recommended_courses = get_popular_courses() %}
|
|
||||||
<div class="cards-parent">
|
|
||||||
{% for course in recommended_courses %}
|
|
||||||
{% if course %}
|
|
||||||
{% set course_details = frappe.get_doc("LMS Course", course.course) %}
|
|
||||||
{{ widgets.CourseCard(course=course_details) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
369
lms/locale/ar.po
369
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
369
lms/locale/bs.po
369
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
371
lms/locale/de.po
371
lms/locale/de.po
File diff suppressed because it is too large
Load Diff
375
lms/locale/eo.po
375
lms/locale/eo.po
File diff suppressed because it is too large
Load Diff
375
lms/locale/es.po
375
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
377
lms/locale/fa.po
377
lms/locale/fa.po
File diff suppressed because it is too large
Load Diff
369
lms/locale/fr.po
369
lms/locale/fr.po
File diff suppressed because it is too large
Load Diff
369
lms/locale/hu.po
369
lms/locale/hu.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
369
lms/locale/pl.po
369
lms/locale/pl.po
File diff suppressed because it is too large
Load Diff
371
lms/locale/ru.po
371
lms/locale/ru.po
File diff suppressed because it is too large
Load Diff
377
lms/locale/sv.po
377
lms/locale/sv.po
File diff suppressed because it is too large
Load Diff
463
lms/locale/tr.po
463
lms/locale/tr.po
File diff suppressed because it is too large
Load Diff
371
lms/locale/zh.po
371
lms/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ def get_context():
|
|||||||
frappe.db.commit() # nosemgrep
|
frappe.db.commit() # nosemgrep
|
||||||
context.csrf_token = csrf_token
|
context.csrf_token = csrf_token
|
||||||
capture("active_site", "lms")
|
capture("active_site", "lms")
|
||||||
|
context.favicon = frappe.db.get_single_value("Website Settings", "favicon")
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user