Compare commits
193 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 | ||
|
|
bb6e97992b | ||
|
|
64fac451f3 | ||
|
|
e45b33a809 | ||
|
|
eb6b72515e | ||
|
|
0550d3aea3 | ||
|
|
f6577acbff | ||
|
|
09c494f38a | ||
|
|
6c600d747e | ||
|
|
9dcfc347d9 | ||
|
|
fb40b627fc | ||
|
|
c597f96375 | ||
|
|
f1961ab614 | ||
|
|
c2c7b7b250 | ||
|
|
c20c272f8e | ||
|
|
85e4115306 | ||
|
|
10c2bc589a | ||
|
|
a30244cb4a | ||
|
|
5691fcdca4 | ||
|
|
f5848207e2 | ||
|
|
ad224161d8 | ||
|
|
5837a1ffab | ||
|
|
1cfd7cdb98 | ||
|
|
56a4aa2a3f | ||
|
|
d91d2ded77 | ||
|
|
6a48d44b14 | ||
|
|
31c5d423d0 | ||
|
|
79177b5f5b | ||
|
|
74658b2054 | ||
|
|
052fffccef | ||
|
|
bd2b558154 | ||
|
|
65ee6b62ea | ||
|
|
26266a22e8 | ||
|
|
e52ca63075 | ||
|
|
4d8b2eb5b4 | ||
|
|
2d81a1ce31 | ||
|
|
052a85fbc0 | ||
|
|
fa0e84c671 | ||
|
|
4759736571 | ||
|
|
f77686feaa | ||
|
|
34548b93f4 | ||
|
|
f438d33f75 | ||
|
|
be1c0de4c6 | ||
|
|
ae5ea9a8aa | ||
|
|
eeb7fb1f78 | ||
|
|
3f32d5bb3b | ||
|
|
12019ca37d | ||
|
|
4d133b2f99 | ||
|
|
e733226b0c | ||
|
|
2ed583a0c3 | ||
|
|
048cee654e | ||
|
|
1293294593 | ||
|
|
a1947a3106 | ||
|
|
eff6cd6bbe | ||
|
|
d784ac5699 | ||
|
|
9acad5157b | ||
|
|
94459efa3f | ||
|
|
e88bc6a5ce | ||
|
|
55a7ab54e9 | ||
|
|
0c324c87cc | ||
|
|
31e8befa11 | ||
|
|
86ab7a6d97 | ||
|
|
14bdfb2d98 | ||
|
|
0036e585da | ||
|
|
cba2343fc0 | ||
|
|
864eebce2f | ||
|
|
156d36fb5e | ||
|
|
068718aa8a | ||
|
|
10219abfd6 | ||
|
|
2ec231a3d0 | ||
|
|
78f29b3aff | ||
|
|
7f768e81f4 | ||
|
|
aa1460eda1 | ||
|
|
85f85063ac | ||
|
|
0a7ce3c5d8 | ||
|
|
8468d0e3db | ||
|
|
059ac27f0b | ||
|
|
a96f8836b1 | ||
|
|
4018116136 | ||
|
|
aa083c8a40 | ||
|
|
8752243e9c | ||
|
|
1d028e81c4 | ||
|
|
2752d3e42c | ||
|
|
aa074ef762 | ||
|
|
bae75cd2f6 | ||
|
|
81a714b5a2 | ||
|
|
10cd44c22f | ||
|
|
a44f59c362 | ||
|
|
8d372fcab4 | ||
|
|
97d6c518b5 | ||
|
|
f331c48e1d | ||
|
|
9d0b10058d | ||
|
|
4ccd3ba71e | ||
|
|
7a6f5a868c | ||
|
|
0fae11d031 | ||
|
|
8a9725c990 | ||
|
|
d0189b0e3a | ||
|
|
c6853cc95e | ||
|
|
f28f37fb2c | ||
|
|
7dbbe9dba4 | ||
|
|
b625d9b099 | ||
|
|
a85c81a4b4 | ||
|
|
1677a4a32b | ||
|
|
776d46f5a2 | ||
|
|
6384eeaa13 | ||
|
|
fdc0befcee | ||
|
|
f2c28eb695 | ||
|
|
4095916991 | ||
|
|
551703364a | ||
|
|
4a2fae023c | ||
|
|
fca206120e | ||
|
|
65b2199065 | ||
|
|
9d03a52bf9 | ||
|
|
c8aa44dfcb | ||
|
|
7fcbe85ab9 | ||
|
|
de0dea7df8 | ||
|
|
43cf7d04b8 | ||
|
|
4d18580482 |
64
.github/workflows/build.yml
vendored
Normal file
64
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
name: Build Container Image
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [amd64, arm64]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Entire Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
platforms: linux/${{ matrix.arch }}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set Branch
|
||||||
|
run: |
|
||||||
|
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
|
||||||
|
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||||
|
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set Image Tag
|
||||||
|
run: |
|
||||||
|
echo "IMAGE_TAG=stable" >> $GITHUB_ENV
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: frappe/frappe_docker
|
||||||
|
path: builds
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
context: builds
|
||||||
|
file: builds/images/layered/Containerfile
|
||||||
|
tags: >
|
||||||
|
ghcr.io/${{ github.repository }}:${{ github.ref_name }},
|
||||||
|
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||||
|
build-args: |
|
||||||
|
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||||
|
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||||
@@ -106,9 +106,9 @@ wget https://frappe.io/easy-install.py
|
|||||||
python3 ./easy-install.py deploy \
|
python3 ./easy-install.py deploy \
|
||||||
--project=learning_prod_setup \
|
--project=learning_prod_setup \
|
||||||
--email=your_email.example.com \
|
--email=your_email.example.com \
|
||||||
--image=ghcr.io/frappe/learning \
|
--image=ghcr.io/frappe/lms \
|
||||||
--version=stable \
|
--version=stable \
|
||||||
--app=learning \
|
--app=lms \
|
||||||
--sitename subdomain.domain.tld
|
--sitename subdomain.domain.tld
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}" />
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@editorjs/simple-image": "^1.6.0",
|
"@editorjs/simple-image": "^1.6.0",
|
||||||
"@editorjs/table": "^2.4.2",
|
"@editorjs/table": "^2.4.2",
|
||||||
"ace-builds": "^1.36.2",
|
"ace-builds": "^1.36.2",
|
||||||
|
"apexcharts": "^4.3.0",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-draggable-next": "^2.2.1",
|
"vue-draggable-next": "^2.2.1",
|
||||||
"vue-router": "^4.0.12",
|
"vue-router": "^4.0.12",
|
||||||
|
"vue3-apexcharts": "^1.8.0",
|
||||||
"vuedraggable": "4.1.0"
|
"vuedraggable": "4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -185,6 +185,17 @@ const addQuizzes = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addAssignments = () => {
|
||||||
|
if (isInstructor.value || isModerator.value) {
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Assignments',
|
||||||
|
icon: 'Pencil',
|
||||||
|
to: 'Assignments',
|
||||||
|
activeFor: ['Assignments', 'AssignmentForm'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addPrograms = () => {
|
const addPrograms = () => {
|
||||||
let activeFor = ['Programs', 'ProgramForm']
|
let activeFor = ['Programs', 'ProgramForm']
|
||||||
let index = 1
|
let index = 1
|
||||||
@@ -247,8 +258,9 @@ watch(userResource, () => {
|
|||||||
if (userResource.data) {
|
if (userResource.data) {
|
||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
isInstructor.value = userResource.data.is_instructor
|
isInstructor.value = userResource.data.is_instructor
|
||||||
addQuizzes()
|
|
||||||
addPrograms()
|
addPrograms()
|
||||||
|
addQuizzes()
|
||||||
|
addAssignments()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
75
frontend/src/components/AssessmentPlugin.vue
Normal file
75
frontend/src/components/AssessmentPlugin.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<div v-if="type == 'quiz'" class="text-lg font-semibold">
|
||||||
|
{{ __('Add a quiz to your lesson') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-lg font-semibold">
|
||||||
|
{{ __('Add an assignment to your lesson') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
v-if="type == 'quiz'"
|
||||||
|
v-model="quiz"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
:label="__('Select a quiz')"
|
||||||
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-else
|
||||||
|
v-model="assignment"
|
||||||
|
doctype="LMS Assignment"
|
||||||
|
:label="__('Select an assignment')"
|
||||||
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<Button variant="solid" @click="addAssessment()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, Button } from 'frappe-ui'
|
||||||
|
import { onMounted, ref, nextTick } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const quiz = ref(null)
|
||||||
|
const assignment = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
onAddition: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
show.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const addAssessment = () => {
|
||||||
|
props.onAddition(props.type == 'quiz' ? quiz.value : assignment.value)
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToForm = () => {
|
||||||
|
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
|
||||||
|
else window.open('/lms/assignments/new', '_blank')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ __('Add') }}
|
{{ __('Add') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="assessments.data?.length">
|
<div v-if="assessments.data?.length" class="text-sm">
|
||||||
<ListView
|
<ListView
|
||||||
:columns="getAssessmentColumns()"
|
:columns="getAssessmentColumns()"
|
||||||
:rows="assessments.data"
|
:rows="assessments.data"
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
:options="{
|
:options="{
|
||||||
showTooltip: false,
|
showTooltip: false,
|
||||||
getRowRoute: (row) => getRowRoute(row),
|
getRowRoute: (row) => getRowRoute(row),
|
||||||
|
selectable: user.data?.is_student ? false : true,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
@@ -41,6 +42,14 @@
|
|||||||
<div v-if="column.key == 'assessment_type'">
|
<div v-if="column.key == 'assessment_type'">
|
||||||
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="column.key == 'title'">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isNaN(row[column.key])">
|
||||||
|
<Badge :theme="getStatusTheme(row[column.key])">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
{{ row[column.key] }}
|
{{ row[column.key] }}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +92,7 @@ import {
|
|||||||
ListSelectBanner,
|
ListSelectBanner,
|
||||||
createResource,
|
createResource,
|
||||||
Button,
|
Button,
|
||||||
|
Badge,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { inject, ref } from 'vue'
|
import { inject, ref } from 'vue'
|
||||||
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
||||||
@@ -148,7 +158,7 @@ const getRowRoute = (row) => {
|
|||||||
return {
|
return {
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
params: {
|
params: {
|
||||||
assignmentName: row.assessment_name,
|
assignmentID: row.assessment_name,
|
||||||
submissionName: row.submission.name,
|
submissionName: row.submission.name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -156,7 +166,7 @@ const getRowRoute = (row) => {
|
|||||||
return {
|
return {
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
params: {
|
params: {
|
||||||
assignmentName: row.assessment_name,
|
assignmentID: row.assessment_name,
|
||||||
submissionName: 'new',
|
submissionName: 'new',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -180,23 +190,33 @@ const getAssessmentColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Assessment',
|
label: 'Assessment',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
width: '30rem',
|
width: '25rem',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
key: 'assessment_type',
|
key: 'assessment_type',
|
||||||
width: '10rem',
|
width: '15rem',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (!user.data?.is_moderator) {
|
if (!user.data?.is_moderator) {
|
||||||
columns.push({
|
columns.push({
|
||||||
label: 'Status/Score',
|
label: 'Status/Percentage',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
align: 'center',
|
align: 'left',
|
||||||
width: '10rem',
|
width: '10rem',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return columns
|
return columns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusTheme = (status) => {
|
||||||
|
if (status === 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (status === 'Not Graded') {
|
||||||
|
return 'orange'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
448
frontend/src/components/Assignment.vue
Normal file
448
frontend/src/components/Assignment.vue
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="assignment.data"
|
||||||
|
class="grid grid-cols-[68%,32%] h-full"
|
||||||
|
:class="{ 'border rounded-lg': !showTitle }"
|
||||||
|
>
|
||||||
|
<div class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]">
|
||||||
|
<div v-if="showTitle" class="text-lg font-semibold mb-5">
|
||||||
|
<div v-if="submissionName === 'new'">
|
||||||
|
{{ __('Submission by') }} {{ user.data?.full_name }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 font-medium mb-2">
|
||||||
|
{{ __('Question') }}:
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-html="assignment.data.question"
|
||||||
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ __('Submission') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Badge v-if="isDirty" theme="orange">
|
||||||
|
{{ __('Not Saved') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="submissionResource.doc?.status"
|
||||||
|
:theme="statusTheme"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{{ submissionResource.doc?.status }}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="solid" @click="submitAssignment()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
submissionName != 'new' &&
|
||||||
|
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
||||||
|
submissionResource.doc?.owner == user.data?.name
|
||||||
|
"
|
||||||
|
class="bg-blue-100 p-3 rounded-md leading-5 text-sm mb-4"
|
||||||
|
>
|
||||||
|
{{ __("You've successfully submitted the assignment.") }}
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
"Once the moderator grades your submission, you'll find the details here."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="showUploader()">
|
||||||
|
<div class="text-xs text-gray-600 mt-1 mb-2">
|
||||||
|
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!submissionFile"
|
||||||
|
:fileTypes="getType()"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => saveSubmission(file)"
|
||||||
|
>
|
||||||
|
<template #default="{ uploading, progress, openFileSelector }">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading
|
||||||
|
? __('Uploading {0}%').format(progress)
|
||||||
|
: __('Upload File')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="submissionFile.file_url"
|
||||||
|
target="_blank"
|
||||||
|
class="flex flex-col cursor-pointer !no-underline"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ submissionFile.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ getFileSize(submissionFile.file_size) }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<X
|
||||||
|
v-if="canModifyAssignment"
|
||||||
|
@click="removeSubmission()"
|
||||||
|
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="assignment.data.type == 'URL'">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Enter a URL') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="answer"
|
||||||
|
type="text"
|
||||||
|
:readonly="!canModifyAssignment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-sm mb-4">
|
||||||
|
{{ __('Write your answer here') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="answer"
|
||||||
|
@change="(val) => (answer = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
user.data?.name == submissionResource.doc?.owner &&
|
||||||
|
submissionResource.doc?.comments
|
||||||
|
"
|
||||||
|
class="mt-8 p-3 bg-blue-100 rounded-md"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-600 font-medium mb-2">
|
||||||
|
{{ __('Comments by Evaluator') }}:
|
||||||
|
</div>
|
||||||
|
<div class="leading-5">
|
||||||
|
{{ submissionResource.doc.comments }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grading -->
|
||||||
|
<div v-if="canGradeSubmission" class="mt-8 space-y-4">
|
||||||
|
<div class="font-semibold mb-2">
|
||||||
|
{{ __('Grading') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-if="submissionResource.doc"
|
||||||
|
v-model="submissionResource.doc.status"
|
||||||
|
:label="__('Grade')"
|
||||||
|
type="select"
|
||||||
|
:options="submissionStatusOptions"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="submissionResource.doc"
|
||||||
|
v-model="submissionResource.doc.comments"
|
||||||
|
:label="__('Comments')"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createResource,
|
||||||
|
createDocumentResource,
|
||||||
|
FileUploader,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
|
import { showToast, getFileSize } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const submissionFile = ref(null)
|
||||||
|
const answer = ref(null)
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
const showTitle = router.currentRoute.value.name == 'AssignmentSubmission'
|
||||||
|
const isDirty = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
submissionName: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardShortcut = (e) => {
|
||||||
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
submitAssignment()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignment = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
name: props.assignmentID,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
if (props.submissionName != 'new') {
|
||||||
|
submissionResource.reload()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newSubmission = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
let doc = {
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
assignment: props.assignmentID,
|
||||||
|
member: user.data?.name,
|
||||||
|
}
|
||||||
|
if (showUploader()) {
|
||||||
|
doc.assignment_attachment = submissionFile.value.file_url
|
||||||
|
} else {
|
||||||
|
doc.answer = answer.value
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
doc: doc,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageResource = createResource({
|
||||||
|
url: 'lms.lms.api.get_file_info',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
file_url: values.image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
submissionFile.value = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submissionResource = createDocumentResource({
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
name: props.submissionName,
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
cache: [user.data?.name, props.assignmentID],
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(submissionResource, () => {
|
||||||
|
if (submissionResource.doc) {
|
||||||
|
if (submissionResource.doc.assignment_attachment) {
|
||||||
|
imageResource.reload({
|
||||||
|
image: submissionResource.doc.assignment_attachment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (submissionResource.doc.answer) {
|
||||||
|
answer.value = submissionResource.doc.answer
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submissionResource.isDirty) {
|
||||||
|
isDirty.value = true
|
||||||
|
} else if (showUploader() && !submissionFile.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
} else if (!showUploader() && !answer.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
} else {
|
||||||
|
isDirty.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(submissionFile, () => {
|
||||||
|
if (props.submissionName == 'new' && submissionFile.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitAssignment = () => {
|
||||||
|
if (props.submissionName != 'new') {
|
||||||
|
let evaluator =
|
||||||
|
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||||
|
? user.data?.name
|
||||||
|
: null
|
||||||
|
submissionResource.setValue.submit(
|
||||||
|
{
|
||||||
|
...submissionResource.doc,
|
||||||
|
evaluator: evaluator,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast(__('Success'), __('Changes saved successfully'), 'check')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
addNewSubmission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNewSubmission = () => {
|
||||||
|
newSubmission.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast('Success', 'Assignment submitted successfully.', 'check')
|
||||||
|
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||||
|
router.push({
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentID: props.assignmentID,
|
||||||
|
submissionName: data.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
markLessonProgress()
|
||||||
|
router.go()
|
||||||
|
}
|
||||||
|
submissionResource.name = data.name
|
||||||
|
submissionResource.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSubmission = (file) => {
|
||||||
|
submissionFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
const markLessonProgress = () => {
|
||||||
|
if (router.currentRoute.value.name == 'Lesson') {
|
||||||
|
let courseName = router.currentRoute.value.params.courseName
|
||||||
|
let chapterNumber = router.currentRoute.value.params.chapterNumber
|
||||||
|
let lessonNumber = router.currentRoute.value.params.lessonNumber
|
||||||
|
|
||||||
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
|
course: courseName,
|
||||||
|
chapter_number: chapterNumber,
|
||||||
|
lesson_number: lessonNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getType = () => {
|
||||||
|
const type = assignment.data?.type
|
||||||
|
if (type == 'Image') {
|
||||||
|
return ['image/*']
|
||||||
|
} else if (type == 'Document') {
|
||||||
|
return [
|
||||||
|
'.doc',
|
||||||
|
'.docx',
|
||||||
|
'.xml',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
]
|
||||||
|
} else if (type == 'PDF') {
|
||||||
|
return ['.pdf']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let type = assignment.data?.type
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||||
|
return 'Only image file is allowed.'
|
||||||
|
} else if (
|
||||||
|
type == 'Document' &&
|
||||||
|
!['doc', 'docx', 'xml'].includes(extension)
|
||||||
|
) {
|
||||||
|
return 'Only document file is allowed.'
|
||||||
|
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||||
|
return 'Only PDF file is allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSubmission = () => {
|
||||||
|
submissionFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGradeSubmission = computed(() => {
|
||||||
|
return (
|
||||||
|
(user.data?.is_moderator ||
|
||||||
|
user.data?.is_evaluator ||
|
||||||
|
user.data?.is_instructor) &&
|
||||||
|
props.submissionName != 'new' &&
|
||||||
|
router.currentRoute.value.name == 'AssignmentSubmission'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canModifyAssignment = computed(() => {
|
||||||
|
return (
|
||||||
|
!submissionResource.doc ||
|
||||||
|
(submissionResource.doc?.owner == user.data?.name &&
|
||||||
|
submissionResource.doc?.status == 'Not Graded')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const submissionStatusOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'Not Graded', value: 'Not Graded' },
|
||||||
|
{ label: 'Pass', value: 'Pass' },
|
||||||
|
{ label: 'Fail', value: 'Fail' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusTheme = computed(() => {
|
||||||
|
if (!submissionResource.doc) {
|
||||||
|
return 'orange'
|
||||||
|
} else if (submissionResource.doc.status == 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (submissionResource.doc.status == 'Not Graded') {
|
||||||
|
return 'blue'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const showUploader = () => {
|
||||||
|
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
46
frontend/src/components/AssignmentBlock.vue
Normal file
46
frontend/src/components/AssignmentBlock.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<Assignment
|
||||||
|
v-if="user.data && submission.data"
|
||||||
|
:assignmentID="assignmentID"
|
||||||
|
:submissionName="submission.data?.name || 'new'"
|
||||||
|
/>
|
||||||
|
<div v-else class="border rounded-md text-center py-20">
|
||||||
|
<div>
|
||||||
|
{{ __('Please login to access the assignment.') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="redirectToLogin()" class="mt-2">
|
||||||
|
<span>
|
||||||
|
{{ __('Login') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { inject, watch } from 'vue'
|
||||||
|
import { Button, createResource } from 'frappe-ui'
|
||||||
|
import Assignment from '@/components/Assignment.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const submission = createResource({
|
||||||
|
url: 'frappe.client.get_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
fieldname: 'name',
|
||||||
|
filters: {
|
||||||
|
assignment: props.assignmentID,
|
||||||
|
member: user.data?.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="space-y-10">
|
||||||
<UpcomingEvaluations
|
<UpcomingEvaluations
|
||||||
:batch="batch.data.name"
|
:batch="batch.data.name"
|
||||||
:endDate="batch.data.evaluation_end_date"
|
:endDate="batch.data.evaluation_end_date"
|
||||||
:courses="batch.data.courses"
|
:courses="batch.data.courses"
|
||||||
:isStudent="isStudent"
|
|
||||||
/>
|
/>
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
|
<StudentHeatmap />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||||
import Assessments from '@/components/Assessments.vue'
|
import Assessments from '@/components/Assessments.vue'
|
||||||
|
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
|
|||||||
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>
|
||||||
@@ -1,100 +1,196 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="">
|
||||||
<div class="text-lg font-semibold">
|
<div class="w-full flex items-center justify-between pb-4">
|
||||||
{{ __('Students') }}
|
<div class="font-medium text-gray-600">
|
||||||
|
{{ __('Statistics') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="openStudentModal()">
|
<div class="grid grid-cols-3 gap-5 mb-8">
|
||||||
<template #prefix>
|
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
<Plus class="h-4 w-4" />
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
</template>
|
<User class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
{{ __('Add') }}
|
</div>
|
||||||
</Button>
|
<div class="flex items-center space-x-2">
|
||||||
</div>
|
<span class="font-semibold">
|
||||||
<div v-if="students.data?.length">
|
{{ students.data?.length }}
|
||||||
<ListView
|
</span>
|
||||||
:columns="getStudentColumns()"
|
<span class="text-gray-700">
|
||||||
:rows="students.data"
|
{{ __('Students') }}
|
||||||
row-key="name"
|
</span>
|
||||||
:options="{ showTooltip: false }"
|
</div>
|
||||||
>
|
</div>
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
|
<BookOpen class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ batch.courses?.length }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
|
{{ __('Courses') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
|
<ShieldCheck class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ assessmentCount }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-700">
|
||||||
|
{{ __('Assessments') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showProgressChart" class="mb-8">
|
||||||
|
<div class="text-gray-600 font-medium">
|
||||||
|
{{ __('Progress') }}
|
||||||
|
</div>
|
||||||
|
<ApexChart
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="chartData"
|
||||||
|
type="bar"
|
||||||
|
height="200"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center text-sm text-gray-700 space-x-4"
|
||||||
>
|
>
|
||||||
<ListHeaderItem
|
<div class="flex items-center space-x-2">
|
||||||
:item="item"
|
<div
|
||||||
v-for="item in getStudentColumns()"
|
class="w-3 h-3 rounded-sm"
|
||||||
:title="item.label"
|
:style="{ 'background-color': theme.colors.green[600] }"
|
||||||
>
|
></div>
|
||||||
<template #prefix="{ item }">
|
<div>
|
||||||
<FeatherIcon
|
{{ __('Courses') }}
|
||||||
v-if="item.icon"
|
|
||||||
:name="item.icon"
|
|
||||||
class="h-4 w-4 stroke-1.5"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ListHeaderItem>
|
|
||||||
</ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow :row="row" v-for="row in students.data">
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
|
||||||
<template #prefix>
|
|
||||||
<div v-if="column.key == 'full_name'">
|
|
||||||
<Avatar
|
|
||||||
class="flex items-center"
|
|
||||||
:image="row['user_image']"
|
|
||||||
:label="item"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="column.key == 'courses'">
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
<div v-else-if="column.icon == 'book-open'">
|
|
||||||
{{ Math.ceil(row.courses[column.key]) }}
|
|
||||||
</div>
|
|
||||||
<div v-else-if="column.icon == 'help-circle'">
|
|
||||||
<Badge
|
|
||||||
v-if="isAssignment(row.assessments[column.key])"
|
|
||||||
:theme="getStatusTheme(row.assessments[column.key])"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ row.assessments[column.key] }}
|
|
||||||
</Badge>
|
|
||||||
<div v-else>{{ parseInt(row.assessments[column.key]) }}</div>
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
<ListSelectBanner>
|
|
||||||
<template #actions="{ unselectAll, selections }">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
@click="removeStudents(selections, unselectAll)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-sm"
|
||||||
|
:style="{ 'background-color': theme.colors.blue[600] }"
|
||||||
|
></div>
|
||||||
|
<div>
|
||||||
|
{{ __('Assessments') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="text-gray-600 font-medium">
|
||||||
|
{{ __('Students') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="openStudentModal()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
</ListSelectBanner>
|
{{ __('Add') }}
|
||||||
</ListView>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-gray-600">
|
|
||||||
{{ __('There are no students in this batch.') }}
|
<div v-if="students.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getStudentColumns()"
|
||||||
|
:rows="students.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem
|
||||||
|
:item="item"
|
||||||
|
v-for="item in getStudentColumns()"
|
||||||
|
:title="item.label"
|
||||||
|
>
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="item.icon"
|
||||||
|
:name="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow
|
||||||
|
:row="row"
|
||||||
|
v-for="row in students.data"
|
||||||
|
class="group cursor-pointer"
|
||||||
|
@click="openStudentProgressModal(row)"
|
||||||
|
>
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key]"
|
||||||
|
:align="column.align"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'full_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['user_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="column.key == 'progress'"
|
||||||
|
class="flex items-center space-x-4 w-full"
|
||||||
|
>
|
||||||
|
<ProgressBar :progress="row[column.key]" size="sm" />
|
||||||
|
<div class="text-xs">{{ row[column.key] }}%</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeStudents(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('There are no students in this batch.') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StudentModal
|
<StudentModal
|
||||||
:batch="props.batch"
|
:batch="props.batch.name"
|
||||||
v-model="showStudentModal"
|
v-model="showStudentModal"
|
||||||
v-model:reloadStudents="students"
|
v-model:reloadStudents="students"
|
||||||
/>
|
/>
|
||||||
|
<BatchStudentProgress
|
||||||
|
:student="selectedStudent"
|
||||||
|
v-model="showStudentProgressModal"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
@@ -106,27 +202,48 @@ import {
|
|||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { Trash2, Plus } from 'lucide-vue-next'
|
import {
|
||||||
import { ref } from 'vue'
|
BookOpen,
|
||||||
|
Clipboard,
|
||||||
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
import { showToast } from '@/utils'
|
import { showToast } from '@/utils'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||||
|
import ApexChart from 'vue3-apexcharts'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
const showStudentModal = ref(false)
|
const showStudentModal = ref(false)
|
||||||
|
const showStudentProgressModal = ref(false)
|
||||||
|
const selectedStudent = ref(null)
|
||||||
|
const chartData = ref(null)
|
||||||
|
const chartOptions = ref(null)
|
||||||
|
const showProgressChart = ref(false)
|
||||||
|
const assessmentCount = ref(0)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const students = createResource({
|
const students = createResource({
|
||||||
url: 'lms.lms.utils.get_batch_students',
|
url: 'lms.lms.utils.get_batch_students',
|
||||||
cache: ['students', props.batch],
|
cache: ['students', props.batch.name],
|
||||||
params: {
|
params: {
|
||||||
batch: props.batch,
|
batch: props.batch?.name,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
chartData.value = getChartData()
|
||||||
|
showProgressChart.value = true
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const getStudentColumns = () => {
|
const getStudentColumns = () => {
|
||||||
@@ -134,36 +251,24 @@ const getStudentColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Full Name',
|
label: 'Full Name',
|
||||||
key: 'full_name',
|
key: 'full_name',
|
||||||
|
width: '20rem',
|
||||||
|
icon: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Progress',
|
||||||
|
key: 'progress',
|
||||||
width: '15rem',
|
width: '15rem',
|
||||||
|
icon: 'activity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last Active',
|
||||||
|
key: 'last_active',
|
||||||
|
width: '10rem',
|
||||||
|
align: 'center',
|
||||||
|
icon: 'clock',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (students.data?.[0].assessments) {
|
|
||||||
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
|
|
||||||
columns.push({
|
|
||||||
label: assessment,
|
|
||||||
key: assessment,
|
|
||||||
width: '10rem',
|
|
||||||
icon: 'help-circle',
|
|
||||||
align: isAssignment(students.data?.[0].assessments[assessment])
|
|
||||||
? 'left'
|
|
||||||
: 'center',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (students.data?.[0].courses) {
|
|
||||||
Object.keys(students.data?.[0].courses).forEach((course) => {
|
|
||||||
columns.push({
|
|
||||||
label: course,
|
|
||||||
key: course,
|
|
||||||
width: '10rem',
|
|
||||||
icon: 'book-open',
|
|
||||||
align: 'center',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return columns
|
return columns
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +276,11 @@ const openStudentModal = () => {
|
|||||||
showStudentModal.value = true
|
showStudentModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openStudentProgressModal = (row) => {
|
||||||
|
showStudentProgressModal.value = true
|
||||||
|
selectedStudent.value = row
|
||||||
|
}
|
||||||
|
|
||||||
const deleteStudents = createResource({
|
const deleteStudents = createResource({
|
||||||
url: 'lms.lms.api.delete_documents',
|
url: 'lms.lms.api.delete_documents',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -196,17 +306,105 @@ const removeStudents = (selections, unselectAll) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusTheme = (status) => {
|
const getChartData = () => {
|
||||||
if (status === 'Pass') {
|
let categories = {}
|
||||||
return 'green'
|
|
||||||
} else if (status == 'Not Graded') {
|
Object.keys(students.data?.[0].courses).forEach((course) => {
|
||||||
return 'orange'
|
categories[course] = {
|
||||||
} else {
|
value: 0,
|
||||||
return 'red'
|
type: 'course',
|
||||||
|
label: course,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
|
||||||
|
categories[assessment] = {
|
||||||
|
value: 0,
|
||||||
|
type: 'assessment',
|
||||||
|
label: assessment,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
students.data.forEach((student) => {
|
||||||
|
Object.keys(student.courses).forEach((course) => {
|
||||||
|
if (student.courses[course] === 100) {
|
||||||
|
categories[course].value += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.keys(student.assessments).forEach((assessment) => {
|
||||||
|
if (student.assessments[assessment] === 100) {
|
||||||
|
categories[assessment].value += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
chartOptions.value = getChartOptions(categories)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: __('Completed by Students'),
|
||||||
|
data: Object.values(categories).map((item) => item.value),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChartOptions = (categories) => {
|
||||||
|
const courseColor = theme.colors.green[700]
|
||||||
|
const assessmentColor = theme.colors.blue[700]
|
||||||
|
const maxY =
|
||||||
|
students.data?.length % 5
|
||||||
|
? students.data?.length + (5 - (students.data?.length % 5))
|
||||||
|
: students.data?.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
chart: {
|
||||||
|
type: 'bar',
|
||||||
|
toolbar: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
bar: {
|
||||||
|
distributed: true,
|
||||||
|
borderRadius: 3,
|
||||||
|
borderRadiusApplication: 'end',
|
||||||
|
horizontal: true,
|
||||||
|
barHeight: '40%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colors: Object.values(categories).map((item) =>
|
||||||
|
item.type === 'course' ? courseColor : assessmentColor
|
||||||
|
),
|
||||||
|
xaxis: {
|
||||||
|
categories: Object.values(categories).map((item) => item.label),
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
fontSize: '10px',
|
||||||
|
},
|
||||||
|
rotate: 0,
|
||||||
|
formatter: function (value) {
|
||||||
|
return value.length > 30 ? `${value.substring(0, 30)}...` : value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
max: maxY,
|
||||||
|
min: 0,
|
||||||
|
stepSize: 10,
|
||||||
|
tickAmount: maxY / 5,
|
||||||
|
/* reversed: true */
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAssignment = (value) => {
|
watch(students, () => {
|
||||||
return isNaN(value)
|
if (students.data?.length) {
|
||||||
}
|
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.apexcharts-legend {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
<slot name="item-label" v-bind="{ active, selected, option }" />
|
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
<template #footer="{ value, close }">
|
||||||
<div>
|
<div v-if="attrs.onCreate">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full !justify-start"
|
class="w-full !justify-start"
|
||||||
@@ -42,6 +42,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full !justify-start"
|
||||||
|
:label="__('Clear')"
|
||||||
|
@click="() => clearValue(close)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<X class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Autocomplete>
|
</Autocomplete>
|
||||||
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
||||||
@@ -52,7 +64,7 @@
|
|||||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus, X } from 'lucide-vue-next'
|
||||||
import { useAttrs, computed, ref } from 'vue'
|
import { useAttrs, computed, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -75,9 +87,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
|
|
||||||
const valuePropPassed = computed(() => 'value' in attrs)
|
const valuePropPassed = computed(() => 'value' in attrs)
|
||||||
|
|
||||||
const value = computed({
|
const value = computed({
|
||||||
@@ -131,7 +141,7 @@ const options = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function reload(val) {
|
const reload = (val) => {
|
||||||
options.update({
|
options.update({
|
||||||
params: {
|
params: {
|
||||||
txt: val,
|
txt: val,
|
||||||
@@ -142,6 +152,11 @@ function reload(val) {
|
|||||||
options.reload()
|
options.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearValue = (close) => {
|
||||||
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
const labelClasses = computed(() => {
|
const labelClasses = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -87,25 +87,29 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="mt-8 mb-4 font-medium">
|
<div class="space-y-4">
|
||||||
{{ __('This course has:') }}
|
<div class="mt-8 font-medium">
|
||||||
</div>
|
{{ __('This course has:') }}
|
||||||
<div class="flex items-center mb-3">
|
</div>
|
||||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
<div class="flex items-center">
|
||||||
<span class="ml-2">
|
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-600" />
|
||||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
<span class="ml-2">
|
||||||
</span>
|
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center mb-3">
|
</div>
|
||||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
<div class="flex items-center">
|
||||||
<span class="ml-2">
|
<Users class="h-4 w-4 stroke-1.5 text-gray-600" />
|
||||||
{{ formatAmount(course.data.enrollments) }}
|
<span class="ml-2">
|
||||||
{{ __('Enrolled Students') }}
|
{{ formatAmount(course.data.enrollments) }}
|
||||||
</span>
|
{{ __('Enrolled Students') }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center">
|
</div>
|
||||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
<div v-if="parseInt(course.data.rating) > 0" class="flex items-center">
|
||||||
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ course.data.rating }} {{ __('Rating') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center text-sm font-medium space-x-2">
|
||||||
|
<span>
|
||||||
|
{{ __('What does include in preview mean?') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mb-1 leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div
|
<div
|
||||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
@@ -56,21 +71,6 @@
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center text-sm font-medium space-x-2">
|
|
||||||
<span>
|
|
||||||
{{ __('What does include in preview mean?') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-600 mb-1 leading-5">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
|
<ExplanationVideos v-model="showExplanation" :title="title" :type="type" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,45 +15,55 @@
|
|||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||||
<div
|
<div
|
||||||
v-for="cls in liveClasses.data"
|
v-for="cls in liveClasses.data"
|
||||||
class="flex flex-col border rounded-md h-full text-sm text-gray-700 p-3"
|
class="flex flex-col border rounded-md h-full text-gray-700 p-3"
|
||||||
>
|
>
|
||||||
<div class="font-semibold text-gray-900 text-lg mb-4">
|
<div class="font-semibold text-gray-900 text-lg mb-1">
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="leading-5 text-gray-700 text-sm mb-4">
|
<div class="short-introduction">
|
||||||
{{ cls.description }}
|
{{ cls.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-2">
|
<div class="space-y-3">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5 text-gray-700" />
|
<div class="flex items-center space-x-2">
|
||||||
<span class="ml-2">
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
<span>
|
||||||
</span>
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center mb-5">
|
</div>
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
<div class="flex items-center space-x-2">
|
||||||
<span class="ml-2">
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
{{ formatTime(cls.time) }}
|
<span>
|
||||||
</span>
|
{{ formatTime(cls.time) }}
|
||||||
</div>
|
</span>
|
||||||
<div class="flex items-center space-x-2 text-gray-900 mt-auto">
|
</div>
|
||||||
<a
|
<div
|
||||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
|
||||||
:href="cls.start_url"
|
class="flex items-center space-x-2 text-gray-900 mt-auto"
|
||||||
target="_blank"
|
|
||||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
|
||||||
>
|
>
|
||||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
<a
|
||||||
{{ __('Start') }}
|
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||||
</a>
|
:href="cls.start_url"
|
||||||
<a
|
target="_blank"
|
||||||
v-if="cls.date <= dayjs().format('YYYY-MM-DD')"
|
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||||
:href="cls.join_url"
|
>
|
||||||
target="_blank"
|
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
{{ __('Start') }}
|
||||||
>
|
</a>
|
||||||
<Video class="h-4 w-4 stroke-1.5" />
|
<a
|
||||||
{{ __('Join') }}
|
:href="cls.join_url"
|
||||||
</a>
|
target="_blank"
|
||||||
|
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-gray-800 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring focus-visible:ring-gray-400 h-7 text-base px-2 rounded"
|
||||||
|
>
|
||||||
|
<Video class="h-4 w-4 stroke-1.5" />
|
||||||
|
{{ __('Join') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center space-x-2 text-yellow-700">
|
||||||
|
<Info class="w-4 h-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('This class has ended') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +78,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Button } from 'frappe-ui'
|
import { createListResource, Button } from 'frappe-ui'
|
||||||
import { Plus, Clock, Calendar, Video, Monitor } from 'lucide-vue-next'
|
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
|
||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
@@ -107,3 +117,15 @@ const openLiveClassModal = () => {
|
|||||||
showLiveClassModal.value = true
|
showLiveClassModal.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.short-introduction {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
111
frontend/src/components/Modals/BatchStudentProgress.vue
Normal file
111
frontend/src/components/Modals/BatchStudentProgress.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: 'xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 space-y-10 text-base">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar :image="student.user_image" size="3xl" />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ student.full_name }}
|
||||||
|
</div>
|
||||||
|
<Badge :theme="student.progress === 100 ? 'green' : 'red'">
|
||||||
|
{{ student.progress }}% {{ __('Complete') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700">
|
||||||
|
{{ student.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Assessments -->
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center border-b pb-1 font-medium">
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ __('Assessment') }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ __('Progress') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="assessment in Object.keys(student.assessments)"
|
||||||
|
class="flex items-center text-gray-700 font-medium"
|
||||||
|
>
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ assessment }}
|
||||||
|
</span>
|
||||||
|
<span v-if="isAssignment(student.assessments[assessment])">
|
||||||
|
<Badge :theme="getStatusTheme(student.assessments[assessment])">
|
||||||
|
{{ student.assessments[assessment] }}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ student.assessments[assessment] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Courses -->
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center border-b pb-1 font-medium">
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ __('Courses') }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ __('Progress') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="course in Object.keys(student.courses)"
|
||||||
|
class="flex items-center text-gray-700 font-medium"
|
||||||
|
>
|
||||||
|
<span class="flex-1">
|
||||||
|
{{ course }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ Math.floor(student.courses[course]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Heatmap -->
|
||||||
|
<StudentHeatmap :member="student.email" :base_days="120" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Avatar, Badge, Dialog } from 'frappe-ui'
|
||||||
|
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const props = defineProps({
|
||||||
|
student: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAssignment = (value) => {
|
||||||
|
return isNaN(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusTheme = (status) => {
|
||||||
|
if (status === 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (status == 'Not Graded') {
|
||||||
|
return 'orange'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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,24 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
|
<Tooltip :text="`${props.progress}%`">
|
||||||
<div
|
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
|
||||||
class="bg-gray-900 h-1 rounded-full"
|
<div
|
||||||
:style="{ width: progressBarWidth }"
|
class="bg-gray-900 rounded-full"
|
||||||
></div>
|
:class="progressBarHeight"
|
||||||
</div>
|
:style="{ width: progressBarWidth }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { Tooltip } from 'frappe-ui'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
progress: {
|
progress: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'sm',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const progressBarWidth = computed(() => {
|
const progressBarWidth = computed(() => {
|
||||||
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
|
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
|
||||||
return `${formattedPercentage}%`
|
return `${formattedPercentage}%`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const progressBarHeight = computed(() => {
|
||||||
|
if (props.size === 'sm') {
|
||||||
|
return 'h-1'
|
||||||
|
}
|
||||||
|
if (props.size === 'md') {
|
||||||
|
return 'h-2'
|
||||||
|
}
|
||||||
|
if (props.size === 'lg') {
|
||||||
|
return 'h-3'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -118,15 +118,17 @@
|
|||||||
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
|
class="w-3.5 h-3.5 text-gray-900 rounded-sm focus:ring-gray-200"
|
||||||
@change="markAnswer(index)"
|
@change="markAnswer(index)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="quiz.data.show_answers"
|
v-else-if="quiz.data.show_answers"
|
||||||
v-for="(answer, idx) in showAnswers"
|
v-for="(answer, idx) in showAnswers"
|
||||||
>
|
>
|
||||||
<div v-if="index - 1 == idx">
|
<div v-if="index - 1 == idx">
|
||||||
<CheckCircle v-if="answer" class="w-4 h-4 text-green-500" />
|
<CheckCircle
|
||||||
|
v-if="answer == 1"
|
||||||
|
class="w-4 h-4 text-green-500"
|
||||||
|
/>
|
||||||
<MinusCircle
|
<MinusCircle
|
||||||
v-else-if="questionDetails.data[`is_correct_${index}`]"
|
v-else-if="answer == 2"
|
||||||
class="w-4 h-4 text-green-500"
|
class="w-4 h-4 text-green-500"
|
||||||
/>
|
/>
|
||||||
<XCircle
|
<XCircle
|
||||||
@@ -271,6 +273,7 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
call,
|
||||||
createResource,
|
createResource,
|
||||||
ListView,
|
ListView,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
@@ -280,6 +283,7 @@ import { ref, watch, reactive, inject, computed } from 'vue'
|
|||||||
import { createToast } from '@/utils/'
|
import { createToast } from '@/utils/'
|
||||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -291,6 +295,7 @@ let questions = reactive([])
|
|||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -496,8 +501,8 @@ const checkAnswer = () => {
|
|||||||
selectedOptions.forEach((option, index) => {
|
selectedOptions.forEach((option, index) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
showAnswers[index] = option && data[index]
|
showAnswers[index] = option && data[index]
|
||||||
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
} else if (data[index] == 2) {
|
||||||
showAnswers[index] = 0
|
showAnswers[index] = 2
|
||||||
} else {
|
} else {
|
||||||
showAnswers[index] = undefined
|
showAnswers[index] = undefined
|
||||||
}
|
}
|
||||||
@@ -560,6 +565,7 @@ const createSubmission = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
markLessonProgress()
|
||||||
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||||
if (quiz.data.duration) clearInterval(timerInterval)
|
if (quiz.data.duration) clearInterval(timerInterval)
|
||||||
},
|
},
|
||||||
@@ -583,6 +589,16 @@ const getInstructions = (question) => {
|
|||||||
else return __('Type your answer')
|
else return __('Type your answer')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markLessonProgress = () => {
|
||||||
|
if (router.currentRoute.value.name == 'Lesson') {
|
||||||
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
|
course: router.currentRoute.value.params.courseName,
|
||||||
|
chapter_number: router.currentRoute.value.params.chapterNumber,
|
||||||
|
lesson_number: router.currentRoute.value.params.lessonNumber,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getSubmissionColumns = () => {
|
const getSubmissionColumns = () => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
v-model="show"
|
|
||||||
:options="{
|
|
||||||
size: 'xl',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<div class="p-5 space-y-4">
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ __('Add a quiz to your lesson') }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
v-model="quiz"
|
|
||||||
doctype="LMS Quiz"
|
|
||||||
:label="__('Select a quiz')"
|
|
||||||
:onCreate="(value, close) => redirectToQuizForm()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="solid" @click="addQuiz()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import { Dialog, Button } from 'frappe-ui'
|
|
||||||
import { onMounted, ref, nextTick } from 'vue'
|
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
|
|
||||||
const show = ref(false)
|
|
||||||
const quiz = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
onQuizAddition: {
|
|
||||||
type: Function,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
show.value = true
|
|
||||||
})
|
|
||||||
|
|
||||||
const addQuiz = () => {
|
|
||||||
props.onQuizAddition(quiz.value)
|
|
||||||
show.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectToQuizForm = () => {
|
|
||||||
window.open('/lms/quizzes/new', '_blank')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
138
frontend/src/components/StudentHeatmap.vue
Normal file
138
frontend/src/components/StudentHeatmap.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="heatmap.data">
|
||||||
|
<div class="text-lg font-semibold mb-2">
|
||||||
|
{{ heatmap.data.total_activities }}
|
||||||
|
{{
|
||||||
|
heatmap.data.total_activities > 1 ? __('activities') : __('activity')
|
||||||
|
}}
|
||||||
|
{{ __('in the last') }}
|
||||||
|
{{ heatmap.data.weeks }}
|
||||||
|
{{ __('weeks') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApexChart :options="chartOptions" :series="chartSeries" height="240" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import ApexChart from 'vue3-apexcharts'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const labels = ref([])
|
||||||
|
const memberName = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
member: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
base_days: {
|
||||||
|
type: Number,
|
||||||
|
default: 200,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
memberName.value = props.member || user.data?.name
|
||||||
|
})
|
||||||
|
|
||||||
|
const heatmap = createResource({
|
||||||
|
url: 'lms.lms.api.get_heatmap_data',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
member: values.member,
|
||||||
|
base_days: props.base_days,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
cache: ['heatmap', memberName.value],
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(memberName, (newVal) => {
|
||||||
|
heatmap.reload(
|
||||||
|
{
|
||||||
|
member: newVal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
labels.value = data.labels
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = computed(() => {
|
||||||
|
return {
|
||||||
|
chart: {
|
||||||
|
type: 'heatmap',
|
||||||
|
toolbar: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
highlightOnHover: false,
|
||||||
|
grid: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
heatmap: {
|
||||||
|
radius: 8,
|
||||||
|
shadeIntensity: 0.2,
|
||||||
|
enableShades: true,
|
||||||
|
colorScale: {
|
||||||
|
ranges: [
|
||||||
|
{ from: 0, to: 0, color: theme.colors.gray[400] },
|
||||||
|
{ from: 1, to: 5, color: theme.colors.green[200] },
|
||||||
|
{ from: 6, to: 15, color: theme.colors.green[500] },
|
||||||
|
{ from: 16, to: 30, color: theme.colors.green[700] },
|
||||||
|
{ from: 31, to: 100, color: theme.colors.green[800] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
type: 'category',
|
||||||
|
categories: labels.value,
|
||||||
|
position: 'top',
|
||||||
|
axisBorder: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
type: 'category',
|
||||||
|
categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||||
|
reversed: true,
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
custom: ({ series, seriesIndex, dataPointIndex, w }) => {
|
||||||
|
return `<div class="text-xs bg-gray-900 text-white font-medium p-1">
|
||||||
|
<div class="text-center">${heatmap.data.heatmap_data[seriesIndex].data[dataPointIndex].label}</div>
|
||||||
|
</div>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartSeries = computed(() => {
|
||||||
|
if (!heatmap.data) return []
|
||||||
|
let series = heatmap.data.heatmap_data.map((row) => {
|
||||||
|
return {
|
||||||
|
name: row.name,
|
||||||
|
data: row.data.map((value) => value.count),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return series
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mb-10">
|
<div>
|
||||||
<Button v-if="isStudent" @click="openEvalModal" class="float-right">
|
<div class="flex items-center justify-between mb-4">
|
||||||
{{ __('Schedule Evaluation') }}
|
<div class="text-lg font-semibold">
|
||||||
</Button>
|
{{ __('Upcoming Evaluations') }}
|
||||||
<div class="text-lg font-semibold mb-4">
|
</div>
|
||||||
{{ __('Upcoming Evaluations') }}
|
<Button @click="openEvalModal">
|
||||||
|
{{ __('Schedule Evaluation') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="upcoming_evals.data?.length">
|
<div v-if="upcoming_evals.data?.length">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
@@ -67,10 +69,6 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
isStudent: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
endDate: {
|
endDate: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
191
frontend/src/pages/AssignmentForm.vue
Normal file
191
frontend/src/pages/AssignmentForm.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
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" />
|
||||||
|
<div class="space-x-2">
|
||||||
|
<router-link
|
||||||
|
v-if="assignment.doc?.name"
|
||||||
|
:to="{
|
||||||
|
name: 'AssignmentSubmissionList',
|
||||||
|
query: {
|
||||||
|
assignmentID: assignment.doc.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{{ __('Submission List') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button variant="solid" @click="saveAssignment()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="w-3/4 mx-auto py-5">
|
||||||
|
<div class="font-semibold mb-4">
|
||||||
|
{{ __('Details') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
|
||||||
|
<FormControl
|
||||||
|
v-model="model.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="model.type"
|
||||||
|
type="select"
|
||||||
|
:options="assignmentOptions"
|
||||||
|
:label="__('Type')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-600 mb-2">
|
||||||
|
{{ __('Question') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="model.question"
|
||||||
|
@change="(val) => (model.question = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
createDocumentResource,
|
||||||
|
createResource,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
reactive,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentID: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
title: '',
|
||||||
|
type: 'PDF',
|
||||||
|
question: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (
|
||||||
|
props.assignmentID == 'new' &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data?.is_instructor
|
||||||
|
) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
if (props.assignmentID !== 'new') {
|
||||||
|
assignment.reload()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardShortcut = (e) => {
|
||||||
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
saveAssignment()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignment = createDocumentResource({
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
name: props.assignmentID,
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newAssignment = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveAssignment = () => {
|
||||||
|
if (props.assignmentID == 'new') {
|
||||||
|
newAssignment.submit({
|
||||||
|
...model,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
assignment.setValue.submit(
|
||||||
|
{
|
||||||
|
...model,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast(__('Success'), __('Assignment saved successfully'), 'check')
|
||||||
|
assignment.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(assignment, () => {
|
||||||
|
Object.keys(assignment.doc).forEach((key) => {
|
||||||
|
model[key] = assignment.doc[key]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{
|
||||||
|
label: __('Assignments'),
|
||||||
|
route: { name: 'Assignments' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const assignmentOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'PDF', value: 'PDF' },
|
||||||
|
{ label: 'Image', value: 'Image' },
|
||||||
|
{ label: 'Document', value: 'Document' },
|
||||||
|
{ label: 'Text', value: 'Text' },
|
||||||
|
{ label: 'URL', value: 'URL' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3,137 +3,20 @@
|
|||||||
class="flex justify-between sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
class="flex justify-between sticky top-0 z-10 border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs :items="breadcrumbs" />
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
<Button variant="solid" @click="submitAssignment()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="container py-5">
|
<div class="overflow-hidden h-[calc(100vh-3.2rem)]">
|
||||||
<div
|
<Assignment :assignmentID="assignmentID" :submissionName="submissionName" />
|
||||||
v-if="submissionResource.data"
|
|
||||||
class="bg-blue-100 p-2 rounded-md leading-5 text-sm italic"
|
|
||||||
>
|
|
||||||
{{ __("You've successfully submitted the assignment.") }}
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
"Once the moderator grades your submission, you'll find the details here."
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="assignment.data">
|
|
||||||
<div>
|
|
||||||
<div class="text-xl font-semibold hidden">
|
|
||||||
{{ __('Question') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm mt-1 hidden">
|
|
||||||
{{
|
|
||||||
__('Read the question carefully before attempting the assignment.')
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-html="assignment.data.question"
|
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="">
|
|
||||||
<div class="text-xl font-semibold mt-10">
|
|
||||||
{{ __('Submission') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="showUploader()">
|
|
||||||
<div class="text-sm mt-1 mb-4">
|
|
||||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
|
||||||
</div>
|
|
||||||
<FileUploader
|
|
||||||
v-if="!submissionFile"
|
|
||||||
:fileTypes="getType()"
|
|
||||||
:validateFile="validateFile"
|
|
||||||
@success="(file) => saveSubmission(file)"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
#default="{
|
|
||||||
file,
|
|
||||||
uploading,
|
|
||||||
progress,
|
|
||||||
uploaded,
|
|
||||||
message,
|
|
||||||
error,
|
|
||||||
total,
|
|
||||||
success,
|
|
||||||
openFileSelector,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
|
||||||
{{
|
|
||||||
uploading
|
|
||||||
? __('Uploading {0}%').format(progress)
|
|
||||||
: __('Upload File')
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</FileUploader>
|
|
||||||
<div v-else>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="border rounded-md p-2 mr-2">
|
|
||||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span>
|
|
||||||
{{ submissionFile.file_name }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500 mt-1">
|
|
||||||
{{ getFileSize(submissionFile.file_size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<X
|
|
||||||
@click="removeSubmission()"
|
|
||||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="assignment.data.type == 'URL'">
|
|
||||||
<div class="text-sm mb-4">
|
|
||||||
{{ __('Enter a URL') }}
|
|
||||||
</div>
|
|
||||||
<FormControl v-model="answer" />
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="text-sm mb-4">
|
|
||||||
{{ __('Write your answer here') }}
|
|
||||||
</div>
|
|
||||||
<TextEditor
|
|
||||||
:content="answer"
|
|
||||||
@change="(val) => (answer = val)"
|
|
||||||
:editable="true"
|
|
||||||
:fixedMenu="true"
|
|
||||||
editorClass="prose-sm max-w-none border-b border-x bg-gray-100 rounded-b-md py-1 px-2 min-h-[7rem]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
Breadcrumbs,
|
import { computed, inject, onMounted } from 'vue'
|
||||||
createResource,
|
import Assignment from '@/components/Assignment.vue'
|
||||||
FileUploader,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
TextEditor,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
|
||||||
import { computed, inject, onMounted, ref } from 'vue'
|
|
||||||
import { showToast, getFileSize } from '../utils'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const submissionFile = ref(null)
|
|
||||||
const answer = ref(null)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
assignmentName: {
|
assignmentID: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
@@ -143,186 +26,40 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const assignment = createResource({
|
const title = createResource({
|
||||||
url: 'frappe.client.get',
|
url: 'frappe.client.get_value',
|
||||||
params: {
|
params: {
|
||||||
doctype: 'LMS Assignment',
|
doctype: 'LMS Assignment',
|
||||||
name: props.assignmentName,
|
fieldname: 'title',
|
||||||
|
filters: {
|
||||||
|
name: props.assignmentID,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const showUploader = () => {
|
|
||||||
return ['PDF', 'Image', 'Document'].includes(assignment.data?.type)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSubmission = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
makeParams(values) {
|
|
||||||
let fieldname = {}
|
|
||||||
if (showUploader()) {
|
|
||||||
fieldname.assignment_attachment = submissionFile.value.file_url
|
|
||||||
} else {
|
|
||||||
fieldname.answer = answer.value
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Assignment Submission',
|
|
||||||
name: props.submissionName,
|
|
||||||
fieldname: fieldname,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageResource = createResource({
|
|
||||||
url: 'lms.lms.api.get_file_info',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
file_url: values.image,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: false,
|
|
||||||
onSuccess(data) {
|
|
||||||
submissionFile.value = data
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const newSubmission = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
let doc = {
|
|
||||||
doctype: 'LMS Assignment Submission',
|
|
||||||
assignment: props.assignmentName,
|
|
||||||
member: user.data?.name,
|
|
||||||
}
|
|
||||||
if (showUploader()) {
|
|
||||||
doc.assignment_attachment = submissionFile.value.file_url
|
|
||||||
} else {
|
|
||||||
doc.answer = answer.value
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
doc: doc,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const submissionResource = createResource({
|
|
||||||
url: 'frappe.client.get_value',
|
|
||||||
params: {
|
|
||||||
doctype: 'LMS Assignment Submission',
|
|
||||||
fieldname: showUploader() ? 'assignment_attachment' : 'answer',
|
|
||||||
filters: {
|
|
||||||
name: props.submissionName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
if (data.assignment_attachment)
|
|
||||||
imageResource.reload({ image: data.assignment_attachment })
|
|
||||||
if (data.answer) answer.value = data.answer
|
|
||||||
},
|
|
||||||
})
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
if (props.submissionName != 'new') {
|
|
||||||
submissionResource.reload()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitAssignment = () => {
|
|
||||||
if (props.submissionName != 'new') {
|
|
||||||
updateSubmission.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showToast('Success', 'Submission updated successfully.', 'check')
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
addNewSubmission()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addNewSubmission = () => {
|
|
||||||
newSubmission.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showToast('Success', 'Assignment submitted successfully.', 'check')
|
|
||||||
router.push({
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
params: {
|
|
||||||
assignmentName: props.assignmentName,
|
|
||||||
submissionName: data.name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
label: 'Assignment',
|
label: 'Submissions',
|
||||||
|
route: { name: 'AssignmentSubmissionList' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: assignment.data?.title,
|
label: title.data?.title,
|
||||||
route: {
|
route: {
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
params: {
|
params: {
|
||||||
assignmentName: assignment.data?.name,
|
assignmentID: props.assignmentID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveSubmission = (file) => {
|
|
||||||
submissionFile.value = file
|
|
||||||
}
|
|
||||||
|
|
||||||
const getType = () => {
|
|
||||||
const type = assignment.data?.type
|
|
||||||
if (type == 'Image') {
|
|
||||||
return ['image/*']
|
|
||||||
} else if (type == 'Document') {
|
|
||||||
return [
|
|
||||||
'.doc',
|
|
||||||
'.docx',
|
|
||||||
'.xml',
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
]
|
|
||||||
} else if (type == 'PDF') {
|
|
||||||
return ['.pdf']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let type = assignment.data?.type
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
} else if (
|
|
||||||
type == 'Document' &&
|
|
||||||
!['doc', 'docx', 'xml'].includes(extension)
|
|
||||||
) {
|
|
||||||
return 'Only document file is allowed.'
|
|
||||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
|
||||||
return 'Only PDF file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSubmission = () => {
|
|
||||||
submissionFile.value = null
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
217
frontend/src/pages/AssignmentSubmissionList.vue
Normal file
217
frontend/src/pages/AssignmentSubmissionList.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
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" />
|
||||||
|
</header>
|
||||||
|
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
|
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||||
|
<Link
|
||||||
|
doctype="LMS Assignment"
|
||||||
|
v-model="assignmentID"
|
||||||
|
:placeholder="__('Assignment')"
|
||||||
|
/>
|
||||||
|
<Link doctype="User" v-model="member" :placeholder="__('Member')" />
|
||||||
|
<FormControl
|
||||||
|
v-model="status"
|
||||||
|
type="select"
|
||||||
|
:options="statusOptions"
|
||||||
|
:placeholder="__('Status')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="submissions.loading || submissions.data?.length"
|
||||||
|
:columns="submissionColumns"
|
||||||
|
:rows="submissions.data"
|
||||||
|
rowKey="name"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in submissionColumns" />
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<router-link
|
||||||
|
v-for="row in submissions.data"
|
||||||
|
:to="{
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentID: row.assignment,
|
||||||
|
submissionName: row.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListRow :row="row">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div v-if="column.key == 'status'">
|
||||||
|
<Badge :theme="getStatusTheme(row[column.key])">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</router-link>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||||
|
>
|
||||||
|
<Pencil class="size-8 mx-auto stroke-1 text-gray-500" />
|
||||||
|
<div class="text-xl font-medium">
|
||||||
|
{{ __('No submissions') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5">
|
||||||
|
{{ __('There are no submissions for this assignment.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
createListResource,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Pencil } from 'lucide-vue-next'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const router = useRouter()
|
||||||
|
const assignmentID = ref('')
|
||||||
|
const member = ref('')
|
||||||
|
const status = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data?.is_instructor && !user.data?.is_moderator) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
assignmentID.value = router.currentRoute.value.query.assignmentID
|
||||||
|
member.value = router.currentRoute.value.query.member
|
||||||
|
status.value = router.currentRoute.value.query.status
|
||||||
|
reloadSubmissions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getAssignmentFilters = () => {
|
||||||
|
let filters = {}
|
||||||
|
if (assignmentID.value) {
|
||||||
|
filters.assignment = assignmentID.value
|
||||||
|
}
|
||||||
|
if (member.value) {
|
||||||
|
filters.member = member.value
|
||||||
|
}
|
||||||
|
if (status.value) {
|
||||||
|
filters.status = status.value
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = createListResource({
|
||||||
|
doctype: 'LMS Assignment Submission',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'assignment',
|
||||||
|
'assignment_title',
|
||||||
|
'member_name',
|
||||||
|
'creation',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
transform(data) {
|
||||||
|
return data.map((row) => {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
creation: dayjs(row.creation).fromNow(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
||||||
|
watch([assignmentID, member, status], () => {
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
assignmentID: assignmentID.value,
|
||||||
|
member: member.value,
|
||||||
|
status: status.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reloadSubmissions()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reloadSubmissions = () => {
|
||||||
|
submissions.update({
|
||||||
|
filters: getAssignmentFilters(),
|
||||||
|
})
|
||||||
|
submissions.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Member',
|
||||||
|
key: 'member_name',
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assignment',
|
||||||
|
key: 'assignment_title',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Submitted',
|
||||||
|
key: 'creation',
|
||||||
|
width: 1,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: '', value: '' },
|
||||||
|
{ label: 'Pass', value: 'Pass' },
|
||||||
|
{ label: 'Fail', value: 'Fail' },
|
||||||
|
{ label: 'Not Graded', value: 'Not Graded' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusTheme = (status) => {
|
||||||
|
if (status === 'Pass') {
|
||||||
|
return 'green'
|
||||||
|
} else if (status === 'Not Graded') {
|
||||||
|
return 'blue'
|
||||||
|
} else {
|
||||||
|
return 'red'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Assignment Submissions',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
187
frontend/src/pages/Assignments.vue
Normal file
187
frontend/src/pages/Assignments.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
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
|
||||||
|
:to="{
|
||||||
|
name: 'AssignmentForm',
|
||||||
|
params: {
|
||||||
|
assignmentID: 'new',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
|
<div class="grid grid-cols-3 gap-5 mb-5">
|
||||||
|
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" />
|
||||||
|
<FormControl
|
||||||
|
v-model="typeFilter"
|
||||||
|
type="select"
|
||||||
|
:options="assignmentTypes"
|
||||||
|
:placeholder="__('Type')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="assignments.data?.length"
|
||||||
|
:columns="assignmentColumns"
|
||||||
|
:rows="assignments.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
selectable: false,
|
||||||
|
getRowRoute: (row) => ({
|
||||||
|
name: 'AssignmentForm',
|
||||||
|
params: {
|
||||||
|
assignmentID: row.name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
</ListView>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center p-5 text-gray-600 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
|
||||||
|
>
|
||||||
|
<Pencil class="size-10 mx-auto stroke-1 text-gray-500" />
|
||||||
|
<div class="text-xl font-medium">
|
||||||
|
{{ __('No assignments found') }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'You have not created any assignments yet. To create a new assignment, click on the "New" button above.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="assignments.data && assignments.hasNextPage"
|
||||||
|
class="flex justify-center my-5"
|
||||||
|
>
|
||||||
|
<Button @click="assignments.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Plus, Pencil } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const titleFilter = ref('')
|
||||||
|
const typeFilter = ref('')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
|
||||||
|
titleFilter.value = router.currentRoute.value.query.title
|
||||||
|
typeFilter.value = router.currentRoute.value.query.type
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([titleFilter, typeFilter], () => {
|
||||||
|
router.push({
|
||||||
|
query: {
|
||||||
|
title: titleFilter.value,
|
||||||
|
type: typeFilter.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reloadAssignments()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reloadAssignments = () => {
|
||||||
|
assignments.update({
|
||||||
|
filters: assignmentFilter.value,
|
||||||
|
})
|
||||||
|
assignments.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentFilter = computed(() => {
|
||||||
|
let filters = {}
|
||||||
|
if (titleFilter.value) {
|
||||||
|
filters.title = ['like', `%${titleFilter.value}%`]
|
||||||
|
}
|
||||||
|
if (typeFilter.value) {
|
||||||
|
filters.type = typeFilter.value
|
||||||
|
}
|
||||||
|
if (!user.data?.is_moderator) {
|
||||||
|
filters.owner = user.data?.email
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignments = createListResource({
|
||||||
|
doctype: 'LMS Assignment',
|
||||||
|
fields: ['name', 'title', 'type', 'creation'],
|
||||||
|
orderBy: 'modified desc',
|
||||||
|
cache: ['assignments'],
|
||||||
|
transform(data) {
|
||||||
|
return data.map((row) => {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
creation: dayjs(row.creation).fromNow(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignmentColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Title'),
|
||||||
|
key: 'title',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Type'),
|
||||||
|
key: 'type',
|
||||||
|
width: 1,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Created'),
|
||||||
|
key: 'creation',
|
||||||
|
width: 1,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const assignmentTypes = computed(() => {
|
||||||
|
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
|
||||||
|
return types.map((type) => {
|
||||||
|
return {
|
||||||
|
label: __(type),
|
||||||
|
value: type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Assignments',
|
||||||
|
route: { name: 'Assignments' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="badge.doc">
|
<div v-if="badge.data">
|
||||||
<div class="p-5 flex flex-col items-center mt-40">
|
<div class="p-5 flex flex-col items-center mt-40">
|
||||||
<div class="text-3xl font-semibold">
|
<div class="text-3xl font-semibold">
|
||||||
{{ badge.doc.title }}
|
{{ badge.data.badge }}
|
||||||
</div>
|
</div>
|
||||||
<img :src="badge.doc.image" :alt="badge.doc.title" class="h-60 mt-2" />
|
<img
|
||||||
<div class="text-lg">
|
:src="badge.data.badge_image"
|
||||||
|
:alt="badge.data.badge"
|
||||||
|
class="h-60 mt-2"
|
||||||
|
/>
|
||||||
|
<div class="">
|
||||||
{{
|
{{
|
||||||
__('This badge has been awarded to {0} on {1}.').format(
|
__('This badge has been awarded to {0} on {1}.').format(
|
||||||
userName,
|
badge.data.member_name,
|
||||||
dayjs(issuedOn.data?.issued_on).format('DD MMM YYYY')
|
dayjs(badge.data.issued_on).format('DD MMM YYYY')
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-lg mt-2">
|
<div class="mt-2">
|
||||||
{{ badge.doc.description }}
|
{{ badge.data.badge_description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createDocumentResource, createResource, Breadcrumbs } from 'frappe-ui'
|
import { createDocumentResource, createResource } from 'frappe-ui'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const allUsers = inject('$allUsers')
|
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
badgeName: {
|
badgeName: {
|
||||||
@@ -39,33 +40,15 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const badge = createDocumentResource({
|
const badge = createResource({
|
||||||
doctype: 'LMS Badge',
|
url: 'frappe.client.get',
|
||||||
name: props.badgeName,
|
|
||||||
})
|
|
||||||
|
|
||||||
const userName = computed(() => {
|
|
||||||
const user = Object.values(allUsers.data).find(
|
|
||||||
(user) => user.name === props.email
|
|
||||||
)
|
|
||||||
return user ? user.full_name : props.email
|
|
||||||
})
|
|
||||||
|
|
||||||
const issuedOn = createResource({
|
|
||||||
url: 'frappe.client.get_value',
|
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: 'LMS Badge Assignment',
|
doctype: 'LMS Badge Assignment',
|
||||||
filters: {
|
filters: {
|
||||||
member: props.email,
|
|
||||||
badge: props.badgeName,
|
badge: props.badgeName,
|
||||||
|
member: props.email,
|
||||||
},
|
},
|
||||||
fieldname: 'issued_on',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
if (!data.issued_on) {
|
|
||||||
router.push({ name: 'Courses' })
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
@@ -77,11 +60,11 @@ const breadcrumbs = computed(() => {
|
|||||||
label: 'Badges',
|
label: 'Badges',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: badge.doc.title,
|
label: badge.data.badge,
|
||||||
route: {
|
route: {
|
||||||
name: 'Badge',
|
name: 'Badge',
|
||||||
params: {
|
params: {
|
||||||
badge: badge.doc.name,
|
badge: badge.data.badge,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
</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-2">
|
<div class="border-r">
|
||||||
<Tabs
|
<Tabs
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
:tabs="tabs"
|
:tabs="tabs"
|
||||||
@@ -59,14 +59,14 @@
|
|||||||
<div v-if="tab.label == 'Courses'">
|
<div v-if="tab.label == 'Courses'">
|
||||||
<BatchCourses :batch="batch.data.name" />
|
<BatchCourses :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Dashboard'">
|
<div v-else-if="tab.label == 'Dashboard' && isStudent">
|
||||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Live Class'">
|
<div v-else-if="tab.label == 'Dashboard'">
|
||||||
<LiveClass :batch="batch.data.name" />
|
<BatchStudents :batch="batch.data" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Students'">
|
<div v-else-if="tab.label == 'Classes'">
|
||||||
<BatchStudents :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'">
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
@@ -81,20 +81,23 @@
|
|||||||
: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>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-gray-700 font-semibold mb-4">
|
||||||
{{ batch.data.title }}
|
{{ __('About this batch') }}:
|
||||||
</div>
|
</div>
|
||||||
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
|
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
||||||
|
|
||||||
<div class="flex avatar-group overlap mb-5">
|
<div class="flex items-center avatar-group overlap mb-5">
|
||||||
<div
|
<div
|
||||||
class="h-6 mr-1"
|
class="h-6 mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -190,11 +193,11 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Laptop,
|
Laptop,
|
||||||
BookOpenCheck,
|
BookOpenCheck,
|
||||||
Contact2,
|
|
||||||
Mail,
|
Mail,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Globe,
|
Globe,
|
||||||
|
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'
|
||||||
@@ -207,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)
|
||||||
@@ -229,7 +233,7 @@ const batch = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||||
if (!isStudent.value) {
|
if (!isStudent.value) {
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
@@ -259,38 +263,42 @@ const isStudent = computed(() => {
|
|||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
let batchTabs = []
|
let batchTabs = []
|
||||||
if (isStudent.value) {
|
batchTabs.push({
|
||||||
batchTabs.push({
|
label: 'Dashboard',
|
||||||
label: 'Dashboard',
|
icon: LayoutDashboard,
|
||||||
icon: LayoutDashboard,
|
})
|
||||||
})
|
|
||||||
}
|
batchTabs.push({
|
||||||
|
label: 'Courses',
|
||||||
|
icon: BookOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Classes',
|
||||||
|
icon: Laptop,
|
||||||
|
})
|
||||||
|
|
||||||
if (user.data?.is_moderator) {
|
if (user.data?.is_moderator) {
|
||||||
batchTabs.push({
|
|
||||||
label: 'Students',
|
|
||||||
icon: Contact2,
|
|
||||||
})
|
|
||||||
batchTabs.push({
|
batchTabs.push({
|
||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
icon: BookOpenCheck,
|
icon: BookOpenCheck,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
batchTabs.push({
|
|
||||||
label: 'Live Class',
|
|
||||||
icon: Laptop,
|
|
||||||
})
|
|
||||||
batchTabs.push({
|
|
||||||
label: 'Courses',
|
|
||||||
icon: BookOpen,
|
|
||||||
})
|
|
||||||
batchTabs.push({
|
batchTabs.push({
|
||||||
label: 'Announcements',
|
label: 'Announcements',
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
})
|
})
|
||||||
|
|
||||||
batchTabs.push({
|
batchTabs.push({
|
||||||
label: 'Discussions',
|
label: 'Discussions',
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
batchTabs.push({
|
||||||
|
label: 'Feedback',
|
||||||
|
icon: ClipboardPen,
|
||||||
|
})
|
||||||
return batchTabs
|
return batchTabs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const courses = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'All Batches', route: { name: 'Batches' } }]
|
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: batch?.data?.title,
|
label: batch?.data?.title,
|
||||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="course.data.rating"
|
v-if="parseInt(course.data.rating) > 0"
|
||||||
:text="__('Average Rating')"
|
:text="__('Average Rating')"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
@@ -25,7 +25,9 @@
|
|||||||
{{ course.data.rating }}
|
{{ course.data.rating }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span v-if="course.data.rating" class="mx-3">·</span>
|
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
|
||||||
|
>·</span
|
||||||
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
v-if="course.data.enrollment_count"
|
v-if="course.data.enrollment_count"
|
||||||
:text="__('Enrolled Students')"
|
:text="__('Enrolled Students')"
|
||||||
@@ -117,7 +119,7 @@ const course = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: course?.data?.title,
|
label: course?.data?.title,
|
||||||
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
|
||||||
|
|||||||
@@ -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,44 +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="divide-y lg:w-3/4 mx-auto p-5">
|
<div class="lg:w-3/4 mx-auto p-5">
|
||||||
<div v-for="job in jobsList">
|
<div
|
||||||
|
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
|
||||||
|
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 },
|
||||||
@@ -54,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'
|
||||||
@@ -71,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 [
|
||||||
@@ -118,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>
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ const progress = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'All Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
label: lesson?.data?.course_title,
|
label: lesson?.data?.course_title,
|
||||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -28,9 +28,7 @@
|
|||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{{ program.members }}
|
{{ program.members }}
|
||||||
{{
|
{{ program.members == 1 ? __('member') : __('members') }}
|
||||||
program.members == 1 ? __(singularize('members')) : __('members')
|
|
||||||
}}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="program.progress"
|
v-if="program.progress"
|
||||||
@@ -133,7 +131,7 @@ import { computed, inject, onMounted, ref } from 'vue'
|
|||||||
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, singularize } from '@/utils'
|
import { showToast } from '@/utils'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|||||||
@@ -256,11 +256,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
e.key === 's' &&
|
|
||||||
(e.ctrlKey || e.metaKey) &&
|
|
||||||
!e.target.classList.contains('ProseMirror')
|
|
||||||
) {
|
|
||||||
submitQuiz()
|
submitQuiz()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,12 +131,6 @@ const routes = [
|
|||||||
component: () => import('@/pages/JobCreation.vue'),
|
component: () => import('@/pages/JobCreation.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/assignment-submission/:assignmentName/:submissionName',
|
|
||||||
name: 'AssignmentSubmission',
|
|
||||||
component: () => import('@/pages/AssignmentSubmission.vue'),
|
|
||||||
props: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/certified-participants',
|
path: '/certified-participants',
|
||||||
name: 'CertifiedParticipants',
|
name: 'CertifiedParticipants',
|
||||||
@@ -193,6 +187,28 @@ const routes = [
|
|||||||
name: 'Programs',
|
name: 'Programs',
|
||||||
component: () => import('@/pages/Programs.vue'),
|
component: () => import('@/pages/Programs.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/assignments',
|
||||||
|
name: 'Assignments',
|
||||||
|
component: () => import('@/pages/Assignments.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/assignments/:assignmentID',
|
||||||
|
name: 'AssignmentForm',
|
||||||
|
component: () => import('@/pages/AssignmentForm.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/assignment-submission/:assignmentID/:submissionName',
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
component: () => import('@/pages/AssignmentSubmission.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/assignment-submissions',
|
||||||
|
name: 'AssignmentSubmissionList',
|
||||||
|
component: () => import('@/pages/AssignmentSubmissionList.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
@@ -201,22 +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' ||
|
|
||||||
to.name == 'Badge')
|
|
||||||
) {
|
|
||||||
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'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
84
frontend/src/utils/assignment.js
Normal file
84
frontend/src/utils/assignment.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Pencil } from 'lucide-vue-next'
|
||||||
|
import { createApp, h } from 'vue'
|
||||||
|
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||||
|
import AssignmentBlock from '@/components/AssignmentBlock.vue'
|
||||||
|
import translationPlugin from '../translation'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
import router from '../router'
|
||||||
|
|
||||||
|
export class Assignment {
|
||||||
|
constructor({ data, api, readOnly }) {
|
||||||
|
this.data = data
|
||||||
|
this.readOnly = readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
static get toolbox() {
|
||||||
|
const app = createApp({
|
||||||
|
render: () =>
|
||||||
|
h(Pencil, { size: 18, strokeWidth: 1.5, color: 'black' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const div = document.createElement('div')
|
||||||
|
app.mount(div)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: __('Assignment'),
|
||||||
|
icon: div.innerHTML,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get isReadOnlySupported() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.wrapper = document.createElement('div')
|
||||||
|
if (Object.keys(this.data).length) {
|
||||||
|
this.renderAssignment(this.data.assignment)
|
||||||
|
} else {
|
||||||
|
this.renderAssignmentModal()
|
||||||
|
}
|
||||||
|
return this.wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAssignment(assignment) {
|
||||||
|
if (this.readOnly) {
|
||||||
|
const app = createApp(AssignmentBlock, {
|
||||||
|
assignmentID: assignment,
|
||||||
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.use(router)
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
app.provide('$user', userResource)
|
||||||
|
app.mount(this.wrapper)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.wrapper.innerHTML = `<div class='border rounded-md p-10 text-center bg-gray-50 mb-2'>
|
||||||
|
<span class="font-medium">
|
||||||
|
Assignment: ${assignment}
|
||||||
|
</span>
|
||||||
|
</div>`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAssignmentModal() {
|
||||||
|
if (this.readOnly) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const app = createApp(AssessmentPlugin, {
|
||||||
|
type: 'assignment',
|
||||||
|
onAddition: (assignment) => {
|
||||||
|
this.data.assignment = assignment
|
||||||
|
this.renderAssignment(assignment)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
|
app.mount(this.wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
save(blockContent) {
|
||||||
|
return {
|
||||||
|
assignment: this.data.assignment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { toast } from 'frappe-ui'
|
import { toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
|
import { Assignment } from '@/utils/assignment'
|
||||||
import { Upload } from '@/utils/upload'
|
import { Upload } from '@/utils/upload'
|
||||||
import { Markdown } from '@/utils/markdownParser'
|
import { Markdown } from '@/utils/markdownParser'
|
||||||
import Header from '@editorjs/header'
|
import Header from '@editorjs/header'
|
||||||
@@ -155,10 +156,14 @@ export function getEditorTools() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
quiz: Quiz,
|
quiz: Quiz,
|
||||||
|
assignment: Assignment,
|
||||||
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,
|
||||||
@@ -177,6 +182,7 @@ export function getEditorTools() {
|
|||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
class: NestedList,
|
class: NestedList,
|
||||||
|
inlineToolbar: true,
|
||||||
config: {
|
config: {
|
||||||
defaultStyle: 'ordered',
|
defaultStyle: 'ordered',
|
||||||
},
|
},
|
||||||
@@ -527,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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,27 @@ export class Markdown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPaste(event) {
|
||||||
|
const data = {
|
||||||
|
text: event.detail.data.innerHTML,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = data
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (!this.wrapper) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.wrapper.innerHTML = this.data.text || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static get pasteConfig() {
|
||||||
|
return {
|
||||||
|
tags: ['P'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.wrapper = document.createElement('div')
|
this.wrapper = document.createElement('div')
|
||||||
this.wrapper.classList.add('cdx-block')
|
this.wrapper.classList.add('cdx-block')
|
||||||
@@ -36,10 +57,6 @@ export class Markdown {
|
|||||||
this.parseContent(event)
|
this.parseContent(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.wrapper.addEventListener('paste', (event) =>
|
|
||||||
this.handlePaste(event)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.wrapper
|
return this.wrapper
|
||||||
@@ -101,19 +118,6 @@ export class Markdown {
|
|||||||
this.api.caret.focus(true)
|
this.api.caret.focus(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePaste(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const clipboardData = event.clipboardData || window.clipboardData
|
|
||||||
const pastedText = clipboardData.getData('text/plain')
|
|
||||||
const sanitizedText = this.processPastedContent(pastedText)
|
|
||||||
document.execCommand('insertText', false, sanitizedText)
|
|
||||||
}
|
|
||||||
|
|
||||||
processPastedContent(text) {
|
|
||||||
return text.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
save(blockContent) {
|
save(blockContent) {
|
||||||
return {
|
return {
|
||||||
text: blockContent.innerHTML,
|
text: blockContent.innerHTML,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import QuizBlock from '@/components/QuizBlock.vue'
|
import QuizBlock from '@/components/QuizBlock.vue'
|
||||||
import QuizPlugin from '@/components/QuizPlugin.vue'
|
import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||||
import { createApp, h } from 'vue'
|
import { createApp, h } from 'vue'
|
||||||
import { usersStore } from '../stores/user'
|
import { usersStore } from '../stores/user'
|
||||||
import translationPlugin from '../translation'
|
import translationPlugin from '../translation'
|
||||||
@@ -63,8 +63,9 @@ export class Quiz {
|
|||||||
if (this.readOnly) {
|
if (this.readOnly) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const app = createApp(QuizPlugin, {
|
const app = createApp(AssessmentPlugin, {
|
||||||
onQuizAddition: (quiz) => {
|
type: 'quiz',
|
||||||
|
onAddition: (quiz) => {
|
||||||
this.data.quiz = quiz
|
this.data.quiz = quiz
|
||||||
this.renderQuiz(quiz)
|
this.renderQuiz(quiz)
|
||||||
},
|
},
|
||||||
|
|||||||
5
frontend/src/utils/theme.js
Normal file
5
frontend/src/utils/theme.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import resolveConfig from 'tailwindcss/resolveConfig'
|
||||||
|
import tailwindConfig from 'tailwind.config.js'
|
||||||
|
|
||||||
|
export const config = resolveConfig(tailwindConfig)
|
||||||
|
export const theme = config.theme
|
||||||
@@ -11,6 +11,10 @@ module.exports = {
|
|||||||
strokeWidth: {
|
strokeWidth: {
|
||||||
1.5: '1.5',
|
1.5: '1.5',
|
||||||
},
|
},
|
||||||
|
screens: {
|
||||||
|
'2xl': '1536px',
|
||||||
|
'3xl': '1920px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
'tailwind.config.js': path.resolve(__dirname, 'tailwind.config.js'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -36,6 +37,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['frappe-ui > feather-icons', 'showdown', 'engine.io-client'],
|
include: [
|
||||||
|
'feather-icons',
|
||||||
|
'showdown',
|
||||||
|
'engine.io-client',
|
||||||
|
'tailwind.config.js',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -471,6 +471,33 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
||||||
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.draggable.js@^3.0.4":
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz#505430e86b5e73b5b5abba12ac6002633897324e"
|
||||||
|
integrity sha512-vWi/Col5Szo74HJVBgMHz23kLVljt3jvngmh0DzST45iO2ubIZ487uUAHIxSZH2tVRyiaaTL+Phaasgp4gUD2g==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.filter.js@^3.0.8":
|
||||||
|
version "3.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.8.tgz#998cb2481a871fa70d7dbaa891c886b335c562d7"
|
||||||
|
integrity sha512-YshF2YDaeRA2StyzAs5nUPrev7npQ38oWD0eTRwnsciSL2KrRPMoUw8BzjIXItb3+dccKGTX3IQOd2NFzmHkog==
|
||||||
|
dependencies:
|
||||||
|
"@svgdotjs/svg.js" "^3.1.1"
|
||||||
|
|
||||||
|
"@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.4":
|
||||||
|
version "3.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz#4716be92a64c66b29921b63f7235fcfb953fb13a"
|
||||||
|
integrity sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.resize.js@^2.0.2":
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz#732e4cae15d09ad3021adeac63bc9fad0dc7255a"
|
||||||
|
integrity sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==
|
||||||
|
|
||||||
|
"@svgdotjs/svg.select.js@^4.0.1":
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@svgdotjs/svg.select.js/-/svg.select.js-4.0.2.tgz#80a10409e6c73206218690eac5c9f94f8c8909b5"
|
||||||
|
integrity sha512-5gWdrvoQX3keo03SCmgaBbD+kFftq0F/f2bzCbNnpkkvW6tk4rl4MakORzFuNjvXPWwB4az9GwuvVxQVnjaK2g==
|
||||||
|
|
||||||
"@swc/helpers@^0.5.0":
|
"@swc/helpers@^0.5.0":
|
||||||
version "0.5.15"
|
version "0.5.15"
|
||||||
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7"
|
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7"
|
||||||
@@ -752,11 +779,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50"
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50"
|
||||||
integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==
|
integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==
|
||||||
|
|
||||||
"@types/json-schema@^7.0.8":
|
|
||||||
version "7.0.15"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
|
||||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
|
||||||
|
|
||||||
"@types/linkify-it@^5":
|
"@types/linkify-it@^5":
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
|
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
|
||||||
@@ -770,23 +792,11 @@
|
|||||||
"@types/linkify-it" "^5"
|
"@types/linkify-it" "^5"
|
||||||
"@types/mdurl" "^2"
|
"@types/mdurl" "^2"
|
||||||
|
|
||||||
"@types/mdast@^3.0.0":
|
|
||||||
version "3.0.15"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.15.tgz#49c524a263f30ffa28b71ae282f813ed000ab9f5"
|
|
||||||
integrity sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==
|
|
||||||
dependencies:
|
|
||||||
"@types/unist" "^2"
|
|
||||||
|
|
||||||
"@types/mdurl@^2":
|
"@types/mdurl@^2":
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
|
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
|
||||||
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
|
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
|
||||||
|
|
||||||
"@types/unist@^2", "@types/unist@^2.0.0", "@types/unist@^2.0.2":
|
|
||||||
version "2.0.11"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4"
|
|
||||||
integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==
|
|
||||||
|
|
||||||
"@types/web-bluetooth@^0.0.20":
|
"@types/web-bluetooth@^0.0.20":
|
||||||
version "0.0.20"
|
version "0.0.20"
|
||||||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
|
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
|
||||||
@@ -904,31 +914,16 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue-demi ">=0.14.8"
|
vue-demi ">=0.14.8"
|
||||||
|
|
||||||
|
"@yr/monotone-cubic-spline@^1.0.3":
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
|
||||||
|
integrity sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==
|
||||||
|
|
||||||
ace-builds@^1.36.2:
|
ace-builds@^1.36.2:
|
||||||
version "1.36.5"
|
version "1.36.5"
|
||||||
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.5.tgz#ae9cc7a32eccc2f484926131c00545cd6b78a6a6"
|
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.5.tgz#ae9cc7a32eccc2f484926131c00545cd6b78a6a6"
|
||||||
integrity sha512-mZ5KVanRT6nLRDLqtG/1YQQLX/gZVC/v526cm1Ru/MTSlrbweSmqv2ZT0d2GaHpJq035MwCMIrj+LgDAUnDXrg==
|
integrity sha512-mZ5KVanRT6nLRDLqtG/1YQQLX/gZVC/v526cm1Ru/MTSlrbweSmqv2ZT0d2GaHpJq035MwCMIrj+LgDAUnDXrg==
|
||||||
|
|
||||||
ajv-keywords@^3.5.2:
|
|
||||||
version "3.5.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
|
|
||||||
integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
|
|
||||||
|
|
||||||
ajv@^6.12.5:
|
|
||||||
version "6.12.6"
|
|
||||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
|
||||||
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
|
||||||
dependencies:
|
|
||||||
fast-deep-equal "^3.1.1"
|
|
||||||
fast-json-stable-stringify "^2.0.0"
|
|
||||||
json-schema-traverse "^0.4.1"
|
|
||||||
uri-js "^4.2.2"
|
|
||||||
|
|
||||||
amdefine@>=0.0.4:
|
|
||||||
version "1.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
|
|
||||||
integrity sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==
|
|
||||||
|
|
||||||
ansi-regex@^5.0.1:
|
ansi-regex@^5.0.1:
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||||
@@ -964,6 +959,18 @@ anymatch@~3.1.2:
|
|||||||
normalize-path "^3.0.0"
|
normalize-path "^3.0.0"
|
||||||
picomatch "^2.0.4"
|
picomatch "^2.0.4"
|
||||||
|
|
||||||
|
apexcharts@^4.3.0:
|
||||||
|
version "4.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-4.3.0.tgz#eccf28e830ce1b5e018cfc0e99d1c6af0076c9c7"
|
||||||
|
integrity sha512-PfvZQpv91T68hzry9l5zP3Gip7sQvF0nFK91uCBrswIKX7rbIdbVNS4fOks9m9yP3Ppgs6LHgU2M/mjoG4NM0A==
|
||||||
|
dependencies:
|
||||||
|
"@svgdotjs/svg.draggable.js" "^3.0.4"
|
||||||
|
"@svgdotjs/svg.filter.js" "^3.0.8"
|
||||||
|
"@svgdotjs/svg.js" "^3.2.4"
|
||||||
|
"@svgdotjs/svg.resize.js" "^2.0.2"
|
||||||
|
"@svgdotjs/svg.select.js" "^4.0.1"
|
||||||
|
"@yr/monotone-cubic-spline" "^1.0.3"
|
||||||
|
|
||||||
arg@^5.0.2:
|
arg@^5.0.2:
|
||||||
version "5.0.2"
|
version "5.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
||||||
@@ -981,11 +988,6 @@ aria-hidden@^1.2.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
async@~0.2.6:
|
|
||||||
version "0.2.10"
|
|
||||||
resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
|
|
||||||
integrity sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==
|
|
||||||
|
|
||||||
autoprefixer@^10.4.2:
|
autoprefixer@^10.4.2:
|
||||||
version "10.4.20"
|
version "10.4.20"
|
||||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.20.tgz#5caec14d43976ef42e32dcb4bd62878e96be5b3b"
|
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.20.tgz#5caec14d43976ef42e32dcb4bd62878e96be5b3b"
|
||||||
@@ -998,21 +1000,11 @@ autoprefixer@^10.4.2:
|
|||||||
picocolors "^1.0.1"
|
picocolors "^1.0.1"
|
||||||
postcss-value-parser "^4.2.0"
|
postcss-value-parser "^4.2.0"
|
||||||
|
|
||||||
bail@^1.0.0:
|
|
||||||
version "1.0.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776"
|
|
||||||
integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==
|
|
||||||
|
|
||||||
balanced-match@^1.0.0:
|
balanced-match@^1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||||
|
|
||||||
big.js@^5.2.2:
|
|
||||||
version "5.2.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
|
|
||||||
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
|
|
||||||
|
|
||||||
binary-extensions@^2.0.0:
|
binary-extensions@^2.0.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
|
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
|
||||||
@@ -1052,21 +1044,6 @@ caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669:
|
|||||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001686.tgz#0e04b8d90de8753188e93c9989d56cb19d902670"
|
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001686.tgz#0e04b8d90de8753188e93c9989d56cb19d902670"
|
||||||
integrity sha512-Y7deg0Aergpa24M3qLC5xjNklnKnhsmSyR/V89dLZ1n0ucJIFNs7PgR2Yfa/Zf6W79SbBicgtGxZr2juHkEUIA==
|
integrity sha512-Y7deg0Aergpa24M3qLC5xjNklnKnhsmSyR/V89dLZ1n0ucJIFNs7PgR2Yfa/Zf6W79SbBicgtGxZr2juHkEUIA==
|
||||||
|
|
||||||
character-entities-legacy@^1.0.0:
|
|
||||||
version "1.1.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1"
|
|
||||||
integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==
|
|
||||||
|
|
||||||
character-entities@^1.0.0:
|
|
||||||
version "1.2.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b"
|
|
||||||
integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==
|
|
||||||
|
|
||||||
character-reference-invalid@^1.0.0:
|
|
||||||
version "1.1.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560"
|
|
||||||
integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==
|
|
||||||
|
|
||||||
chart.js@^4.4.1:
|
chart.js@^4.4.1:
|
||||||
version "4.4.7"
|
version "4.4.7"
|
||||||
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.7.tgz#7a01ee0b4dac3c03f2ab0589af888db296d896fa"
|
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.7.tgz#7a01ee0b4dac3c03f2ab0589af888db296d896fa"
|
||||||
@@ -1148,22 +1125,6 @@ cross-spawn@^7.0.0:
|
|||||||
shebang-command "^2.0.0"
|
shebang-command "^2.0.0"
|
||||||
which "^2.0.1"
|
which "^2.0.1"
|
||||||
|
|
||||||
css-loader@^5.0.0:
|
|
||||||
version "5.2.7"
|
|
||||||
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-5.2.7.tgz#9b9f111edf6fb2be5dc62525644cbc9c232064ae"
|
|
||||||
integrity sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==
|
|
||||||
dependencies:
|
|
||||||
icss-utils "^5.1.0"
|
|
||||||
loader-utils "^2.0.0"
|
|
||||||
postcss "^8.2.15"
|
|
||||||
postcss-modules-extract-imports "^3.0.0"
|
|
||||||
postcss-modules-local-by-default "^4.0.0"
|
|
||||||
postcss-modules-scope "^3.0.0"
|
|
||||||
postcss-modules-values "^4.0.0"
|
|
||||||
postcss-value-parser "^4.1.0"
|
|
||||||
schema-utils "^3.0.0"
|
|
||||||
semver "^7.3.5"
|
|
||||||
|
|
||||||
cssesc@^3.0.0:
|
cssesc@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||||
@@ -1179,13 +1140,6 @@ dayjs@^1.11.6:
|
|||||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c"
|
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c"
|
||||||
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
|
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
|
||||||
|
|
||||||
debug@^4.0.0:
|
|
||||||
version "4.4.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a"
|
|
||||||
integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==
|
|
||||||
dependencies:
|
|
||||||
ms "^2.1.3"
|
|
||||||
|
|
||||||
debug@~4.3.1, debug@~4.3.2:
|
debug@~4.3.1, debug@~4.3.2:
|
||||||
version "4.3.7"
|
version "4.3.7"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52"
|
||||||
@@ -1218,17 +1172,6 @@ eastasianwidth@^0.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
|
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
|
||||||
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
|
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
|
||||||
|
|
||||||
editorjs-md-parser@^0.0.3:
|
|
||||||
version "0.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/editorjs-md-parser/-/editorjs-md-parser-0.0.3.tgz#cd54c6caca72590894eb5c61827b73ecbb557666"
|
|
||||||
integrity sha512-8ohPGuCoO3oag5wjky+sukP5BV4fEGhnzElxOfoLhpdYrLmMB1uTVgOOlYknoZLozAWOb4cpx1zUS9qVvK0V4g==
|
|
||||||
dependencies:
|
|
||||||
css-loader "^5.0.0"
|
|
||||||
remark "^13.0.0"
|
|
||||||
remark-parse "^9.0.0"
|
|
||||||
require "^2.4.20"
|
|
||||||
style-loader "^2.0.0"
|
|
||||||
|
|
||||||
electron-to-chromium@^1.5.41:
|
electron-to-chromium@^1.5.41:
|
||||||
version "1.5.68"
|
version "1.5.68"
|
||||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.68.tgz#4f46be4d465ef00e2100d5557b66f4af70e3ce6c"
|
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.68.tgz#4f46be4d465ef00e2100d5557b66f4af70e3ce6c"
|
||||||
@@ -1244,11 +1187,6 @@ emoji-regex@^9.2.2:
|
|||||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
|
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
|
||||||
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
||||||
|
|
||||||
emojis-list@^3.0.0:
|
|
||||||
version "3.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
|
|
||||||
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
|
|
||||||
|
|
||||||
engine.io-client@~6.6.1:
|
engine.io-client@~6.6.1:
|
||||||
version "6.6.2"
|
version "6.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.2.tgz#e0a09e1c90effe5d6264da1c56d7281998f1e50b"
|
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.2.tgz#e0a09e1c90effe5d6264da1c56d7281998f1e50b"
|
||||||
@@ -1314,12 +1252,7 @@ estree-walker@^2.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
|
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
|
||||||
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
|
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
|
||||||
|
|
||||||
extend@^3.0.0:
|
fast-deep-equal@^3.1.3:
|
||||||
version "3.0.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
|
||||||
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
|
|
||||||
|
|
||||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||||
@@ -1335,11 +1268,6 @@ fast-glob@^3.3.2:
|
|||||||
merge2 "^1.3.0"
|
merge2 "^1.3.0"
|
||||||
micromatch "^4.0.4"
|
micromatch "^4.0.4"
|
||||||
|
|
||||||
fast-json-stable-stringify@^2.0.0:
|
|
||||||
version "2.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
|
||||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
|
||||||
|
|
||||||
fastq@^1.6.0:
|
fastq@^1.6.0:
|
||||||
version "1.17.1"
|
version "1.17.1"
|
||||||
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
|
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47"
|
||||||
@@ -1455,29 +1383,11 @@ hasown@^2.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind "^1.1.2"
|
function-bind "^1.1.2"
|
||||||
|
|
||||||
icss-utils@^5.0.0, icss-utils@^5.1.0:
|
|
||||||
version "5.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
|
|
||||||
integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
|
|
||||||
|
|
||||||
idb-keyval@^6.2.0:
|
idb-keyval@^6.2.0:
|
||||||
version "6.2.1"
|
version "6.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.1.tgz#94516d625346d16f56f3b33855da11bfded2db33"
|
resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.1.tgz#94516d625346d16f56f3b33855da11bfded2db33"
|
||||||
integrity sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==
|
integrity sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==
|
||||||
|
|
||||||
is-alphabetical@^1.0.0:
|
|
||||||
version "1.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d"
|
|
||||||
integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==
|
|
||||||
|
|
||||||
is-alphanumerical@^1.0.0:
|
|
||||||
version "1.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf"
|
|
||||||
integrity sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==
|
|
||||||
dependencies:
|
|
||||||
is-alphabetical "^1.0.0"
|
|
||||||
is-decimal "^1.0.0"
|
|
||||||
|
|
||||||
is-binary-path@~2.1.0:
|
is-binary-path@~2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
|
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
|
||||||
@@ -1485,11 +1395,6 @@ is-binary-path@~2.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions "^2.0.0"
|
binary-extensions "^2.0.0"
|
||||||
|
|
||||||
is-buffer@^2.0.0:
|
|
||||||
version "2.0.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191"
|
|
||||||
integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==
|
|
||||||
|
|
||||||
is-core-module@^2.13.0:
|
is-core-module@^2.13.0:
|
||||||
version "2.15.1"
|
version "2.15.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37"
|
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37"
|
||||||
@@ -1497,11 +1402,6 @@ is-core-module@^2.13.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hasown "^2.0.2"
|
hasown "^2.0.2"
|
||||||
|
|
||||||
is-decimal@^1.0.0:
|
|
||||||
version "1.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5"
|
|
||||||
integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==
|
|
||||||
|
|
||||||
is-extglob@^2.1.1:
|
is-extglob@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
||||||
@@ -1519,21 +1419,11 @@ is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob "^2.1.1"
|
is-extglob "^2.1.1"
|
||||||
|
|
||||||
is-hexadecimal@^1.0.0:
|
|
||||||
version "1.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7"
|
|
||||||
integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==
|
|
||||||
|
|
||||||
is-number@^7.0.0:
|
is-number@^7.0.0:
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
||||||
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
||||||
|
|
||||||
is-plain-obj@^2.0.0:
|
|
||||||
version "2.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
|
||||||
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
|
|
||||||
|
|
||||||
isexe@^2.0.0:
|
isexe@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
@@ -1553,16 +1443,6 @@ jiti@^1.21.6:
|
|||||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268"
|
||||||
integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==
|
integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==
|
||||||
|
|
||||||
json-schema-traverse@^0.4.1:
|
|
||||||
version "0.4.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
|
||||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
|
||||||
|
|
||||||
json5@^2.1.2:
|
|
||||||
version "2.2.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
|
||||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
|
||||||
|
|
||||||
lilconfig@^3.0.0, lilconfig@^3.1.3:
|
lilconfig@^3.0.0, lilconfig@^3.1.3:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
|
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4"
|
||||||
@@ -1585,15 +1465,6 @@ linkifyjs@^4.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.2.0.tgz#9dd30222b9cbabec9c950e725ec00031c7fa3f08"
|
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.2.0.tgz#9dd30222b9cbabec9c950e725ec00031c7fa3f08"
|
||||||
integrity sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==
|
integrity sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==
|
||||||
|
|
||||||
loader-utils@^2.0.0:
|
|
||||||
version "2.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
|
|
||||||
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
|
|
||||||
dependencies:
|
|
||||||
big.js "^5.2.2"
|
|
||||||
emojis-list "^3.0.0"
|
|
||||||
json5 "^2.1.2"
|
|
||||||
|
|
||||||
lodash.castarray@^4.4.0:
|
lodash.castarray@^4.4.0:
|
||||||
version "4.4.0"
|
version "4.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
|
resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
|
||||||
@@ -1609,11 +1480,6 @@ lodash.merge@^4.6.2:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||||
|
|
||||||
longest-streak@^2.0.0:
|
|
||||||
version "2.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4"
|
|
||||||
integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==
|
|
||||||
|
|
||||||
lru-cache@^10.2.0:
|
lru-cache@^10.2.0:
|
||||||
version "10.4.3"
|
version "10.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
||||||
@@ -1648,34 +1514,6 @@ markdown-it@^14.0.0:
|
|||||||
punycode.js "^2.3.1"
|
punycode.js "^2.3.1"
|
||||||
uc.micro "^2.1.0"
|
uc.micro "^2.1.0"
|
||||||
|
|
||||||
mdast-util-from-markdown@^0.8.0:
|
|
||||||
version "0.8.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz#d1ef2ca42bc377ecb0463a987910dae89bd9a28c"
|
|
||||||
integrity sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==
|
|
||||||
dependencies:
|
|
||||||
"@types/mdast" "^3.0.0"
|
|
||||||
mdast-util-to-string "^2.0.0"
|
|
||||||
micromark "~2.11.0"
|
|
||||||
parse-entities "^2.0.0"
|
|
||||||
unist-util-stringify-position "^2.0.0"
|
|
||||||
|
|
||||||
mdast-util-to-markdown@^0.6.0:
|
|
||||||
version "0.6.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz#b33f67ca820d69e6cc527a93d4039249b504bebe"
|
|
||||||
integrity sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==
|
|
||||||
dependencies:
|
|
||||||
"@types/unist" "^2.0.0"
|
|
||||||
longest-streak "^2.0.0"
|
|
||||||
mdast-util-to-string "^2.0.0"
|
|
||||||
parse-entities "^2.0.0"
|
|
||||||
repeat-string "^1.0.0"
|
|
||||||
zwitch "^1.0.0"
|
|
||||||
|
|
||||||
mdast-util-to-string@^2.0.0:
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz#b8cfe6a713e1091cb5b728fc48885a4767f8b97b"
|
|
||||||
integrity sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==
|
|
||||||
|
|
||||||
mdurl@^2.0.0:
|
mdurl@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
|
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
|
||||||
@@ -1686,14 +1524,6 @@ merge2@^1.3.0:
|
|||||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||||
|
|
||||||
micromark@~2.11.0:
|
|
||||||
version "2.11.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.4.tgz#d13436138eea826383e822449c9a5c50ee44665a"
|
|
||||||
integrity sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==
|
|
||||||
dependencies:
|
|
||||||
debug "^4.0.0"
|
|
||||||
parse-entities "^2.0.0"
|
|
||||||
|
|
||||||
micromatch@^4.0.4, micromatch@^4.0.8:
|
micromatch@^4.0.4, micromatch@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||||
@@ -1768,13 +1598,6 @@ object-hash@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
|
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
|
||||||
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
|
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
|
||||||
|
|
||||||
optimist@~0.3.5:
|
|
||||||
version "0.3.7"
|
|
||||||
resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.3.7.tgz#c90941ad59e4273328923074d2cf2e7cbc6ec0d9"
|
|
||||||
integrity sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==
|
|
||||||
dependencies:
|
|
||||||
wordwrap "~0.0.2"
|
|
||||||
|
|
||||||
orderedmap@^2.0.0:
|
orderedmap@^2.0.0:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
|
resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
|
||||||
@@ -1785,18 +1608,6 @@ package-json-from-dist@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
||||||
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
|
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
|
||||||
|
|
||||||
parse-entities@^2.0.0:
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8"
|
|
||||||
integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==
|
|
||||||
dependencies:
|
|
||||||
character-entities "^1.0.0"
|
|
||||||
character-entities-legacy "^1.0.0"
|
|
||||||
character-reference-invalid "^1.0.0"
|
|
||||||
is-alphanumerical "^1.0.0"
|
|
||||||
is-decimal "^1.0.0"
|
|
||||||
is-hexadecimal "^1.0.0"
|
|
||||||
|
|
||||||
path-key@^3.1.0:
|
path-key@^3.1.0:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||||
@@ -1867,34 +1678,6 @@ postcss-load-config@^4.0.2:
|
|||||||
lilconfig "^3.0.0"
|
lilconfig "^3.0.0"
|
||||||
yaml "^2.3.4"
|
yaml "^2.3.4"
|
||||||
|
|
||||||
postcss-modules-extract-imports@^3.0.0:
|
|
||||||
version "3.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz#b4497cb85a9c0c4b5aabeb759bb25e8d89f15002"
|
|
||||||
integrity sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==
|
|
||||||
|
|
||||||
postcss-modules-local-by-default@^4.0.0:
|
|
||||||
version "4.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.1.0.tgz#b0db6bc81ffc7bdc52eb0f84d6ca0bedf0e36d21"
|
|
||||||
integrity sha512-rm0bdSv4jC3BDma3s9H19ZddW0aHX6EoqwDYU2IfZhRN+53QrufTRo2IdkAbRqLx4R2IYbZnbjKKxg4VN5oU9Q==
|
|
||||||
dependencies:
|
|
||||||
icss-utils "^5.0.0"
|
|
||||||
postcss-selector-parser "^7.0.0"
|
|
||||||
postcss-value-parser "^4.1.0"
|
|
||||||
|
|
||||||
postcss-modules-scope@^3.0.0:
|
|
||||||
version "3.2.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz#1bbccddcb398f1d7a511e0a2d1d047718af4078c"
|
|
||||||
integrity sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==
|
|
||||||
dependencies:
|
|
||||||
postcss-selector-parser "^7.0.0"
|
|
||||||
|
|
||||||
postcss-modules-values@^4.0.0:
|
|
||||||
version "4.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c"
|
|
||||||
integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==
|
|
||||||
dependencies:
|
|
||||||
icss-utils "^5.0.0"
|
|
||||||
|
|
||||||
postcss-nested@^6.2.0:
|
postcss-nested@^6.2.0:
|
||||||
version "6.2.0"
|
version "6.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131"
|
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131"
|
||||||
@@ -1918,20 +1701,12 @@ postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2:
|
|||||||
cssesc "^3.0.0"
|
cssesc "^3.0.0"
|
||||||
util-deprecate "^1.0.2"
|
util-deprecate "^1.0.2"
|
||||||
|
|
||||||
postcss-selector-parser@^7.0.0:
|
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
|
||||||
version "7.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz#41bd8b56f177c093ca49435f65731befe25d6b9c"
|
|
||||||
integrity sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==
|
|
||||||
dependencies:
|
|
||||||
cssesc "^3.0.0"
|
|
||||||
util-deprecate "^1.0.2"
|
|
||||||
|
|
||||||
postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
|
|
||||||
version "4.2.0"
|
version "4.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||||
|
|
||||||
postcss@^8.2.15, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.48, postcss@^8.4.5:
|
postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.48, postcss@^8.4.5:
|
||||||
version "8.4.49"
|
version "8.4.49"
|
||||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
|
||||||
integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
|
integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
|
||||||
@@ -2104,11 +1879,6 @@ punycode.js@^2.3.1:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
|
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
|
||||||
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
|
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
|
||||||
|
|
||||||
punycode@^2.1.0:
|
|
||||||
version "2.3.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
|
||||||
|
|
||||||
queue-microtask@^1.2.2:
|
queue-microtask@^1.2.2:
|
||||||
version "1.2.3"
|
version "1.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||||
@@ -2145,42 +1915,6 @@ readdirp@~3.6.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch "^2.2.1"
|
picomatch "^2.2.1"
|
||||||
|
|
||||||
remark-parse@^9.0.0:
|
|
||||||
version "9.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-9.0.0.tgz#4d20a299665880e4f4af5d90b7c7b8a935853640"
|
|
||||||
integrity sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==
|
|
||||||
dependencies:
|
|
||||||
mdast-util-from-markdown "^0.8.0"
|
|
||||||
|
|
||||||
remark-stringify@^9.0.0:
|
|
||||||
version "9.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-9.0.1.tgz#576d06e910548b0a7191a71f27b33f1218862894"
|
|
||||||
integrity sha512-mWmNg3ZtESvZS8fv5PTvaPckdL4iNlCHTt8/e/8oN08nArHRHjNZMKzA/YW3+p7/lYqIw4nx1XsjCBo/AxNChg==
|
|
||||||
dependencies:
|
|
||||||
mdast-util-to-markdown "^0.6.0"
|
|
||||||
|
|
||||||
remark@^13.0.0:
|
|
||||||
version "13.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/remark/-/remark-13.0.0.tgz#d15d9bf71a402f40287ebe36067b66d54868e425"
|
|
||||||
integrity sha512-HDz1+IKGtOyWN+QgBiAT0kn+2s6ovOxHyPAFGKVE81VSzJ+mq7RwHFledEvB5F1p4iJvOah/LOKdFuzvRnNLCA==
|
|
||||||
dependencies:
|
|
||||||
remark-parse "^9.0.0"
|
|
||||||
remark-stringify "^9.0.0"
|
|
||||||
unified "^9.1.0"
|
|
||||||
|
|
||||||
repeat-string@^1.0.0:
|
|
||||||
version "1.6.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
|
|
||||||
integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==
|
|
||||||
|
|
||||||
require@^2.4.20:
|
|
||||||
version "2.4.20"
|
|
||||||
resolved "https://registry.yarnpkg.com/require/-/require-2.4.20.tgz#66cb6baaabb65de8a71d793f5c65fd184f3798b6"
|
|
||||||
integrity sha512-7eop5rvh38qhQQQOoUyf68meVIcxT2yFySNywTbxoEECgkX4KDqqDRaEszfvFnuB3fuZVjDdJZ1TI/Esr16RRA==
|
|
||||||
dependencies:
|
|
||||||
std "0.1.40"
|
|
||||||
uglify-js "2.3.0"
|
|
||||||
|
|
||||||
resolve@^1.1.7, resolve@^1.22.8:
|
resolve@^1.1.7, resolve@^1.22.8:
|
||||||
version "1.22.8"
|
version "1.22.8"
|
||||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
|
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
|
||||||
@@ -2234,20 +1968,6 @@ run-parallel@^1.1.9:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask "^1.2.2"
|
queue-microtask "^1.2.2"
|
||||||
|
|
||||||
schema-utils@^3.0.0:
|
|
||||||
version "3.3.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe"
|
|
||||||
integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==
|
|
||||||
dependencies:
|
|
||||||
"@types/json-schema" "^7.0.8"
|
|
||||||
ajv "^6.12.5"
|
|
||||||
ajv-keywords "^3.5.2"
|
|
||||||
|
|
||||||
semver@^7.3.5:
|
|
||||||
version "7.6.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
|
|
||||||
integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
|
|
||||||
|
|
||||||
shebang-command@^2.0.0:
|
shebang-command@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
|
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
|
||||||
@@ -2300,18 +2020,6 @@ source-map-js@^1.2.0, source-map-js@^1.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||||
|
|
||||||
source-map@~0.1.7:
|
|
||||||
version "0.1.43"
|
|
||||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346"
|
|
||||||
integrity sha512-VtCvB9SIQhk3aF6h+N85EaqIaBFIAfZ9Cu+NJHHVvc8BbEcnvDcFw6sqQ2dQrT6SlOrZq3tIvyD9+EGq/lJryQ==
|
|
||||||
dependencies:
|
|
||||||
amdefine ">=0.0.4"
|
|
||||||
|
|
||||||
std@0.1.40:
|
|
||||||
version "0.1.40"
|
|
||||||
resolved "https://registry.yarnpkg.com/std/-/std-0.1.40.tgz#3678a5f65094d9e1b6b5e26edbfc0212b8342b71"
|
|
||||||
integrity sha512-wUf57hkDGCoVShrhPA8Q7lAg2Qosk+FaMlECmAsr1A4/rL2NRXFHQGBcgMUFKVkPEemJFW9gzjCQisRty14ohg==
|
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"string-width-cjs@npm:string-width@^4.2.0":
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
@@ -2360,14 +2068,6 @@ strip-ansi@^7.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex "^6.0.1"
|
ansi-regex "^6.0.1"
|
||||||
|
|
||||||
style-loader@^2.0.0:
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-2.0.0.tgz#9669602fd4690740eaaec137799a03addbbc393c"
|
|
||||||
integrity sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==
|
|
||||||
dependencies:
|
|
||||||
loader-utils "^2.0.0"
|
|
||||||
schema-utils "^3.0.0"
|
|
||||||
|
|
||||||
sucrase@^3.35.0:
|
sucrase@^3.35.0:
|
||||||
version "3.35.0"
|
version "3.35.0"
|
||||||
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263"
|
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263"
|
||||||
@@ -2442,11 +2142,6 @@ to-regex-range@^5.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-number "^7.0.0"
|
is-number "^7.0.0"
|
||||||
|
|
||||||
trough@^1.0.0:
|
|
||||||
version "1.0.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406"
|
|
||||||
integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==
|
|
||||||
|
|
||||||
ts-interface-checker@^0.1.9:
|
ts-interface-checker@^0.1.9:
|
||||||
version "0.1.13"
|
version "0.1.13"
|
||||||
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
|
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
|
||||||
@@ -2467,34 +2162,6 @@ uc.micro@^2.0.0, uc.micro@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
||||||
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
|
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
|
||||||
|
|
||||||
uglify-js@2.3.0:
|
|
||||||
version "2.3.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.3.0.tgz#2cdec16d378a8a2b6ecfb6989784cf8b7ae5491f"
|
|
||||||
integrity sha512-AQvbxRKdaQeYADywQaao0k8Tj+7NGEVTne6xwgX1yQpv/G8b0CKdIw70HkCptwfvNGDsVe+0Bng3U9hfWbxxfg==
|
|
||||||
dependencies:
|
|
||||||
async "~0.2.6"
|
|
||||||
optimist "~0.3.5"
|
|
||||||
source-map "~0.1.7"
|
|
||||||
|
|
||||||
unified@^9.1.0:
|
|
||||||
version "9.2.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975"
|
|
||||||
integrity sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==
|
|
||||||
dependencies:
|
|
||||||
bail "^1.0.0"
|
|
||||||
extend "^3.0.0"
|
|
||||||
is-buffer "^2.0.0"
|
|
||||||
is-plain-obj "^2.0.0"
|
|
||||||
trough "^1.0.0"
|
|
||||||
vfile "^4.0.0"
|
|
||||||
|
|
||||||
unist-util-stringify-position@^2.0.0:
|
|
||||||
version "2.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz#cce3bfa1cdf85ba7375d1d5b17bdc4cada9bd9da"
|
|
||||||
integrity sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==
|
|
||||||
dependencies:
|
|
||||||
"@types/unist" "^2.0.2"
|
|
||||||
|
|
||||||
update-browserslist-db@^1.1.1:
|
update-browserslist-db@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz#80846fba1d79e82547fb661f8d141e0945755fe5"
|
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz#80846fba1d79e82547fb661f8d141e0945755fe5"
|
||||||
@@ -2503,36 +2170,11 @@ update-browserslist-db@^1.1.1:
|
|||||||
escalade "^3.2.0"
|
escalade "^3.2.0"
|
||||||
picocolors "^1.1.0"
|
picocolors "^1.1.0"
|
||||||
|
|
||||||
uri-js@^4.2.2:
|
|
||||||
version "4.4.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
|
||||||
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
|
|
||||||
dependencies:
|
|
||||||
punycode "^2.1.0"
|
|
||||||
|
|
||||||
util-deprecate@^1.0.2:
|
util-deprecate@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||||
|
|
||||||
vfile-message@^2.0.0:
|
|
||||||
version "2.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a"
|
|
||||||
integrity sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==
|
|
||||||
dependencies:
|
|
||||||
"@types/unist" "^2.0.0"
|
|
||||||
unist-util-stringify-position "^2.0.0"
|
|
||||||
|
|
||||||
vfile@^4.0.0:
|
|
||||||
version "4.2.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.1.tgz#03f1dce28fc625c625bc6514350fbdb00fa9e624"
|
|
||||||
integrity sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==
|
|
||||||
dependencies:
|
|
||||||
"@types/unist" "^2.0.0"
|
|
||||||
is-buffer "^2.0.0"
|
|
||||||
unist-util-stringify-position "^2.0.0"
|
|
||||||
vfile-message "^2.0.0"
|
|
||||||
|
|
||||||
vite@^5.0.11:
|
vite@^5.0.11:
|
||||||
version "5.4.11"
|
version "5.4.11"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5"
|
||||||
@@ -2566,6 +2208,11 @@ vue-router@^4.0.12:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@vue/devtools-api" "^6.6.4"
|
"@vue/devtools-api" "^6.6.4"
|
||||||
|
|
||||||
|
vue3-apexcharts@^1.8.0:
|
||||||
|
version "1.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz#1984648d966aa91bc4dc3e87fa847f5289f7f1cf"
|
||||||
|
integrity sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==
|
||||||
|
|
||||||
vue@^3.4.23:
|
vue@^3.4.23:
|
||||||
version "3.5.13"
|
version "3.5.13"
|
||||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
|
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
|
||||||
@@ -2596,11 +2243,6 @@ which@^2.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe "^2.0.0"
|
isexe "^2.0.0"
|
||||||
|
|
||||||
wordwrap@~0.0.2:
|
|
||||||
version "0.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
|
|
||||||
integrity sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==
|
|
||||||
|
|
||||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
@@ -2633,8 +2275,3 @@ yaml@^2.3.4:
|
|||||||
version "2.6.1"
|
version "2.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773"
|
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773"
|
||||||
integrity sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==
|
integrity sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==
|
||||||
|
|
||||||
zwitch@^1.0.0:
|
|
||||||
version "1.0.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
|
||||||
integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.17.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(
|
||||||
|
|||||||
248
lms/lms/api.py
248
lms/lms/api.py
@@ -13,10 +13,21 @@ from frappe.translate import get_all_translations
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.query_builder import DocType
|
from frappe.query_builder import DocType
|
||||||
from frappe.query_builder.functions import Count
|
from frappe.query_builder.functions import Count
|
||||||
from frappe.utils import time_diff, now_datetime, get_datetime, flt
|
from frappe.utils import (
|
||||||
|
time_diff,
|
||||||
|
now_datetime,
|
||||||
|
get_datetime,
|
||||||
|
cint,
|
||||||
|
flt,
|
||||||
|
now,
|
||||||
|
add_days,
|
||||||
|
format_date,
|
||||||
|
date_diff,
|
||||||
|
)
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from lms.lms.utils import get_average_rating, get_lesson_count
|
from lms.lms.utils import get_average_rating, get_lesson_count
|
||||||
from xml.dom.minidom import parseString
|
from xml.dom.minidom import parseString
|
||||||
|
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -168,6 +179,7 @@ def get_user_info():
|
|||||||
user.is_instructor = "Course Creator" in user.roles
|
user.is_instructor = "Course Creator" in user.roles
|
||||||
user.is_moderator = "Moderator" in user.roles
|
user.is_moderator = "Moderator" in user.roles
|
||||||
user.is_evaluator = "Batch Evaluator" in user.roles
|
user.is_evaluator = "Batch Evaluator" in user.roles
|
||||||
|
user.is_student = "LMS Student" in user.roles
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -349,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()
|
||||||
@@ -395,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",
|
||||||
{
|
{
|
||||||
@@ -591,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.
|
||||||
|
<<<<<<< HEAD
|
||||||
search (str): Search term to filter the results.
|
search (str): Search term to filter the results.
|
||||||
Returns: List of members.
|
=======
|
||||||
|
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"]]}
|
||||||
@@ -1030,3 +1061,150 @@ def delete_scorm_package(scorm_package_path):
|
|||||||
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
|
scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:])
|
||||||
if os.path.exists(scorm_package_path):
|
if os.path.exists(scorm_package_path):
|
||||||
shutil.rmtree(scorm_package_path)
|
shutil.rmtree(scorm_package_path)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def mark_lesson_progress(course, chapter_number, lesson_number):
|
||||||
|
chapter_name = frappe.get_value(
|
||||||
|
"Chapter Reference", {"parent": course, "idx": chapter_number}, "chapter"
|
||||||
|
)
|
||||||
|
lesson_name = frappe.get_value(
|
||||||
|
"Lesson Reference", {"parent": chapter_name, "idx": lesson_number}, "lesson"
|
||||||
|
)
|
||||||
|
save_progress(lesson_name, course)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_heatmap_data(member=None, base_days=200):
|
||||||
|
if not member:
|
||||||
|
member = frappe.session.user
|
||||||
|
|
||||||
|
base_date, start_date, number_of_days, days = calculate_date_ranges(base_days)
|
||||||
|
date_count = initialize_date_count(days)
|
||||||
|
|
||||||
|
lesson_completions, quiz_submissions, assignment_submissions = fetch_activity_data(
|
||||||
|
member, start_date
|
||||||
|
)
|
||||||
|
count_dates(lesson_completions, date_count)
|
||||||
|
count_dates(quiz_submissions, date_count)
|
||||||
|
count_dates(assignment_submissions, date_count)
|
||||||
|
|
||||||
|
heatmap_data, labels, total_activities, weeks = prepare_heatmap_data(
|
||||||
|
start_date, number_of_days, date_count
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"heatmap_data": heatmap_data,
|
||||||
|
"labels": labels,
|
||||||
|
"total_activities": total_activities,
|
||||||
|
"weeks": weeks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_date_ranges(base_days):
|
||||||
|
today = format_date(now(), "YYYY-MM-dd")
|
||||||
|
day_today = get_datetime(today).strftime("%w")
|
||||||
|
padding_end = 6 - cint(day_today)
|
||||||
|
|
||||||
|
base_date = add_days(today, -base_days)
|
||||||
|
day_of_base_date = cint(get_datetime(base_date).strftime("%w"))
|
||||||
|
start_date = add_days(base_date, -day_of_base_date)
|
||||||
|
number_of_days = base_days + day_of_base_date + padding_end
|
||||||
|
days = [add_days(start_date, i) for i in range(number_of_days + 1)]
|
||||||
|
|
||||||
|
return base_date, start_date, number_of_days, days
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_date_count(days):
|
||||||
|
return {format_date(day, "YYYY-MM-dd"): 0 for day in days}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_activity_data(member, start_date):
|
||||||
|
lesson_completions = frappe.get_all(
|
||||||
|
"LMS Course Progress",
|
||||||
|
fields=["creation"],
|
||||||
|
filters={"member": member, "creation": [">=", start_date]},
|
||||||
|
)
|
||||||
|
|
||||||
|
quiz_submissions = frappe.get_all(
|
||||||
|
"LMS Quiz Submission",
|
||||||
|
fields=["creation"],
|
||||||
|
filters={"member": member, "creation": [">=", start_date]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assignment_submissions = frappe.get_all(
|
||||||
|
"LMS Assignment Submission",
|
||||||
|
fields=["creation"],
|
||||||
|
filters={"member": member, "creation": [">=", start_date]},
|
||||||
|
)
|
||||||
|
|
||||||
|
return lesson_completions, quiz_submissions, assignment_submissions
|
||||||
|
|
||||||
|
|
||||||
|
def count_dates(data, date_count):
|
||||||
|
for entry in data:
|
||||||
|
date = format_date(entry.creation, "YYYY-MM-dd")
|
||||||
|
if date in date_count:
|
||||||
|
date_count[date] += 1
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_heatmap_data(start_date, number_of_days, date_count):
|
||||||
|
days_of_week = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||||
|
heatmap_data = {day: [] for day in days_of_week}
|
||||||
|
week_count = -(number_of_days // -7)
|
||||||
|
labels = [None] * week_count
|
||||||
|
last_seen_month = None
|
||||||
|
sorted_dates = sorted(date_count.keys())
|
||||||
|
|
||||||
|
for date in sorted_dates:
|
||||||
|
activity_count = date_count[date]
|
||||||
|
day_of_week = get_datetime(date).strftime("%a")
|
||||||
|
current_month = get_datetime(date).strftime("%b")
|
||||||
|
column_index = get_week_difference(start_date, date)
|
||||||
|
|
||||||
|
if 0 <= column_index < week_count:
|
||||||
|
heatmap_data[day_of_week].append(
|
||||||
|
{
|
||||||
|
"date": date,
|
||||||
|
"count": activity_count,
|
||||||
|
"label": f"{activity_count} activities on {format_date(date, 'dd MMM')}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if last_seen_month != current_month:
|
||||||
|
labels[column_index] = current_month
|
||||||
|
last_seen_month = current_month
|
||||||
|
|
||||||
|
for (index, label) in enumerate(labels):
|
||||||
|
if not label:
|
||||||
|
labels[index] = ""
|
||||||
|
|
||||||
|
formatted_heatmap_data = [
|
||||||
|
{"name": day, "data": heatmap_data[day]} for day in days_of_week
|
||||||
|
]
|
||||||
|
|
||||||
|
total_activities = sum(date_count.values())
|
||||||
|
return formatted_heatmap_data, labels, total_activities, week_count
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_difference(start_date, current_date):
|
||||||
|
diff_in_days = date_diff(current_date, start_date)
|
||||||
|
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):
|
||||||
@@ -89,27 +79,25 @@ def save_progress(lesson, course):
|
|||||||
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
"LMS Enrollment", {"course": course, "member": frappe.session.user}
|
||||||
)
|
)
|
||||||
if not membership:
|
if not membership:
|
||||||
return
|
|
||||||
|
|
||||||
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
|
|
||||||
|
|
||||||
if frappe.db.exists(
|
|
||||||
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
quiz_completed = get_quiz_progress(lesson)
|
|
||||||
if not quiz_completed:
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
frappe.get_doc(
|
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
|
||||||
{
|
already_completed = frappe.db.exists(
|
||||||
"doctype": "LMS Course Progress",
|
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
|
||||||
"lesson": lesson,
|
)
|
||||||
"status": "Complete",
|
|
||||||
"member": frappe.session.user,
|
quiz_completed = get_quiz_progress(lesson)
|
||||||
}
|
assignment_completed = get_assignment_progress(lesson)
|
||||||
).save(ignore_permissions=True)
|
|
||||||
|
if not already_completed and quiz_completed and assignment_completed:
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "LMS Course Progress",
|
||||||
|
"lesson": lesson,
|
||||||
|
"status": "Complete",
|
||||||
|
"member": frappe.session.user,
|
||||||
|
}
|
||||||
|
).save(ignore_permissions=True)
|
||||||
|
|
||||||
progress = get_course_progress(course)
|
progress = get_course_progress(course)
|
||||||
capture_progress_for_analytics(progress, course)
|
capture_progress_for_analytics(progress, course)
|
||||||
@@ -159,6 +147,32 @@ def get_quiz_progress(lesson):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_assignment_progress(lesson):
|
||||||
|
lesson_details = frappe.db.get_value(
|
||||||
|
"Course Lesson", lesson, ["body", "content"], as_dict=1
|
||||||
|
)
|
||||||
|
assignments = []
|
||||||
|
|
||||||
|
if lesson_details.content:
|
||||||
|
content = json.loads(lesson_details.content)
|
||||||
|
|
||||||
|
for block in content.get("blocks"):
|
||||||
|
if block.get("type") == "assignment":
|
||||||
|
assignments.append(block.get("data").get("assignment"))
|
||||||
|
|
||||||
|
elif lesson_details.body:
|
||||||
|
macros = find_macros(lesson_details.body)
|
||||||
|
assignments = [value for name, value in macros if name == "Assignment"]
|
||||||
|
|
||||||
|
for assignment in assignments:
|
||||||
|
if not frappe.db.exists(
|
||||||
|
"LMS Assignment Submission",
|
||||||
|
{"assignment": assignment, "member": frappe.session.user},
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_lesson_info(chapter):
|
def get_lesson_info(chapter):
|
||||||
return frappe.db.get_value("Course Chapter", chapter, "course")
|
return frappe.db.get_value("Course Chapter", chapter, "course")
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"title",
|
"title",
|
||||||
"grade_assignment",
|
|
||||||
"question",
|
"question",
|
||||||
"column_break_hmwv",
|
"column_break_hmwv",
|
||||||
"type",
|
"type",
|
||||||
|
"grade_assignment",
|
||||||
|
"section_break_sjti",
|
||||||
"show_answer",
|
"show_answer",
|
||||||
"answer"
|
"answer"
|
||||||
],
|
],
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "question",
|
"fieldname": "question",
|
||||||
"fieldtype": "Text Editor",
|
"fieldtype": "Text Editor",
|
||||||
"label": "Question"
|
"label": "Question",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "type",
|
"fieldname": "type",
|
||||||
@@ -28,14 +30,16 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Type",
|
"label": "Type",
|
||||||
"options": "Document\nPDF\nURL\nImage\nText"
|
"options": "Document\nPDF\nURL\nImage\nText",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "title",
|
"fieldname": "title",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Title"
|
"label": "Title",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_hmwv",
|
"fieldname": "column_break_hmwv",
|
||||||
@@ -60,11 +64,15 @@
|
|||||||
"fieldname": "grade_assignment",
|
"fieldname": "grade_assignment",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Grade Assignment"
|
"label": "Grade Assignment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_sjti",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-04-05 12:01:36.601160",
|
"modified": "2024-12-24 09:36:31.464508",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment",
|
"name": "LMS Assignment",
|
||||||
|
|||||||
@@ -14,19 +14,17 @@
|
|||||||
"member",
|
"member",
|
||||||
"member_name",
|
"member_name",
|
||||||
"section_break_dlzh",
|
"section_break_dlzh",
|
||||||
"question",
|
|
||||||
"column_break_zvis",
|
|
||||||
"assignment_attachment",
|
"assignment_attachment",
|
||||||
"answer",
|
"answer",
|
||||||
"section_break_rqal",
|
"column_break_oqqy",
|
||||||
"status",
|
|
||||||
"evaluator",
|
"evaluator",
|
||||||
"column_break_esgd",
|
"status",
|
||||||
"comments",
|
"comments",
|
||||||
"section_break_cwaw",
|
"section_break_rqal",
|
||||||
"lesson",
|
"question",
|
||||||
|
"column_break_esgd",
|
||||||
"course",
|
"course",
|
||||||
"column_break_ygdu"
|
"lesson"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -89,8 +87,7 @@
|
|||||||
"fieldname": "evaluator",
|
"fieldname": "evaluator",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Evaluator",
|
"label": "Evaluator",
|
||||||
"options": "User",
|
"options": "User"
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);",
|
"depends_on": "eval:!([\"URL\", \"Text\"]).includes(doc.type);",
|
||||||
@@ -128,14 +125,6 @@
|
|||||||
"fieldname": "column_break_esgd",
|
"fieldname": "column_break_esgd",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "section_break_cwaw",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_ygdu",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);",
|
"depends_on": "eval:([\"URL\", \"Text\"]).includes(doc.type);",
|
||||||
"fieldname": "answer",
|
"fieldname": "answer",
|
||||||
@@ -148,14 +137,14 @@
|
|||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_zvis",
|
"fieldname": "column_break_oqqy",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-04-05 15:57:22.758563",
|
"modified": "2024-12-24 21:22:35.212732",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Assignment Submission",
|
"name": "LMS Assignment Submission",
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ from frappe import _
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import validate_url, validate_email_address
|
from frappe.utils import validate_url, validate_email_address
|
||||||
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.desk.doctype.notification_log.notification_log import make_notification_logs
|
||||||
|
|
||||||
|
|
||||||
class LMSAssignmentSubmission(Document):
|
class LMSAssignmentSubmission(Document):
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_duplicates()
|
self.validate_duplicates()
|
||||||
self.validate_url()
|
self.validate_url()
|
||||||
|
self.validate_status()
|
||||||
|
|
||||||
def after_insert(self):
|
def after_insert(self):
|
||||||
if not frappe.flags.in_test:
|
if not frappe.flags.in_test:
|
||||||
@@ -69,6 +71,31 @@ class LMSAssignmentSubmission(Document):
|
|||||||
header=[subject, "green"],
|
header=[subject, "green"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_status(self):
|
||||||
|
if not self.is_new():
|
||||||
|
doc_before_save = self.get_doc_before_save()
|
||||||
|
if (
|
||||||
|
doc_before_save.status != self.status or doc_before_save.comments != self.comments
|
||||||
|
):
|
||||||
|
self.trigger_update_notification()
|
||||||
|
|
||||||
|
def trigger_update_notification(self):
|
||||||
|
notification = frappe._dict(
|
||||||
|
{
|
||||||
|
"subject": _(
|
||||||
|
"There has been an update on your submission for assignment {0}"
|
||||||
|
).format(self.assignment_title),
|
||||||
|
"email_content": self.comments,
|
||||||
|
"document_type": self.doctype,
|
||||||
|
"document_name": self.name,
|
||||||
|
"for_user": self.owner,
|
||||||
|
"from_user": self.evaluator,
|
||||||
|
"type": "Alert",
|
||||||
|
"link": f"/assignment-submission/{self.assignment}/{self.name}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
make_notification_logs(notification, [self.member])
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def upload_assignment(
|
def upload_assignment(
|
||||||
|
|||||||
@@ -10,5 +10,11 @@ frappe.ui.form.on("LMS Badge Assignment", {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (frm.doc.name)
|
||||||
|
frm.add_web_link(
|
||||||
|
`/badges/${frm.doc.badge}/${frm.doc.member}`,
|
||||||
|
"See on Website"
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"member",
|
"member",
|
||||||
|
"member_name",
|
||||||
"issued_on",
|
"issued_on",
|
||||||
"column_break_ugix",
|
"column_break_ugix",
|
||||||
"badge",
|
"badge",
|
||||||
@@ -57,11 +58,18 @@
|
|||||||
"label": "Badge Description",
|
"label": "Badge Description",
|
||||||
"read_only": 1,
|
"read_only": 1,
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "member.full_name",
|
||||||
|
"fieldname": "member_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Member Name",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-05-13 20:16:00.191517",
|
"modified": "2025-01-06 12:32:28.450028",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Badge Assignment",
|
"name": "LMS Badge Assignment",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -132,13 +132,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "duration",
|
"fieldname": "duration",
|
||||||
"fieldtype": "Duration",
|
"fieldtype": "Data",
|
||||||
"label": "Duration"
|
"label": "Duration (in minutes)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-10-11 22:39:40.381183",
|
"modified": "2025-01-06 11:02:09.749207",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Quiz",
|
"name": "LMS Quiz",
|
||||||
|
|||||||
173
lms/lms/utils.py
173
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
|
||||||
@@ -1030,6 +1012,7 @@ def get_course_details(course):
|
|||||||
course_details.tags = course_details.tags.split(",") if course_details.tags else []
|
course_details.tags = course_details.tags.split(",") if course_details.tags else []
|
||||||
|
|
||||||
course_details.instructors = get_instructors(course_details.name)
|
course_details.instructors = get_instructors(course_details.name)
|
||||||
|
# course_details.is_instructor = is_instructor(course_details.name)
|
||||||
if course_details.paid_course:
|
if course_details.paid_course:
|
||||||
"""course_details.course_price, course_details.currency = check_multicurrency(
|
"""course_details.course_price, course_details.currency = check_multicurrency(
|
||||||
course_details.course_price, course_details.currency, None, course_details.amount_usd
|
course_details.course_price, course_details.currency, None, course_details.amount_usd
|
||||||
@@ -1048,7 +1031,6 @@ def get_course_details(course):
|
|||||||
["name", "course", "current_lesson", "progress", "member"],
|
["name", "course", "current_lesson", "progress", "member"],
|
||||||
as_dict=1,
|
as_dict=1,
|
||||||
)
|
)
|
||||||
course_details.is_instructor = is_instructor(course_details.name)
|
|
||||||
|
|
||||||
if course_details.membership and course_details.membership.current_lesson:
|
if course_details.membership and course_details.membership.current_lesson:
|
||||||
course_details.current_lesson = get_lesson_index(
|
course_details.current_lesson = get_lesson_index(
|
||||||
@@ -1210,21 +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_details(batch.name))
|
|
||||||
|
|
||||||
batches = categorize_batches(batches)
|
|
||||||
return batches
|
|
||||||
|
|
||||||
|
|
||||||
@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(
|
||||||
@@ -1327,7 +1294,6 @@ def get_question_details(question):
|
|||||||
for i in range(1, 5):
|
for i in range(1, 5):
|
||||||
fields.append(f"option_{i}")
|
fields.append(f"option_{i}")
|
||||||
fields.append(f"explanation_{i}")
|
fields.append(f"explanation_{i}")
|
||||||
fields.append(f"is_correct_{i}")
|
|
||||||
|
|
||||||
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
|
question_details = frappe.db.get_value("LMS Question", question, fields, as_dict=1)
|
||||||
return question_details
|
return question_details
|
||||||
@@ -1421,7 +1387,7 @@ def get_quiz_details(assessment, member):
|
|||||||
if len(existing_submission):
|
if len(existing_submission):
|
||||||
assessment.submission = existing_submission[0]
|
assessment.submission = existing_submission[0]
|
||||||
assessment.completed = True
|
assessment.completed = True
|
||||||
assessment.status = assessment.submission.score
|
assessment.status = assessment.submission.percentage or assessment.submission.score
|
||||||
else:
|
else:
|
||||||
assessment.status = "Not Attempted"
|
assessment.status = "Not Attempted"
|
||||||
assessment.color = "red"
|
assessment.color = "red"
|
||||||
@@ -1487,8 +1453,20 @@ def get_batch_students(batch):
|
|||||||
|
|
||||||
detail.courses_completed = courses_completed
|
detail.courses_completed = courses_completed
|
||||||
detail.assessments_completed = assessments_completed
|
detail.assessments_completed = assessments_completed
|
||||||
students.append(detail)
|
if len(batch_courses) + len(assessments):
|
||||||
|
detail.progress = flt(
|
||||||
|
(
|
||||||
|
(courses_completed + assessments_completed)
|
||||||
|
/ (len(batch_courses) + len(assessments))
|
||||||
|
* 100
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail.progress = 0
|
||||||
|
|
||||||
|
students.append(detail)
|
||||||
|
students = sorted(students, key=lambda x: x.progress, reverse=True)
|
||||||
return students
|
return students
|
||||||
|
|
||||||
|
|
||||||
@@ -1739,31 +1717,31 @@ def enroll_in_batch(batch, payment_name=None):
|
|||||||
if not frappe.db.exists(
|
if not frappe.db.exists(
|
||||||
"Batch Student", {"parent": batch, "student": frappe.session.user}
|
"Batch Student", {"parent": batch, "student": frappe.session.user}
|
||||||
):
|
):
|
||||||
student = frappe.new_doc("Batch Student")
|
batch_doc = frappe.get_doc("LMS Batch", batch)
|
||||||
current_count = frappe.db.count("Batch Student", {"parent": batch})
|
if batch_doc.seat_count and len(batch_doc.students) >= batch_doc.seat_count:
|
||||||
|
frappe.throw(_("The batch is full. Please contact the Administrator."))
|
||||||
|
|
||||||
student.update(
|
new_student = {
|
||||||
{
|
"student": frappe.session.user,
|
||||||
"student": frappe.session.user,
|
"parent": batch,
|
||||||
"parent": batch,
|
"parenttype": "LMS Batch",
|
||||||
"parenttype": "LMS Batch",
|
"parentfield": "students",
|
||||||
"parentfield": "students",
|
"idx": len(batch_doc.students) + 1,
|
||||||
"idx": current_count + 1,
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if payment_name:
|
if payment_name:
|
||||||
payment = frappe.db.get_value(
|
payment = frappe.db.get_value(
|
||||||
"LMS Payment", payment_name, ["name", "source"], as_dict=True
|
"LMS Payment", payment_name, ["name", "source"], as_dict=True
|
||||||
)
|
)
|
||||||
student.update(
|
new_student.update(
|
||||||
{
|
{
|
||||||
"payment": payment.name,
|
"payment": payment.name,
|
||||||
"source": payment.source,
|
"source": payment.source,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
student.save(ignore_permissions=True)
|
batch_doc.append("students", new_student)
|
||||||
|
batch_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -1852,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 %}
|
||||||
|
|||||||
665
lms/locale/ar.po
665
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
665
lms/locale/bs.po
665
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
667
lms/locale/de.po
667
lms/locale/de.po
File diff suppressed because it is too large
Load Diff
668
lms/locale/eo.po
668
lms/locale/eo.po
File diff suppressed because it is too large
Load Diff
671
lms/locale/es.po
671
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
679
lms/locale/fa.po
679
lms/locale/fa.po
File diff suppressed because it is too large
Load Diff
665
lms/locale/fr.po
665
lms/locale/fr.po
File diff suppressed because it is too large
Load Diff
665
lms/locale/hu.po
665
lms/locale/hu.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
665
lms/locale/pl.po
665
lms/locale/pl.po
File diff suppressed because it is too large
Load Diff
667
lms/locale/ru.po
667
lms/locale/ru.po
File diff suppressed because it is too large
Load Diff
707
lms/locale/sv.po
707
lms/locale/sv.po
File diff suppressed because it is too large
Load Diff
755
lms/locale/tr.po
755
lms/locale/tr.po
File diff suppressed because it is too large
Load Diff
667
lms/locale/zh.po
667
lms/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -96,3 +96,4 @@ lms.patches.v2_0.give_discussions_permissions
|
|||||||
lms.patches.v2_0.delete_web_forms
|
lms.patches.v2_0.delete_web_forms
|
||||||
lms.patches.v2_0.update_desk_access_for_lms_roles
|
lms.patches.v2_0.update_desk_access_for_lms_roles
|
||||||
lms.patches.v2_0.update_quiz_submission_data
|
lms.patches.v2_0.update_quiz_submission_data
|
||||||
|
lms.patches.v2_0.convert_quiz_duration_to_minutes
|
||||||
10
lms/patches/v2_0/convert_quiz_duration_to_minutes.py
Normal file
10
lms/patches/v2_0/convert_quiz_duration_to_minutes.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe.utils import ceil, flt
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
quizzes = frappe.get_all(
|
||||||
|
"LMS Quiz", fields=["name", "duration"], filters={"duration": [">", 0]}
|
||||||
|
)
|
||||||
|
for quiz in quizzes:
|
||||||
|
frappe.db.set_value("LMS Quiz", quiz.name, "duration", ceil(flt(quiz.duration) / 60))
|
||||||
@@ -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