mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
Merge pull request #2081 from pateljannat/batch-dashboard-update
Batch dashboard update
This commit is contained in:
@@ -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");
|
||||
|
||||
Vendored
-13
@@ -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>
|
||||
@@ -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.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)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<Button v-if="canScheduleEvals" @click="openEvalModal">
|
||||
{{ __('Schedule Evaluation') }}
|
||||
{{ __('Schedule') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -31,7 +31,7 @@
|
||||
<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-2' : 'grid-cols-1'"
|
||||
>
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border text-ink-gray-7 rounded-md p-3">
|
||||
@@ -114,7 +114,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,486 @@
|
||||
<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-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.description"
|
||||
:label="__('Short Description')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="batchDetail.doc.video_link"
|
||||
:label="__('Preview Video')"
|
||||
type="video"
|
||||
:required="false"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</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>
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+12
-15
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
+32
-56
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
+68
-49
@@ -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 {
|
||||
+4
-41
@@ -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(
|
||||
{},
|
||||
@@ -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>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div
|
||||
v-if="isAdmin() && !props.zoomAccount"
|
||||
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 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-ink-gray-7 mt-5">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
v-if="showLiveClassModal"
|
||||
v-model="showLiveClassModal"
|
||||
:batch="props.batch"
|
||||
:zoomAccount="props.zoomAccount"
|
||||
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,
|
||||
},
|
||||
zoomAccount: String,
|
||||
})
|
||||
|
||||
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.zoomAccount) 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>
|
||||
@@ -0,0 +1,199 @@
|
||||
<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"
|
||||
@create="
|
||||
() => {
|
||||
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 { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { ref, inject, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cleanError, openSettings } from '@/utils'
|
||||
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)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
watch(show, () => {
|
||||
if (show.value) capture('batch_form_opened')
|
||||
})
|
||||
</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',
|
||||
},
|
||||
|
||||
@@ -373,7 +373,7 @@ const courseResource = createDocumentResource({
|
||||
watch(
|
||||
() => courseResource.doc,
|
||||
() => {
|
||||
check_permission()
|
||||
checkPermission()
|
||||
getMetaInfo('courses', courseResource.doc?.name, meta)
|
||||
updateCourseData()
|
||||
}
|
||||
@@ -516,7 +516,7 @@ const removeTag = (tag) => {
|
||||
makeFormDirty()
|
||||
}
|
||||
|
||||
const check_permission = () => {
|
||||
const checkPermission = () => {
|
||||
let user_is_instructor = false
|
||||
if (user.data?.is_moderator) return
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Create Course'),
|
||||
title: __('New Course'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
@@ -67,7 +67,7 @@
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="saveCourse(close)">
|
||||
{{ __('Create') }}
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
-12
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
+282
-248
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -165,7 +165,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 +194,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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+59
-77
@@ -1099,7 +1099,7 @@ def get_batch_details(batch: str):
|
||||
is_student_enrolled = frappe.session.user in batch_students
|
||||
|
||||
if not (is_batch_published or is_batch_admin or is_student_enrolled):
|
||||
return
|
||||
return {}
|
||||
|
||||
batch_details = frappe.db.get_value(
|
||||
"LMS Batch",
|
||||
@@ -1123,6 +1123,7 @@ def get_batch_details(batch: str):
|
||||
"evaluation_end_date",
|
||||
"allow_self_enrollment",
|
||||
"certification",
|
||||
"evaluation",
|
||||
"timezone",
|
||||
"category",
|
||||
"zoom_account",
|
||||
@@ -1143,6 +1144,10 @@ def get_batch_details(batch: str):
|
||||
batch_details.courses = frappe.get_all(
|
||||
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
|
||||
)
|
||||
batch_details.assessments = frappe.get_all(
|
||||
"LMS Assessment", {"parent": batch}, ["assessment_name", "assessment_type"]
|
||||
)
|
||||
|
||||
if can_modify_batch(batch):
|
||||
batch_details.students = batch_students
|
||||
elif is_student_enrolled:
|
||||
@@ -1342,43 +1347,13 @@ def get_exercise_details(assessment: dict, member: str) -> dict:
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_batch_assessment_count(batch: str) -> int:
|
||||
frappe.only_for(["Moderator", "Batch Evaluator"])
|
||||
if not frappe.db.exists("LMS Batch", batch):
|
||||
frappe.throw(_("The specified batch does not exist."))
|
||||
return frappe.db.count("LMS Assessment", {"parent": batch})
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_batch_students(
|
||||
filters: dict, offset: int = 0, limit_start: int = 0, limit_page_length: int = None, limit: int = None
|
||||
):
|
||||
# limit_start and limit_page_length are used for backward compatibility
|
||||
start = limit_start or offset
|
||||
page_length = limit_page_length or limit
|
||||
batch = filters.get("batch")
|
||||
if not batch:
|
||||
return []
|
||||
|
||||
def get_batch_student_progress(member: str, batch: str) -> dict:
|
||||
if not can_modify_batch(batch):
|
||||
frappe.throw(_("You are not authorized to view the students of this batch."))
|
||||
|
||||
students = []
|
||||
students_list = frappe.get_all(
|
||||
"LMS Batch Enrollment",
|
||||
filters={"batch": batch},
|
||||
fields=["member", "name"],
|
||||
offset=start,
|
||||
limit=page_length,
|
||||
order_by="creation desc",
|
||||
)
|
||||
|
||||
for student in students_list:
|
||||
details = get_batch_student_details(student)
|
||||
calculate_student_progress(batch, details)
|
||||
students.append(details)
|
||||
|
||||
return students
|
||||
details = get_batch_student_details(member)
|
||||
calculate_student_progress(batch, details)
|
||||
return details
|
||||
|
||||
|
||||
def get_course_completion_stats(batch: str) -> list:
|
||||
@@ -1472,16 +1447,14 @@ def get_batch_chart_data(batch: str) -> list:
|
||||
return get_course_completion_stats(batch) + get_assignment_pass_stats(batch) + get_quiz_pass_stats(batch)
|
||||
|
||||
|
||||
def get_batch_student_details(student: dict) -> dict:
|
||||
def get_batch_student_details(student: str) -> dict:
|
||||
details = frappe.db.get_value(
|
||||
"User",
|
||||
student.member,
|
||||
["full_name", "email", "username", "last_active", "user_image"],
|
||||
student,
|
||||
["full_name", "email", "username", "last_active", "user_image", "name"],
|
||||
as_dict=True,
|
||||
)
|
||||
details.last_active = format_datetime(details.last_active, "dd MMM YY")
|
||||
details.name = student.name
|
||||
details.assessments = frappe._dict()
|
||||
return details
|
||||
|
||||
|
||||
@@ -1511,8 +1484,7 @@ def calculate_student_progress(batch: str, details: dict):
|
||||
|
||||
def calculate_course_progress(batch_courses: list, details: dict):
|
||||
course_progress = []
|
||||
details.courses = frappe._dict()
|
||||
|
||||
details.courses = []
|
||||
for course in batch_courses:
|
||||
progress = (
|
||||
frappe.db.get_value(
|
||||
@@ -1520,7 +1492,7 @@ def calculate_course_progress(batch_courses: list, details: dict):
|
||||
)
|
||||
or 0
|
||||
)
|
||||
details.courses[course.title] = progress
|
||||
details.courses.append({"course": course.course, "title": course.title, "progress": progress})
|
||||
course_progress.append(progress)
|
||||
|
||||
details.average_course_progress = (
|
||||
@@ -1530,14 +1502,15 @@ def calculate_course_progress(batch_courses: list, details: dict):
|
||||
|
||||
def calculate_assessment_progress(assessments: list, details: dict):
|
||||
assessments_completed = 0
|
||||
details.assessments = frappe._dict()
|
||||
details.assessments = []
|
||||
|
||||
for assessment in assessments:
|
||||
title = frappe.db.get_value(assessment.assessment_type, assessment.assessment_name, "title")
|
||||
assessment_info = has_submitted_assessment(
|
||||
assessment.assessment_name, assessment.assessment_type, details.email
|
||||
)
|
||||
details.assessments[title] = assessment_info
|
||||
assessment_info.title = title
|
||||
details.assessments.append(assessment_info)
|
||||
|
||||
if assessment_info.result == "Pass":
|
||||
assessments_completed += 1
|
||||
@@ -1551,6 +1524,24 @@ def has_submitted_assessment(assessment: str, assessment_type: str, member: str
|
||||
if not member:
|
||||
member = frappe.session.user
|
||||
|
||||
doctype, docfield, fields, not_attempted = get_assessment_meta(assessment_type)
|
||||
filters = {}
|
||||
filters[docfield] = assessment
|
||||
filters["member"] = member
|
||||
|
||||
attempt = frappe.db.exists(doctype, filters)
|
||||
if attempt:
|
||||
return get_assessment_attempt_details(doctype, filters, fields, assessment_type, assessment)
|
||||
else:
|
||||
return frappe._dict(
|
||||
{
|
||||
"status": not_attempted,
|
||||
"result": "Failed",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_assessment_meta(assessment_type: str):
|
||||
if assessment_type == "LMS Assignment":
|
||||
doctype = "LMS Assignment Submission"
|
||||
docfield = "assignment"
|
||||
@@ -1567,39 +1558,30 @@ def has_submitted_assessment(assessment: str, assessment_type: str, member: str
|
||||
fields = ["status"]
|
||||
not_attempted = "Not Attempted"
|
||||
|
||||
filters = {}
|
||||
filters[docfield] = assessment
|
||||
filters["member"] = member
|
||||
return doctype, docfield, fields, not_attempted
|
||||
|
||||
attempt = frappe.db.exists(doctype, filters)
|
||||
if attempt:
|
||||
fields.append("name")
|
||||
attempt_details = frappe.db.get_value(doctype, filters, fields, as_dict=1)
|
||||
if assessment_type == "LMS Quiz":
|
||||
result = "Failed"
|
||||
passing_percentage = frappe.db.get_value("LMS Quiz", assessment, "passing_percentage")
|
||||
if attempt_details.percentage >= passing_percentage:
|
||||
result = "Pass"
|
||||
else:
|
||||
result = attempt_details.status
|
||||
return frappe._dict(
|
||||
{
|
||||
"status": attempt_details.percentage
|
||||
if assessment_type == "LMS Quiz"
|
||||
else attempt_details.status,
|
||||
"result": result,
|
||||
"assessment": assessment,
|
||||
"type": assessment_type,
|
||||
"submission": attempt_details.name,
|
||||
}
|
||||
)
|
||||
|
||||
def get_assessment_attempt_details(
|
||||
doctype: str, filters: dict, fields: list, assessment_type: str, assessment: str
|
||||
):
|
||||
fields.append("name")
|
||||
attempt_details = frappe.db.get_value(doctype, filters, fields, as_dict=1)
|
||||
if assessment_type == "LMS Quiz":
|
||||
result = "Failed"
|
||||
passing_percentage = frappe.db.get_value("LMS Quiz", assessment, "passing_percentage")
|
||||
if attempt_details.percentage >= passing_percentage:
|
||||
result = "Pass"
|
||||
else:
|
||||
return frappe._dict(
|
||||
{
|
||||
"status": not_attempted,
|
||||
"result": "Failed",
|
||||
}
|
||||
)
|
||||
result = attempt_details.status
|
||||
return frappe._dict(
|
||||
{
|
||||
"status": attempt_details.percentage if assessment_type == "LMS Quiz" else attempt_details.status,
|
||||
"result": result,
|
||||
"assessment": assessment,
|
||||
"type": assessment_type,
|
||||
"submission": attempt_details.name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def can_access_topic(doctype: str, docname: str) -> bool:
|
||||
@@ -1665,7 +1647,7 @@ def create_discussion_topic(doctype: str, docname: str) -> str:
|
||||
@frappe.whitelist()
|
||||
def get_discussion_replies(topic: str):
|
||||
topic_details = frappe.db.get_value(
|
||||
"Discussion Topic", topic, ["reference_doctype", "reference_docname"], as_dict=1
|
||||
"Discussion Topic", topic, ["reference_doctype", "reference_docname"], as_dict=True
|
||||
)
|
||||
if not can_access_topic(topic_details.reference_doctype, topic_details.reference_docname):
|
||||
frappe.throw(_("You are not authorized to view the discussion replies for this topic."))
|
||||
|
||||
Reference in New Issue
Block a user