Merge pull request #2125 from frappe/develop
chore: merge `develop` into `main-hotfix`
This commit is contained in:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request: {}
|
||||
jobs:
|
||||
tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -54,25 +54,21 @@ describe("Batch Creation", () => {
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Batch").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/batches/new/edit");
|
||||
cy.get("label").contains("Title").type("Test Batch");
|
||||
|
||||
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||
cy.get("label").contains("End Date").type("2030-10-31");
|
||||
cy.get("label").contains("Start Time").type("10:00");
|
||||
cy.get("label").contains("End Time").type("11:00");
|
||||
cy.get("label").contains("Timezone").type("IST");
|
||||
cy.get("label").contains("Seat Count").type("10");
|
||||
cy.get("label").contains("Published").click();
|
||||
|
||||
cy.get("label")
|
||||
.contains("Short Description")
|
||||
.contains("Description")
|
||||
.type("Test Batch Short Description to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
@@ -90,13 +86,14 @@ describe("Batch Creation", () => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Save").click();
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.button("Save").click();
|
||||
cy.wait(1000);
|
||||
let batchName;
|
||||
cy.url().then((url) => {
|
||||
console.log(url);
|
||||
batchName = url.split("/").pop();
|
||||
batchName = url.split("/").pop().split("#")[0];
|
||||
cy.wrap(batchName).as("batchName");
|
||||
});
|
||||
cy.wait(500);
|
||||
@@ -115,7 +112,7 @@ describe("Batch Creation", () => {
|
||||
.click();
|
||||
|
||||
cy.get("@batchName").then((batchName) => {
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("Test Batch Short Description to test the UI")
|
||||
@@ -132,7 +129,7 @@ describe("Batch Creation", () => {
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).click();
|
||||
});
|
||||
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
@@ -154,14 +151,14 @@ describe("Batch Creation", () => {
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
)
|
||||
.should("be.visible");
|
||||
cy.get("button:visible").contains("Manage Batch").click();
|
||||
cy.get("button:visible").contains("Dashboard").click();
|
||||
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Students").click();
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get("button").contains("Enroll").click();
|
||||
cy.get('div[role="dialog"]')
|
||||
.first()
|
||||
.find("input[id^='headlessui-combobox-input-v-']")
|
||||
.find("div[label='Student']")
|
||||
.find("div")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("input[placeholder='Search']").type(randomEmail);
|
||||
@@ -169,7 +166,7 @@ describe("Batch Creation", () => {
|
||||
cy.get("button").contains("Submit").click();
|
||||
|
||||
// Verify Seat Count
|
||||
cy.get("span").contains("Details").click();
|
||||
cy.get("button:visible").contains("Overview").click();
|
||||
cy.contains("div:visible", "9 Seats Left").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Create").last().click();
|
||||
cy.button("Save").last().click();
|
||||
|
||||
// Edit Course Details
|
||||
cy.wait(500);
|
||||
@@ -65,12 +65,9 @@ describe("Course Creation", () => {
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click();
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("div").contains("Business").click();
|
||||
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
|
||||
13
frontend/components.d.ts
vendored
13
frontend/components.d.ts
vendored
@@ -8,14 +8,10 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default']
|
||||
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
||||
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
|
||||
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
|
||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
||||
Assessments: typeof import('./src/components/Assessments.vue')['default']
|
||||
Assignment: typeof import('./src/components/Assignment.vue')['default']
|
||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||
@@ -24,16 +20,8 @@ declare module 'vue' {
|
||||
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
|
||||
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
|
||||
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
|
||||
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
||||
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
|
||||
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
|
||||
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
||||
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
||||
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
||||
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
||||
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
||||
@@ -82,7 +70,6 @@ declare module 'vue' {
|
||||
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
|
||||
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
|
||||
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
||||
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
||||
|
||||
@@ -51,12 +51,12 @@
|
||||
"vuedraggable": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"autoprefixer": "10.4.2",
|
||||
"postcss": "8.4.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"vite": "5.0.11",
|
||||
"vite-plugin-pwa": "0.15.0"
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="">
|
||||
<div class="w-full flex items-center justify-between pb-4">
|
||||
<div class="font-medium text-ink-gray-7">
|
||||
{{ __('Statistics') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: studentCount.data || 0 }"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Certified'),
|
||||
value: certificationCount.data || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Courses'),
|
||||
value: batch?.data?.courses?.length || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Assessments'), value: assessmentCount.data || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border rounded-lg p-3 min-h-[300px]"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
swapXY: true,
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
batch: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const studentCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_student_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: { batch: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const assessmentCount = createResource({
|
||||
url: 'lms.lms.utils.get_batch_assessment_count',
|
||||
cache: ['batch_assessment_count', props.batch?.data?.name],
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const chartData = createResource({
|
||||
url: 'lms.lms.utils.get_batch_chart_data',
|
||||
cache: ['batch_chart_data', props.batch?.data?.name],
|
||||
params: { batch: props.batch?.data?.name },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_certificate_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: { batch_name: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const filteredChartData = computed(() =>
|
||||
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
|
||||
)
|
||||
|
||||
const showProgressChart = computed(
|
||||
() =>
|
||||
studentCount.data &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.data)
|
||||
)
|
||||
</script>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No announcements') }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -64,8 +64,8 @@
|
||||
import { Dialog, FormControl } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = ref(false)
|
||||
const quiz = ref(null)
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7 font-medium mb-2">
|
||||
{{ __('Question') }}:
|
||||
<div class="text-ink-gray-9 font-semibold mb-5">
|
||||
{{ __('Assignment Question') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="assignment.data.question"
|
||||
@@ -42,7 +42,11 @@
|
||||
>
|
||||
{{ submissionResource.doc?.status }}
|
||||
</Badge>
|
||||
<Button variant="solid" @click="submitAssignment()">
|
||||
<Button
|
||||
v-if="canModifyAssignment"
|
||||
variant="solid"
|
||||
@click="submitAssignment()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -73,12 +77,14 @@
|
||||
}}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionResource.doc?.assignment_attachment"
|
||||
v-if="!attachment"
|
||||
:fileTypes="getType()"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
:validateFile="validateFile"
|
||||
:validateFile="
|
||||
(file) => validateFile(file, assignment.data.type.toLowerCase())
|
||||
"
|
||||
@success="(file) => saveSubmission(file)"
|
||||
>
|
||||
<template #default="{ uploading, progress, openFileSelector }">
|
||||
@@ -94,7 +100,7 @@
|
||||
<div v-else>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<a
|
||||
:href="submissionResource.doc.assignment_attachment"
|
||||
:href="attachment"
|
||||
target="_blank"
|
||||
class="cursor-pointer !no-underline text-sm leading-5"
|
||||
>
|
||||
@@ -103,11 +109,7 @@
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<span>
|
||||
{{
|
||||
submissionResource.doc.assignment_attachment
|
||||
.split('/')
|
||||
.pop()
|
||||
}}
|
||||
{{ attachment.split('/').pop() }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -138,6 +140,7 @@
|
||||
@change="(val) => (answer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
:readonly="!canModifyAssignment"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
@@ -150,7 +153,7 @@
|
||||
user.data?.name == submissionResource.doc?.owner &&
|
||||
submissionResource.doc?.comments
|
||||
"
|
||||
class="mt-8 p-3 border rounded-lg"
|
||||
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
|
||||
>
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Comments by Evaluator') }}
|
||||
@@ -213,8 +216,10 @@ import {
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { validateFile } from '@/utils'
|
||||
|
||||
const answer = ref(null)
|
||||
const attachment = ref(null)
|
||||
const comments = ref(null)
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
@@ -264,118 +269,98 @@ const assignment = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
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.answer = answer.value
|
||||
}
|
||||
return {
|
||||
doc: doc,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createDocumentResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
auto: false,
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
auto: false,
|
||||
cache: [user.data?.name, props.assignmentID],
|
||||
})
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (submissionResource.doc) {
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
if (submissionResource.isDirty) {
|
||||
isDirty.value = true
|
||||
} else if (
|
||||
showUploader() &&
|
||||
!submissionResource.doc.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
} else if (!showUploader() && !answer.value) {
|
||||
isDirty.value = true
|
||||
} else {
|
||||
isDirty.value = false
|
||||
}
|
||||
if (!submissionResource.doc) return
|
||||
console.log(submissionResource.doc)
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
attachment.value = submissionResource.doc.assignment_attachment
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => submissionResource.doc,
|
||||
() => {
|
||||
if (
|
||||
props.submissionName == 'new' &&
|
||||
submissionResource.doc?.assignment_attachment
|
||||
) {
|
||||
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,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
updateSubmission()
|
||||
} else {
|
||||
addNewSubmission()
|
||||
}
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
newSubmission.submit(
|
||||
{},
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
} else {
|
||||
doc.assignment_attachment = attachment.value
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: doc,
|
||||
})
|
||||
.then((data) => {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
isDirty.value = false
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const updateSubmission = () => {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
assignment_attachment: attachment.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -383,7 +368,7 @@ const addNewSubmission = () => {
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
isDirty.value = true
|
||||
submissionResource.doc.assignment_attachment = file.file_url
|
||||
attachment.value = file.file_url
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
@@ -417,21 +402,6 @@ const getType = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
isDirty.value = true
|
||||
submissionResource.doc.assignment_attachment = ''
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
/>
|
||||
<Assessments :batch="batch.data.name" />
|
||||
<!-- <StudentHeatmap /> -->
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,226 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ studentCount.data ?? 0 }} {{ __('Students') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
class="max-h-[75vh]"
|
||||
:columns="studentColumns"
|
||||
:rows="students.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in studentColumns"
|
||||
: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 hover:bg-surface-gray-2 rounded"
|
||||
@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>
|
||||
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
|
||||
<Button @click="students.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudentModal
|
||||
:batch="props.batch.data.name"
|
||||
v-model="showStudentModal"
|
||||
v-model:reloadStudents="students"
|
||||
v-model:batchModal="props.batch"
|
||||
/>
|
||||
<BatchStudentProgress
|
||||
:student="selectedStudent"
|
||||
v-model="showStudentProgressModal"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
FeatherIcon,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
const showStudentProgressModal = ref(false)
|
||||
const selectedStudent = ref(null)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const studentCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_student_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: { batch: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const students = createListResource({
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
cache: ['batch_students', props.batch?.data?.name],
|
||||
pageLength: 50,
|
||||
filters: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const studentColumns = [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: '25rem',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Progress',
|
||||
key: 'progress',
|
||||
width: '15rem',
|
||||
icon: 'activity',
|
||||
},
|
||||
{
|
||||
label: 'Last Active',
|
||||
key: 'last_active',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
|
||||
const openStudentModal = () => {
|
||||
showStudentModal.value = true
|
||||
}
|
||||
|
||||
const openStudentProgressModal = (row) => {
|
||||
showStudentProgressModal.value = true
|
||||
selectedStudent.value = row
|
||||
}
|
||||
|
||||
const deleteStudents = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
documents: values.students,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections, unselectAll) => {
|
||||
deleteStudents.submit(
|
||||
{
|
||||
students: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
students.reload()
|
||||
studentCount.reload()
|
||||
props.batch.reload()
|
||||
toast.success(__('Students deleted successfully'))
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,95 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Label -->
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||
</div>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
class="form-input w-full"
|
||||
:class="inputClasses"
|
||||
type="text"
|
||||
:value="selectedValue"
|
||||
autocomplete="off"
|
||||
@click="onFocus"
|
||||
/>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
|
||||
<!-- Dropdown -->
|
||||
<ComboboxOptions
|
||||
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals shadow-lg"
|
||||
>
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Options -->
|
||||
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
|
||||
<template v-for="group in groups" :key="group.key">
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
<Combobox
|
||||
v-model="selectedValue"
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="
|
||||
() => {
|
||||
showOptions = !showOptions
|
||||
togglePopover()
|
||||
}
|
||||
"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
|
||||
<ComboboxOption
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value === option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen" class="">
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value == option.label && option.description
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="slots.footer"
|
||||
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
|
||||
>
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{
|
||||
value: selectedValue,
|
||||
close,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,15 +147,15 @@ import {
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
ComboboxButton,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
@@ -139,93 +186,107 @@ const props = defineProps({
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
const trigger = ref(null)
|
||||
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const search = ref(null)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
const selectedValue = ref(props.modelValue)
|
||||
const query = ref('')
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
watch(selectedValue, (val) => {
|
||||
query.value = ''
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
function clearValue() {
|
||||
emit('update:modelValue', null)
|
||||
function close() {
|
||||
showOptions.value = false
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options?.length) return []
|
||||
if (!props.options || props.options.length == 0) return []
|
||||
|
||||
const normalized = props.options[0]?.group
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
return normalized
|
||||
.map((group, i) => ({
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}))
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) return options
|
||||
const q = query.value.toLowerCase()
|
||||
return options.filter((option) =>
|
||||
[option.label, option.value]
|
||||
.filter(Boolean)
|
||||
.some((text) => text.toString().toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
emit('update:query', val)
|
||||
},
|
||||
{ debounce: 300 }
|
||||
)
|
||||
|
||||
const onFocus = () => {
|
||||
trigger.value?.$el.click()
|
||||
nextTick(() => {
|
||||
search.value?.focus()
|
||||
if (!query.value) {
|
||||
return options
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value]
|
||||
return searchTexts.some((text) =>
|
||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
selectedValue.value = null
|
||||
trigger.value?.$el.click()
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
let selectedOption = allOptions.find((o) => o.value === option)
|
||||
return selectedOption?.label || option
|
||||
}
|
||||
return option?.label
|
||||
}
|
||||
|
||||
const textColor = computed(() =>
|
||||
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
)
|
||||
watch(query, (q) => {
|
||||
emit('update:query', q)
|
||||
})
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const sizeClasses = {
|
||||
let sizeClasses = {
|
||||
sm: 'text-base rounded h-7',
|
||||
md: 'text-base rounded h-8',
|
||||
lg: 'text-lg rounded-md h-10',
|
||||
xl: 'text-xl rounded-md h-10',
|
||||
}[props.size]
|
||||
|
||||
const paddingClasses = {
|
||||
let paddingClasses = {
|
||||
sm: 'py-1.5 px-2',
|
||||
md: 'py-1.5 px-2.5',
|
||||
lg: 'py-1.5 px-3',
|
||||
xl: 'py-1.5 px-3',
|
||||
}[props.size]
|
||||
|
||||
const variant = props.disabled ? 'disabled' : props.variant
|
||||
|
||||
const variantClasses = {
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
outline:
|
||||
@@ -246,4 +307,6 @@ const inputClasses = computed(() => {
|
||||
'transition-colors w-full',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
|
||||
@@ -95,7 +95,8 @@ const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
'border object-cover',
|
||||
shape === 'circle'
|
||||
? 'w-20 h-20 rounded-full'
|
||||
: 'w-44 h-auto min-h-20 rounded-md',
|
||||
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
|
||||
]"
|
||||
/>
|
||||
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div class="grid gap-8 mt-10">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="flex items-center">
|
||||
<div class="flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -46,11 +46,11 @@
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasPermission() && !props.zoomAccount"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3 text-xs"
|
||||
>
|
||||
<AlertCircle class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Please add a zoom account to the batch to create live classes.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': hasPermission() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
:batch="props.batch"
|
||||
:zoomAccount="props.zoomAccount"
|
||||
v-model="showLiveClassModal"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!props.zoomAccount) return false
|
||||
return hasPermission()
|
||||
}
|
||||
|
||||
const hasPermission = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!hasPermission()) return
|
||||
if (cls.attendees <= 0) return
|
||||
showAttendance.value = true
|
||||
attendanceFor.value = cls
|
||||
}
|
||||
</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>
|
||||
@@ -20,11 +20,15 @@
|
||||
:options="assessmentTypes"
|
||||
v-model="assessmentType"
|
||||
:label="__('Type')"
|
||||
placeholder=" "
|
||||
@update:modelValue="() => (assessment = null)"
|
||||
/>
|
||||
<Link
|
||||
v-if="assessmentType"
|
||||
v-model="assessment"
|
||||
:doctype="assessmentType"
|
||||
:label="__('Assessment')"
|
||||
placeholder=" "
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
close()
|
||||
@@ -49,7 +53,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Course'),
|
||||
size: 'sm',
|
||||
title: __('Add a course to the batch'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Submit'),
|
||||
@@ -41,7 +41,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||
import { Dialog, toast } from 'frappe-ui'
|
||||
import { ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
@@ -63,37 +63,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const createBatchCourse = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Batch Course',
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addCourse = (close) => {
|
||||
createBatchCourse.submit(
|
||||
{},
|
||||
courses.value.insert.submit(
|
||||
{
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_course')
|
||||
|
||||
close()
|
||||
courses.value.reload()
|
||||
course.value = null
|
||||
evaluator.value = null
|
||||
toast.success(__('Course added to batch successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.log(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<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 text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="
|
||||
Object.keys(student.assessments).length ||
|
||||
Object.keys(student.courses).length
|
||||
"
|
||||
:theme="student.progress === 100 ? 'green' : 'red'"
|
||||
>
|
||||
{{ student.progress }}% {{ __('Complete') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ student.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Assessments -->
|
||||
<div
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Percentage/Status') }}
|
||||
</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="assessment in Object.keys(student.assessments)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
:to="{
|
||||
name:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? 'AssignmentSubmission'
|
||||
: '',
|
||||
params:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? {
|
||||
assignmentID:
|
||||
student.assessments[assessment].assessment,
|
||||
submissionName:
|
||||
student.assessments[assessment].submission,
|
||||
}
|
||||
: {},
|
||||
}"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ assessment }}
|
||||
</span>
|
||||
<span v-if="isAssignment(student.assessments[assessment].status)">
|
||||
<Badge
|
||||
:theme="
|
||||
getStatusTheme(student.assessments[assessment].status)
|
||||
"
|
||||
>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</Badge>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Courses -->
|
||||
<div
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Progress') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="course in Object.keys(student.courses)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ course }}
|
||||
</span>
|
||||
<span>
|
||||
{{ Math.floor(student.courses[course]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap -->
|
||||
<StudentHeatmap :member="student.email" :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>
|
||||
@@ -55,6 +55,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
|
||||
{{ __('Please select a course to view available slots.') }}
|
||||
</div>
|
||||
<div v-else class="text-ink-red-3">
|
||||
{{ __('No slots available for the selected course.') }}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 min-h-[300px]">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Training Feedback') }}
|
||||
</div>
|
||||
<ListView
|
||||
|
||||
@@ -84,16 +84,10 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
createResource,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
Autocomplete,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
|
||||
import { reactive, inject, onMounted } from 'vue'
|
||||
import { getTimezones, getUserTimezone } from '@/utils/'
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
|
||||
const liveClasses = defineModel('reloadLiveClasses')
|
||||
const show = defineModel()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Enroll a Student'),
|
||||
size: 'sm',
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
@@ -51,8 +51,6 @@ import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const students = defineModel('reloadStudents')
|
||||
const batchModal = defineModel('batchModal')
|
||||
const student = ref(null)
|
||||
const payment = ref(null)
|
||||
const user = inject('$user')
|
||||
@@ -61,33 +59,37 @@ const show = defineModel()
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
students: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const addStudent = (close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
batch: props.batch,
|
||||
props.students.insert.submit(
|
||||
{
|
||||
member: student.value,
|
||||
payment: payment.value,
|
||||
batch: props.batch.data?.name,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
|
||||
students.value.reload()
|
||||
batchModal.value.reload()
|
||||
student.value = null
|
||||
payment.value = null
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
student.value = null
|
||||
payment.value = null
|
||||
props.batch.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||
title: __('Video Statistics'),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
@@ -21,17 +21,22 @@
|
||||
class="mt-2 mr-5 w-[25%]"
|
||||
/> -->
|
||||
</div>
|
||||
<div v-if="currentTab" class="mt-4">
|
||||
<div
|
||||
v-if="currentTab"
|
||||
:class="{
|
||||
'mt-5': tabs.length > 1,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||
<div
|
||||
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
|
||||
class="space-y-5 border rounded-md p-2 pt-4 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||
<div class="px-4">
|
||||
{{ __('Member') }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{{ __('Watch Time') }}
|
||||
{{ __('Watch Time (mins)') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -68,15 +73,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Average Watch Time'),
|
||||
value: averageWatchTime,
|
||||
}"
|
||||
<NumberChartGraph
|
||||
:title="__('Average Watch Time (mins)')"
|
||||
:value="averageWatchTime"
|
||||
/>
|
||||
<div v-if="isPlyrSource">
|
||||
<div class="video-player" :src="currentTab"></div>
|
||||
<div
|
||||
class="video-player"
|
||||
:data-plyr-provider="provider"
|
||||
:src="currentTab"
|
||||
></div>
|
||||
</div>
|
||||
<VideoBlock v-else :file="currentTab" />
|
||||
</div>
|
||||
@@ -101,6 +107,7 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { enablePlyr, formatTimestamp } from '@/utils'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const currentTab = ref<string>('')
|
||||
@@ -171,7 +178,7 @@ watch(show, () => {
|
||||
|
||||
const statisticsData = computed(() => {
|
||||
const grouped = <Record<string, any[]>>{}
|
||||
statistics.data.forEach((item: { source: string }) => {
|
||||
statistics.data?.forEach((item: { source: string }) => {
|
||||
if (!grouped[item.source]) {
|
||||
grouped[item.source] = []
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ const referenceDoctypeOptions = computed(() => {
|
||||
})
|
||||
|
||||
const eventOptions = computed(() => {
|
||||
let options = ['New', 'Value Change', 'Auto Assign']
|
||||
let options = ['New', 'Value Change', 'Manual Assignment']
|
||||
return options.map((event) => ({ label: __(event), value: event }))
|
||||
})
|
||||
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
<div class="text-xl font-semibold leading-none text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
|
||||
@@ -219,6 +219,25 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Allow Job Posting',
|
||||
name: 'allow_job_posting',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
columns: [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<Button v-if="canScheduleEvals" @click="openEvalModal">
|
||||
{{ __('Schedule Evaluation') }}
|
||||
{{ __('Schedule') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -31,12 +31,14 @@
|
||||
<div v-if="upcoming_evals.data?.length">
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="forHome ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-3'"
|
||||
:class="forHome ? 'grid-cols-1 md:grid-cols-4' : 'grid-cols-1'"
|
||||
>
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border text-ink-gray-7 rounded-md p-3">
|
||||
<div
|
||||
class="border hover:border-outline-gray-3 text-ink-gray-7 rounded-md p-3"
|
||||
>
|
||||
<div class="flex justify-between mb-3">
|
||||
<span class="text-lg font-semibold text-ink-gray-9 leading-5">
|
||||
<span class="font-semibold text-ink-gray-9 leading-5">
|
||||
{{ evl.course_title }}
|
||||
</span>
|
||||
<Menu
|
||||
@@ -114,7 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!endDateHasPassed" class="text-ink-gray-5">
|
||||
<div v-else-if="!endDateHasPassed" class="text-ink-gray-7">
|
||||
{{ __('Schedule an evaluation to get certified.') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +202,7 @@ const openEvalCall = (evl) => {
|
||||
|
||||
const evaluationCourses = computed(() => {
|
||||
return props.courses.filter((course) => {
|
||||
return course.evaluator != ''
|
||||
return course.evaluator && course.evaluator != ''
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div v-if="badge.data">
|
||||
<div class="p-5 flex flex-col items-center mt-40">
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ badge.data.badge }}
|
||||
</div>
|
||||
<img
|
||||
: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(
|
||||
badge.data.member_name,
|
||||
dayjs(badge.data.issued_on).format('DD MMM YYYY')
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{{ badge.data.badge_description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, usePageMeta } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
badgeName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const badge = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Badge Assignment',
|
||||
filters: {
|
||||
badge: props.badgeName,
|
||||
member: props.email,
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Badges'),
|
||||
},
|
||||
{
|
||||
label: badge.data.badge,
|
||||
route: {
|
||||
name: 'Badge',
|
||||
params: {
|
||||
badge: badge.data.badge,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: badge.data.badge,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,395 +0,0 @@
|
||||
<template>
|
||||
<div v-if="isAdmin || isStudent" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
v-if="isAdmin && batch.data?.certification"
|
||||
@click="openCertificateDialog = true"
|
||||
>
|
||||
{{ __('Generate Certificates') }}
|
||||
</Button>
|
||||
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
|
||||
<span>
|
||||
{{ __('Make an Announcement') }}
|
||||
</span>
|
||||
<template #suffix>
|
||||
<SendIcon class="h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
v-if="batch.data"
|
||||
class="grid grid-cols-1 md:grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
|
||||
>
|
||||
<div class="border-r">
|
||||
<Tabs
|
||||
v-model="tabIndex"
|
||||
as="div"
|
||||
:tabs="tabs"
|
||||
tablistClass="overflow-y-hidden bg-surface-white"
|
||||
>
|
||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||
<div>
|
||||
<button
|
||||
class="group -mb-px flex items-center gap-1 border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
|
||||
:class="{ 'text-ink-gray-9': selected }"
|
||||
>
|
||||
<component
|
||||
v-if="tab.icon"
|
||||
:is="tab.icon"
|
||||
class="h-4 stroke-1.5"
|
||||
/>
|
||||
{{ __(tab.label) }}
|
||||
<Badge
|
||||
v-if="tab.count"
|
||||
:class="{
|
||||
'text-ink-gray-9 border border-gray-900': selected,
|
||||
}"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
size="sm"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #tab-panel="{ tab }">
|
||||
<div class="pt-5 px-5 pb-10">
|
||||
<div v-if="tab.label == 'Courses'">
|
||||
<BatchCourses :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard' && isStudent">
|
||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard'">
|
||||
<AdminBatchDashboard :batch="batch" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Students'">
|
||||
<BatchStudents :batch="batch" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Classes'">
|
||||
<LiveClass
|
||||
:batch="batch.data.name"
|
||||
:zoomAccount="batch.data.zoom_account"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Assessments'">
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Announcements'">
|
||||
<Announcements :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Discussions'">
|
||||
<Discussions
|
||||
doctype="LMS Batch"
|
||||
:docname="batch.data.name"
|
||||
:title="__('Discussions')"
|
||||
:key="batch.data.name"
|
||||
:singleThread="true"
|
||||
:scrollToBottom="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="p-5 border-t md:border-t-0">
|
||||
<div class="mb-10">
|
||||
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||
{{ __('About this batch') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="batch.data.description"
|
||||
class="leading-5 mb-4 text-ink-gray-7"
|
||||
></div>
|
||||
|
||||
<div class="flex items-center avatar-group overlap mb-5">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.timezone"
|
||||
class="flex items-center mb-3 text-ink-gray-7"
|
||||
>
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
|
||||
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||
{{ __('Feedback') }}
|
||||
</div>
|
||||
<BatchFeedback :batch="batch.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
<AnnouncementModal
|
||||
v-model="showAnnouncementModal"
|
||||
:batch="batch.data.name"
|
||||
:students="batch.data.students"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!user.data?.name" class="">
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div v-if="user.data" class="mb-4 leading-6">
|
||||
{{
|
||||
__(
|
||||
'You are not a member of this batch. Please checkout our upcoming batches.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else class="mb-4 leading-6">
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
params: {
|
||||
batchName: batch.data?.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full">
|
||||
{{ __('Upcoming Batches') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
v-else
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@click="redirectToLogin()"
|
||||
>
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BulkCertificates
|
||||
v-if="batch.data"
|
||||
v-model="openCertificateDialog"
|
||||
:batch="batch.data"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject, ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createResource,
|
||||
Tabs,
|
||||
Badge,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
Clock,
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Laptop,
|
||||
BookOpenCheck,
|
||||
Mail,
|
||||
SendIcon,
|
||||
MessageCircle,
|
||||
Globe,
|
||||
ClipboardPen,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
import BatchCourses from '@/components/BatchCourses.vue'
|
||||
import LiveClass from '@/components/LiveClass.vue'
|
||||
import BatchStudents from '@/components/BatchStudents.vue'
|
||||
import AdminBatchDashboard from '@/components/AdminBatchDashboard.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
import Announcements from '@/components/Annoucements.vue'
|
||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
||||
import BatchFeedback from '@/components/BatchFeedback.vue'
|
||||
import dayjs from 'dayjs/esm'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
|
||||
const user = inject('$user')
|
||||
const showAnnouncementModal = ref(false)
|
||||
const openCertificateDialog = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
const tabIndex = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
batchTabs.push({
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
})
|
||||
|
||||
if (isAdmin.value) {
|
||||
batchTabs.push({
|
||||
label: 'Students',
|
||||
icon: ClipboardPen,
|
||||
})
|
||||
}
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Classes',
|
||||
icon: Laptop,
|
||||
})
|
||||
|
||||
if (isAdmin.value) {
|
||||
batchTabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
})
|
||||
}
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Announcements',
|
||||
icon: Mail,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Discussions',
|
||||
icon: MessageCircle,
|
||||
})
|
||||
return batchTabs
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const hash = route.hash
|
||||
if (hash) {
|
||||
tabs.value.forEach((tab, index) => {
|
||||
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
|
||||
tabIndex.value = index
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
if (!isStudent.value) {
|
||||
crumbs.push({
|
||||
label: __('Details'),
|
||||
route: {
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: batch.data?.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'Batch', params: { batchName: props.batchName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return (
|
||||
user?.data &&
|
||||
batch.data?.students?.length &&
|
||||
batch.data?.students.includes(user.data.name)
|
||||
)
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=${getLmsRoute(
|
||||
`batches/${props.batchName}`
|
||||
)}`
|
||||
}
|
||||
|
||||
const openAnnouncementModal = () => {
|
||||
showAnnouncementModal.value = true
|
||||
}
|
||||
|
||||
watch(tabIndex, () => {
|
||||
const tab = tabs.value[tabIndex.value]
|
||||
if (tab.label != route.hash.replace('#', '')) {
|
||||
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
|
||||
}
|
||||
})
|
||||
|
||||
const canMakeAnnouncement = () => {
|
||||
if (readOnlyMode) return false
|
||||
|
||||
if (!batch.data?.students?.length) return false
|
||||
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,158 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5 pb-10">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
|
||||
<div
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
v-html="batch.data.batch_details"
|
||||
></div>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.courses.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
v-if="batch.data.courses"
|
||||
v-for="course in courses.data"
|
||||
:key="course.course"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :course="course" :key="course.name" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.batch_details_raw">
|
||||
<div
|
||||
v-html="batch.data.batch_details_raw"
|
||||
class="batch-description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Breadcrumbs, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||
import DateRange from '../components/Common/DateRange.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
router.push({ name: 'Batches' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
cache: ['batchCourses', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.batch-description strong {
|
||||
font-weight: 600;
|
||||
color: theme('colors.gray.900') !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,592 +0,0 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
|
||||
<template #icon>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Short Description')"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
v-model="batch.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.allow_self_enrollment"
|
||||
type="checkbox"
|
||||
:label="__('Allow self enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.certification"
|
||||
type="checkbox"
|
||||
:label="__('Certification')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Date and Time') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Batch Start Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('Batch End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Session Start Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('Session End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
:placeholder="__('Example: IST (+5:30)')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.evaluation_end_date"
|
||||
:label="__('Evaluation End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div>
|
||||
<label class="block text-sm text-ink-gray-5 mb-1">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="batch.batch_details"
|
||||
@change="(val) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Configurations') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
<Link
|
||||
doctype="Email Template"
|
||||
:label="__('Email Template')"
|
||||
v-model="batch.confirmation_email_template"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Email Templates', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Zoom Settings"
|
||||
:label="__('Zoom Account')"
|
||||
v-model="batch.zoom_account"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Zoom Accounts', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.medium"
|
||||
type="select"
|
||||
:options="[
|
||||
{
|
||||
label: 'Online',
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: 'Offline',
|
||||
value: 'Offline',
|
||||
},
|
||||
]"
|
||||
:label="__('Medium')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
v-model="batch.category"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Uploader
|
||||
v-model="batch.video_link"
|
||||
:label="__('Preview Video')"
|
||||
type="video"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Pricing') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="batch.paid_batch"
|
||||
type="checkbox"
|
||||
:label="__('Paid Batch')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.paid_batch"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-5"
|
||||
>
|
||||
<FormControl
|
||||
v-model="batch.amount"
|
||||
:label="__('Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<Link
|
||||
doctype="Currency"
|
||||
v-model="batch.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Uploader
|
||||
v-model="batch.meta_image"
|
||||
:label="__('Meta Image')"
|
||||
type="image"
|
||||
:required="false"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.description"
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
:label="__('Meta Keywords')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
:placeholder="__('Comma separated keywords for SEO')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
FormControl,
|
||||
Button,
|
||||
TextEditor,
|
||||
createResource,
|
||||
usePageMeta,
|
||||
toast,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
} from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = reactive({
|
||||
title: '',
|
||||
published: false,
|
||||
description: '',
|
||||
batch_details: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
timezone: '',
|
||||
evaluation_end_date: '',
|
||||
confirmation_email_template: '',
|
||||
seat_count: '',
|
||||
medium: '',
|
||||
category: '',
|
||||
allow_self_enrollment: false,
|
||||
certification: false,
|
||||
meta_image: null,
|
||||
paid_batch: false,
|
||||
currency: '',
|
||||
amount: 0,
|
||||
zoom_account: '',
|
||||
video_link: '',
|
||||
})
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) window.location.href = '/login'
|
||||
if (props.batchName != 'new') {
|
||||
fetchBatchInfo()
|
||||
} else {
|
||||
capture('batch_form_opened')
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const fetchBatchInfo = () => {
|
||||
batchDetail.reload()
|
||||
getMetaInfo('batches', props.batchName, meta)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveBatch()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const newBatch = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch',
|
||||
meta_image: batch.image,
|
||||
video_link: batch.video_link,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const batchDetail = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
updateBatchData(data)
|
||||
},
|
||||
})
|
||||
|
||||
const updateBatchData = (data) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (['start_time', 'end_time'].includes(key)) {
|
||||
batch[key] = formatTime(data[key])
|
||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'paid_batch',
|
||||
'allow_self_enrollment',
|
||||
'certification',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batch[key] = batch[key] ? true : false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
let [hours, minutes, seconds] = timeStr.split(':')
|
||||
hours = hours.length == 1 ? '0' + hours : hours
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const editBatch = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
fieldname: {
|
||||
meta_image: batch.meta_image,
|
||||
video_link: batch.video_link,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
batch.description = sanitizeHTML(batch.description)
|
||||
batch.batch_details = sanitizeHTML(batch.batch_details)
|
||||
|
||||
Object.keys(batch).forEach((key) => {
|
||||
if (
|
||||
!['description', 'batch_details'].includes(key) &&
|
||||
typeof batch[key] === 'string'
|
||||
) {
|
||||
batch[key] = escapeHTML(batch[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveBatch = () => {
|
||||
validateFields()
|
||||
if (batchDetail.data) {
|
||||
editBatchDetails()
|
||||
} else {
|
||||
createNewBatch()
|
||||
}
|
||||
}
|
||||
|
||||
const createNewBatch = () => {
|
||||
newBatch.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_batch', true, false, () => {
|
||||
localStorage.setItem('firstBatch', data.name)
|
||||
})
|
||||
}
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
capture('batch_created')
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editBatchDetails = () => {
|
||||
editBatch.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteBatch = () => {
|
||||
$dialog({
|
||||
title: __('Confirm your action to delete'),
|
||||
message: __(
|
||||
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
trashBatch(close)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const trashBatch = (close) => {
|
||||
call('lms.lms.api.delete_batch', {
|
||||
batch: props.batchName,
|
||||
}).then(() => {
|
||||
toast.success(__('Batch deleted successfully'))
|
||||
close()
|
||||
router.push({
|
||||
name: 'Batches',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: __('Batches'),
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
},
|
||||
]
|
||||
if (batchDetail.data) {
|
||||
crumbs.push({
|
||||
label: batchDetail.data.title,
|
||||
route: {
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: props.batchName,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.batchName == 'new' ? __('New Batch') : __('Edit Batch'),
|
||||
route: { name: 'BatchForm', params: { batchName: props.batchName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.batchName == 'new' ? 'New Batch' : batchDetail.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
267
frontend/src/pages/Batches/BatchDetail.vue
Normal file
267
frontend/src/pages/Batches/BatchDetail.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 border-b flex items-center justify-between bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center space-x-2">
|
||||
<Badge v-if="childRef?.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<Button @click="childRef.deleteBatch()">
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="childRef.submitBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-else-if="isAdmin"
|
||||
:options="batchMenu"
|
||||
placement="left"
|
||||
side="left"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</header>
|
||||
<div>
|
||||
<BatchOverview v-if="!isAdmin && !isStudent" :batch="batch" />
|
||||
<div v-else>
|
||||
<Tabs :tabs="tabs" v-model="tabIndex">
|
||||
<template #tab-panel="{ tab }">
|
||||
<div
|
||||
v-if="tab.label == 'Discussions'"
|
||||
class="w-[90%] lg:w-[75%] mx-auto mt-5"
|
||||
>
|
||||
<Discussions
|
||||
doctype="LMS Batch"
|
||||
:docname="batch.data.name"
|
||||
:title="__('Discussions')"
|
||||
:key="batch.data.name"
|
||||
:singleThread="true"
|
||||
:scrollToBottom="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<component
|
||||
v-else
|
||||
:is="tab.component"
|
||||
:batch="batch"
|
||||
ref="childRef"
|
||||
/>
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BulkCertificates
|
||||
v-if="batch.data"
|
||||
v-model="openCertificateDialog"
|
||||
:batch="batch.data"
|
||||
/>
|
||||
<AnnouncementModal
|
||||
v-if="showAnnouncementModal"
|
||||
v-model="showAnnouncementModal"
|
||||
:batch="batch.data.name"
|
||||
:students="batch.data.students"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardPen,
|
||||
EllipsisVertical,
|
||||
Laptop,
|
||||
List,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
SendIcon,
|
||||
Settings2,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, inject, markRaw, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createResource,
|
||||
Dropdown,
|
||||
Tabs,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import AdminBatchDashboard from '@/pages/Batches/components/AdminBatchDashboard.vue'
|
||||
import StudentBatchDashboard from '@/pages/Batches/components/BatchDashboard.vue'
|
||||
import BatchOverview from '@/pages/Batches/BatchOverview.vue'
|
||||
import LiveClass from '@/pages/Batches/components/LiveClass.vue'
|
||||
import Announcements from '@/pages/Batches/components/Announcements.vue'
|
||||
import AnnouncementModal from '@/pages/Batches/components/AnnouncementModal.vue'
|
||||
import BatchForm from '@/pages/Batches/BatchForm.vue'
|
||||
import BulkCertificates from '@/pages/Batches/components/BulkCertificates.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const childRef = ref(null)
|
||||
const tabIndex = ref(0)
|
||||
const tabs = ref([])
|
||||
const openCertificateDialog = ref(false)
|
||||
const showAnnouncementModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const updateTabIndex = () => {
|
||||
const hash = route.hash
|
||||
if (hash) {
|
||||
tabs.value.forEach((tab, index) => {
|
||||
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
|
||||
tabIndex.value = index
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(tabIndex, () => {
|
||||
const tab = tabs.value[tabIndex.value]
|
||||
if (tab.label != route.hash.replace('#', '')) {
|
||||
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
|
||||
}
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
router.push({ name: 'Batches' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(batch, () => {
|
||||
updateTabs()
|
||||
updateTabIndex()
|
||||
})
|
||||
|
||||
const updateTabs = () => {
|
||||
addToTabs('Overview', markRaw(BatchOverview), List)
|
||||
if (!user.data) return
|
||||
if (isAdmin.value) {
|
||||
addToTabs('Dashboard', markRaw(AdminBatchDashboard), TrendingUp)
|
||||
} else if (isStudent.value) {
|
||||
addToTabs('Dashboard', markRaw(StudentBatchDashboard), ClipboardPen)
|
||||
}
|
||||
addToTabs('Classes', markRaw(LiveClass), Laptop)
|
||||
addToTabs('Announcements', markRaw(Announcements), Mail)
|
||||
addToTabs('Discussions', markRaw(Discussions), MessageCircle)
|
||||
if (isAdmin.value) {
|
||||
addToTabs('Settings', markRaw(BatchForm), Settings2)
|
||||
}
|
||||
}
|
||||
|
||||
const addToTabs = (label, component, icon) => {
|
||||
if (!tabs.value.some((tab) => tab.label === label)) {
|
||||
tabs.value.push({
|
||||
label,
|
||||
component,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || batch.data?.is_evaluator
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return batch.data?.students?.includes(user.data?.name)
|
||||
})
|
||||
|
||||
const openAnnouncementModal = () => {
|
||||
showAnnouncementModal.value = true
|
||||
}
|
||||
|
||||
const canMakeAnnouncement = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!batch.data?.students?.length) return false
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const batchMenu = computed(() => {
|
||||
let options = [
|
||||
{
|
||||
label: __('Generate Certificates'),
|
||||
onClick() {
|
||||
openCertificateDialog.value = true
|
||||
},
|
||||
condition: () => batch.data?.certification,
|
||||
},
|
||||
{
|
||||
label: __('Make an Announcement'),
|
||||
onClick() {
|
||||
openAnnouncementModal()
|
||||
},
|
||||
condition: () => canMakeAnnouncement(),
|
||||
},
|
||||
]
|
||||
return options
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.batch-description strong {
|
||||
font-weight: 600;
|
||||
color: theme('colors.gray.900') !important;
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/pages/Batches/BatchForm.vue
Normal file
484
frontend/src/pages/Batches/BatchForm.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[3fr,2fr]">
|
||||
<div v-if="batchDetail.doc" class="py-5 lg:h-[88vh] lg:overflow-y-auto">
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.start_date"
|
||||
:label="__('Batch Start Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.end_date"
|
||||
:label="__('Batch End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.allow_self_enrollment"
|
||||
type="checkbox"
|
||||
:label="__('Allow Self Enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.start_time"
|
||||
:label="__('Session Start Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.end_time"
|
||||
:label="__('Session End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
:placeholder="__('Example: IST (+5:30)')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
v-model="batchDetail.doc.category"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Certification') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 items-start">
|
||||
<div class="flex flex-col space-y-5">
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.evaluation"
|
||||
type="checkbox"
|
||||
:label="__('Evaluation')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="batchDetail.doc.evaluation"
|
||||
v-model="batchDetail.doc.evaluation_end_date"
|
||||
:label="__('Evaluation End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.certification"
|
||||
type="checkbox"
|
||||
:label="__('Certification')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.description"
|
||||
:label="__('Short Description')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-ink-gray-5 mb-2">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="batchDetail.doc.batch_details"
|
||||
@change="(val) => (batchDetail.doc.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[16rem] overflow-y-scroll mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.medium"
|
||||
type="select"
|
||||
:options="mediumOptions"
|
||||
:label="__('Medium')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
doctype="Email Template"
|
||||
:label="__('Enrollment Confirmation Email Template')"
|
||||
v-model="batchDetail.doc.confirmation_email_template"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Email Templates', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Link
|
||||
doctype="LMS Zoom Settings"
|
||||
:label="__('Zoom Account')"
|
||||
v-model="batchDetail.doc.zoom_account"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Zoom Accounts', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="batchDetail.doc.video_link"
|
||||
:label="__('Preview Video')"
|
||||
type="video"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Pricing') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.paid_batch"
|
||||
type="checkbox"
|
||||
:label="__('Paid Batch')"
|
||||
/>
|
||||
<div
|
||||
v-if="batchDetail.doc.paid_batch"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-5"
|
||||
>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.amount"
|
||||
:label="__('Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<Link
|
||||
doctype="Currency"
|
||||
v-model="batchDetail.doc.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="meta.description"
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
:label="__('Meta Keywords')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="__('Comma separated keywords')"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="batchDetail.doc.meta_image"
|
||||
:label="__('Meta Image')"
|
||||
type="image"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l min-w-0">
|
||||
<div class="border-b p-4">
|
||||
<BatchCourses :batch="batch" />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<Assessments :batch="batch.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
toRaw,
|
||||
watch,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import {
|
||||
FormControl,
|
||||
TextEditor,
|
||||
createDocumentResource,
|
||||
toast,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
} from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
|
||||
import Assessments from '@/pages/Batches/components/Assessments.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
const isDirty = ref(false)
|
||||
const originalDoc = ref(null)
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) window.location.href = '/login'
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
submitBatch()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const batchDetail = createDocumentResource({
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batch.data?.name,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => batchDetail.doc,
|
||||
() => {
|
||||
if (!batchDetail.doc) return
|
||||
getMetaInfo('batches', batchDetail.doc?.name, meta)
|
||||
updateBatchData()
|
||||
}
|
||||
)
|
||||
|
||||
const updateBatchData = () => {
|
||||
Object.keys(batchDetail.doc).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
instructors.value = []
|
||||
batchDetail.doc.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (['start_time', 'end_time'].includes(key)) {
|
||||
batchDetail.doc[key] = formatTime(batchDetail.doc[key])
|
||||
}
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'paid_batch',
|
||||
'allow_self_enrollment',
|
||||
'certification',
|
||||
'evaluation',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batchDetail.doc[key] = batchDetail.doc[key] ? true : false
|
||||
}
|
||||
originalDoc.value = structuredClone(toRaw(batchDetail.doc))
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
let [hours, minutes, seconds] = timeStr.split(':')
|
||||
hours = hours.length == 1 ? '0' + hours : hours
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
batchDetail.doc.description = sanitizeHTML(batchDetail.doc.description)
|
||||
batchDetail.doc.batch_details = sanitizeHTML(batchDetail.doc.batch_details)
|
||||
|
||||
Object.keys(batchDetail.doc).forEach((key) => {
|
||||
if (
|
||||
!['description', 'batch_details'].includes(key) &&
|
||||
typeof batchDetail.doc[key] === 'string'
|
||||
) {
|
||||
batchDetail.doc[key] = escapeHTML(batchDetail.doc[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitBatch = () => {
|
||||
validateFields()
|
||||
updateBatch()
|
||||
}
|
||||
|
||||
const updateBatch = () => {
|
||||
batchDetail.setValue.submit(
|
||||
{
|
||||
...batchDetail.doc,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
toast.success(__('Batch updated successfully'))
|
||||
nextTick(() => {
|
||||
originalDoc.value = structuredClone(data)
|
||||
isDirty.value = false
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => batchDetail.doc,
|
||||
() => {
|
||||
if (originalDoc.value) {
|
||||
isDirty.value =
|
||||
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const deleteBatch = () => {
|
||||
$dialog({
|
||||
title: __('Confirm your action to delete'),
|
||||
message: __(
|
||||
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
trashBatch(close)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const trashBatch = (close) => {
|
||||
call('lms.lms.api.delete_batch', {
|
||||
batch: props.batch.data.name,
|
||||
}).then(() => {
|
||||
toast.success(__('Batch deleted successfully'))
|
||||
close()
|
||||
router.push({
|
||||
name: 'Batches',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mediumOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Online',
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: 'Offline',
|
||||
value: 'Offline',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
submitBatch,
|
||||
deleteBatch,
|
||||
isDirty,
|
||||
})
|
||||
</script>
|
||||
90
frontend/src/pages/Batches/BatchOverview.vue
Normal file
90
frontend/src/pages/Batches/BatchOverview.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="m-5 pb-10">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
|
||||
<div
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
v-html="batch.data.batch_details"
|
||||
></div>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="courses.data?.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
v-if="courses.data?.length"
|
||||
v-for="course in courses.data"
|
||||
:key="course.course"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :course="course" :key="course.name" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.batch_details_raw">
|
||||
<div
|
||||
v-html="batch.data.batch_details_raw"
|
||||
class="batch-description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource } from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchOverlay from '@/pages/Batches/components/BatchOverlay.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
cache: ['batchCourses', props.batch?.data?.name],
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
@@ -10,10 +10,7 @@
|
||||
label: __('New Batch'),
|
||||
icon: 'users',
|
||||
onClick() {
|
||||
router.push({
|
||||
name: 'BatchForm',
|
||||
params: { batchName: 'new' },
|
||||
})
|
||||
showBatchModal = true
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -45,20 +42,6 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<!-- <router-link
|
||||
v-if="canCreateBatch()"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: { batchName: 'new' },
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</router-link> -->
|
||||
</header>
|
||||
<div class="p-5 pb-10">
|
||||
<div
|
||||
@@ -125,6 +108,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<NewBatchModal
|
||||
v-if="showBatchModal"
|
||||
v-model="showBatchModal"
|
||||
:batches="batches"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -141,8 +129,9 @@ import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronDown, Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import NewBatchModal from '@/pages/Batches/components/NewBatchModal.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -159,6 +148,7 @@ const currentTab = ref(is_student.value ? 'all' : 'upcoming')
|
||||
const orderBy = ref('start_date')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const router = useRouter()
|
||||
const showBatchModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
277
frontend/src/pages/Batches/components/AdminBatchDashboard.vue
Normal file
277
frontend/src/pages/Batches/components/AdminBatchDashboard.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="p-5">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChartGraph
|
||||
:title="__('Enrolled')"
|
||||
:value="formatAmount(batch.data?.students?.length) || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
:title="__('Certified')"
|
||||
:value="certificationCount.data || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
class="border rounded-md"
|
||||
:title="__('Courses')"
|
||||
:value="batch?.data?.courses?.length || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
class="border rounded-md"
|
||||
:title="__('Assessments')"
|
||||
:value="batch?.data?.assessments?.length || 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-5 items-start">
|
||||
<div class="border rounded-lg py-3 px-4 order-2 lg:order-1">
|
||||
<div class="flex items-center justify-between space-x-2 mb-3">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by name')"
|
||||
type="text"
|
||||
/>
|
||||
<Button @click="showEnrollmentModal = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="students.loading || students.data?.length"
|
||||
class="max-h-[63vh] overflow-y-auto"
|
||||
>
|
||||
<ListView
|
||||
:columns="studentColumns"
|
||||
:rows="students.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
onRowClick: (row: any) => {
|
||||
currentStudent = row.member
|
||||
showProgressModal = true
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in studentColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in students.data" class="max-h-[500px]">
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<!-- <ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
/> -->
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="students.data && students.hasNextPage"
|
||||
class="flex justify-center my-3"
|
||||
>
|
||||
<Button @click="students.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-1 lg:order-2">
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border rounded-lg p-3 min-h-[300px]"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="p-4 border rounded-lg mt-5">
|
||||
<BatchFeedback v-if="batch.data" :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StudentModal
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:batch="batch"
|
||||
:students="students"
|
||||
/>
|
||||
<BatchStudentProgress
|
||||
v-if="showProgressModal"
|
||||
v-model="showProgressModal"
|
||||
:student="currentStudent"
|
||||
:batch="batch?.data?.name"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AxisChart,
|
||||
createResource,
|
||||
createListResource,
|
||||
dayjs,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
Avatar,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { formatAmount } from '@/utils'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
|
||||
import BatchStudentProgress from '@/pages/Batches/components/BatchStudentProgress.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const showEnrollmentModal = ref<boolean>(false)
|
||||
const showProgressModal = ref<boolean>(false)
|
||||
const currentStudent = ref<any>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
batch: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const chartData = createResource({
|
||||
url: 'lms.lms.utils.get_batch_chart_data',
|
||||
cache: ['batch_chart_data', props.batch?.data?.name],
|
||||
params: { batch: props.batch?.data?.name },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_certificate_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: { batch_name: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const students = createListResource({
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_username',
|
||||
'member_image',
|
||||
'creation',
|
||||
],
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const filteredChartData = computed(() =>
|
||||
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
|
||||
)
|
||||
|
||||
watch(searchFilter, () => {
|
||||
let filters: Record<string, any> = {
|
||||
batch: props.batch?.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
}
|
||||
|
||||
students.update({ filters })
|
||||
students.reload()
|
||||
})
|
||||
|
||||
const studentColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Name'),
|
||||
key: 'member_name',
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
label: __('Enrolled On'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const showProgressChart = computed(
|
||||
() =>
|
||||
students.data?.length &&
|
||||
(props.batch?.data?.courses?.length ||
|
||||
props.batch?.data?.assessments?.length)
|
||||
)
|
||||
</script>
|
||||
58
frontend/src/pages/Batches/components/Announcements.vue
Normal file
58
frontend/src/pages/Batches/components/Announcements.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="w-[90%] lg:w-[75%] mx-auto mt-5">
|
||||
<div class="text-ink-gray-9 font-semibold text-lg mb-5">
|
||||
{{ __('Announcements') }}
|
||||
</div>
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 leading-5">
|
||||
{{ __('No announcements have been made yet for this batch') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch.data?.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
<div class="text-ink-gray-9 font-semibold">
|
||||
{{ __('Assessments') }}
|
||||
</div>
|
||||
<Button v-if="canAddAssessments()" @click="showModal = true">
|
||||
@@ -16,6 +16,7 @@
|
||||
:columns="getAssessmentColumns()"
|
||||
:rows="assessments.data"
|
||||
row-key="name"
|
||||
class="border rounded-lg"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
getRowRoute: (row) => getRowRoute(row),
|
||||
@@ -23,20 +24,17 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in assessments.data">
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in assessments.data"
|
||||
class="!rounded-none"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'assessment_type'">
|
||||
@@ -57,7 +55,7 @@
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<ListSelectBanner class="!min-w-0">
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -71,8 +69,8 @@
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No Assessments') }}
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No assessments added to this batch') }}
|
||||
</div>
|
||||
</div>
|
||||
<AssessmentModal
|
||||
@@ -210,12 +208,11 @@ const getAssessmentColumns = () => {
|
||||
{
|
||||
label: __('Assessment'),
|
||||
key: 'title',
|
||||
width: '25rem',
|
||||
},
|
||||
{
|
||||
label: __('Type'),
|
||||
key: 'assessment_type',
|
||||
width: '15rem',
|
||||
width: '10rem',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-medium text-ink-gray-9">
|
||||
<div class="text-ink-gray-9 font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||
<Button v-if="isAdmin()" @click="openCourseModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="courses.data?.length">
|
||||
<div v-if="courses.data?.length" class="text-sm">
|
||||
<ListView
|
||||
:columns="getCoursesColumns()"
|
||||
:rows="courses.data"
|
||||
row-key="batch_course"
|
||||
row-key="name"
|
||||
class="border rounded-lg"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: user.data?.is_student ? false : true,
|
||||
@@ -26,20 +27,13 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in courses.data">
|
||||
<ListRow :row="row" v-for="row in courses.data" class="!rounded-none">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div>
|
||||
@@ -49,7 +43,7 @@
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<ListSelectBanner class="!min-w-0">
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -63,21 +57,21 @@
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No courses added') }}
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No courses added to this batch') }}
|
||||
</div>
|
||||
<BatchCourseModal
|
||||
v-model="showCourseModal"
|
||||
:batch="batch"
|
||||
:batch="batch.data?.name"
|
||||
v-model:courses="courses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue'
|
||||
import { ref, inject, nextTick } from 'vue'
|
||||
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
|
||||
import {
|
||||
createResource,
|
||||
createListResource,
|
||||
Button,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
@@ -96,16 +90,20 @@ const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batch,
|
||||
const courses = createListResource({
|
||||
doctype: 'Batch Course',
|
||||
filters: {
|
||||
parent: props.batch.data?.name,
|
||||
parenttype: 'LMS Batch',
|
||||
},
|
||||
fields: ['name', 'course', 'title', 'evaluator'],
|
||||
parent: 'LMS Batch',
|
||||
orderBy: 'idx',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -118,47 +116,25 @@ const getCoursesColumns = () => {
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Lessons',
|
||||
key: 'lessons',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
label: 'Enrollments',
|
||||
align: 'right',
|
||||
key: 'enrollments',
|
||||
label: 'Evaluator',
|
||||
key: 'evaluator',
|
||||
width: '10rem',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const deleteCourses = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Batch Course',
|
||||
documents: values.courses,
|
||||
}
|
||||
},
|
||||
})
|
||||
const removeCourses = async (selections, unselectAll) => {
|
||||
for (const course of selections) {
|
||||
await courses.delete.submit(course)
|
||||
}
|
||||
|
||||
const removeCourses = (selections, unselectAll) => {
|
||||
deleteCourses.submit(
|
||||
{
|
||||
courses: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
courses.reload()
|
||||
toast.success(__('Courses deleted successfully'))
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
unselectAll()
|
||||
toast.success(__('Courses deleted successfully'))
|
||||
}
|
||||
|
||||
const canSeeAddButton = () => {
|
||||
const isAdmin = () => {
|
||||
if (readOnlyMode) {
|
||||
return false
|
||||
}
|
||||
137
frontend/src/pages/Batches/components/BatchDashboard.vue
Normal file
137
frontend/src/pages/Batches/components/BatchDashboard.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="h-[88vh]">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[2fr,1fr] gap-5">
|
||||
<div class="p-5">
|
||||
<div class="mb-8 space-y-2">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Curriculum') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
"As a part of this batch's curriculum you will have to complete the following courses and assessments."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-10">
|
||||
<div>
|
||||
<div class="text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<ListView
|
||||
v-if="batch.data?.courses?.length"
|
||||
:columns="courseColumns"
|
||||
:rows="batch.data?.courses"
|
||||
row-key="name"
|
||||
class="border rounded-lg"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: user.data?.is_student ? false : true,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: row.course },
|
||||
}),
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in batch.data?.courses"
|
||||
class="!rounded-none text-sm"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key === 'progress'">
|
||||
{{ getProgress(row.course) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No courses added to this batch') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <BatchCourses :batch="batch" /> -->
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l h-[88vh] divide-y">
|
||||
<div v-if="batch.data?.evaluation" class="p-4 mb-5">
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<BatchFeedback :batch="batch.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import {
|
||||
createListResource,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
} from 'frappe-ui'
|
||||
import Assessments from '@/pages/Batches/components/Assessments.vue'
|
||||
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
|
||||
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
member: user.data?.name,
|
||||
course: ['in', props.batch.data?.courses?.map((c) => c.course)],
|
||||
},
|
||||
fields: ['course', 'progress', 'name'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getProgress = (course) => {
|
||||
const progress = progressList.data?.find((p) => p.course === course)
|
||||
return progress ? Math.round(progress.progress) : 0
|
||||
}
|
||||
|
||||
const courseColumns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: __('Course'),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
label: __('Progress'),
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
@@ -1,63 +1,77 @@
|
||||
<template>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
@click="showFeedbackForm = !showFeedbackForm"
|
||||
class="underline cursor-pointer"
|
||||
>{{ __('Click here') }}</span
|
||||
>
|
||||
{{ __('to view your feedback.') }}
|
||||
<div>
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="space-y-1">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Feedback') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ __('Help us improve by providing your feedback.') }}
|
||||
<div
|
||||
v-if="feedbackList.data?.length && isAdmin"
|
||||
class="leading-5 text-ink-gray-7 text-sm mb-2 mt-5"
|
||||
>
|
||||
{{ __('Average Feedback Received') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="feedback[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
<Button
|
||||
v-if="feedbackList.data?.length && isAdmin"
|
||||
variant="outline"
|
||||
@click="showAllFeedback = true"
|
||||
>
|
||||
{{ __('View all feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
@click="showFeedbackForm = !showFeedbackForm"
|
||||
class="underline cursor-pointer"
|
||||
>{{ __('Click here') }}</span
|
||||
>
|
||||
{{ __('to view your feedback.') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ __('Help us improve by providing your feedback.') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||
<div class="space-y-4">
|
||||
<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="9"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
<Button v-if="!readOnly" @click="submitFeedback">
|
||||
{{ __('Submit Feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="feedback.feedback"
|
||||
type="textarea"
|
||||
:label="__('Feedback')"
|
||||
:rows="9"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
<Button v-if="!readOnly" @click="submitFeedback">
|
||||
{{ __('Submit Feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="feedbackList.data?.length">
|
||||
<div class="leading-5 text-sm mb-2 mt-5">
|
||||
{{ __('Average Feedback Received') }}
|
||||
<div v-else-if="feedbackList.data?.length">
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="average[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="average[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="true"
|
||||
/>
|
||||
<div v-else class="text-ink-gray-7 leading-5">
|
||||
{{ __('No feedback received yet.') }}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
|
||||
{{ __('View all feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 mt-5 leading-5">
|
||||
{{ __('No feedback received yet.') }}
|
||||
</div>
|
||||
<FeedbackModal
|
||||
v-if="feedbackList.data?.length"
|
||||
@@ -66,7 +80,7 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
|
||||
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
|
||||
@@ -159,10 +173,15 @@ const submitFeedback = () => {
|
||||
onSuccess: () => {
|
||||
feedbackList.reload()
|
||||
showFeedbackForm.value = false
|
||||
readOnly.value = true
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.feedback-list > button > div {
|
||||
@@ -26,7 +26,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="batch.data.amount"
|
||||
class="text-lg font-semibold mb-3 text-ink-gray-9"
|
||||
class="text-lg font-semibold mb-5 text-ink-gray-9"
|
||||
>
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</div>
|
||||
@@ -57,25 +57,6 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="canAccessBatch"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<template #prefix>
|
||||
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||
<Settings v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
@@ -84,13 +65,13 @@
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="
|
||||
v-if="
|
||||
batch.data.paid_batch &&
|
||||
batch.data.seats_left > 0 &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
>
|
||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||
<Button v-if="!canAccessBatch" class="w-full mt-4" variant="solid">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -114,24 +95,6 @@
|
||||
</template>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="canEditBatch"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full mt-2">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -174,7 +137,7 @@ const enroll = createResource({
|
||||
|
||||
const enrollInBatch = () => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||
window.location.href = `/login?redirect-to=/batches/${props.batch.data.name}`
|
||||
}
|
||||
enroll.submit(
|
||||
{},
|
||||
222
frontend/src/pages/Batches/components/BatchStudentProgress.vue
Normal file
222
frontend/src/pages/Batches/components/BatchStudentProgress.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div v-if="studentDetails.data" class="p-5 space-y-10 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar :image="studentDetails.data.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ studentDetails.data.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="
|
||||
Object.keys(studentDetails.data.assessments).length ||
|
||||
Object.keys(studentDetails.data.courses).length
|
||||
"
|
||||
:theme="studentDetails.data.progress === 100 ? 'green' : 'red'"
|
||||
>
|
||||
{{ studentDetails.data.progress }}% {{ __('Complete') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ studentDetails.data.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Assessments -->
|
||||
<ListView
|
||||
:columns="assessmentColumns"
|
||||
:rows="studentDetails.data.assessments"
|
||||
row-key="title"
|
||||
class="border border-outline-gray-modals rounded-lg"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
onRowClick: (row: any) => {
|
||||
redirectToAssessment(row)
|
||||
}
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in studentDetails.data.assessments">
|
||||
<ListRow :row="row" class="!rounded-none">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<div
|
||||
v-if="column.key == 'status' && isAssignment(row.status)"
|
||||
>
|
||||
<Badge :theme="getStatusTheme(row[column.key])">
|
||||
{{ row[column.key] }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
|
||||
<!-- Courses -->
|
||||
<ListView
|
||||
:columns="courseColumns"
|
||||
:rows="studentDetails.data.courses"
|
||||
row-key="title"
|
||||
class="border border-outline-gray-modals rounded-lg"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
onRowClick: (row: any) => {
|
||||
redirectToCourse(row)
|
||||
}
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in studentDetails.data.courses">
|
||||
<ListRow :row="row" class="!rounded-none">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<ProgressBar
|
||||
v-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4 max-w-32"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="column.key == 'progress'"
|
||||
class="text-xs !ml-0 !mr-3 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
createResource,
|
||||
Dialog,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const router = useRouter()
|
||||
const props = defineProps<{
|
||||
student: string
|
||||
batch: string
|
||||
}>()
|
||||
|
||||
const studentDetails = createResource({
|
||||
url: 'lms.lms.utils.get_batch_student_progress',
|
||||
makeParams() {
|
||||
return {
|
||||
member: props.student,
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const redirectToAssessment = (row: any) => {
|
||||
console.log(row)
|
||||
if (!row.submission) return
|
||||
if (row.type == 'LMS Assignment') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: row.assessment,
|
||||
submissionName: row.submission,
|
||||
},
|
||||
})
|
||||
} else if (row.type == 'LMS Programming Exercise') {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: row.assessment,
|
||||
submissionID: row.submission,
|
||||
},
|
||||
})
|
||||
} else if (row.type == 'LMS Quiz') {
|
||||
router.push({
|
||||
name: 'QuizSubmission',
|
||||
params: {
|
||||
submission: row.submission,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const redirectToCourse = (row: any) => {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: row.course,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const assessmentColumns = [
|
||||
{ key: 'title', label: 'Assessment', align: 'left', width: '60%' },
|
||||
{ key: 'status', label: 'Percentage/Status', align: 'right' },
|
||||
]
|
||||
|
||||
const courseColumns = [
|
||||
{ key: 'title', label: 'Course', align: 'left', width: '70%' },
|
||||
{ key: 'progress', label: 'Progress', align: 'right' },
|
||||
]
|
||||
|
||||
const isAssignment = (value: any) => {
|
||||
return isNaN(value)
|
||||
}
|
||||
|
||||
const getStatusTheme = (status: string) => {
|
||||
if (status === 'Pass') {
|
||||
return 'green'
|
||||
} else if (status == 'Not Graded') {
|
||||
return 'orange'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
227
frontend/src/pages/Batches/components/LiveClass.vue
Normal file
227
frontend/src/pages/Batches/components/LiveClass.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div
|
||||
v-if="isAdmin() && !batch.data?.zoom_account"
|
||||
class="flex lg:items-center space-x-2 mb-5 bg-surface-amber-1 px-3 py-2 rounded-lg text-ink-amber-3"
|
||||
>
|
||||
<AlertCircle class="size-7 md:size-4 stroke-1.5" />
|
||||
<span class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'Link a Zoom account to this batch from the Settings tab to create live classes'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': isAdmin() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 mt-5">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
v-if="showLiveClassModal"
|
||||
v-model="showLiveClassModal"
|
||||
:batch="batch.data?.name"
|
||||
:zoomAccount="batch.data?.zoom_account"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch.data?.name,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!props.batch.data?.zoom_account) return false
|
||||
return isAdmin()
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!isAdmin()) return
|
||||
if (cls.attendees <= 0) return
|
||||
showAttendance.value = true
|
||||
attendanceFor.value = cls
|
||||
}
|
||||
</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>
|
||||
200
frontend/src/pages/Batches/components/NewBatchModal.vue
Normal file
200
frontend/src/pages/Batches/components/NewBatchModal.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('New Batch'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Start Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('End Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="batch.category"
|
||||
:label="__('Category')"
|
||||
:allowCreate="true"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Start Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('End Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 border-t mt-5 pt-5">
|
||||
<MultiSelect
|
||||
v-model="batch.instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
:required="true"
|
||||
:rows="4"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="batch.batch_details"
|
||||
@change="(val: string) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="saveBatch(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { ref, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cleanError, openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const router = useRouter()
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const user = inject<any>('$user')
|
||||
|
||||
const props = defineProps<{
|
||||
batches: any
|
||||
}>()
|
||||
|
||||
const batch = ref({
|
||||
title: '',
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
timezone: null,
|
||||
description: '',
|
||||
batch_details: '',
|
||||
instructors: [],
|
||||
category: null,
|
||||
seat_count: 0,
|
||||
})
|
||||
|
||||
const saveBatch = (close: () => void = () => {}) => {
|
||||
props.batches.insert.submit(
|
||||
{
|
||||
...batch.value,
|
||||
instructors: batch.value.instructors.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess(data: any) {
|
||||
toast.success(__('Batch created successfully'))
|
||||
close()
|
||||
capture('batch_created')
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: { batchName: data.name },
|
||||
hash: '#settings',
|
||||
})
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_batch', true, false, () => {
|
||||
localStorage.setItem('firstBatch', data.name)
|
||||
})
|
||||
}
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]))
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.target &&
|
||||
e.target instanceof HTMLElement &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveBatch()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
capture('batch_form_opened')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
capture('batch_form_closed', {
|
||||
data: batch.value,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -354,14 +354,12 @@ const updateLessonProgress = (value: string) => {
|
||||
}
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
@@ -397,7 +395,7 @@ const progressColumns = computed(() => {
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
label: __('Start Date'),
|
||||
label: __('Enrolled On'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="pl-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] overflow-hidden">
|
||||
<div v-if="courseResource.doc" class="h-[88vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-[70%,30%]">
|
||||
<div v-if="courseResource.doc" class="lg:max-h-[88vh] lg:overflow-y-auto">
|
||||
<div class="my-5">
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Settings') }}
|
||||
{{ __('Publishing Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div
|
||||
@@ -191,59 +191,20 @@
|
||||
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
|
||||
{{ __('Pricing and Certification') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="courseResource.doc.paid_course"
|
||||
:label="__('Paid Course')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="courseResource.doc.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="courseResource.doc.paid_certificate"
|
||||
:label="__('Paid Certificate')"
|
||||
@change="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-if="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
class="space-y-5"
|
||||
>
|
||||
<FormControl
|
||||
v-if="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
v-model="courseResource.doc.course_price"
|
||||
:label="__('Amount')"
|
||||
:required="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<Link
|
||||
v-if="courseResource.doc.paid_certificate"
|
||||
doctype="Course Evaluator"
|
||||
v-model="courseResource.doc.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
:required="courseResource.doc.paid_certificate"
|
||||
:onCreate="
|
||||
(value, close) => openSettings('Evaluators', close)
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Link
|
||||
v-if="
|
||||
courseResource.doc.paid_course ||
|
||||
courseResource.doc.paid_certificate
|
||||
"
|
||||
doctype="Currency"
|
||||
v-model="courseResource.doc.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
@@ -254,11 +215,21 @@
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="courseResource.doc.paid_certificate" class="space-y-5">
|
||||
<Link
|
||||
doctype="Course Evaluator"
|
||||
v-model="courseResource.doc.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
:required="courseResource.doc.paid_certificate"
|
||||
:onCreate="
|
||||
(value, close) => openSettings('Evaluators', close)
|
||||
"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="courseResource.doc.paid_certificate"
|
||||
v-model="courseResource.doc.timezone"
|
||||
:label="__('Timezone')"
|
||||
:required="courseResource.doc.paid_certificate"
|
||||
:placeholder="__('e.g. IST, UTC, GMT...')"
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
@@ -290,7 +261,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l h-[88vh] overflow-y-auto">
|
||||
<div class="min-h-0 border-l">
|
||||
<CourseOutline
|
||||
v-if="courseResource.doc"
|
||||
:courseName="courseResource.doc.name"
|
||||
@@ -304,7 +275,6 @@
|
||||
<script setup>
|
||||
import {
|
||||
TextEditor,
|
||||
Button,
|
||||
createResource,
|
||||
createDocumentResource,
|
||||
FormControl,
|
||||
@@ -373,9 +343,9 @@ const courseResource = createDocumentResource({
|
||||
watch(
|
||||
() => courseResource.doc,
|
||||
() => {
|
||||
check_permission()
|
||||
getMetaInfo('courses', courseResource.doc?.name, meta)
|
||||
updateCourseData()
|
||||
checkPermission()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -516,11 +486,10 @@ const removeTag = (tag) => {
|
||||
makeFormDirty()
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
const checkPermission = () => {
|
||||
let user_is_instructor = false
|
||||
if (user.data?.is_moderator) return
|
||||
|
||||
instructors.value.forEach((instructor) => {
|
||||
instructors.value?.forEach((instructor) => {
|
||||
if (!user_is_instructor && instructor == user.data?.name) {
|
||||
user_is_instructor = true
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
v-model="title"
|
||||
:placeholder="__('Search by Title')"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="w-full lg:min-w-0 lg:w-32 xl:w-40"
|
||||
@input="updateCourses()"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Create Course'),
|
||||
title: __('New Course'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
@@ -18,8 +18,7 @@
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
:label="__('Category')"
|
||||
:allowCreate="true"
|
||||
@create="
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
@@ -67,7 +66,7 @@
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="saveCourse(close)">
|
||||
{{ __('Create') }}
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -75,10 +74,11 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cleanError, openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
<template>
|
||||
<!-- <header
|
||||
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="[{ label: __('Home'), route: { name: 'Home' } }]" />
|
||||
</header> -->
|
||||
<div class="w-full px-5 pt-5 pb-10">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -11,9 +6,8 @@
|
||||
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
|
||||
</div>
|
||||
<div>
|
||||
<TabButtons v-if="isAdmin" v-model="currentTab" :buttons="tabs" />
|
||||
<div
|
||||
v-else
|
||||
v-if="!isAdmin"
|
||||
@click="showStreakModal = true"
|
||||
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
@@ -35,19 +29,16 @@
|
||||
:liveClasses="adminLiveClasses"
|
||||
:evals="adminEvals"
|
||||
/>
|
||||
<StudentHome v-else :myLiveClasses="myLiveClasses" />
|
||||
<StudentHome
|
||||
v-else-if="currentTab === 'student'"
|
||||
:myLiveClasses="myLiveClasses"
|
||||
/>
|
||||
</div>
|
||||
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
call,
|
||||
createResource,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { call, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import StudentHome from '@/pages/Home/StudentHome.vue'
|
||||
import AdminHome from '@/pages/Home/AdminHome.vue'
|
||||
@@ -56,10 +47,10 @@ import Streak from '@/pages/Home/Streak.vue'
|
||||
const user = inject<any>('$user')
|
||||
const { brand } = sessionStore()
|
||||
const evalCount = ref(0)
|
||||
const currentTab = ref<'student' | 'instructor'>('instructor')
|
||||
const currentTab = ref<'student' | 'instructor'>('student')
|
||||
const showStreakModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const fetchEvalCount = () => {
|
||||
call('frappe.client.get_count', {
|
||||
doctype: 'LMS Certificate Request',
|
||||
filters: {
|
||||
@@ -70,7 +61,7 @@ onMounted(() => {
|
||||
}).then((data: any) => {
|
||||
evalCount.value = data
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return (
|
||||
@@ -80,6 +71,15 @@ const isAdmin = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) {
|
||||
currentTab.value = 'instructor'
|
||||
} else {
|
||||
currentTab.value = 'student'
|
||||
fetchEvalCount()
|
||||
}
|
||||
})
|
||||
|
||||
const myLiveClasses = createResource({
|
||||
url: 'lms.lms.api.get_my_live_classes',
|
||||
auto: !isAdmin.value ? true : false,
|
||||
@@ -151,11 +151,6 @@ const subtitle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ label: __('Student'), value: 'student' },
|
||||
{ label: __('Instructor'), value: 'instructor' },
|
||||
]
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Home'),
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10">
|
||||
<div class="mt-10 space-y-10">
|
||||
<UpcomingEvaluations :forHome="true" />
|
||||
<div v-if="myLiveClasses.data?.length">
|
||||
<div class="font-semibold text-lg mb-3 text-ink-gray-9">
|
||||
{{ __('Upcoming Live Classes') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-5">
|
||||
<div
|
||||
v-for="cls in myLiveClasses.data"
|
||||
class="border rounded-md hover:border-outline-gray-3 p-2"
|
||||
class="border rounded-md hover:border-outline-gray-3 p-3"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
|
||||
<div class="font-semibold text-ink-gray-9 leading-5 mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="text-ink-gray-5 leading-5 mb-4">
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="myCourses.data?.length">
|
||||
<div v-if="myCourses.data?.length" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg text-ink-gray-9">
|
||||
{{
|
||||
@@ -150,7 +150,7 @@ import {
|
||||
Video,
|
||||
} from 'lucide-vue-next'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<Button variant="solid" @click="saveJob()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
<div class="space-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<Button variant="solid" @click="saveJob()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="container border-b mb-4 pb-5">
|
||||
@@ -109,15 +114,25 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
call,
|
||||
FormControl,
|
||||
createResource,
|
||||
createDocumentResource,
|
||||
Button,
|
||||
TextEditor,
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, onMounted, reactive, inject } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||
@@ -126,6 +141,8 @@ import Uploader from '@/components/Controls/Uploader.vue'
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
const isDirty = ref(false)
|
||||
const originalJobData = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
jobName: {
|
||||
@@ -134,67 +151,6 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const newJob = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Job Opportunity',
|
||||
company_logo: job.company_logo,
|
||||
...job,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const updateJob = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Job Opportunity',
|
||||
name: props.jobName,
|
||||
fieldname: {
|
||||
company_logo: job.company_logo,
|
||||
...job,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const jobDetail = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Job Opportunity',
|
||||
name: props.jobName,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
if (data.owner != user.data?.name && !user.data?.is_moderator) {
|
||||
router.push({
|
||||
name: 'Jobs',
|
||||
})
|
||||
}
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (Object.hasOwn(job, key)) job[key] = data[key]
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const job = reactive({
|
||||
job_title: '',
|
||||
location: '',
|
||||
country: '',
|
||||
type: 'Full Time',
|
||||
work_mode: 'On-site',
|
||||
status: 'Open',
|
||||
company_name: '',
|
||||
company_website: '',
|
||||
company_logo: null,
|
||||
description: '',
|
||||
company_email_address: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) {
|
||||
router.push({
|
||||
@@ -202,22 +158,64 @@ onMounted(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (props.jobName != 'new') jobDetail.reload()
|
||||
addKeyboardShortcuts()
|
||||
if (props.jobName != 'new') jobDetails.reload()
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const addKeyboardShortcuts = () => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
saveJob()
|
||||
const job = reactive({
|
||||
job_title: '',
|
||||
type: '',
|
||||
work_mode: '',
|
||||
location: '',
|
||||
country: '',
|
||||
status: 'Open',
|
||||
description: '',
|
||||
company_name: '',
|
||||
company_website: '',
|
||||
company_email_address: '',
|
||||
company_logo: '',
|
||||
})
|
||||
|
||||
const jobDetails = createDocumentResource({
|
||||
doctype: 'Job Opportunity',
|
||||
name: props.jobName != 'new' ? props.jobName : undefined,
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
auto: props.jobName != 'new',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => jobDetails?.doc,
|
||||
() => {
|
||||
if (!jobDetails.doc) return
|
||||
if (jobDetails.doc.owner != user.data?.name && !user.data?.is_moderator) {
|
||||
router.push({
|
||||
name: 'Jobs',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (jobDetails.doc) {
|
||||
Object.assign(job, jobDetails.doc)
|
||||
originalJobData.value = JSON.parse(JSON.stringify(jobDetails.doc))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
job,
|
||||
() => {
|
||||
isDirty.value = Object.keys(job).some((key) => {
|
||||
return job[key] != originalJobData.value?.[key]
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const saveJob = () => {
|
||||
validateJobFields()
|
||||
if (jobDetail.data) {
|
||||
if (jobDetails?.doc) {
|
||||
editJobDetails()
|
||||
} else {
|
||||
createNewJob()
|
||||
@@ -225,38 +223,46 @@ const saveJob = () => {
|
||||
}
|
||||
|
||||
const createNewJob = () => {
|
||||
newJob.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
router.push({
|
||||
name: 'JobDetail',
|
||||
params: {
|
||||
job: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Job Opportunity',
|
||||
company_logo: job.company_logo,
|
||||
...job,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
router.push({
|
||||
name: 'JobDetail',
|
||||
params: {
|
||||
job: data.name,
|
||||
},
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const editJobDetails = () => {
|
||||
updateJob.submit(
|
||||
{},
|
||||
jobDetails.setValue.submit(
|
||||
{
|
||||
company_logo: job.company_logo,
|
||||
...job,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
jobDetails.reload()
|
||||
router.push({
|
||||
name: 'JobDetail',
|
||||
params: {
|
||||
job: data.name,
|
||||
job: props.jobName,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -271,27 +277,38 @@ const validateJobFields = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
saveJob()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const jobTypes = computed(() => {
|
||||
return [
|
||||
{ label: 'Full Time', value: 'Full Time' },
|
||||
{ label: 'Part Time', value: 'Part Time' },
|
||||
{ label: 'Contract', value: 'Contract' },
|
||||
{ label: 'Freelance', value: 'Freelance' },
|
||||
{ label: __('Full Time'), value: 'Full Time' },
|
||||
{ label: __('Part Time'), value: 'Part Time' },
|
||||
{ label: __('Contract'), value: 'Contract' },
|
||||
{ label: __('Freelance'), value: 'Freelance' },
|
||||
]
|
||||
})
|
||||
|
||||
const workModes = computed(() => {
|
||||
return [
|
||||
{ label: 'On site', value: 'On-site' },
|
||||
{ label: 'Hybrid', value: 'Hybrid' },
|
||||
{ label: 'Remote', value: 'Remote' },
|
||||
{ label: __('On site'), value: 'On-site' },
|
||||
{ label: __('Hybrid'), value: 'Hybrid' },
|
||||
{ label: __('Remote'), value: 'Remote' },
|
||||
]
|
||||
})
|
||||
|
||||
const jobStatuses = computed(() => {
|
||||
return [
|
||||
{ label: 'Open', value: 'Open' },
|
||||
{ label: 'Closed', value: 'Closed' },
|
||||
{ label: __('Open'), value: 'Open' },
|
||||
{ label: __('Closed'), value: 'Closed' },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -302,8 +319,11 @@ const breadcrumbs = computed(() => {
|
||||
route: { name: 'Jobs' },
|
||||
},
|
||||
{
|
||||
label: props.jobName == 'new' ? __('New Job') : __('Edit Job'),
|
||||
route: { name: 'JobForm' },
|
||||
label: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
|
||||
route:
|
||||
props.jobName == 'new'
|
||||
? {}
|
||||
: { name: 'JobDetail', params: { job: props.jobName } },
|
||||
},
|
||||
]
|
||||
return crumbs
|
||||
@@ -311,7 +331,7 @@ const breadcrumbs = computed(() => {
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.jobName == 'new' ? __('New Job') : jobDetail.data?.job_title,
|
||||
title: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
|
||||
/>
|
||||
<router-link
|
||||
v-if="user.data?.name"
|
||||
v-if="
|
||||
user.data?.name && settings.data?.allow_job_posting && !readOnlyMode
|
||||
"
|
||||
:to="{
|
||||
name: 'JobForm',
|
||||
params: {
|
||||
@@ -16,7 +18,7 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button v-if="!readOnlyMode" variant="solid">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
@@ -123,7 +125,8 @@ import {
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Search } from 'lucide-vue-next'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { inject, computed, ref, onMounted, watch } from 'vue'
|
||||
import JobCard from '@/components/JobCard.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
@@ -133,6 +136,7 @@ const user = inject('$user')
|
||||
const jobType = ref(null)
|
||||
const workMode = ref(null)
|
||||
const { brand } = sessionStore()
|
||||
const { settings } = useSettings()
|
||||
const searchQuery = ref('')
|
||||
const country = ref(null)
|
||||
const filters = ref({})
|
||||
|
||||
@@ -400,7 +400,7 @@ const sidebarStore = useSidebar()
|
||||
const plyrSources = ref([])
|
||||
const showInlineMenu = ref(false)
|
||||
const currentTab = ref('Notes')
|
||||
let timerInterval
|
||||
let timerInterval = null
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
@@ -742,7 +742,7 @@ const updateVideoTime = (video) => {
|
||||
|
||||
const startTimer = () => {
|
||||
if (!lesson.data?.membership) return
|
||||
let timerInterval = setInterval(() => {
|
||||
timerInterval = setInterval(() => {
|
||||
timer.value++
|
||||
if (timer.value == 30) {
|
||||
clearInterval(timerInterval)
|
||||
|
||||
@@ -70,13 +70,16 @@
|
||||
<div class="leading-5 mb-4">
|
||||
{{ badge.badge_description }}
|
||||
</div>
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-ink-gray-7 font-medium mb-1">
|
||||
{{ __('Issued on') }}:
|
||||
</span>
|
||||
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-if="user.data?.name == profile.data?.name"
|
||||
class="flex flex-col mt-4"
|
||||
>
|
||||
<span class="text-xs text-ink-gray-7 font-medium mb-1">
|
||||
{{ __('Share on') }}:
|
||||
</span>
|
||||
@@ -125,6 +128,7 @@ import DOMPurify from 'dompurify'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const { branding } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
@@ -135,13 +139,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const badges = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
url: 'lms.lms.api.get_badges',
|
||||
params: {
|
||||
doctype: 'LMS Badge Assignment',
|
||||
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
|
||||
filters: {
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
member: props.profile.data.name,
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
@@ -160,14 +160,16 @@ const shareOnSocial = (badge, medium) => {
|
||||
let shareUrl
|
||||
const url = encodeURIComponent(
|
||||
`${window.location.origin}${getLmsRoute(
|
||||
`badges/${badge.badge}/${props.profile.data?.email}`
|
||||
`user/${props.profile.data?.username}`
|
||||
)}`
|
||||
)
|
||||
const summary = `I am happy to announce that I earned the ${
|
||||
badge.badge
|
||||
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
||||
const summary = __(
|
||||
'I am happy to announce that I earned the {0} badge on {1} at {2}'
|
||||
).format(
|
||||
badge.badge,
|
||||
dayjs(badge.issued_on).format('DD MMM YYYY'),
|
||||
branding.data?.app_name
|
||||
}.`
|
||||
)
|
||||
|
||||
if (medium == 'LinkedIn')
|
||||
shareUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&text=${summary}`
|
||||
|
||||
@@ -42,18 +42,16 @@ const routes = [
|
||||
{
|
||||
path: '/batches',
|
||||
name: 'Batches',
|
||||
component: () => import('@/pages/Batches.vue'),
|
||||
component: () => import('@/pages/Batches/Batches.vue'),
|
||||
},
|
||||
{
|
||||
path: '/batches/details/:batchName',
|
||||
name: 'BatchDetail',
|
||||
component: () => import('@/pages/BatchDetail.vue'),
|
||||
props: true,
|
||||
redirect: (to) => `/batches/${to.params.batchName}`,
|
||||
},
|
||||
{
|
||||
path: '/batches/:batchName',
|
||||
name: 'Batch',
|
||||
component: () => import('@/pages/Batch.vue'),
|
||||
name: 'BatchDetail',
|
||||
component: () => import('@/pages/Batches/BatchDetail.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -125,12 +123,6 @@ const routes = [
|
||||
component: () => import('@/pages/LessonForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/batches/:batchName/edit',
|
||||
name: 'BatchForm',
|
||||
component: () => import('@/pages/BatchForm.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/job-opening/:jobName/edit',
|
||||
name: 'JobForm',
|
||||
@@ -147,12 +139,6 @@ const routes = [
|
||||
name: 'Notifications',
|
||||
component: () => import('@/pages/Notifications.vue'),
|
||||
},
|
||||
{
|
||||
path: '/badges/:badgeName/:email',
|
||||
name: 'Badge',
|
||||
component: () => import('@/pages/Badge.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/quizzes',
|
||||
name: 'Quizzes',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { usersStore } from './user'
|
||||
import router from '@/router'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
export const sessionStore = defineStore('lms-session', () => {
|
||||
@@ -22,19 +21,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
let user = ref(sessionUser())
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
|
||||
const login = createResource({
|
||||
url: 'login',
|
||||
onError() {
|
||||
throw new Error('Invalid email or password')
|
||||
},
|
||||
onSuccess() {
|
||||
userResource.reload()
|
||||
user.value = sessionUser()
|
||||
login.reset()
|
||||
router.replace({ path: '/' })
|
||||
},
|
||||
})
|
||||
|
||||
const logout = createResource({
|
||||
url: 'logout',
|
||||
onSuccess() {
|
||||
@@ -59,7 +45,6 @@ export const sessionStore = defineStore('lms-session', () => {
|
||||
return {
|
||||
user,
|
||||
isLoggedIn,
|
||||
login,
|
||||
logout,
|
||||
brand,
|
||||
branding,
|
||||
|
||||
@@ -4,9 +4,10 @@ import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
|
||||
import translationPlugin from '../translation'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { call } from 'frappe-ui'
|
||||
import router from '@/router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
|
||||
const router = useRouter()
|
||||
export class Assignment {
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.data = data
|
||||
|
||||
@@ -236,10 +236,10 @@ export function getEditorTools() {
|
||||
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
codesandbox: {
|
||||
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
|
||||
regex: /^https:\/\/codesandbox\.io\/(?:(?:p\/(?:sandbox|devbox)\/)|(?:embed\/)|(?:s\/))?([A-Za-z0-9_-]+)(?:[\/\?].*)?$/,
|
||||
embedUrl:
|
||||
'https://codesandbox.io/embed/<%= remote_id %>?view=editor+%2B+preview&module=%2Findex.html',
|
||||
html: "<iframe style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;' sandbox='allow-mods allow-forms allow-popups allow-scripts allow-same-origin' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
html: "<iframe style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;' sandbox='allow-modals allow-forms allow-popups allow-scripts allow-same-origin' frameborder='0' allowfullscreen='true'></iframe>",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -644,6 +644,7 @@ export const validateFile = async (
|
||||
showToast = true,
|
||||
fileType = 'image'
|
||||
) => {
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
const error = (msg) => {
|
||||
if (showToast) toast.error(msg)
|
||||
console.error(msg)
|
||||
@@ -653,6 +654,16 @@ export const validateFile = async (
|
||||
return error(__('Only {0} file is allowed.').format(fileType))
|
||||
}
|
||||
|
||||
if (fileType == 'pdf' && extension !== 'pdf') {
|
||||
return error(__('Only PDF files are allowed.'))
|
||||
}
|
||||
|
||||
if (fileType == 'document' && !['doc', 'docx'].includes(extension)) {
|
||||
return error(
|
||||
__('Only document file of type .doc or .docx are allowed.')
|
||||
)
|
||||
}
|
||||
|
||||
if (file.type === 'image/svg+xml') {
|
||||
const text = await file.text()
|
||||
|
||||
@@ -680,7 +691,6 @@ export const validateFile = async (
|
||||
export const escapeHTML = (text) => {
|
||||
if (!text) return ''
|
||||
let escape_html_mapping = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
|
||||
@@ -4,8 +4,10 @@ import { createApp, h } from 'vue'
|
||||
import { usersStore } from '../stores/user'
|
||||
import translationPlugin from '../translation'
|
||||
import { CircleHelp } from 'lucide-vue-next'
|
||||
import router from '@/router'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export class Quiz {
|
||||
constructor({ data, api, readOnly }) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
const isDev = mode === 'development'
|
||||
console.log(mode, isDev)
|
||||
const frappeui = await importFrappeUIPlugin(isDev)
|
||||
|
||||
const config = {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
jsonpointer "^5.0.0"
|
||||
leven "^3.1.0"
|
||||
|
||||
"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
|
||||
"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
|
||||
integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
|
||||
@@ -38,7 +38,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d"
|
||||
integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
|
||||
|
||||
"@babel/core@^7.11.1":
|
||||
"@babel/core@^7.24.4":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322"
|
||||
integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==
|
||||
@@ -1211,6 +1211,23 @@
|
||||
dependencies:
|
||||
"@swc/helpers" "^0.5.0"
|
||||
|
||||
"@isaacs/balanced-match@^4.0.1":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29"
|
||||
integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==
|
||||
|
||||
"@isaacs/brace-expansion@^5.0.1":
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz#0ef5a92d91f2fff2a37646ce54da9e5f599f6eff"
|
||||
integrity sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==
|
||||
dependencies:
|
||||
"@isaacs/balanced-match" "^4.0.1"
|
||||
|
||||
"@isaacs/cliui@^9.0.0":
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3"
|
||||
integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==
|
||||
|
||||
"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5":
|
||||
version "0.3.13"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
|
||||
@@ -1371,17 +1388,16 @@
|
||||
"@babel/helper-module-imports" "^7.10.4"
|
||||
"@rollup/pluginutils" "^3.1.0"
|
||||
|
||||
"@rollup/plugin-node-resolve@^11.2.1":
|
||||
version "11.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz#82aa59397a29cd4e13248b106e6a4a1880362a60"
|
||||
integrity sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==
|
||||
"@rollup/plugin-node-resolve@^15.2.3":
|
||||
version "15.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz#66008953c2524be786aa319d49e32f2128296a78"
|
||||
integrity sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==
|
||||
dependencies:
|
||||
"@rollup/pluginutils" "^3.1.0"
|
||||
"@types/resolve" "1.17.1"
|
||||
builtin-modules "^3.1.0"
|
||||
"@rollup/pluginutils" "^5.0.1"
|
||||
"@types/resolve" "1.20.2"
|
||||
deepmerge "^4.2.2"
|
||||
is-module "^1.0.0"
|
||||
resolve "^1.19.0"
|
||||
resolve "^1.22.1"
|
||||
|
||||
"@rollup/plugin-replace@^2.4.1":
|
||||
version "2.4.2"
|
||||
@@ -1391,6 +1407,15 @@
|
||||
"@rollup/pluginutils" "^3.1.0"
|
||||
magic-string "^0.25.7"
|
||||
|
||||
"@rollup/plugin-terser@^0.4.3":
|
||||
version "0.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz#15dffdb3f73f121aa4fbb37e7ca6be9aeea91962"
|
||||
integrity sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==
|
||||
dependencies:
|
||||
serialize-javascript "^6.0.1"
|
||||
smob "^1.0.0"
|
||||
terser "^5.17.4"
|
||||
|
||||
"@rollup/pluginutils@^3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
|
||||
@@ -1400,6 +1425,15 @@
|
||||
estree-walker "^1.0.1"
|
||||
picomatch "^2.2.2"
|
||||
|
||||
"@rollup/pluginutils@^5.0.1":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
|
||||
integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.0"
|
||||
estree-walker "^2.0.2"
|
||||
picomatch "^4.0.2"
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@4.57.1":
|
||||
version "4.57.1"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088"
|
||||
@@ -1901,19 +1935,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
|
||||
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
|
||||
|
||||
"@types/node@*":
|
||||
version "25.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.3.tgz#9c18245be768bdb4ce631566c7da303a5c99a7f8"
|
||||
integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==
|
||||
dependencies:
|
||||
undici-types "~7.16.0"
|
||||
|
||||
"@types/resolve@1.17.1":
|
||||
version "1.17.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
|
||||
integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
"@types/resolve@1.20.2":
|
||||
version "1.20.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
|
||||
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
|
||||
|
||||
"@types/trusted-types@^2.0.2", "@types/trusted-types@^2.0.7":
|
||||
version "2.0.7"
|
||||
@@ -2267,14 +2292,6 @@ bl@^4.1.0:
|
||||
inherits "^2.0.4"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.12"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"
|
||||
integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
|
||||
@@ -2313,11 +2330,6 @@ buffer@^5.5.0:
|
||||
base64-js "^1.3.1"
|
||||
ieee754 "^1.1.13"
|
||||
|
||||
builtin-modules@^3.1.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
|
||||
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
|
||||
|
||||
call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
|
||||
@@ -2350,9 +2362,9 @@ camelcase-css@^2.0.1:
|
||||
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
|
||||
|
||||
caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001759:
|
||||
version "1.0.30001770"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84"
|
||||
integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==
|
||||
version "1.0.30001769"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2"
|
||||
integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==
|
||||
|
||||
chalk@^4.1.0:
|
||||
version "4.1.2"
|
||||
@@ -2446,11 +2458,6 @@ common-tags@^1.8.0:
|
||||
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6"
|
||||
integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||
|
||||
confbox@^0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
|
||||
@@ -2483,6 +2490,15 @@ crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6:
|
||||
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
|
||||
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
|
||||
|
||||
cross-spawn@^7.0.6:
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||
dependencies:
|
||||
path-key "^3.1.0"
|
||||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
crypto-random-string@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
|
||||
@@ -2540,7 +2556,7 @@ dayjs@^1.11.13:
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938"
|
||||
integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==
|
||||
|
||||
debug@^4.1.0, debug@^4.3.1, debug@^4.3.4, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1:
|
||||
debug@^4.1.0, debug@^4.3.1, debug@^4.3.6, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
@@ -2863,7 +2879,7 @@ fast-deep-equal@^3.1.3:
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-glob@^3.2.12, fast-glob@^3.3.2:
|
||||
fast-glob@^3.3.2:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
|
||||
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
|
||||
@@ -2933,6 +2949,14 @@ for-each@^0.3.3, for-each@^0.3.5:
|
||||
dependencies:
|
||||
is-callable "^1.2.7"
|
||||
|
||||
foreground-child@^3.3.1:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
|
||||
integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
|
||||
dependencies:
|
||||
cross-spawn "^7.0.6"
|
||||
signal-exit "^4.0.1"
|
||||
|
||||
fraction.js@^4.1.2:
|
||||
version "4.3.7"
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
@@ -3009,11 +3033,6 @@ fs-extra@^9.0.1:
|
||||
jsonfile "^6.0.1"
|
||||
universalify "^2.0.0"
|
||||
|
||||
fs.realpath@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||
|
||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
@@ -3103,17 +3122,17 @@ glob-parent@^6.0.2:
|
||||
dependencies:
|
||||
is-glob "^4.0.3"
|
||||
|
||||
glob@^7.1.6:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
|
||||
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
|
||||
glob@^11.0.1:
|
||||
version "11.1.0"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6"
|
||||
integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==
|
||||
dependencies:
|
||||
fs.realpath "^1.0.0"
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "^3.1.1"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
foreground-child "^3.3.1"
|
||||
jackspeak "^4.1.1"
|
||||
minimatch "^10.1.1"
|
||||
minipass "^7.1.2"
|
||||
package-json-from-dist "^1.0.0"
|
||||
path-scurry "^2.0.0"
|
||||
|
||||
globalthis@^1.0.4:
|
||||
version "1.0.4"
|
||||
@@ -3205,15 +3224,7 @@ ieee754@^1.1.13:
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||
|
||||
inflight@^1.0.4:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
|
||||
integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
|
||||
dependencies:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2, inherits@^2.0.3, inherits@^2.0.4:
|
||||
inherits@^2.0.3, inherits@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
@@ -3459,6 +3470,18 @@ isarray@^2.0.5:
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
|
||||
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
jackspeak@^4.1.1:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.2.3.tgz#27ef80f33b93412037c3bea4f8eddf80e1931483"
|
||||
integrity sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==
|
||||
dependencies:
|
||||
"@isaacs/cliui" "^9.0.0"
|
||||
|
||||
jake@^10.8.5:
|
||||
version "10.9.4"
|
||||
resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.4.tgz#d626da108c63d5cfb00ab5c25fadc7e0084af8e6"
|
||||
@@ -3468,15 +3491,6 @@ jake@^10.8.5:
|
||||
filelist "^1.0.4"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
jest-worker@^26.2.1:
|
||||
version "26.6.2"
|
||||
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed"
|
||||
integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
merge-stream "^2.0.0"
|
||||
supports-color "^7.0.0"
|
||||
|
||||
jiti@^1.21.7:
|
||||
version "1.21.7"
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
|
||||
@@ -3599,6 +3613,11 @@ lowlight@^3.3.0:
|
||||
devlop "^1.0.0"
|
||||
highlight.js "~11.11.0"
|
||||
|
||||
lru-cache@^11.0.0:
|
||||
version "11.2.6"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.6.tgz#356bf8a29e88a7a2945507b31f6429a65a192c58"
|
||||
integrity sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==
|
||||
|
||||
lru-cache@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
|
||||
@@ -3669,11 +3688,6 @@ mdurl@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
|
||||
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
|
||||
|
||||
merge-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
||||
|
||||
merge2@^1.3.0:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||
@@ -3697,12 +3711,12 @@ mini-svg-data-uri@^1.2.3:
|
||||
resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
|
||||
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
|
||||
|
||||
minimatch@^3.1.1:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
minimatch@^10.1.1:
|
||||
version "10.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.2.tgz#6c3f289f9de66d628fa3feb1842804396a43d81c"
|
||||
integrity sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
"@isaacs/brace-expansion" "^5.0.1"
|
||||
|
||||
minimatch@^5.0.1:
|
||||
version "5.1.6"
|
||||
@@ -3711,6 +3725,11 @@ minimatch@^5.0.1:
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minipass@^7.1.2:
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
|
||||
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
|
||||
|
||||
mlly@^1.7.4, mlly@^1.8.0:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.0.tgz#e074612b938af8eba1eaf43299cbc89cb72d824e"
|
||||
@@ -3797,13 +3816,6 @@ ohash@^2.0.11:
|
||||
resolved "https://registry.yarnpkg.com/ohash/-/ohash-2.0.11.tgz#60b11e8cff62ca9dee88d13747a5baa145f5900b"
|
||||
integrity sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==
|
||||
|
||||
once@^1.3.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
|
||||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
onetime@^5.1.0:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
|
||||
@@ -3840,21 +3852,34 @@ own-keys@^1.0.1:
|
||||
object-keys "^1.1.1"
|
||||
safe-push-apply "^1.0.0"
|
||||
|
||||
package-json-from-dist@^1.0.0:
|
||||
version "1.0.1"
|
||||
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==
|
||||
|
||||
package-manager-detector@^1.3.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734"
|
||||
integrity sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==
|
||||
|
||||
path-is-absolute@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
|
||||
path-key@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||
|
||||
path-parse@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||
|
||||
path-scurry@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.1.tgz#4b6572376cfd8b811fca9cd1f5c24b3cbac0fe10"
|
||||
integrity sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==
|
||||
dependencies:
|
||||
lru-cache "^11.0.0"
|
||||
minipass "^7.1.2"
|
||||
|
||||
pathe@^2.0.1, pathe@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
||||
@@ -4006,7 +4031,7 @@ pretty-bytes@^5.3.0:
|
||||
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
|
||||
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
|
||||
|
||||
pretty-bytes@^6.0.0:
|
||||
pretty-bytes@^6.1.1:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
|
||||
integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
|
||||
@@ -4299,7 +4324,7 @@ regjsparser@^0.13.0:
|
||||
dependencies:
|
||||
jsesc "~3.1.0"
|
||||
|
||||
reka-ui@^2.5.0:
|
||||
reka-ui@^2.5.0, reka-ui@^2.8.0:
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/reka-ui/-/reka-ui-2.8.0.tgz#612023ad40c5c10999aef304f2b828cdd08da6a8"
|
||||
integrity sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA==
|
||||
@@ -4320,7 +4345,7 @@ require-from-string@^2.0.2:
|
||||
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
|
||||
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
|
||||
|
||||
resolve@^1.1.7, resolve@^1.19.0, resolve@^1.22.11, resolve@^1.22.8:
|
||||
resolve@^1.1.7, resolve@^1.22.1, resolve@^1.22.11, resolve@^1.22.8:
|
||||
version "1.22.11"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
|
||||
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
|
||||
@@ -4342,17 +4367,7 @@ reusify@^1.0.4:
|
||||
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
|
||||
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
|
||||
|
||||
rollup-plugin-terser@^7.0.0:
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d"
|
||||
integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.10.4"
|
||||
jest-worker "^26.2.1"
|
||||
serialize-javascript "^4.0.0"
|
||||
terser "^5.0.0"
|
||||
|
||||
rollup@^2.43.1:
|
||||
rollup@^2.79.2:
|
||||
version "2.79.2"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.2.tgz#f150e4a5db4b121a21a747d762f701e5e9f49090"
|
||||
integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==
|
||||
@@ -4448,10 +4463,10 @@ semver@^6.3.1:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
serialize-javascript@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
|
||||
integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
|
||||
serialize-javascript@^6.0.1:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
|
||||
integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
|
||||
dependencies:
|
||||
randombytes "^2.1.0"
|
||||
|
||||
@@ -4486,6 +4501,18 @@ set-proto@^1.0.0:
|
||||
es-errors "^1.3.0"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
shebang-command@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
|
||||
integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
|
||||
dependencies:
|
||||
shebang-regex "^3.0.0"
|
||||
|
||||
shebang-regex@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||
|
||||
side-channel-list@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
|
||||
@@ -4531,11 +4558,21 @@ signal-exit@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
|
||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||
|
||||
signal-exit@^4.0.1:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
|
||||
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
|
||||
|
||||
slugify@^1.6.6:
|
||||
version "1.6.6"
|
||||
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
|
||||
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
|
||||
|
||||
smob@^1.0.0:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/smob/-/smob-1.6.1.tgz#930607366738545aee542a93e03e47b54e0303e0"
|
||||
integrity sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==
|
||||
|
||||
socket.io-client@4.7.2:
|
||||
version "4.7.2"
|
||||
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.2.tgz#f2f13f68058bd4e40f94f2a1541f275157ff2c08"
|
||||
@@ -4711,7 +4748,7 @@ sucrase@^3.35.0:
|
||||
tinyglobby "^0.2.11"
|
||||
ts-interface-checker "^0.1.9"
|
||||
|
||||
supports-color@^7.0.0, supports-color@^7.1.0:
|
||||
supports-color@^7.1.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
|
||||
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
|
||||
@@ -4766,7 +4803,7 @@ tempy@^0.6.0:
|
||||
type-fest "^0.16.0"
|
||||
unique-string "^2.0.0"
|
||||
|
||||
terser@^5.0.0:
|
||||
terser@^5.17.4:
|
||||
version "5.46.0"
|
||||
resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695"
|
||||
integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==
|
||||
@@ -4800,7 +4837,7 @@ tinyexec@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
|
||||
integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
|
||||
|
||||
tinyglobby@^0.2.11, tinyglobby@^0.2.12, tinyglobby@^0.2.14, tinyglobby@^0.2.15:
|
||||
tinyglobby@^0.2.10, tinyglobby@^0.2.11, tinyglobby@^0.2.12, tinyglobby@^0.2.14, tinyglobby@^0.2.15:
|
||||
version "0.2.15"
|
||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
|
||||
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
|
||||
@@ -4924,11 +4961,6 @@ unbox-primitive@^1.1.0:
|
||||
has-symbols "^1.1.0"
|
||||
which-boxed-primitive "^1.1.1"
|
||||
|
||||
undici-types@~7.16.0:
|
||||
version "7.16.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
|
||||
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
|
||||
|
||||
unicode-canonical-property-names-ecmascript@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"
|
||||
@@ -5102,16 +5134,16 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
||||
vite-plugin-pwa@0.15.0:
|
||||
version "0.15.0"
|
||||
resolved "https://registry.yarnpkg.com/vite-plugin-pwa/-/vite-plugin-pwa-0.15.0.tgz#4e1b87097e97e77e4e4d92743d80606c0345dbcd"
|
||||
integrity sha512-gpmx3BeubsRIXRBkjPToOTJbo8fknNmZFQs24i0TPZyaNVa0n27YHDo0Y72amnO70WvHKGE3e1fn8SYUP7e8SA==
|
||||
vite-plugin-pwa@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz#3c7de17d4eed662f273095a0ac52f7a98d0cde36"
|
||||
integrity sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==
|
||||
dependencies:
|
||||
debug "^4.3.4"
|
||||
fast-glob "^3.2.12"
|
||||
pretty-bytes "^6.0.0"
|
||||
workbox-build "^6.5.4"
|
||||
workbox-window "^6.5.4"
|
||||
debug "^4.3.6"
|
||||
pretty-bytes "^6.1.1"
|
||||
tinyglobby "^0.2.10"
|
||||
workbox-build "^7.4.0"
|
||||
workbox-window "^7.4.0"
|
||||
|
||||
vite@5.0.11:
|
||||
version "5.0.11"
|
||||
@@ -5263,168 +5295,170 @@ which-typed-array@^1.1.16, which-typed-array@^1.1.19:
|
||||
gopd "^1.2.0"
|
||||
has-tostringtag "^1.0.2"
|
||||
|
||||
workbox-background-sync@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.6.1.tgz#08d603a33717ce663e718c30cc336f74909aff2f"
|
||||
integrity sha512-trJd3ovpWCvzu4sW0E8rV3FUyIcC0W8G+AZ+VcqzzA890AsWZlUGOTSxIMmIHVusUw/FDq1HFWfy/kC/WTRqSg==
|
||||
which@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
|
||||
dependencies:
|
||||
isexe "^2.0.0"
|
||||
|
||||
workbox-background-sync@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz#5fcf83162b540f799966fdd8df0858f91b787d77"
|
||||
integrity sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==
|
||||
dependencies:
|
||||
idb "^7.0.1"
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-broadcast-update@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-6.6.1.tgz#0fad9454cf8e4ace0c293e5617c64c75d8a8c61e"
|
||||
integrity sha512-fBhffRdaANdeQ1V8s692R9l/gzvjjRtydBOvR6WCSB0BNE2BacA29Z4r9/RHd9KaXCPl6JTdI9q0bR25YKP8TQ==
|
||||
workbox-broadcast-update@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz#f0ee7d2af51d199e32213a429dff03f14ba76dea"
|
||||
integrity sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-build@^6.5.4:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-6.6.1.tgz#6010e9ce550910156761448f2dbea8cfcf759cb0"
|
||||
integrity sha512-INPgDx6aRycAugUixbKgiEQBWD0MPZqU5r0jyr24CehvNuLPSXp/wGOpdRJmts656lNiXwqV7dC2nzyrzWEDnw==
|
||||
workbox-build@^7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-7.4.0.tgz#033f88ebbd9c6312983f3fb9c17a4161369d693f"
|
||||
integrity sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==
|
||||
dependencies:
|
||||
"@apideck/better-ajv-errors" "^0.3.1"
|
||||
"@babel/core" "^7.11.1"
|
||||
"@babel/core" "^7.24.4"
|
||||
"@babel/preset-env" "^7.11.0"
|
||||
"@babel/runtime" "^7.11.2"
|
||||
"@rollup/plugin-babel" "^5.2.0"
|
||||
"@rollup/plugin-node-resolve" "^11.2.1"
|
||||
"@rollup/plugin-node-resolve" "^15.2.3"
|
||||
"@rollup/plugin-replace" "^2.4.1"
|
||||
"@rollup/plugin-terser" "^0.4.3"
|
||||
"@surma/rollup-plugin-off-main-thread" "^2.2.3"
|
||||
ajv "^8.6.0"
|
||||
common-tags "^1.8.0"
|
||||
fast-json-stable-stringify "^2.1.0"
|
||||
fs-extra "^9.0.1"
|
||||
glob "^7.1.6"
|
||||
glob "^11.0.1"
|
||||
lodash "^4.17.20"
|
||||
pretty-bytes "^5.3.0"
|
||||
rollup "^2.43.1"
|
||||
rollup-plugin-terser "^7.0.0"
|
||||
rollup "^2.79.2"
|
||||
source-map "^0.8.0-beta.0"
|
||||
stringify-object "^3.3.0"
|
||||
strip-comments "^2.0.1"
|
||||
tempy "^0.6.0"
|
||||
upath "^1.2.0"
|
||||
workbox-background-sync "6.6.1"
|
||||
workbox-broadcast-update "6.6.1"
|
||||
workbox-cacheable-response "6.6.1"
|
||||
workbox-core "6.6.1"
|
||||
workbox-expiration "6.6.1"
|
||||
workbox-google-analytics "6.6.1"
|
||||
workbox-navigation-preload "6.6.1"
|
||||
workbox-precaching "6.6.1"
|
||||
workbox-range-requests "6.6.1"
|
||||
workbox-recipes "6.6.1"
|
||||
workbox-routing "6.6.1"
|
||||
workbox-strategies "6.6.1"
|
||||
workbox-streams "6.6.1"
|
||||
workbox-sw "6.6.1"
|
||||
workbox-window "6.6.1"
|
||||
workbox-background-sync "7.4.0"
|
||||
workbox-broadcast-update "7.4.0"
|
||||
workbox-cacheable-response "7.4.0"
|
||||
workbox-core "7.4.0"
|
||||
workbox-expiration "7.4.0"
|
||||
workbox-google-analytics "7.4.0"
|
||||
workbox-navigation-preload "7.4.0"
|
||||
workbox-precaching "7.4.0"
|
||||
workbox-range-requests "7.4.0"
|
||||
workbox-recipes "7.4.0"
|
||||
workbox-routing "7.4.0"
|
||||
workbox-strategies "7.4.0"
|
||||
workbox-streams "7.4.0"
|
||||
workbox-sw "7.4.0"
|
||||
workbox-window "7.4.0"
|
||||
|
||||
workbox-cacheable-response@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-6.6.1.tgz#284c2b86be3f4fd191970ace8c8e99797bcf58e9"
|
||||
integrity sha512-85LY4veT2CnTCDxaVG7ft3NKaFbH6i4urZXgLiU4AiwvKqS2ChL6/eILiGRYXfZ6gAwDnh5RkuDbr/GMS4KSag==
|
||||
workbox-cacheable-response@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz#f684380c07dfce4ed1aa555c8a29a2a1f8421d46"
|
||||
integrity sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-core@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.6.1.tgz#7184776d4134c5ed2f086878c882728fc9084265"
|
||||
integrity sha512-ZrGBXjjaJLqzVothoE12qTbVnOAjFrHDXpZe7coCb6q65qI/59rDLwuFMO4PcZ7jcbxY+0+NhUVztzR/CbjEFw==
|
||||
workbox-core@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.4.0.tgz#5cb59ae7655f2727423268fb1ba698f37809189d"
|
||||
integrity sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==
|
||||
|
||||
workbox-expiration@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.6.1.tgz#a841fa36676104426dbfb9da1ef6a630b4f93739"
|
||||
integrity sha512-qFiNeeINndiOxaCrd2DeL1Xh1RFug3JonzjxUHc5WkvkD2u5abY3gZL1xSUNt3vZKsFFGGORItSjVTVnWAZO4A==
|
||||
workbox-expiration@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.4.0.tgz#f7162a45ad8b28de84acea478df421b4d0065e61"
|
||||
integrity sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==
|
||||
dependencies:
|
||||
idb "^7.0.1"
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-google-analytics@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-6.6.1.tgz#a07a6655ab33d89d1b0b0a935ffa5dea88618c5d"
|
||||
integrity sha512-1TjSvbFSLmkpqLcBsF7FuGqqeDsf+uAXO/pjiINQKg3b1GN0nBngnxLcXDYo1n/XxK4N7RaRrpRlkwjY/3ocuA==
|
||||
workbox-google-analytics@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz#208d8e584e8262af8a14140c3a990d13021c8257"
|
||||
integrity sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==
|
||||
dependencies:
|
||||
workbox-background-sync "6.6.1"
|
||||
workbox-core "6.6.1"
|
||||
workbox-routing "6.6.1"
|
||||
workbox-strategies "6.6.1"
|
||||
workbox-background-sync "7.4.0"
|
||||
workbox-core "7.4.0"
|
||||
workbox-routing "7.4.0"
|
||||
workbox-strategies "7.4.0"
|
||||
|
||||
workbox-navigation-preload@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.6.1.tgz#61a34fe125558dd88cf09237f11bd966504ea059"
|
||||
integrity sha512-DQCZowCecO+wRoIxJI2V6bXWK6/53ff+hEXLGlQL4Rp9ZaPDLrgV/32nxwWIP7QpWDkVEtllTAK5h6cnhxNxDA==
|
||||
workbox-navigation-preload@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz#3133983b2690dee733d18f56760fdd5182a6ffaf"
|
||||
integrity sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-precaching@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.6.1.tgz#dedeeba10a2d163d990bf99f1c2066ac0d1a19e2"
|
||||
integrity sha512-K4znSJ7IKxCnCYEdhNkMr7X1kNh8cz+mFgx9v5jFdz1MfI84pq8C2zG+oAoeE5kFrUf7YkT5x4uLWBNg0DVZ5A==
|
||||
workbox-precaching@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.4.0.tgz#daf486953353acaf84142b78cf28a890c466b242"
|
||||
integrity sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-routing "6.6.1"
|
||||
workbox-strategies "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
workbox-routing "7.4.0"
|
||||
workbox-strategies "7.4.0"
|
||||
|
||||
workbox-range-requests@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.6.1.tgz#ddaf7e73af11d362fbb2f136a9063a4c7f507a39"
|
||||
integrity sha512-4BDzk28govqzg2ZpX0IFkthdRmCKgAKreontYRC5YsAPB2jDtPNxqx3WtTXgHw1NZalXpcH/E4LqUa9+2xbv1g==
|
||||
workbox-range-requests@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz#1be334d6a7a0b158d6094e8698460393863d28a2"
|
||||
integrity sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-recipes@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-6.6.1.tgz#ea70d2b2b0b0bce8de0a9d94f274d4a688e69fae"
|
||||
integrity sha512-/oy8vCSzromXokDA+X+VgpeZJvtuf8SkQ8KL0xmRivMgJZrjwM3c2tpKTJn6PZA6TsbxGs3Sc7KwMoZVamcV2g==
|
||||
workbox-recipes@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-7.4.0.tgz#217e6394f965bed8fbf15ad83370f03356c885c9"
|
||||
integrity sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==
|
||||
dependencies:
|
||||
workbox-cacheable-response "6.6.1"
|
||||
workbox-core "6.6.1"
|
||||
workbox-expiration "6.6.1"
|
||||
workbox-precaching "6.6.1"
|
||||
workbox-routing "6.6.1"
|
||||
workbox-strategies "6.6.1"
|
||||
workbox-cacheable-response "7.4.0"
|
||||
workbox-core "7.4.0"
|
||||
workbox-expiration "7.4.0"
|
||||
workbox-precaching "7.4.0"
|
||||
workbox-routing "7.4.0"
|
||||
workbox-strategies "7.4.0"
|
||||
|
||||
workbox-routing@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-6.6.1.tgz#cba9a1c7e0d1ea11e24b6f8c518840efdc94f581"
|
||||
integrity sha512-j4ohlQvfpVdoR8vDYxTY9rA9VvxTHogkIDwGdJ+rb2VRZQ5vt1CWwUUZBeD/WGFAni12jD1HlMXvJ8JS7aBWTg==
|
||||
workbox-routing@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.4.0.tgz#4b5bc90256515dc5cf49b356b101721fd135d013"
|
||||
integrity sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-strategies@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.6.1.tgz#38d0f0fbdddba97bd92e0c6418d0b1a2ccd5b8bf"
|
||||
integrity sha512-WQLXkRnsk4L81fVPkkgon1rZNxnpdO5LsO+ws7tYBC6QQQFJVI6v98klrJEjFtZwzw/mB/HT5yVp7CcX0O+mrw==
|
||||
workbox-strategies@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.4.0.tgz#59130734400722d39ce4a0a1a22a363e99913946"
|
||||
integrity sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
|
||||
workbox-streams@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.6.1.tgz#b2f7ba7b315c27a6e3a96a476593f99c5d227d26"
|
||||
integrity sha512-maKG65FUq9e4BLotSKWSTzeF0sgctQdYyTMq529piEN24Dlu9b6WhrAfRpHdCncRS89Zi2QVpW5V33NX8PgH3Q==
|
||||
workbox-streams@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.4.0.tgz#e5b8e6b540f08e05f3c51b748c54056d24f20e8c"
|
||||
integrity sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==
|
||||
dependencies:
|
||||
workbox-core "6.6.1"
|
||||
workbox-routing "6.6.1"
|
||||
workbox-core "7.4.0"
|
||||
workbox-routing "7.4.0"
|
||||
|
||||
workbox-sw@6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-6.6.1.tgz#d4c4ca3125088e8b9fd7a748ed537fa0247bd72c"
|
||||
integrity sha512-R7whwjvU2abHH/lR6kQTTXLHDFU2izht9kJOvBRYK65FbwutT4VvnUAJIgHvfWZ/fokrOPhfoWYoPCMpSgUKHQ==
|
||||
workbox-sw@7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-7.4.0.tgz#05c9659399b8f3716e14406be66eb118fcb3968f"
|
||||
integrity sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==
|
||||
|
||||
workbox-window@6.6.1, workbox-window@^6.5.4:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-6.6.1.tgz#f22a394cbac36240d0dadcbdebc35f711bb7b89e"
|
||||
integrity sha512-wil4nwOY58nTdCvif/KEZjQ2NP8uk3gGeRNy2jPBbzypU4BT4D9L8xiwbmDBpZlSgJd2xsT9FvSNU0gsxV51JQ==
|
||||
workbox-window@7.4.0, workbox-window@^7.4.0:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-7.4.0.tgz#5399a5261b8c34d9d102f2d832d5857ee4d5748a"
|
||||
integrity sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==
|
||||
dependencies:
|
||||
"@types/trusted-types" "^2.0.2"
|
||||
workbox-core "6.6.1"
|
||||
|
||||
wrappy@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||
workbox-core "7.4.0"
|
||||
|
||||
ws@~8.17.1:
|
||||
version "8.17.1"
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
"hidden": 0,
|
||||
"icon_type": "App",
|
||||
"idx": 0,
|
||||
"label": "Frappe LMS",
|
||||
"label": "Frappe Learning",
|
||||
"link": "/lms",
|
||||
"link_type": "External",
|
||||
"logo_url": "/assets/lms/frontend/learning.svg",
|
||||
"modified": "2025-12-15 14:31:50.704854",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Frappe LMS",
|
||||
"name": "Frappe Learning",
|
||||
"owner": "Administrator",
|
||||
"restrict_removal": 0,
|
||||
"roles": [],
|
||||
"standard": 1
|
||||
}
|
||||
19
lms/hooks.py
19
lms/hooks.py
@@ -5,7 +5,7 @@ from . import __version__ as app_version
|
||||
app_name = "frappe_lms"
|
||||
app_title = "Learning"
|
||||
app_publisher = "Frappe"
|
||||
app_description = "Frappe LMS App"
|
||||
app_description = "Open Source Learning Management System built with Frappe Framework"
|
||||
app_icon_url = "/assets/lms/images/lms-logo.png"
|
||||
app_icon_title = "Learning"
|
||||
app_color = "grey"
|
||||
@@ -86,13 +86,16 @@ after_migrate = [
|
||||
# -----------
|
||||
# Permissions evaluated in scripted ways
|
||||
|
||||
# permission_query_conditions = {
|
||||
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
||||
# }
|
||||
#
|
||||
# has_permission = {
|
||||
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
||||
# }
|
||||
permission_query_conditions = {
|
||||
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.get_permission_query_conditions",
|
||||
}
|
||||
|
||||
has_permission = {
|
||||
"LMS Live Class": "lms.lms.doctype.lms_live_class.lms_live_class.has_permission",
|
||||
"LMS Batch": "lms.lms.doctype.lms_batch.lms_batch.has_permission",
|
||||
"LMS Program": "lms.lms.doctype.lms_program.lms_program.has_permission",
|
||||
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_permission",
|
||||
}
|
||||
|
||||
# DocType Class
|
||||
# ---------------
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2025-12-02 16:58:49.903274",
|
||||
"modified": "2026-02-19 14:26:14.027340",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "Job",
|
||||
"name": "Job Opportunity",
|
||||
@@ -149,24 +149,16 @@
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"select": 1,
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// Copyright (c) 2022, Frappe and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Job Settings", {
|
||||
// refresh: function(frm) {
|
||||
// }
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2022-02-07 12:01:41.422955",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"allow_posting",
|
||||
"title",
|
||||
"subtitle"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "allow_posting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow Job Posting From Website"
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Job Board Title"
|
||||
},
|
||||
{
|
||||
"fieldname": "subtitle",
|
||||
"fieldtype": "Data",
|
||||
"label": "Job Board Subtitle"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-02-11 15:56:38.958317",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Job",
|
||||
"name": "Job Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# Copyright (c) 2022, Frappe and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class JobSettings(Document):
|
||||
pass
|
||||
@@ -1,9 +0,0 @@
|
||||
# Copyright (c) 2022, Frappe and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
|
||||
class TestJobSettings(unittest.TestCase):
|
||||
pass
|
||||
@@ -31,6 +31,7 @@ from pypika import functions as fn
|
||||
|
||||
from lms.lms.doctype.course_lesson.course_lesson import save_progress
|
||||
from lms.lms.utils import (
|
||||
LMS_ROLES,
|
||||
can_modify_batch,
|
||||
can_modify_course,
|
||||
get_average_rating,
|
||||
@@ -41,6 +42,7 @@ from lms.lms.utils import (
|
||||
get_lms_route,
|
||||
has_course_instructor_role,
|
||||
has_evaluator_role,
|
||||
has_lms_role,
|
||||
has_moderator_role,
|
||||
)
|
||||
|
||||
@@ -393,7 +395,7 @@ def get_all_users():
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_sidebar_settings():
|
||||
lms_settings = frappe.get_single("LMS Settings")
|
||||
if not lms_settings.allow_guest_access:
|
||||
if frappe.session.user == "Guest" and not lms_settings.allow_guest_access:
|
||||
return []
|
||||
|
||||
sidebar_items = frappe._dict()
|
||||
@@ -473,7 +475,7 @@ def delete_lesson(lesson: str, chapter: str):
|
||||
update_index(lessons, chapter)
|
||||
|
||||
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
|
||||
frappe.db.delete("Course Lesson", lesson)
|
||||
frappe.delete_doc("Course Lesson", lesson)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -606,12 +608,7 @@ def check_app_permission():
|
||||
if frappe.session.user == "Administrator":
|
||||
return True
|
||||
|
||||
roles = frappe.get_roles()
|
||||
lms_roles = ["Moderator", "Course Creator", "Batch Evaluator", "LMS Student"]
|
||||
if any(role in roles for role in lms_roles):
|
||||
return True
|
||||
|
||||
return False
|
||||
return has_lms_role()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -1296,6 +1293,7 @@ def get_lms_settings():
|
||||
"contact_us_url",
|
||||
"livecode_url",
|
||||
"disable_pwa",
|
||||
"allow_job_posting",
|
||||
]
|
||||
|
||||
settings = frappe._dict()
|
||||
@@ -1308,7 +1306,6 @@ def get_lms_settings():
|
||||
@frappe.whitelist()
|
||||
def cancel_evaluation(evaluation: dict):
|
||||
evaluation = frappe._dict(evaluation)
|
||||
print(evaluation.member, frappe.session.user)
|
||||
if evaluation.member != frappe.session.user:
|
||||
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
|
||||
|
||||
@@ -1369,6 +1366,9 @@ def get_certification_details(course: str):
|
||||
@frappe.whitelist()
|
||||
def save_role(user: str, role: str, value: int):
|
||||
frappe.only_for("Moderator")
|
||||
if role not in LMS_ROLES:
|
||||
frappe.throw(_("You do not have permission to modify this role."), frappe.PermissionError)
|
||||
|
||||
if cint(value):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
@@ -1716,8 +1716,12 @@ def get_profile_details(username: str):
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
details.roles = frappe.get_roles(details.name)
|
||||
roles = frappe.get_roles(details.name)
|
||||
if not has_lms_role():
|
||||
frappe.throw(
|
||||
_("User does not have permission to access this user's profile details."), frappe.PermissionError
|
||||
)
|
||||
details.roles = roles
|
||||
return details
|
||||
|
||||
|
||||
@@ -2204,3 +2208,17 @@ def get_assessment_from_lesson(course: str, assessmentType: str):
|
||||
assessments.append(quiz_name)
|
||||
|
||||
return assessments
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_badges(member: str):
|
||||
if not has_lms_role():
|
||||
frappe.throw(_("You do not have permission to access badges."), frappe.PermissionError)
|
||||
|
||||
badges = frappe.get_all(
|
||||
"LMS Badge Assignment",
|
||||
{"member": member},
|
||||
["name", "member", "badge", "badge_image", "badge_description", "issued_on"],
|
||||
)
|
||||
|
||||
return badges
|
||||
|
||||
@@ -4,25 +4,12 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from lms.lms.utils import get_course_progress, get_lesson_count
|
||||
from lms.lms.utils import get_lesson_count
|
||||
|
||||
|
||||
class CourseChapter(Document):
|
||||
def on_update(self):
|
||||
self.recalculate_course_progress()
|
||||
self.update_lesson_count()
|
||||
frappe.enqueue(method=self.recalculate_course_progress, queue="short", timeout=300, is_async=True)
|
||||
|
||||
def recalculate_course_progress(self):
|
||||
"""Recalculate course progress if a new lesson is added or removed"""
|
||||
previous_lessons = self.get_doc_before_save() and self.get_doc_before_save().as_dict().lessons
|
||||
current_lessons = self.lessons
|
||||
|
||||
if previous_lessons and previous_lessons != current_lessons:
|
||||
enrolled_members = frappe.get_all("LMS Enrollment", {"course": self.course}, ["member", "name"])
|
||||
for enrollment in enrolled_members:
|
||||
new_progress = get_course_progress(self.course, enrollment.member)
|
||||
frappe.db.set_value("LMS Enrollment", enrollment.name, "progress", new_progress)
|
||||
|
||||
def update_lesson_count(self):
|
||||
"""Update lesson count in the course"""
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-10 11:41:51.802016",
|
||||
"modified": "2026-02-23 14:50:11.733278",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "Course Evaluator",
|
||||
@@ -125,6 +125,18 @@
|
||||
"role": "Batch Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Course Creator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
{
|
||||
"fieldname": "body",
|
||||
"fieldtype": "Markdown Editor",
|
||||
"ignore_xss_filter": 1,
|
||||
"label": "Body"
|
||||
},
|
||||
{
|
||||
@@ -161,7 +160,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-04-10 15:19:22.400932",
|
||||
"modified": "2026-02-20 13:49:25.599827",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Lesson",
|
||||
|
||||
@@ -9,15 +9,39 @@ from frappe.model.document import Document
|
||||
from frappe.realtime import get_website_room
|
||||
from frappe.utils.telemetry import capture
|
||||
|
||||
from lms.lms.utils import get_course_progress
|
||||
from lms.lms.utils import get_course_progress, recalculate_course_progress
|
||||
|
||||
from ...md import find_macros
|
||||
|
||||
|
||||
class CourseLesson(Document):
|
||||
def after_insert(self):
|
||||
self.validate_progress_recalculation()
|
||||
|
||||
def after_delete(self):
|
||||
self.validate_progress_recalculation()
|
||||
|
||||
def on_update(self):
|
||||
self.validate_quiz_id()
|
||||
|
||||
def validate_progress_recalculation(self):
|
||||
if not self.course or not self.chapter:
|
||||
return
|
||||
|
||||
enrollments = frappe.db.get_all(
|
||||
"LMS Enrollment",
|
||||
filters={"course": self.course},
|
||||
fields=["name", "member"],
|
||||
)
|
||||
if not len(enrollments):
|
||||
return
|
||||
|
||||
frappe.enqueue(method=self.recalculate_progress, queue="long", is_async=True, enrollments=enrollments)
|
||||
|
||||
def recalculate_progress(self, enrollments):
|
||||
for enrollment in enrollments:
|
||||
recalculate_course_progress(self.course, enrollment.member)
|
||||
|
||||
def validate_quiz_id(self):
|
||||
if self.quiz_id and not frappe.db.exists("LMS Quiz", self.quiz_id):
|
||||
frappe.throw(_("Invalid Quiz ID"))
|
||||
|
||||
@@ -5,7 +5,7 @@ frappe.ui.form.on("LMS Badge", {
|
||||
refresh: (frm) => {
|
||||
frm.events.set_field_options(frm);
|
||||
|
||||
if (frm.doc.event == "Auto Assign") {
|
||||
if (frm.doc.event == "Manual Assignment" && frm.doc.enabled) {
|
||||
add_assign_button(frm);
|
||||
}
|
||||
},
|
||||
@@ -49,11 +49,13 @@ const add_assign_button = (frm) => {
|
||||
frappe.call({
|
||||
method: "lms.lms.doctype.lms_badge.lms_badge.assign_badge",
|
||||
args: {
|
||||
badge: frm.doc,
|
||||
badge_name: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.msgprint(r.message);
|
||||
if (r.message == "success") {
|
||||
frappe.toast(__("Badge assigned successfully"));
|
||||
} else {
|
||||
frappe.toast(__("Failed to assign badge"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,14 +52,14 @@
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Event",
|
||||
"options": "New\nValue Change\nAuto Assign",
|
||||
"options": "New\nValue Change\nManual Assignment",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "condition",
|
||||
"fieldtype": "Code",
|
||||
"label": "Condition",
|
||||
"mandatory_depends_on": "eval:doc.event == \"Auto Assign\""
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.event == 'Value Change'",
|
||||
@@ -100,7 +100,7 @@
|
||||
"link_fieldname": "badge"
|
||||
}
|
||||
],
|
||||
"modified": "2026-02-03 10:52:37.122370",
|
||||
"modified": "2026-02-20 17:58:25.924109",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Badge",
|
||||
@@ -131,15 +131,6 @@
|
||||
"role": "Moderator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe.model.document import Document
|
||||
|
||||
class LMSBadge(Document):
|
||||
def on_update(self):
|
||||
if self.event == "Auto Assign" and self.condition:
|
||||
if self.event == "Manual Assignment" and self.condition:
|
||||
try:
|
||||
json.loads(self.condition)
|
||||
except ValueError:
|
||||
@@ -54,6 +54,7 @@ def award(doc, member):
|
||||
}
|
||||
)
|
||||
assignment.save()
|
||||
return assignment.name
|
||||
|
||||
|
||||
def eval_condition(doc, condition):
|
||||
@@ -61,16 +62,30 @@ def eval_condition(doc, condition):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def assign_badge(badge: str, user: str):
|
||||
badge = frappe._dict(json.loads(badge))
|
||||
if not badge.event == "Auto Assign":
|
||||
def assign_badge(badge_name: str):
|
||||
assignments = []
|
||||
badge = frappe.db.get_value(
|
||||
"LMS Badge",
|
||||
badge_name,
|
||||
["name", "event", "reference_doctype", "condition", "user_field"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not badge:
|
||||
frappe.throw(_("Badge {0} not found").format(badge_name), frappe.DoesNotExistError)
|
||||
|
||||
if not badge.event == "Manual Assignment":
|
||||
return
|
||||
|
||||
fields = ["name"]
|
||||
fields.append(badge.user_field)
|
||||
list = frappe.get_all(badge.reference_doctype, filters=badge.condition, fields=fields)
|
||||
for doc in list:
|
||||
award(badge, doc.get(badge.user_field))
|
||||
docs = frappe.get_all(badge.reference_doctype, filters=json.loads(badge.condition), fields=fields)
|
||||
|
||||
for doc in docs:
|
||||
assignment_name = award(badge, doc.get(badge.user_field))
|
||||
if assignment_name:
|
||||
assignments.append(assignment_name)
|
||||
|
||||
return "success" if assignments else "failed"
|
||||
|
||||
|
||||
def process_badges(doc, state):
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-04 17:06:26.090276",
|
||||
"modified": "2026-02-19 15:06:08.389081",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Badge Assignment",
|
||||
@@ -120,10 +120,6 @@
|
||||
"read": 1,
|
||||
"role": "LMS Student"
|
||||
},
|
||||
{
|
||||
"read": 1,
|
||||
"role": "LMS Student"
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
|
||||
@@ -10,17 +10,17 @@ from lms.lms.doctype.lms_badge.lms_badge import eval_condition
|
||||
|
||||
class LMSBadgeAssignment(Document):
|
||||
def validate(self):
|
||||
self.validate_owner()
|
||||
self.validate_duplicate_badge_assignment()
|
||||
self.validate_badge_criteria()
|
||||
self.validate_owner()
|
||||
|
||||
def validate_owner(self):
|
||||
if self.owner == self.member:
|
||||
return
|
||||
|
||||
roles = frappe.get_roles(self.owner)
|
||||
if "Moderator" not in roles:
|
||||
frappe.throw(_("You must be a Moderator to assign badges to users."))
|
||||
event = frappe.db.get_value("LMS Badge", self.badge, "event")
|
||||
if event == "Manual Assignment":
|
||||
roles = frappe.get_roles(frappe.session.user)
|
||||
admins = ["Moderator", "Course Creator", "Batch Evaluator"]
|
||||
if not any(role in roles for role in admins):
|
||||
frappe.throw(_("You must be an Admin to assign badges to users."))
|
||||
|
||||
def validate_duplicate_badge_assignment(self):
|
||||
grant_only_once = frappe.db.get_value("LMS Badge", self.badge, "grant_only_once")
|
||||
@@ -40,25 +40,27 @@ class LMSBadgeAssignment(Document):
|
||||
"LMS Badge", self.badge, ["reference_doctype", "user_field", "condition", "enabled"], as_dict=True
|
||||
)
|
||||
|
||||
if badge_details:
|
||||
if badge_details.reference_doctype and badge_details.user_field and badge_details.condition:
|
||||
user_fieldname = frappe.db.get_value(
|
||||
"DocField",
|
||||
{"parent": badge_details.reference_doctype, "fieldname": badge_details.user_field},
|
||||
"fieldname",
|
||||
if not badge_details:
|
||||
return
|
||||
|
||||
if badge_details.reference_doctype and badge_details.user_field and badge_details.condition:
|
||||
user_fieldname = frappe.db.get_value(
|
||||
"DocField",
|
||||
{"parent": badge_details.reference_doctype, "fieldname": badge_details.user_field},
|
||||
"fieldname",
|
||||
)
|
||||
|
||||
documents = frappe.get_all(
|
||||
badge_details.reference_doctype,
|
||||
{user_fieldname: self.member},
|
||||
)
|
||||
|
||||
for document in documents:
|
||||
reference_value = eval_condition(
|
||||
frappe.get_doc(badge_details.reference_doctype, document.name),
|
||||
badge_details.condition,
|
||||
)
|
||||
if reference_value:
|
||||
return
|
||||
|
||||
documents = frappe.get_all(
|
||||
badge_details.reference_doctype,
|
||||
{user_fieldname: self.member},
|
||||
)
|
||||
|
||||
for document in documents:
|
||||
reference_value = eval_condition(
|
||||
frappe.get_doc(badge_details.reference_doctype, document.name),
|
||||
badge_details.condition,
|
||||
)
|
||||
if reference_value:
|
||||
return
|
||||
|
||||
frappe.throw(_("Member does not meet the criteria for the badge {0}.").format(self.badge))
|
||||
frappe.throw(_("Member does not meet the criteria for the badge {0}.").format(self.badge))
|
||||
|
||||
@@ -50,7 +50,7 @@ frappe.ui.form.on("LMS Batch", {
|
||||
refresh: (frm) => {
|
||||
const lmsPath = frappe.boot.lms_path || "lms";
|
||||
frm.add_web_link(
|
||||
`/${lmsPath}/batches/details/${frm.doc.name}`,
|
||||
`/${lmsPath}/batches/${frm.doc.name}`,
|
||||
"See on website"
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,41 +9,43 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"section_break_earo",
|
||||
"published",
|
||||
"title",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"column_break_4",
|
||||
"allow_self_enrollment",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"timezone",
|
||||
"section_break_wuxt",
|
||||
"seat_count",
|
||||
"column_break_uamg",
|
||||
"category",
|
||||
"section_break_cssv",
|
||||
"published",
|
||||
"evaluation",
|
||||
"evaluation_end_date",
|
||||
"column_break_wfkz",
|
||||
"allow_self_enrollment",
|
||||
"column_break_vnrp",
|
||||
"certification",
|
||||
"section_break_6",
|
||||
"description",
|
||||
"column_break_hlqw",
|
||||
"instructors",
|
||||
"zoom_account",
|
||||
"column_break_hlqw",
|
||||
"batch_details",
|
||||
"section_break_rgfj",
|
||||
"medium",
|
||||
"category",
|
||||
"confirmation_email_template",
|
||||
"column_break_flwy",
|
||||
"seat_count",
|
||||
"evaluation_end_date",
|
||||
"zoom_account",
|
||||
"notification_sent",
|
||||
"section_break_jedp",
|
||||
"video_link",
|
||||
"column_break_kpct",
|
||||
"meta_image",
|
||||
"section_break_khcn",
|
||||
"batch_details",
|
||||
"batch_details_raw",
|
||||
"section_break_jgji",
|
||||
"courses",
|
||||
"section_break_khcn",
|
||||
"batch_details_raw",
|
||||
"assessment_tab",
|
||||
"assessment",
|
||||
"schedule_tab",
|
||||
@@ -297,6 +299,7 @@
|
||||
"label": "Allow accessing future dates"
|
||||
},
|
||||
{
|
||||
"depends_on": "evaluation",
|
||||
"fieldname": "evaluation_end_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Evaluation End Date"
|
||||
@@ -341,10 +344,6 @@
|
||||
"fieldname": "column_break_wfkz",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_vnrp",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "certification",
|
||||
@@ -358,7 +357,8 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_cssv",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Certification"
|
||||
},
|
||||
{
|
||||
"fieldname": "zoom_account",
|
||||
@@ -385,6 +385,20 @@
|
||||
{
|
||||
"fieldname": "column_break_kpct",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "evaluation",
|
||||
"fieldtype": "Check",
|
||||
"label": "Evaluation"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_wuxt",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uamg",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -407,7 +421,7 @@
|
||||
"link_fieldname": "payment_for_document"
|
||||
}
|
||||
],
|
||||
"modified": "2026-01-13 18:50:27.420712",
|
||||
"modified": "2026-02-13 14:23:51.913875",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Batch",
|
||||
|
||||
@@ -20,6 +20,7 @@ from lms.lms.utils import (
|
||||
get_lesson_url,
|
||||
get_lms_route,
|
||||
get_quiz_details,
|
||||
guest_access_allowed,
|
||||
update_payment_record,
|
||||
)
|
||||
|
||||
@@ -165,7 +166,7 @@ def send_email_notification_for_published_batch(batch):
|
||||
"medium": batch.medium,
|
||||
"timezone": batch.timezone,
|
||||
"instructors": instructors,
|
||||
"batch_url": frappe.utils.get_url(get_lms_route(f"batches/details/{batch.name}")),
|
||||
"batch_url": frappe.utils.get_url(get_lms_route(f"batches/{batch.name}")),
|
||||
}
|
||||
|
||||
frappe.sendmail(
|
||||
@@ -194,7 +195,7 @@ def send_system_notification_for_published_batch(batch):
|
||||
"document_name": batch.name,
|
||||
"from_user": instructors[0] if instructors else None,
|
||||
"type": "Alert",
|
||||
"link": get_lms_route(f"batches/details/{batch.name}"),
|
||||
"link": get_lms_route(f"batches/{batch.name}"),
|
||||
}
|
||||
)
|
||||
make_notification_logs(notification, students)
|
||||
@@ -213,6 +214,10 @@ def create_live_class(
|
||||
auto_recording: str,
|
||||
description: str = None,
|
||||
):
|
||||
roles = frappe.get_roles()
|
||||
if not any(role in roles for role in ["Moderator", "Batch Evaluator"]):
|
||||
frappe.throw(_("You do not have permission to create a live class."))
|
||||
|
||||
payload = {
|
||||
"topic": title,
|
||||
"start_time": format_datetime(f"{date} {time}", "yyyy-MM-ddTHH:mm:ssZ"),
|
||||
@@ -391,3 +396,26 @@ def send_mail(batch, student):
|
||||
args=args,
|
||||
header=[_(f"Batch Start Reminder: {batch.title}"), "orange"],
|
||||
)
|
||||
|
||||
|
||||
def has_permission(doc, ptype="read", user=None):
|
||||
user = user or frappe.session.user
|
||||
if user == "Guest" and not guest_access_allowed():
|
||||
return False
|
||||
|
||||
roles = frappe.get_roles(user)
|
||||
if "Moderator" in roles or "Batch Evaluator" in roles:
|
||||
return True
|
||||
|
||||
if ptype not in ("read", "select", "print"):
|
||||
return False
|
||||
|
||||
is_enrolled = frappe.db.exists("LMS Batch Enrollment", {"batch": doc.name, "member": user})
|
||||
if is_enrolled:
|
||||
return True
|
||||
|
||||
is_batch_published = frappe.db.get_value("LMS Batch", doc.name, "published")
|
||||
if is_batch_published:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"member",
|
||||
"member_name",
|
||||
"member_username",
|
||||
"member_image",
|
||||
"column_break_sjzm",
|
||||
"batch",
|
||||
"payment",
|
||||
@@ -70,11 +71,17 @@
|
||||
"label": "Batch",
|
||||
"options": "LMS Batch",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "member.user_image",
|
||||
"fieldname": "member_image",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "Member Image"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-03 10:51:28.475356",
|
||||
"modified": "2026-02-10 16:07:28.315982",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Batch Enrollment",
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-17 16:50:31.128747",
|
||||
"modified": "2026-02-20 17:32:34.580862",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Certificate",
|
||||
@@ -153,27 +153,6 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
@@ -197,6 +176,15 @@
|
||||
"role": "Course Creator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.utils.telemetry import capture
|
||||
|
||||
class LMSCertificate(Document):
|
||||
def validate(self):
|
||||
self.validate_criteria()
|
||||
self.validate_duplicate_certificate()
|
||||
|
||||
def autoname(self):
|
||||
@@ -54,6 +55,43 @@ class LMSCertificate(Document):
|
||||
header=[subject, "green"],
|
||||
)
|
||||
|
||||
def validate_criteria(self):
|
||||
self.validate_role_of_owner()
|
||||
self.validate_batch_enrollment()
|
||||
self.validate_course_enrollment()
|
||||
|
||||
def validate_role_of_owner(self):
|
||||
roles = frappe.get_roles()
|
||||
is_admin = any(role in roles for role in ["Moderator", "Course Creator", "Batch Evaluator"])
|
||||
if not self.course and not self.batch_name and not is_admin:
|
||||
frappe.throw(_("Course or Batch is required to issue a certificate."))
|
||||
|
||||
def validate_batch_enrollment(self):
|
||||
if self.batch_name:
|
||||
is_enrolled = frappe.db.exists(
|
||||
"LMS Batch Enrollment", {"batch": self.batch_name, "member": self.member}
|
||||
)
|
||||
if not is_enrolled:
|
||||
frappe.throw(_("Certification cannot be issued as the member is not enrolled in this batch."))
|
||||
|
||||
def validate_course_enrollment(self):
|
||||
if self.course:
|
||||
is_enrolled = frappe.db.exists("LMS Enrollment", {"course": self.course, "member": self.member})
|
||||
if not is_enrolled:
|
||||
frappe.throw(
|
||||
_("Certification cannot be issued as the member is not enrolled in this course.")
|
||||
)
|
||||
|
||||
completion_certificate = frappe.db.get_value("LMS Course", self.course, "enable_certification")
|
||||
if completion_certificate:
|
||||
progress = frappe.db.get_value(
|
||||
"LMS Enrollment", {"course": self.course, "member": self.member}, "progress"
|
||||
)
|
||||
if progress < 100:
|
||||
frappe.throw(
|
||||
_("Certification cannot be issued as the member has not completed the course.")
|
||||
)
|
||||
|
||||
def validate_duplicate_certificate(self):
|
||||
self.validate_course_duplicates()
|
||||
self.validate_batch_duplicates()
|
||||
@@ -177,3 +215,23 @@ def validate_certification_eligibility(course):
|
||||
)
|
||||
if progress < 100:
|
||||
frappe.throw(_("You have not completed the course yet."))
|
||||
|
||||
|
||||
def has_permission(doc, ptype="read", user=None):
|
||||
user = user or frappe.session.user
|
||||
roles = frappe.get_roles(user)
|
||||
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
|
||||
return True
|
||||
if doc.owner == user:
|
||||
return True
|
||||
if ptype not in ("read", "select", "print"):
|
||||
return False
|
||||
return doc.published
|
||||
|
||||
|
||||
def get_permission_query_conditions(user):
|
||||
user = user or frappe.session.user
|
||||
roles = frappe.get_roles(user)
|
||||
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
|
||||
return None
|
||||
return """(`tabLMS Certificate`.published = 1)"""
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-10 11:40:50.679211",
|
||||
"modified": "2026-02-23 14:45:44.994705",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Certificate Request",
|
||||
@@ -192,6 +192,7 @@
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -209,6 +210,18 @@
|
||||
"role": "Batch Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Course Creator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.utils import (
|
||||
format_time,
|
||||
get_datetime,
|
||||
get_fullname,
|
||||
get_system_timezone,
|
||||
get_time,
|
||||
getdate,
|
||||
nowtime,
|
||||
@@ -118,16 +119,7 @@ class LMSCertificateRequest(Document):
|
||||
def validate_timezone(self):
|
||||
if self.timezone:
|
||||
return
|
||||
if self.batch_name:
|
||||
timezone = frappe.db.get_value("LMS Batch", self.batch_name, "timezone")
|
||||
if timezone:
|
||||
self.timezone = timezone
|
||||
return
|
||||
if self.course:
|
||||
timezone = frappe.db.get_value("LMS Course", self.course, "timezone")
|
||||
if timezone:
|
||||
self.timezone = timezone
|
||||
return
|
||||
self.timezone = get_system_timezone()
|
||||
|
||||
def send_notification(self):
|
||||
outgoing_email_account = frappe.get_cached_value(
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"pricing_tab",
|
||||
"pricing_section",
|
||||
"paid_course",
|
||||
"enable_certification",
|
||||
"paid_certificate",
|
||||
"column_break_acoj",
|
||||
"enable_certification",
|
||||
"section_break_vqbh",
|
||||
"course_price",
|
||||
"currency",
|
||||
@@ -168,7 +168,7 @@
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"mandatory_depends_on": "paid_course",
|
||||
"mandatory_depends_on": "eval: doc.paid_course || doc.paid_certificate",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
@@ -181,7 +181,7 @@
|
||||
"fieldname": "course_price",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount",
|
||||
"mandatory_depends_on": "paid_course"
|
||||
"mandatory_depends_on": "eval: doc.paid_course || doc.paid_certificate"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_acoj",
|
||||
@@ -314,7 +314,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-01-13 18:48:56.069280",
|
||||
"modified": "2026-02-19 11:41:57.038869",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
|
||||
@@ -4,20 +4,9 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from lms.lms.doctype.lms_enrollment.lms_enrollment import update_program_progress
|
||||
from lms.lms.utils import get_course_progress
|
||||
from lms.lms.utils import recalculate_course_progress
|
||||
|
||||
|
||||
class LMSCourseProgress(Document):
|
||||
def after_delete(self):
|
||||
progress = get_course_progress(self.course, self.member)
|
||||
membership = frappe.db.get_value(
|
||||
"LMS Enrollment",
|
||||
{
|
||||
"member": self.member,
|
||||
"course": self.course,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
|
||||
update_program_progress(self.member)
|
||||
recalculate_course_progress(self.course, self.member)
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.utils import ceil
|
||||
|
||||
|
||||
class LMSEnrollment(Document):
|
||||
def validate(self):
|
||||
def before_insert(self):
|
||||
self.validate_duplicate_enrollment()
|
||||
self.validate_course_enrollment_eligibility()
|
||||
self.validate_owner()
|
||||
@@ -27,10 +27,11 @@ class LMSEnrollment(Document):
|
||||
{
|
||||
"course": self.course,
|
||||
"member": self.member,
|
||||
"name": ["!=", self.name],
|
||||
},
|
||||
)
|
||||
|
||||
if existing_enrollment:
|
||||
if existing_enrollment and existing_enrollment != self.name:
|
||||
frappe.throw(_("Student is already enrolled in this course."))
|
||||
|
||||
def validate_course_enrollment_eligibility(self):
|
||||
@@ -49,7 +50,10 @@ class LMSEnrollment(Document):
|
||||
)
|
||||
|
||||
if self.enrollment_from_batch:
|
||||
return
|
||||
if frappe.db.exists(
|
||||
"LMS Batch Enrollment", {"batch": self.enrollment_from_batch, "member": self.member}
|
||||
):
|
||||
return
|
||||
|
||||
if not course_details.published and not is_admin():
|
||||
frappe.throw(_("You cannot enroll in an unpublished course."))
|
||||
|
||||
@@ -169,3 +169,18 @@ def get_minutes(duration_in_seconds):
|
||||
if duration_in_seconds:
|
||||
return int(duration_in_seconds) // 60
|
||||
return 0
|
||||
|
||||
|
||||
def has_permission(doc, ptype="read", user=None):
|
||||
user = user or frappe.session.user
|
||||
roles = frappe.get_roles(user)
|
||||
if "Moderator" in roles or "Batch Evaluator" in roles:
|
||||
return True
|
||||
|
||||
if ptype not in ("read", "select", "print"):
|
||||
return False
|
||||
|
||||
return frappe.db.exists(
|
||||
"LMS Batch Enrollment",
|
||||
{"batch": doc.batch_name, "member": user},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
from lms.lms.utils import guest_access_allowed
|
||||
|
||||
|
||||
class LMSProgram(Document):
|
||||
def validate(self):
|
||||
@@ -41,3 +43,27 @@ class LMSProgram(Document):
|
||||
|
||||
if self.member_count != member_count:
|
||||
self.member_count = member_count
|
||||
|
||||
|
||||
def has_permission(doc, ptype="read", user=None):
|
||||
user = user or frappe.session.user
|
||||
|
||||
if user == "Guest" and not guest_access_allowed():
|
||||
return False
|
||||
|
||||
roles = frappe.get_roles(user)
|
||||
if "Moderator" in roles or "Course Creator" in roles:
|
||||
return True
|
||||
|
||||
if ptype not in ("read", "select", "print"):
|
||||
return False
|
||||
|
||||
is_enrolled = frappe.db.exists("LMS Program Member", {"parent": doc.name, "member": user})
|
||||
if is_enrolled:
|
||||
return True
|
||||
|
||||
is_program_published = frappe.db.get_value("LMS Program", doc.name, "published")
|
||||
if is_program_published:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-24 14:42:08.288983",
|
||||
"modified": "2026-02-20 14:43:56.587110",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Programming Exercise Submission",
|
||||
@@ -146,6 +146,7 @@
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user