Merge pull request #1502 from pateljannat/issues-105

fix: misc fixes
This commit is contained in:
Jannat Patel
2025-05-14 14:10:16 +05:30
committed by GitHub
77 changed files with 1297 additions and 4784 deletions

View File

@@ -105,7 +105,7 @@ jobs:
- name: cypress pre-requisites - name: cypress pre-requisites
run: | run: |
cd ~/frappe-bench/apps/lms cd ~/frappe-bench/apps/lms
yarn add cypress@^10 --no-lockfile yarn add cypress@^10 --no-lockfile -W
- name: UI Tests - name: UI Tests
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless

View File

@@ -1,4 +1,4 @@
module.exports = { export default {
parserPreset: "conventional-changelog-conventionalcommits", parserPreset: "conventional-changelog-conventionalcommits",
rules: { rules: {
"subject-empty": [2, "never"], "subject-empty": [2, "never"],

View File

@@ -1,6 +1,6 @@
const { defineConfig } = require("cypress"); import { defineConfig } from "cypress";
module.exports = defineConfig({ export default defineConfig({
projectId: "vandxn", projectId: "vandxn",
adminPassword: "admin", adminPassword: "admin",
testUser: "frappe@example.com", testUser: "frappe@example.com",

View File

@@ -47,6 +47,7 @@ declare module 'vue' {
Discussions: typeof import('./src/components/Discussions.vue')['default'] Discussions: typeof import('./src/components/Discussions.vue')['default']
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default'] EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default'] EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default'] EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Evaluators.vue')['default'] Evaluators: typeof import('./src/components/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default'] Event: typeof import('./src/components/Modals/Event.vue')['default']

View File

@@ -2,6 +2,7 @@
"name": "frappe-ui-frontend", "name": "frappe-ui-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"serve": "vite preview", "serve": "vite preview",
@@ -26,7 +27,7 @@
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.134", "frappe-ui": "^0.1.143",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",

View File

@@ -1,4 +1,4 @@
module.exports = { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},

View File

@@ -1,12 +1,13 @@
<template> <template>
<Layout> <FrappeUIProvider>
<router-view /> <Layout>
</Layout> <router-view />
<Dialogs /> </Layout>
<Toasts /> <Dialogs />
</FrappeUIProvider>
</template> </template>
<script setup> <script setup>
import { Toasts } from 'frappe-ui' import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'

View File

@@ -125,7 +125,7 @@
@click="redirectToWebsite()" @click="redirectToWebsite()"
/> />
</Tooltip> </Tooltip>
<Tooltip :text="__('Help')"> <Tooltip v-if="showOnboarding" :text="__('Help')">
<CircleHelp <CircleHelp
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer" class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click=" @click="

View File

@@ -191,10 +191,11 @@ import {
FileUploader, FileUploader,
FormControl, FormControl,
TextEditor, TextEditor,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { showToast, getFileSize } from '@/utils' import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const submissionFile = ref(null) const submissionFile = ref(null)
@@ -284,7 +285,7 @@ const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission', doctype: 'LMS Assignment Submission',
name: props.submissionName, name: props.submissionName,
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
auto: false, auto: false,
cache: [user.data?.name, props.assignmentID], cache: [user.data?.name, props.assignmentID],
@@ -338,7 +339,7 @@ const submitAssignment = () => {
}, },
{ {
onSuccess(data) { onSuccess(data) {
showToast(__('Success'), __('Changes saved successfully'), 'check') toast.success(__('Changes saved successfully'))
}, },
} }
) )
@@ -352,7 +353,7 @@ const addNewSubmission = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
showToast('Success', 'Assignment submitted successfully.', 'check') toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') { if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({ router.push({
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
@@ -370,7 +371,7 @@ const addNewSubmission = () => {
submissionResource.reload() submissionResource.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -86,9 +86,9 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils'
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const showCourseModal = ref(false) const showCourseModal = ref(false)
@@ -152,7 +152,7 @@ const removeCourses = (selections, unselectAll) => {
{ {
onSuccess(data) { onSuccess(data) {
courses.reload() courses.reload()
showToast(__('Success'), __('Courses deleted successfully'), 'check') toast.success(__('Courses deleted successfully'))
unselectAll() unselectAll()
}, },
} }

View File

@@ -2,7 +2,12 @@
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72"> <div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div <div
v-if="batch.data.seat_count && seats_left > 0" v-if="batch.data.seat_count && seats_left > 0"
class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md" class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
> >
{{ seats_left }} {{ seats_left }}
<span v-if="seats_left > 1"> <span v-if="seats_left > 1">
@@ -117,9 +122,9 @@
</template> </template>
<script setup> <script setup>
import { inject, computed } from 'vue' import { inject, computed } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui' import { Badge, Button, createResource, toast } from 'frappe-ui'
import { BookOpen, Clock, Globe } from 'lucide-vue-next' import { BookOpen, Clock, Globe } from 'lucide-vue-next'
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils' import { formatNumberIntoCurrency, formatTime } from '@/utils'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -151,11 +156,7 @@ const enrollInBatch = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
showToast( toast.success(__('You have been enrolled in this batch'))
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
router.push({ router.push({
name: 'Batch', name: 'Batch',
params: { params: {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class=""> <div v-if="batch.data" class="">
<div class="w-full flex items-center justify-between pb-4"> <div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7"> <div class="font-medium text-ink-gray-7">
{{ __('Statistics') }} {{ __('Statistics') }}
@@ -46,7 +46,7 @@
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="font-semibold"> <span class="font-semibold">
{{ batch.courses?.length }} {{ batch.data.courses?.length }}
</span> </span>
<span> <span>
{{ __('Courses') }} {{ __('Courses') }}
@@ -201,9 +201,10 @@
</div> </div>
<StudentModal <StudentModal
:batch="props.batch.name" :batch="props.batch.data.name"
v-model="showStudentModal" v-model="showStudentModal"
v-model:reloadStudents="students" v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/> />
<BatchStudentProgress <BatchStudentProgress
:student="selectedStudent" :student="selectedStudent"
@@ -223,6 +224,7 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
BookOpen, BookOpen,
@@ -234,7 +236,6 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue' import StudentModal from '@/components/Modals/StudentModal.vue'
import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue' import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts' import ApexChart from 'vue3-apexcharts'
@@ -258,15 +259,15 @@ const props = defineProps({
const students = createResource({ const students = createResource({
url: 'lms.lms.utils.get_batch_students', url: 'lms.lms.utils.get_batch_students',
cache: ['students', props.batch.name],
params: { params: {
batch: props.batch?.name, batch: props.batch?.data?.name,
}, },
auto: true, auto: true,
onSuccess(data) { onSuccess(data) {
chartData.value = getChartData() chartData.value = getChartData()
showProgressChart.value = showProgressChart.value =
data.length && (props.batch?.courses?.length || assessmentCount.value) data.length &&
(props.batch?.data?.courses?.length || assessmentCount.value)
}, },
}) })
@@ -323,7 +324,8 @@ const removeStudents = (selections, unselectAll) => {
{ {
onSuccess(data) { onSuccess(data) {
students.reload() students.reload()
showToast(__('Success'), __('Students deleted successfully'), 'check') props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll() unselectAll()
}, },
} }
@@ -434,7 +436,7 @@ const certificationCount = createResource({
params: { params: {
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
filters: { filters: {
batch_name: props.batch.name, batch_name: props.batch?.data?.name,
}, },
}, },
auto: true, auto: true,

View File

@@ -5,10 +5,11 @@
{{ label }} {{ label }}
</div> </div>
<Button @click="() => showCategoryForm()"> <Button @click="() => showCategoryForm()">
<template #icon> <template #prefix>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" /> <Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" /> <X v-else class="h-3 w-3 stroke-1.5" />
</template> </template>
{{ showForm ? __('Close') : __('New') }}
</Button> </Button>
</div> </div>
@@ -28,12 +29,11 @@
</div> </div>
<div class="overflow-y-scroll"> <div class="overflow-y-scroll">
<div class="text-base divide-y space-y-2"> <div class="text-base space-y-2">
<FormControl <FormControl
:value="cat.category" :value="cat.category"
type="text" type="text"
v-for="cat in categories.data" v-for="cat in categories.data"
class=""
@change.stop="(e) => update(cat.name, e.target.value)" @change.stop="(e) => update(cat.name, e.target.value)"
/> />
</div> </div>

View File

@@ -92,7 +92,10 @@
{{ option.label }} {{ option.label }}
</div> </div>
<div <div
v-if="option.description" v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7" class="text-xs text-ink-gray-7"
v-html="option.description" v-html="option.description"
></div> ></div>

View File

@@ -34,7 +34,7 @@
<Button <Button
variant="ghost" variant="ghost"
class="w-full !justify-start" class="w-full !justify-start"
label="Create New" :label="__('Create New')"
@click="attrs.onCreate(value, close)" @click="attrs.onCreate(value, close)"
> >
<template #prefix> <template #prefix>

View File

@@ -4,78 +4,91 @@
{{ label }} {{ label }}
<span class="text-ink-red-3" v-if="required">*</span> <span class="text-ink-red-3" v-if="required">*</span>
</label> </label>
<div class="grid grid-cols-3 gap-2"> <div class="w-full">
<Button <Combobox v-model="selectedValue" nullable>
ref="emails" <Popover class="w-full" v-model:show="showOptions">
v-for="value in values" <template #target="{ togglePopover }">
:key="value" <ComboboxInput
:label="value" ref="search"
theme="gray" class="search-input form-input w-full focus-visible:!ring-0"
variant="subtle" type="text"
class="rounded-md word-break-all" :value="query"
@keydown.delete.capture.stop="removeLastValue" @change="
> (e) => {
<template #suffix> query = e.target.value
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" /> showOptions = true
</template> }
</Button> "
<div class=""> autocomplete="off"
<Combobox v-model="selectedValue" nullable> @focus="() => togglePopover()"
<Popover class="w-full" v-model:show="showOptions"> @keydown.delete.capture.stop="removeLastValue"
<template #target="{ togglePopover }"> />
<ComboboxInput </template>
ref="search" <template #body="{ isOpen, close }">
class="search-input form-input w-full focus-visible:!ring-0" <div v-show="isOpen">
type="text" <div
:value="query" class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
@change=" >
(e) => { <ComboboxOptions
query = e.target.value class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
showOptions = true static
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
> >
<ComboboxOptions <ComboboxOption
class="my-1 max-h-[12rem] overflow-y-auto px-1.5" v-for="option in options"
static :key="option.value"
:value="option"
v-slot="{ active }"
> >
<ComboboxOption <li
v-for="option in options" :class="[
:key="option.value" 'flex cursor-pointer items-center rounded px-2 py-1 text-base',
:value="option" { 'bg-surface-gray-2': active },
v-slot="{ active }" ]"
> >
<li <div class="flex flex-col gap-1 p-1">
:class="[ <div class="text-base font-medium text-ink-gray-8">
'flex cursor-pointer items-center rounded px-2 py-1 text-base', {{ option.description }}
{ '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.description }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div> </div>
</li> <div class="text-sm text-ink-gray-5">
</ComboboxOption> {{ option.value }}
</ComboboxOptions> </div>
</div> </div>
</li>
</ComboboxOption>
<div
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[98%] pt-2 bg-white border-t"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
</div> </div>
</template> </div>
</Popover> </template>
</Combobox> </Popover>
</Combobox>
</div>
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
<div
v-for="value in values"
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
>
<span class="break-all">
{{ value }}
</span>
<X
class="size-4 stroke-1.5 cursor-pointer"
@click="removeValue(value)"
/>
</div> </div>
</div> </div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> --> <!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
@@ -90,9 +103,9 @@ import {
ComboboxOption, ComboboxOption,
} from '@headlessui/vue' } from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui' import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue' import { ref, computed, nextTick, useAttrs } from 'vue'
import { watchDebounced } from '@vueuse/core' import { watchDebounced } from '@vueuse/core'
import { X } from 'lucide-vue-next' import { X, Plus } from 'lucide-vue-next'
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -124,7 +137,7 @@ const props = defineProps({
}) })
const values = defineModel() const values = defineModel()
const attrs = useAttrs()
const emails = ref([]) const emails = ref([])
const search = ref(null) const search = ref(null)
const error = ref(null) const error = ref(null)

View File

@@ -146,8 +146,8 @@
<script setup> <script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next' import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Badge, Button, createResource } from 'frappe-ui' import { Badge, Button, createResource, toast } from 'frappe-ui'
import { showToast, formatAmount } from '@/utils/' import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
@@ -172,11 +172,7 @@ const video_link = computed(() => {
function enrollStudent() { function enrollStudent() {
if (!user.data) { if (!user.data) {
showToast( toast.success(__('You need to login first to enroll for this course'))
__('Please Login'),
__('You need to login first to enroll for this course'),
'alert-circle'
)
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 1000) }, 1000)
@@ -192,11 +188,7 @@ function enrollStudent() {
capture('enrolled_in_course', { capture('enrolled_in_course', {
course: props.course.data.name, course: props.course.data.name,
}) })
showToast( toast.success(__('You have been enrolled in this course'))
__('Success'),
__('You have been enrolled in this course'),
'check'
)
setTimeout(() => { setTimeout(() => {
router.push({ router.push({
name: 'Lesson', name: 'Lesson',

View File

@@ -147,7 +147,7 @@
/> />
</template> </template>
<script setup> <script setup>
import { Button, createResource, Tooltip } from 'frappe-ui' import { Button, createResource, Tooltip, toast } from 'frappe-ui'
import { getCurrentInstance, inject, ref } from 'vue' import { getCurrentInstance, inject, ref } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue' import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
@@ -162,7 +162,6 @@ import {
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChapterModal from '@/components/Modals/ChapterModal.vue' import ChapterModal from '@/components/Modals/ChapterModal.vue'
import { showToast } from '@/utils'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -215,7 +214,7 @@ const deleteLesson = createResource({
}, },
onSuccess() { onSuccess() {
outline.reload() outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check') toast.success(__('Lesson deleted successfully'))
}, },
}) })
@@ -230,7 +229,7 @@ const updateLessonIndex = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check') toast.success(__('Lesson moved successfully'))
}, },
}) })
@@ -288,7 +287,7 @@ const deleteChapter = createResource({
}, },
onSuccess() { onSuccess() {
outline.reload() outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check') toast.success(__('Chapter deleted successfully'))
}, },
}) })
@@ -317,11 +316,7 @@ const redirectToChapter = (chapter) => {
event.preventDefault() event.preventDefault()
if (props.allowEdit) return if (props.allowEdit) return
if (!user.data) { if (!user.data) {
showToast( toast.success(__('Please enroll for this course to view this lesson'))
__('You are not enrolled'),
__('Please enroll for this course to view this lesson'),
'alert-circle'
)
return return
} }

View File

@@ -93,12 +93,11 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui' import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue' import { ref, inject, onMounted } from 'vue'
import { createToast } from '../utils'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
@@ -192,14 +191,7 @@ const postReply = () => {
replies.reload() replies.reload()
}, },
onError(err) { onError(err) {
createToast({ toast.error(err.messages?.[0] || err)
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}, },
} }
) )

View File

@@ -0,0 +1,24 @@
<template>
<div class="flex flex-col items-center justify-center mt-60">
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
{{ __('No {0}').format(type?.toLowerCase()) }}
</div>
<div
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
>
{{
__(
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
).format(type?.toLowerCase())
}}
</div>
</div>
</template>
<script setup lang="ts">
import { BookOpen, GraduationCap } from 'lucide-vue-next'
const props = defineProps({
type: String,
})
</script>

View File

@@ -17,10 +17,11 @@
:debounce="300" :debounce="300"
/> />
<Button @click="() => (showForm = !showForm)"> <Button @click="() => (showForm = !showForm)">
<template #icon> <template #prefix>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" /> <Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" /> <X v-else class="size-4 stroke-1.5" />
</template> </template>
{{ showForm ? __('Close') : __('New') }}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -17,10 +17,11 @@
:debounce="300" :debounce="300"
/> />
<Button @click="() => (showForm = !showForm)"> <Button @click="() => (showForm = !showForm)">
<template #icon> <template #prefix>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" /> <Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" /> <X v-else class="size-4 stroke-1.5" />
</template> </template>
{{ showForm ? __('Close') : __('New') }}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -31,6 +31,7 @@
<div class="mb-4"> <div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5"> <div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Announcement') }} {{ __('Announcement') }}
<span class="text-ink-red-3">*</span>
</div> </div>
<TextEditor <TextEditor
:fixedMenu="true" :fixedMenu="true"
@@ -43,9 +44,8 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui' import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import { showToast } from '@/utils/'
const show = defineModel() const show = defineModel()
@@ -87,22 +87,21 @@ const makeAnnouncement = (close) => {
{ {
validate() { validate() {
if (!props.students.length) { if (!props.students.length) {
return 'No students in this batch' return __('No students in this batch')
} }
if (!announcement.subject) { if (!announcement.subject) {
return 'Subject is required' return __('Subject is required')
}
if (!announcement.announcement) {
return __('Announcement is required')
} }
}, },
onSuccess() { onSuccess() {
close() close()
showToast( toast.success(__('Announcement has been sent successfully'))
__('Success'),
__('Announcement has been sent successfully'),
'check'
)
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'alert-circle') toast.error(__(err.messages?.[0] || err))
}, },
} }
) )

View File

@@ -25,21 +25,39 @@
v-model="assessment" v-model="assessment"
:doctype="assessmentType" :doctype="assessmentType"
:label="__('Assessment')" :label="__('Assessment')"
:onCreate="
(value, close) => {
close()
if (assessmentType === 'LMS Quiz') {
router.push({
name: 'QuizForm',
params: {
quizID: 'new',
},
})
} else if (assessmentType === 'LMS Assignment') {
router.push({
name: 'Assignments',
})
}
}
"
/> />
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, createResource } from 'frappe-ui' import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { showToast } from '@/utils' import { useRouter } from 'vue-router'
const show = defineModel() const show = defineModel()
const assessmentType = ref(null) const assessmentType = ref(null)
const assessment = ref(null) const assessment = ref(null)
const assessments = defineModel('assessments') const assessments = defineModel('assessments')
const router = useRouter()
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -70,7 +88,7 @@ const addAssessment = (close) => {
{ {
onSuccess(data) { onSuccess(data) {
assessments.value.reload() assessments.value.reload()
showToast(__('Success'), __('Assessment added successfully'), 'check') toast.success(__('Assessment added successfully'))
close() close()
}, },
} }

View File

@@ -37,7 +37,7 @@
@change="(val) => (assignment.question = val)" @change="(val) => (assignment.question = val)"
:editable="true" :editable="true"
:fixedMenu="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]" 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-[18rem] overflow-y-auto"
/> />
</div> </div>
</div> </div>
@@ -64,9 +64,8 @@
</Dialog> </Dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor } from 'frappe-ui' import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue' import { computed, reactive, watch } from 'vue'
import { showToast } from '@/utils'
const show = defineModel() const show = defineModel()
const assignments = defineModel<Assignments>('assignments') const assignments = defineModel<Assignments>('assignments')
@@ -123,11 +122,7 @@ const saveAssignment = () => {
{ {
onSuccess() { onSuccess() {
show.value = false show.value = false
showToast( toast.success(__('Assignment created successfully'))
__('Success'),
__('Assignment created successfully'),
'check'
)
}, },
} }
) )
@@ -140,11 +135,7 @@ const saveAssignment = () => {
{ {
onSuccess() { onSuccess() {
show.value = false show.value = false
showToast( toast.success(__('Assignment updated successfully'))
__('Success'),
__('Assignment updated successfully'),
'check'
)
}, },
} }
) )

View File

@@ -19,32 +19,43 @@
v-model="course" v-model="course"
:label="__('Course')" :label="__('Course')"
:required="true" :required="true"
:onCreate="
(value, close) => {
close()
router.push({
name: 'CourseForm',
params: {
courseName: 'new',
},
})
}
"
/> />
<Link <Link
doctype="Course Evaluator" doctype="Course Evaluator"
v-model="evaluator" v-model="evaluator"
:label="__('Evaluator')" :label="__('Evaluator')"
:onCreate="(value, close) => openSettings(close)" :onCreate="(value, close) => openSettings('Evaluators', close)"
class="mt-4" class="mt-4"
/> />
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource } from 'frappe-ui' import { Dialog, createResource, toast } from 'frappe-ui'
import { ref, inject } from 'vue' import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { useSettings } from '@/stores/settings' import { openSettings } from '@/utils'
import { useRouter } from 'vue-router'
const show = defineModel() const show = defineModel()
const course = ref(null) const course = ref(null)
const evaluator = ref(null) const evaluator = ref(null)
const user = inject('$user') const user = inject('$user')
const courses = defineModel('courses') const courses = defineModel('courses')
const router = useRouter()
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const settingsStore = useSettings()
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -83,15 +94,9 @@ const addCourse = (close) => {
evaluator.value = null evaluator.value = null
}, },
onError(err) { onError(err) {
showToast('Error', err.message[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
} }
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Evaluators'
settingsStore.isSettingsOpen = true
}
</script> </script>

View File

@@ -14,7 +14,13 @@
<div class="text-xl font-semibold"> <div class="text-xl font-semibold">
{{ student.full_name }} {{ student.full_name }}
</div> </div>
<Badge :theme="student.progress === 100 ? 'green' : 'red'"> <Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }} {{ student.progress }}% {{ __('Complete') }}
</Badge> </Badge>
</div> </div>
@@ -26,7 +32,10 @@
<div class="space-y-8"> <div class="space-y-8">
<!-- Assessments --> <!-- Assessments -->
<div class="space-y-2 text-sm"> <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"> <div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1"> <span class="flex-1">
{{ __('Assessment') }} {{ __('Assessment') }}
@@ -73,7 +82,10 @@
</div> </div>
<!-- Courses --> <!-- Courses -->
<div class="space-y-2 text-sm"> <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"> <div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1"> <span class="flex-1">
{{ __('Courses') }} {{ __('Courses') }}

View File

@@ -62,9 +62,8 @@
</template> </template>
<script setup> <script setup>
import { inject, reactive } from 'vue' import { inject, reactive } from 'vue'
import { createResource, Dialog, FormControl, Switch } from 'frappe-ui' import { createResource, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
const show = defineModel() const show = defineModel()
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -112,13 +111,13 @@ const generateCertificates = (close) => {
}, },
{ {
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
}) })
close() close()
showToast(__('Success'), __('Certificates generated successfully'), 'check') toast.success(__('Certificates generated successfully'))
} }
const getCourses = () => { const getCourses = () => {

View File

@@ -76,9 +76,10 @@ import {
FileUploader, FileUploader,
FormControl, FormControl,
Switch, Switch,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, watch, inject } from 'vue' import { reactive, watch, inject } from 'vue'
import { showToast, getFileSize } from '@/utils/' import { getFileSize } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
@@ -150,21 +151,17 @@ const addChapter = async (close) => {
onSuccess(data) { onSuccess(data) {
cleanChapter() cleanChapter()
outline.value.reload() outline.value.reload()
showToast( toast.success(__('Chapter added successfully'))
__('Success'),
__('Chapter added successfully'),
'check'
)
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
close() close()
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -196,11 +193,11 @@ const editChapter = (close) => {
}, },
onSuccess() { onSuccess() {
outline.value.reload() outline.value.reload()
showToast(__('Success'), __('Chapter updated successfully'), 'check') toast.success(__('Chapter updated successfully'))
close() close()
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -34,9 +34,15 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui' import {
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import { showToast, singularize } from '@/utils' import { singularize } from '@/utils'
const topics = defineModel('reloadTopics') const topics = defineModel('reloadTopics')
@@ -115,7 +121,7 @@ const submitTopic = (close) => {
) )
}, },
onError(err) { onError(err) {
showToast('Error', err.message, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -93,10 +93,11 @@ import {
Button, Button,
createResource, createResource,
TextEditor, TextEditor,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { getFileSize, showToast, escapeHTML } from '@/utils' import { getFileSize, escapeHTML } from '@/utils'
const reloadProfile = defineModel('reloadProfile') const reloadProfile = defineModel('reloadProfile')
@@ -155,7 +156,7 @@ const saveProfile = (close) => {
reloadProfile.value.reload() reloadProfile.value.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -68,7 +68,7 @@
<script setup> <script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui' import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
import { reactive, watch, inject } from 'vue' import { reactive, watch, inject } from 'vue'
import { createToast, formatTime } from '@/utils/' import { formatTime } from '@/utils/'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -148,14 +148,7 @@ function submitEvaluation(close) {
unavailabilityMessage = false unavailabilityMessage = false
} }
createToast({ toast.warn(__('Evaluator is unavailable'))
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
text: message,
icon: unavailabilityMessage ? 'alert-circle' : 'x',
iconClasses: 'bg-yellow-600 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}, },
}) })
} }

View File

@@ -144,6 +144,7 @@ import {
Tabs, Tabs,
Tooltip, Tooltip,
Textarea, Textarea,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
User, User,
@@ -157,7 +158,7 @@ import {
ClipboardList, ClipboardList,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { inject, reactive, watch, ref, computed } from 'vue' import { inject, reactive, watch, ref, computed } from 'vue'
import { formatTime, showToast } from '@/utils' import { formatTime } from '@/utils'
import Rating from '@/components/Controls/Rating.vue' import Rating from '@/components/Controls/Rating.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
@@ -252,7 +253,7 @@ const saveEvaluation = () => {
} else { } else {
show.value = false show.value = false
} }
showToast(__('Success'), __('Evaluation saved successfully'), 'check') toast.success(__('Evaluation saved successfully'))
}, },
} }
) )
@@ -307,7 +308,7 @@ const saveCertificate = () => {
{}, {},
{ {
onSuccess: () => { onSuccess: () => {
showToast(__('Success'), __('Certificate saved successfully'), 'check') toast.success(__('Certificate saved successfully'))
}, },
} }
) )

View File

@@ -64,10 +64,10 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui' import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
import { FileText } from 'lucide-vue-next' import { FileText } from 'lucide-vue-next'
import { ref, inject } from 'vue' import { ref, inject } from 'vue'
import { createToast, getFileSize } from '@/utils/' import { getFileSize } from '@/utils/'
const resume = ref(null) const resume = ref(null)
const show = defineModel() const show = defineModel()
@@ -112,24 +112,12 @@ const submitResume = (close) => {
} }
}, },
onSuccess() { onSuccess() {
createToast({ toast.success('Your application has been submitted successfully')
title: 'Success',
text: 'Your application has been submitted',
icon: 'check',
iconClasses: 'bg-surface-green-3 text-ink-white rounded-md p-px',
})
application.value.reload() application.value.reload()
close() close()
}, },
onError(err) { onError(err) {
createToast({ toast.error(err.messages?.[0] || err)
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}, },
} }
) )

View File

@@ -94,9 +94,10 @@ import {
Tooltip, Tooltip,
FormControl, FormControl,
Autocomplete, Autocomplete,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, inject, onMounted } from 'vue' import { reactive, inject, onMounted } from 'vue'
import { getTimezones, createToast, getUserTimezone } from '@/utils/' import { getTimezones, getUserTimezone } from '@/utils/'
const liveClasses = defineModel('reloadLiveClasses') const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel() const show = defineModel()
@@ -202,14 +203,7 @@ const submitLiveClass = (close) => {
close() close()
}, },
onError(err) { onError(err) {
createToast({ toast.error(err.messages?.[0] || err)
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
}, },
}) })
} }

View File

@@ -30,11 +30,10 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource } from 'frappe-ui' import { Dialog, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import IconPicker from '@/components/Controls/IconPicker.vue' import IconPicker from '@/components/Controls/IconPicker.vue'
import { showToast } from '@/utils'
const sidebar = defineModel('reloadSidebar') const sidebar = defineModel('reloadSidebar')
const show = defineModel() const show = defineModel()
@@ -78,10 +77,10 @@ const addWebPage = (close) => {
onSuccess() { onSuccess() {
sidebar.value.reload() sidebar.value.reload()
close() close()
showToast('Success', 'Web page added to sidebar', 'check') toast.success(__('Web page added to sidebar'))
}, },
onError(err) { onError(err) {
showToast('Error', err.message[0] || err, 'x') toast.error(err.message[0] || err)
close() close()
}, },
} }

View File

@@ -121,10 +121,10 @@ import {
createResource, createResource,
Switch, Switch,
Button, Button,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, watch, reactive, ref, inject } from 'vue' import { computed, watch, reactive, ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
const show = defineModel() const show = defineModel()
@@ -260,7 +260,7 @@ const addQuestion = () => {
}) })
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -278,12 +278,12 @@ const addQuestionRow = (question) => {
updateOnboardingStep('create_first_quiz') updateOnboardingStep('create_first_quiz')
show.value = false show.value = false
showToast(__('Success'), __('Question added successfully'), 'check') toast.success(__('Question added successfully'))
quiz.value.reload() quiz.value.reload()
show.value = false show.value = false
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
show.value = false show.value = false
}, },
} }
@@ -328,18 +328,14 @@ const updateQuestion = () => {
{ {
onSuccess() { onSuccess() {
show.value = false show.value = false
showToast( toast.success(__('Question updated successfully'))
__('Success'),
__('Question updated successfully'),
'check'
)
quiz.value.reload() quiz.value.reload()
}, },
} }
) )
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -32,10 +32,9 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, Textarea, createResource } from 'frappe-ui' import { Dialog, Textarea, createResource, toast } from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from 'vue'
import Rating from '@/components/Controls/Rating.vue' import Rating from '@/components/Controls/Rating.vue'
import { createToast } from '@/utils/'
const show = defineModel() const show = defineModel()
const reviews = defineModel('reloadReviews') const reviews = defineModel('reloadReviews')
@@ -78,11 +77,7 @@ function submitReview(close) {
hasReviewed.value.reload() hasReviewed.value.reload()
}, },
onError(err) { onError(err) {
createToast({ toast.error(err.messages?.[0] || err)
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'text-ink-red-4 bg-surface-red-4',
})
}, },
}) })
close() close()

View File

@@ -1,5 +1,5 @@
<template> <template>
<Dialog v-model="show" :options="{ size: '4xl' }"> <Dialog v-model="show" :options="{ size: '5xl' }">
<template #body> <template #body>
<div class="flex h-[calc(100vh_-_8rem)]"> <div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2"> <div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">

View File

@@ -19,19 +19,25 @@
doctype="User" doctype="User"
v-model="student" v-model="student"
:filters="{ ignore_user_type: 1 }" :filters="{ ignore_user_type: 1 }"
:onCreate="
(value, close) => {
openSettings('Members', close)
}
"
/> />
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource } from 'frappe-ui' import { Dialog, createResource, toast } from 'frappe-ui'
import { ref, inject } from 'vue' import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { showToast } from '@/utils'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils'
const students = defineModel('reloadStudents') const students = defineModel('reloadStudents')
const batchModal = defineModel('batchModal')
const student = ref() const student = ref()
const user = inject('$user') const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
@@ -66,11 +72,12 @@ const addStudent = (close) => {
updateOnboardingStep('add_batch_student') updateOnboardingStep('add_batch_student')
students.value.reload() students.value.reload()
batchModal.value.reload()
student.value = null student.value = null
close() close()
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -291,9 +291,9 @@ import {
ListView, ListView,
TextEditor, TextEditor,
FormControl, FormControl,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue' import { ref, watch, reactive, inject, computed } from 'vue'
import { createToast, showToast } from '@/utils/'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next' import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import { timeAgo } from '@/utils' import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -494,12 +494,7 @@ const getAnswers = () => {
const checkAnswer = () => { const checkAnswer = () => {
let answers = getAnswers() let answers = getAnswers()
if (!answers.length) { if (!answers.length) {
createToast({ toast.warn(__('Please select an option'))
title: 'Please select an option',
icon: 'alert-circle',
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
position: 'top-center',
})
return return
} }
@@ -589,7 +584,7 @@ const createSubmission = () => {
const errorTitle = err?.message || '' const errorTitle = err?.message || ''
if (errorTitle.includes('MaximumAttemptsExceededError')) { if (errorTitle.includes('MaximumAttemptsExceededError')) {
const errorMessage = err.messages?.[0] || err const errorMessage = err.messages?.[0] || err
showToast(__('Error'), __(errorMessage), 'x') toast.error(__(errorMessage))
setTimeout(() => { setTimeout(() => {
window.location.reload() window.location.reload()
}, 3000) }, 3000)

View File

@@ -27,9 +27,8 @@
</template> </template>
<script setup> <script setup>
import { Button, Badge } from 'frappe-ui' import { Button, Badge, toast } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/SettingFields.vue'
import { showToast } from '@/utils'
const props = defineProps({ const props = defineProps({
fields: { fields: {
@@ -61,7 +60,7 @@ const update = () => {
{}, {},
{ {
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -21,14 +21,28 @@
</header> </header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5"> <div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="grid grid-cols-3 gap-5 mb-5"> <div class="flex items-center justify-between mb-5">
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" /> <div
<FormControl v-if="assignmentCount"
v-model="typeFilter" class="text-xl font-semibold text-ink-gray-7 mb-4"
type="select" >
:options="assignmentTypes" {{ __('{0} Assignments').format(assignmentCount) }}
:placeholder="__('Type')" </div>
/> <div
v-if="assignments.data?.length || assigmentCount > 0"
class="grid grid-cols-2 gap-5"
>
<FormControl
v-model="titleFilter"
:placeholder="__('Search by title')"
/>
<FormControl
v-model="typeFilter"
type="select"
:options="assignmentTypes"
:placeholder="__('Type')"
/>
</div>
</div> </div>
<ListView <ListView
v-if="assignments.data?.length" v-if="assignments.data?.length"
@@ -46,22 +60,7 @@
}" }"
> >
</ListView> </ListView>
<div <EmptyState v-else type="Assignments" />
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<Pencil class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No assignments found') }}
</div>
<div class="leading-5">
{{
__(
'You have not created any assignments yet. To create a new assignment, click on the "New" button above.'
)
}}
</div>
</div>
<div <div
v-if="assignments.data && assignments.hasNextPage" v-if="assignments.data && assignments.hasNextPage"
class="flex justify-center my-5" class="flex justify-center my-5"
@@ -81,16 +80,18 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
ListView, ListView,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { Plus, Pencil } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import AssignmentForm from '@/components/Modals/AssignmentForm.vue' import AssignmentForm from '@/components/Modals/AssignmentForm.vue'
import EmptyState from '@/components/EmptyState.vue'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -98,6 +99,7 @@ const titleFilter = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const showAssignmentForm = ref(false) const showAssignmentForm = ref(false)
const assignmentID = ref('new') const assignmentID = ref('new')
const assignmentCount = ref(0)
const { brand } = sessionStore() const { brand } = sessionStore()
const router = useRouter() const router = useRouter()
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
@@ -106,7 +108,7 @@ onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
} }
getAssignmentCount()
titleFilter.value = router.currentRoute.value.query.title titleFilter.value = router.currentRoute.value.query.title
typeFilter.value = router.currentRoute.value.query.type typeFilter.value = router.currentRoute.value.query.type
}) })
@@ -179,6 +181,14 @@ const assignmentColumns = computed(() => {
] ]
}) })
const getAssignmentCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Assignment',
}).then((data) => {
assignmentCount.value = data
})
}
const assignmentTypes = computed(() => { const assignmentTypes = computed(() => {
let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text'] let types = ['', 'Document', 'Image', 'PDF', 'URL', 'Text']
return types.map((type) => { return types.map((type) => {

View File

@@ -67,7 +67,7 @@
<BatchDashboard :batch="batch" :isStudent="isStudent" /> <BatchDashboard :batch="batch" :isStudent="isStudent" />
</div> </div>
<div v-else-if="tab.label == 'Dashboard'"> <div v-else-if="tab.label == 'Dashboard'">
<BatchStudents :batch="batch.data" /> <BatchStudents :batch="batch" />
</div> </div>
<div v-else-if="tab.label == 'Classes'"> <div v-else-if="tab.label == 'Classes'">
<LiveClass :batch="batch.data.name" /> <LiveClass :batch="batch.data.name" />
@@ -357,6 +357,9 @@ watch(tabIndex, () => {
const canMakeAnnouncement = () => { const canMakeAnnouncement = () => {
if (readOnlyMode) return false if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }

View File

@@ -6,67 +6,45 @@
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
</header> </header>
<div class="m-5 pb-10"> <div class="m-5 pb-10">
<div> <div class="flex justify-between w-full">
<div class="text-3xl font-semibold text-ink-gray-9"> <div class="md:w-2/3">
{{ batch.data.title }} <div class="text-3xl font-semibold text-ink-gray-9">
</div> {{ batch.data.title }}
<div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }}
</div>
<div
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center space-x-0 md:space-x-5 lg:w-1/2"
>
<div
v-if="batch.data?.courses?.length"
class="flex items-center text-ink-gray-7"
>
<BookOpen class="h-4 w-4 mr-2 stroke-1.5" />
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
</div> </div>
<span v-if="batch.data?.courses?.length" class="hidden lg:block" <div class="my-3 leading-6 text-ink-gray-7">
>&middot;</span {{ batch.data.description }}
>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
/>
<span class="hidden lg:block" v-if="batch.data.start_date"
>&middot;</span
>
<div class="flex items-center text-ink-gray-7">
<Clock class="h-4 w-4 mr-2 stroke-1.5" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div> </div>
</div> <div class="flex avatar-group overlap">
<div class="flex avatar-group overlap mt-3"> <div
<div class="h-6 mr-1"
class="h-6 mr-1" :class="{
:class="{ 'avatar-group overlap': batch.data.instructors.length > 1,
'avatar-group overlap': batch.data.instructors.length > 1, }"
}" >
> <UserAvatar
<UserAvatar v-for="instructor in batch.data.instructors"
v-for="instructor in batch.data.instructors" :user="instructor"
:user="instructor" />
/> </div>
<CourseInstructors :instructors="batch.data.instructors" />
</div> </div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
</div>
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
<div class="order-2 lg:order-none">
<div <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-6" 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" v-html="batch.data.batch_details"
></div> ></div>
</div>
<div class="hidden md:block">
<BatchOverlay :batch="batch" />
</div>
</div>
<!-- <div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
<div class="order-2 lg:order-none">
</div> </div>
<div class="order-1 lg:order-none"> <div class="order-1 lg:order-none">
<BatchOverlay :batch="batch" /> <BatchOverlay :batch="batch" />
</div> </div>
</div> </div> -->
<div v-if="batch.data.courses.length"> <div v-if="batch.data.courses.length">
<div class="flex items-center mt-10"> <div class="flex items-center mt-10">
<div class="text-2xl font-semibold"> <div class="text-2xl font-semibold">

View File

@@ -8,13 +8,13 @@
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</header> </header>
<div class="w-3/4 mx-auto py-5"> <div class="py-5">
<div class=""> <div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="space-y-10 mb-4"> <div class="grid grid-cols-2 gap-5">
<div class="space-y-4"> <div class="space-y-5">
<FormControl <FormControl
v-model="batch.title" v-model="batch.title"
:label="__('Title')" :label="__('Title')"
@@ -26,31 +26,162 @@
doctype="User" doctype="User"
:label="__('Instructors')" :label="__('Instructors')"
:required="true" :required="true"
:onCreate="(close) => openSettings('Members', close)"
:filters="{ ignore_user_type: 1 }" :filters="{ ignore_user_type: 1 }"
/> />
</div> </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="grid grid-cols-2 gap-10"> <div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="flex flex-col space-y-5"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
<FormControl {{ __('Settings') }}
v-model="batch.published" </div>
type="checkbox" <div class="grid grid-cols-3 gap-5">
:label="__('Published')" <FormControl
/> v-model="batch.published"
<FormControl type="checkbox"
v-model="batch.allow_self_enrollment" :label="__('Published')"
type="checkbox" />
:label="__('Allow self enrollment')" <FormControl
/> v-model="batch.allow_self_enrollment"
<FormControl type="checkbox"
v-model="batch.certification" :label="__('Allow self enrollment')"
type="checkbox" />
:label="__('Certification')" <FormControl
/> v-model="batch.certification"
</div> type="checkbox"
:label="__('Certification')"
/>
</div>
</div>
<div class="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-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('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-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 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-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-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"
/>
</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">
<div> <div>
<div class="text-xs text-ink-gray-5 mb-2"> <div class="text-xs text-ink-gray-5">
{{ __('Meta Image') }} {{ __('Meta Image') }}
</div> </div>
<FileUploader <FileUploader
@@ -70,11 +201,9 @@
<Button @click="openFileSelector"> <Button @click="openFileSelector">
{{ __('Upload') }} {{ __('Upload') }}
</Button> </Button>
<div class="mt-2 text-ink-gray-5 text-sm"> <div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{ {{
__( __('Appears when the batch URL is shared on socials')
'Appears when the batch URL is shared on any online platform'
)
}} }}
</div> </div>
</div> </div>
@@ -106,119 +235,16 @@
</div> </div>
</div> </div>
<div class="my-10"> <div class="px-20 pb-5 space-y-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Date and Time') }} {{ __('Pricing') }}
</div>
<div class="grid grid-cols-3 gap-10">
<div>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
class="mb-4"
:required="true"
/>
</div>
<div>
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
class="mb-4"
:required="true"
/>
</div>
<div>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div>
</div>
</div>
<div class="mb-10">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-10">
<div>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<FormControl
v-model="batch.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
<div>
<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"
/>
</div>
<div>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
/>
</div>
</div>
</div>
<div class="">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Payment') }}
</div> </div>
<FormControl <FormControl
v-model="batch.paid_batch" v-model="batch.paid_batch"
type="checkbox" type="checkbox"
:label="__('Paid Batch')" :label="__('Paid Batch')"
/> />
<div class="grid grid-cols-3 gap-10 mt-4"> <div v-if="batch.paid_batch" class="grid grid-cols-3 gap-5">
<FormControl <FormControl
v-model="batch.amount" v-model="batch.amount"
:label="__('Amount')" :label="__('Amount')"
@@ -232,33 +258,6 @@
/> />
</div> </div>
</div> </div>
<div class="my-10">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Description') }}
</div>
<FormControl
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
class="my-4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
<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 bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] mb-4"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -279,15 +278,16 @@ import {
TextEditor, TextEditor,
createResource, createResource,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
import { Image } from 'lucide-vue-next' import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { openSettings } from '@/utils'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
@@ -459,7 +459,7 @@ const createNewBatch = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Message', err.messages?.[0] || err, 'alert-circle') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -478,7 +478,7 @@ const editBatchDetails = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Message', err.messages?.[0] || err, 'alert-circle') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -20,12 +20,14 @@
</header> </header>
<div class="p-5 pb-10"> <div class="p-5 pb-10">
<div <div
v-if="batchCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('All Batches') }} {{ __('All Batches') }}
</div> </div>
<div <div
v-if="batches.data?.length || batchCount"
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4" class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
> >
<TabButtons <TabButtons
@@ -70,22 +72,8 @@
<BatchCard :batch="batch" /> <BatchCard :batch="batch" />
</router-link> </router-link>
</div> </div>
<div <EmptyState v-else-if="!batches.list.loading" type="Batches" />
v-else-if="!batches.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No batches found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no batches matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<div <div
v-if="!batches.list.loading && batches.hasNextPage" v-if="!batches.list.loading && batches.hasNextPage"
class="flex justify-center mt-5" class="flex justify-center mt-5"
@@ -100,6 +88,7 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
Select, Select,
@@ -107,9 +96,10 @@ import {
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import BatchCard from '@/components/BatchCard.vue' import BatchCard from '@/components/BatchCard.vue'
import EmptyState from '@/components/EmptyState.vue'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -125,10 +115,12 @@ const is_student = computed(() => user.data?.is_student)
const currentTab = ref(is_student.value ? 'All' : 'Upcoming') const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
const orderBy = ref('start_date') const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const batchCount = ref(0)
onMounted(() => { onMounted(() => {
setFiltersFromQuery() setFiltersFromQuery()
updateBatches() updateBatches()
getBatchCount()
categories.value = [ categories.value = [
{ {
label: '', label: '',
@@ -306,6 +298,14 @@ const canCreateBatch = () => {
return false return false
} }
const getBatchCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Batch',
}).then((data) => {
batchCount.value = data
})
}
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ {
label: __('Batches'), label: __('Batches'),

View File

@@ -156,9 +156,9 @@ import {
FormControl, FormControl,
Breadcrumbs, Breadcrumbs,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, inject, onMounted, computed } from 'vue' import { reactive, inject, onMounted, computed } from 'vue'
import { showToast } from '@/utils/'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue' import NotPermitted from '@/components/NotPermitted.vue'
@@ -259,7 +259,7 @@ const generatePaymentLink = () => {
window.location.href = data window.location.href = data
}, },
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -333,14 +333,7 @@ const validateAddress = () => {
} }
const showError = (err) => { const showError = (err) => {
createToast({ toast.error(err.messages?.[0] || err)
title: 'Error',
text: err.messages?.[0] || err,
icon: 'x',
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: 'top-center',
timeout: 10,
})
} }
const changeCurrency = (country) => { const changeCurrency = (country) => {

View File

@@ -3,7 +3,7 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link :to="{ name: 'Batches' }"> <router-link :to="{ name: 'Batches', query: { certification: true } }">
<Button> <Button>
<template #prefix> <template #prefix>
<GraduationCap class="h-4 w-4 stroke-1.5" /> <GraduationCap class="h-4 w-4 stroke-1.5" />
@@ -101,22 +101,7 @@
</Button> </Button>
</div> </div>
</div> </div>
<div <EmptyState v-else type="Certified Members" />
v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No certified members') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'No certified members found. Please check again later or get certified yourself.'
)
}}
</div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
@@ -130,8 +115,9 @@ import {
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, GraduationCap } from 'lucide-vue-next' import { GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue'
const currentCategory = ref('') const currentCategory = ref('')
const filters = ref({}) const filters = ref({})

View File

@@ -19,62 +19,112 @@
</Button> </Button>
</div> </div>
</header> </header>
<div class="mt-5 mb-10"> <div class="mt-5 mb-5">
<div class="container mb-5"> <div class="px-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<FormControl <div class="grid grid-cols-2 gap-5">
v-model="course.title" <FormControl
:label="__('Title')" v-model="course.title"
class="mb-4" :label="__('Title')"
:required="true" :required="true"
/> />
<FormControl <Link
v-model="course.short_introduction" doctype="LMS Category"
:label="__('Short Introduction')" v-model="course.category"
:placeholder=" :label="__('Category')"
__( :onCreate="(value, close) => openSettings('Categories', close)"
'A one line introduction to the course that appears on the course card'
)
"
class="mb-4"
:required="true"
/>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course Description') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val) => (course.description = 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]"
/> />
</div> </div>
<div class="mb-4"> <div class="grid grid-cols-2 gap-5">
<div class="text-xs text-ink-gray-5 mb-2"> <MultiSelect
{{ __('Course Image') }} v-model="instructors"
<span class="text-ink-red-3">*</span> doctype="User"
:label="__('Instructors')"
:filters="{ ignore_user_type: 1 }"
:onCreate="(close) => openSettings('Members', close)"
:required="true"
/>
<div>
<div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Tags') }}
</div>
<div class="flex items-center">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
class="w-full"
@keyup.enter="updateTags()"
id="tags"
/>
</div>
</div> </div>
<FileUploader </div>
v-if="!course.course_image" <div class="grid grid-cols-2 gap-5">
:fileTypes="['image/*']" <FormControl
:validateFile="validateFile" v-model="course.short_introduction"
@success="(file) => saveImage(file)" type="textarea"
> :rows="4"
<template :label="__('Short Introduction')"
v-slot="{ file, progress, uploading, openFileSelector }" :placeholder="
__(
'A one line introduction to the course that appears on the course card'
)
"
:required="true"
/>
<div class="mb-4">
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Course Image') }}
<span class="text-ink-red-3">*</span>
</div>
<FileUploader
v-if="!course.course_image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
> >
<div class="flex items-center"> <template
<div class="border rounded-md w-fit py-5 px-20"> v-slot="{ file, progress, uploading, openFileSelector }"
<Image class="size-5 stroke-1 text-ink-gray-7" /> >
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{
__('Appears on the course card in the course list')
}}
</div>
</div>
</div> </div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="course.course_image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4"> <div class="ml-4">
<Button @click="openFileSelector"> <Button @click="removeImage()">
{{ __('Upload') }} {{ __('Remove') }}
</Button> </Button>
<div class="mt-2 text-ink-gray-5 text-sm"> <div class="mt-2 text-ink-gray-5 text-sm">
{{ {{
@@ -83,85 +133,17 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="course.course_image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{ __('Appears on the course card in the course list') }}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<FormControl
v-model="course.video_link"
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
class="mb-4"
/>
<div class="mb-4">
<div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Tags') }}
</div>
<div class="flex items-center">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
>
{{ tag }}
<X
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
@click="removeTag(tag)"
/>
</div>
<FormControl
v-model="newTag"
:placeholder="__('Add a keyword and then press enter')"
class="w-72"
@keyup.enter="updateTags()"
id="tags"
/>
</div>
</div>
<div class="w-1/2 mb-4">
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings(close)"
/>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:filters="{ ignore_user_type: 1 }"
:required="true"
/>
</div> </div>
<div class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4"> <div class="px-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
<div class="grid grid-cols-2 gap-10 mb-4"> <div class="grid grid-cols-2 gap-5">
<div <div class="flex flex-col space-y-5">
v-if="user.data?.is_moderator"
class="flex flex-col space-y-4"
>
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.published" v-model="course.published"
@@ -171,10 +153,9 @@
v-model="course.published_on" v-model="course.published_on"
:label="__('Published On')" :label="__('Published On')"
type="date" type="date"
class="mb-5"
/> />
</div> </div>
<div class="flex flex-col space-y-3"> <div class="flex flex-col space-y-5">
<FormControl <FormControl
type="checkbox" type="checkbox"
v-model="course.upcoming" v-model="course.upcoming"
@@ -193,7 +174,34 @@
</div> </div>
</div> </div>
</div> </div>
<div class="container border-t space-y-4">
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course Description') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val) => (course.description = 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]"
/>
</div>
<FormControl
v-model="course.video_link"
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
/>
</div>
<div class="px-10 pb-5 space-y-5">
<div class="text-lg font-semibold mt-5"> <div class="text-lg font-semibold mt-5">
{{ __('Pricing and Certification') }} {{ __('Pricing and Certification') }}
</div> </div>
@@ -214,19 +222,31 @@
:label="__('Paid Certificate')" :label="__('Paid Certificate')"
/> />
</div> </div>
<FormControl v-model="course.course_price" :label="__('Amount')" /> <div class="grid grid-cols-2 gap-5">
<Link <div class="space-y-5">
doctype="Currency" <FormControl
v-model="course.currency" v-if="course.paid_course || course.paid_certificate"
:filters="{ enabled: 1 }" v-model="course.course_price"
:label="__('Currency')" :label="__('Amount')"
/> />
<Link <Link
v-if="course.paid_certificate" v-if="course.paid_certificate"
doctype="Course Evaluator" doctype="Course Evaluator"
v-model="course.evaluator" v-model="course.evaluator"
:label="__('Evaluator')" :label="__('Evaluator')"
/> :onCreate="
(value, close) => openSettings('Evaluators', close)
"
/>
</div>
<Link
v-if="course.paid_course || course.paid_certificate"
doctype="Currency"
v-model="course.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -250,6 +270,7 @@ import {
FormControl, FormControl,
FileUploader, FileUploader,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
inject, inject,
@@ -261,13 +282,12 @@ import {
watch, watch,
getCurrentInstance, getCurrentInstance,
} from 'vue' } from 'vue'
import { showToast } from '@/utils'
import { Image, Trash2, X } from 'lucide-vue-next' import { Image, Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { useSettings } from '@/stores/settings' import { openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -277,7 +297,6 @@ const newTag = ref('')
const { brand } = sessionStore() const { brand } = sessionStore()
const router = useRouter() const router = useRouter()
const instructors = ref([]) const instructors = ref([])
const settingsStore = useSettings()
const app = getCurrentInstance() const app = getCurrentInstance()
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties const { $dialog } = app.appContext.config.globalProperties
@@ -429,10 +448,10 @@ const submitCourse = () => {
}, },
{ {
onSuccess() { onSuccess() {
showToast('Success', 'Course updated successfully', 'check') toast.success(__('Course updated successfully'))
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -446,14 +465,14 @@ const submitCourse = () => {
} }
capture('course_created') capture('course_created')
showToast('Success', 'Course created successfully', 'check') toast.success(__('Course created successfully'))
router.push({ router.push({
name: 'CourseForm', name: 'CourseForm',
params: { courseName: data.name }, params: { courseName: data.name },
}) })
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
}) })
} }
@@ -467,7 +486,7 @@ const deleteCourse = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast(__('Success'), __('Course deleted successfully'), 'check') toast.success(__('Course deleted successfully'))
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
}, },
}) })
@@ -531,12 +550,6 @@ const removeImage = () => {
course.course_image = null course.course_image = null
} }
const openSettings = (close) => {
close()
settingsStore.activeTab = 'Categories'
settingsStore.isSettingsOpen = true
}
const check_permission = () => { const check_permission = () => {
let user_is_instructor = false let user_is_instructor = false
if (user.data?.is_moderator) return if (user.data?.is_moderator) return

View File

@@ -20,12 +20,14 @@
</header> </header>
<div class="p-5 pb-10"> <div class="p-5 pb-10">
<div <div
v-if="courseCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('All Courses') }} {{ __('All Courses') }}
</div> </div>
<div <div
v-if="courses.data?.length || courseCount"
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4" class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
> >
<TabButtons :buttons="courseTabs" v-model="currentTab" /> <TabButtons :buttons="courseTabs" v-model="currentTab" />
@@ -66,22 +68,7 @@
<CourseCard :course="course" /> <CourseCard :course="course" />
</router-link> </router-link>
</div> </div>
<div <EmptyState v-else-if="!courses.list.loading" type="Courses" />
v-else-if="!courses.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No courses found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no courses matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<div <div
v-if="!courses.list.loading && courses.hasNextPage" v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5" class="flex justify-center mt-5"
@@ -104,10 +91,11 @@ import {
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref, watch } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils' import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import router from '../router' import router from '../router'
const user = inject('$user') const user = inject('$user')
@@ -121,12 +109,13 @@ const certification = ref(false)
const filters = ref({}) const filters = ref({})
const currentTab = ref('Live') const currentTab = ref('Live')
const { brand } = sessionStore() const { brand } = sessionStore()
const readOnlyMode = window.read_only_mode const courseCount = ref(0)
onMounted(() => { onMounted(() => {
identifyUserPersona() identifyUserPersona()
setFiltersFromQuery() setFiltersFromQuery()
updateCourses() updateCourses()
getCourseCount()
categories.value = [ categories.value = [
{ {
label: '', label: '',
@@ -175,19 +164,22 @@ const identifyUserPersona = async () => {
if (user.data?.is_system_manager && !user.data?.developer_mode) { if (user.data?.is_system_manager && !user.data?.developer_mode) {
let personaCaptured = await isPersonaCaptured() let personaCaptured = await isPersonaCaptured()
if (personaCaptured) return if (personaCaptured) return
if (!courseCount.value) {
call('frappe.client.get_count', { router.push({
doctype: 'LMS Course', name: 'PersonaForm',
}).then((data) => { })
if (!data) { }
router.push({
name: 'PersonaForm',
})
}
})
} }
} }
const getCourseCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Course',
}).then((data) => {
courseCount.value = data
})
}
const updateCourses = () => { const updateCourses = () => {
updateFilters() updateFilters()
courses.update({ courses.update({

View File

@@ -94,6 +94,12 @@
{{ dayjs(job.data.creation).fromNow() }} {{ dayjs(job.data.creation).fromNow() }}
</Badge> </Badge>
<Badge size="lg"> <Badge size="lg">
<template #prefix>
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ job.data.type }}
</Badge>
<Badge v-if="applicationCount.data" size="lg">
<template #prefix> <template #prefix>
<SquareUserRound class="size-3 stroke-2 text-ink-gray-7" /> <SquareUserRound class="size-3 stroke-2 text-ink-gray-7" />
</template> </template>
@@ -102,12 +108,6 @@
applicationCount.data == 1 ? __('applicant') : __('applicants') applicationCount.data == 1 ? __('applicant') : __('applicants')
}} }}
</Badge> </Badge>
<Badge size="lg">
<template #prefix>
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ job.data.type }}
</Badge>
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@
</Button> </Button>
</header> </header>
<div class="py-5"> <div class="py-5">
<div class="container border-b mb-4 pb-4"> <div class="container border-b mb-4 pb-5">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Job Details') }} {{ __('Job Details') }}
</div> </div>
@@ -20,6 +20,15 @@
:label="__('Title')" :label="__('Title')"
:required="true" :required="true"
/> />
<FormControl
v-model="job.type"
:label="__('Type')"
type="select"
:options="jobTypes"
:required="true"
/>
</div>
<div class="space-y-4">
<FormControl <FormControl
v-model="job.location" v-model="job.location"
:label="__('City')" :label="__('City')"
@@ -31,17 +40,8 @@
:label="__('Country')" :label="__('Country')"
:required="true" :required="true"
/> />
</div>
<div>
<FormControl
v-model="job.type"
:label="__('Type')"
type="select"
:options="jobTypes"
class="mb-4"
:required="true"
/>
<FormControl <FormControl
v-if="jobName != 'new'"
v-model="job.status" v-model="job.status"
:label="__('Status')" :label="__('Status')"
type="select" type="select"
@@ -51,7 +51,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="container border-b mb-4 pb-4"> <div class="container border-b mb-4 pb-5">
<div class="text-lg font-semibold mb-4"> <div class="text-lg font-semibold mb-4">
{{ __('Company Details') }} {{ __('Company Details') }}
</div> </div>
@@ -145,12 +145,13 @@ import {
TextEditor, TextEditor,
FileUploader, FileUploader,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, onMounted, reactive, inject } from 'vue' import { computed, onMounted, reactive, inject } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getFileSize, showToast } from '../utils' import { getFileSize } from '@/utils'
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
@@ -259,7 +260,7 @@ const createNewJob = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -278,7 +279,7 @@ const editJobDetails = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -26,15 +26,17 @@
</header> </header>
<div> <div>
<div <div
v-if="jobCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
> >
<div <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
v-if="jobCount"
class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"
>
{{ __('{0} Open Jobs').format(jobCount) }} {{ __('{0} Open Jobs').format(jobCount) }}
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div
v-if="jobs.data?.length || jobCount > 0"
class="grid grid-cols-1 md:grid-cols-3 gap-2"
>
<FormControl <FormControl
type="text" type="text"
:placeholder="__('Search')" :placeholder="__('Search')"
@@ -79,21 +81,7 @@
</router-link> </router-link>
</div> </div>
</div> </div>
<div <EmptyState v-else type="Job Openings" />
v-else
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 mt-56"
>
<Laptop class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No jobs found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{ __('There are no jobs available at the moment.') }}
</div>
<div class="leading-5 w-1/5 text-center">
{{ __('Post a new job or check again later.') }}
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -106,11 +94,12 @@ import {
FormControl, FormControl,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { Laptop, Plus, Search } from 'lucide-vue-next' import { Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { inject, computed, ref, onMounted, watch } from 'vue' import { inject, computed, ref, onMounted, watch } from 'vue'
import JobCard from '@/components/JobCard.vue' import JobCard from '@/components/JobCard.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import EmptyState from '@/components/EmptyState.vue'
const user = inject('$user') const user = inject('$user')
const jobType = ref(null) const jobType = ref(null)

View File

@@ -84,6 +84,7 @@ import {
createResource, createResource,
FormControl, FormControl,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
computed, computed,
@@ -97,7 +98,7 @@ import { sessionStore } from '../stores/session'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue' import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { createToast, getEditorTools, enablePlyr } from '@/utils' import { getEditorTools, enablePlyr } from '@/utils'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
@@ -410,14 +411,14 @@ const createNewLesson = () => {
updateOnboardingStep('create_first_lesson') updateOnboardingStep('create_first_lesson')
capture('lesson_created') capture('lesson_created')
showToast('Success', 'Lesson created successfully', 'check') toast.success(__('Lesson created successfully'))
lessonDetails.reload() lessonDetails.reload()
}, },
} }
) )
}, },
onError(err) { onError(err) {
showToast('Error', err.message, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -434,11 +435,11 @@ const editCurrentLesson = () => {
}, },
onSuccess() { onSuccess() {
showSuccessMessage showSuccessMessage
? showToast('Success', 'Lesson updated successfully', 'check') ? toast.success(__('Lesson updated successfully'))
: '' : ''
}, },
onError(err) { onError(err) {
showToast('Error', err.message, 'x') toast.error(err.message)
}, },
} }
) )
@@ -453,20 +454,6 @@ const validateLesson = () => {
} }
} }
const showToast = (title, text, icon) => {
createToast({
title: title,
text: text,
icon: icon,
iconClasses:
icon == 'check'
? 'bg-surface-green-3 text-ink-white rounded-md p-px'
: 'bg-surface-red-5 text-ink-white rounded-md p-px',
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: icon == 'check' ? 5 : 10,
})
}
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
let crumbs = [ let crumbs = [
{ {

View File

@@ -141,9 +141,9 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, FormControl, Button, Badge } from 'frappe-ui' import { createResource, FormControl, Button, Badge, toast } from 'frappe-ui'
import { computed, reactive, ref, onMounted, inject } from 'vue' import { computed, reactive, ref, onMounted, inject } from 'vue'
import { showToast, convertToTitleCase } from '@/utils' import { convertToTitleCase } from '@/utils'
import { Plus, X, Check, CircleAlert } from 'lucide-vue-next' import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
@@ -198,7 +198,7 @@ const createSlot = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast('Success', 'Slot added successfully', 'check') toast.success(__('Slot added successfully'))
evaluator.reload() evaluator.reload()
showSlotsTemplate.value = 0 showSlotsTemplate.value = 0
newSlot.day = '' newSlot.day = ''
@@ -206,7 +206,7 @@ const createSlot = createResource({
newSlot.end_time = '' newSlot.end_time = ''
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
}) })
@@ -221,10 +221,10 @@ const updateSlot = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast('Success', 'Availability updated successfully', 'check') toast.success(__('Availability updated successfully'))
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
}) })
@@ -237,11 +237,11 @@ const deleteSlot = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast('Success', 'Slot deleted successfully', 'check') toast.success(__('Slot deleted successfully'))
evaluator.reload() evaluator.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
}) })
@@ -256,10 +256,10 @@ const updateUnavailability = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast('Success', 'Unavailability updated successfully', 'check') toast.success(__('Unavailability updated successfully'))
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
}) })

View File

@@ -44,9 +44,9 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { FormControl, createResource } from 'frappe-ui' import { FormControl, createResource, toast } from 'frappe-ui'
import { ref } from 'vue' import { ref } from 'vue'
import { showToast, convertToTitleCase } from '@/utils' import { convertToTitleCase } from '@/utils'
import { CircleAlert } from 'lucide-vue-next' import { CircleAlert } from 'lucide-vue-next'
const moderator = ref(false) const moderator = ref(false)
@@ -102,7 +102,7 @@ const changeRole = (role) => {
}, },
{ {
onSuccess(data) { onSuccess(data) {
showToast('Success', 'Role updated successfully', 'check') toast.success(__('Role updated successfully'))
}, },
} }
) )

View File

@@ -168,6 +168,7 @@
ignore_user_type: 1, ignore_user_type: 1,
}" }"
:label="__('Program Member')" :label="__('Program Member')"
:onCreate="(value, close) => openSettings('Members', close)"
/> />
</template> </template>
</Dialog> </Dialog>
@@ -187,12 +188,13 @@ import {
ListHeaderItem, ListHeaderItem,
ListSelectBanner, ListSelectBanner,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast } from '@/utils/'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session' import { sessionStore } from '@/stores/session'
import { openSettings } from '@/utils'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
@@ -229,11 +231,11 @@ const addProgramCourse = () => {
onSuccess(data) { onSuccess(data) {
showDialog.value = false showDialog.value = false
course.value = null course.value = null
showToast(__('Success'), __('Course added to program'), 'check') toast.success(__('Course added to program'))
program.reload() program.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -251,11 +253,11 @@ const addProgramMember = () => {
onSuccess(data) { onSuccess(data) {
showDialog.value = false showDialog.value = false
member.value = null member.value = null
showToast(__('Success'), __('Member added to program'), 'check') toast.success(__('Member added to program'))
program.reload() program.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -272,11 +274,11 @@ const remove = (selections, unselectAll, doctype) => {
{ {
onSuccess(data) { onSuccess(data) {
unselectAll() unselectAll()
showToast(__('Success'), __('Items removed successfully'), 'check') toast.success(__('Items removed successfully'))
program.reload() program.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -298,11 +300,11 @@ const updateOrder = (e) => {
}, },
{ {
onSuccess(data) { onSuccess(data) {
showToast(__('Success'), __('Course moved successfully'), 'check') toast.success(__('Course moved successfully'))
program.reload() program.reload()
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -82,22 +82,7 @@
</div> </div>
</div> </div>
</div> </div>
<div <EmptyState v-else type="Programs" />
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No programs found') }}
</div>
<div class="leading-5">
{{
__(
'There are no programs available at the moment. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<Dialog <Dialog
v-model="showDialog" v-model="showDialog"
@@ -127,13 +112,14 @@ import {
Dialog, Dialog,
FormControl, FormControl,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next' import { Edit, Plus, LockKeyhole } from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { showToast } from '@/utils'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
const { brand } = sessionStore() const { brand } = sessionStore()
@@ -198,7 +184,7 @@ const enrollMember = (program, course) => {
} }
}) })
.catch((err) => { .catch((err) => {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}) })
} }

View File

@@ -198,6 +198,7 @@ import {
ListSelectBanner, ListSelectBanner,
Button, Button,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
computed, computed,
@@ -210,7 +211,7 @@ import {
} from 'vue' } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { Plus, Trash2 } from 'lucide-vue-next' import { Plus, Trash2 } from 'lucide-vue-next'
import { showToast, updateDocumentTitle } from '@/utils' import { updateDocumentTitle } from '@/utils'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Question from '@/components/Modals/Question.vue' import Question from '@/components/Modals/Question.vue'
@@ -340,14 +341,14 @@ const createQuiz = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
showToast(__('Success'), __('Quiz created successfully'), 'check') toast.success(__('Quiz created successfully'))
router.push({ router.push({
name: 'QuizForm', name: 'QuizForm',
params: { quizID: data.name }, params: { quizID: data.name },
}) })
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -359,10 +360,10 @@ const updateQuiz = () => {
{ {
onSuccess(data) { onSuccess(data) {
quiz.total_marks = data.total_marks quiz.total_marks = data.total_marks
showToast(__('Success'), __('Quiz updated successfully'), 'check') toast.success(__('Quiz updated successfully'))
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -428,7 +429,7 @@ const deleteQuestions = (selections, unselectAll) => {
}, },
{ {
onSuccess() { onSuccess() {
showToast(__('Success'), __('Questions deleted successfully'), 'check') toast.success(__('Questions deleted successfully'))
quizDetails.reload() quizDetails.reload()
unselectAll() unselectAll()
}, },

View File

@@ -80,10 +80,10 @@ import {
Button, Button,
Badge, Badge,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, onBeforeUnmount, onMounted, inject } from 'vue' import { computed, onBeforeUnmount, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
const { brand } = sessionStore() const { brand } = sessionStore()
@@ -147,7 +147,7 @@ const saveSubmission = () => {
{}, {},
{ {
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -40,18 +40,7 @@
</Button> </Button>
</div> </div>
</div> </div>
<div <EmptyState v-else />
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No submissions') }}
</div>
<div class="leading-5">
{{ __('No quiz submissions found. Please check again later.') }}
</div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
@@ -65,10 +54,10 @@ import {
ListHeaderItem, ListHeaderItem,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { BookOpen } from 'lucide-vue-next'
import { computed, onMounted, inject } from 'vue' import { computed, onMounted, inject } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import EmptyState from '@/components/EmptyState.vue'
const { brand } = sessionStore() const { brand } = sessionStore()
const router = useRouter() const router = useRouter()

View File

@@ -21,6 +21,9 @@
</router-link> </router-link>
</header> </header>
<div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5"> <div v-if="quizzes.data?.length" class="md:w-3/4 md:mx-auto py-5 mx-5">
<div v-if="quizCount" class="text-xl font-semibold text-ink-gray-7 mb-4">
{{ __('{0} Quizzes').format(quizCount) }}
</div>
<ListView <ListView
:columns="quizColumns" :columns="quizColumns"
:rows="quizzes.data" :rows="quizzes.data"
@@ -53,27 +56,13 @@
</Button> </Button>
</div> </div>
</div> </div>
<div <EmptyState v-else type="Quizzes" />
v-else
class="text-center p-5 text-ink-gray-5 mt-52 w-3/4 md:w-1/2 mx-auto space-y-2"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-xl font-medium">
{{ __('No quizzes found') }}
</div>
<div class="leading-5">
{{
__(
'You have not created any quizzes yet. To create a new quiz, click on the "New Quiz" button above.'
)
}}
</div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
ListView, ListView,
ListRows, ListRows,
@@ -83,19 +72,22 @@ import {
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { computed, inject, onMounted } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, Plus } from 'lucide-vue-next' import { Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import EmptyState from '@/components/EmptyState.vue'
const { brand } = sessionStore() const { brand } = sessionStore()
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
const quizCount = ref(0)
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
} }
getQuizCount()
}) })
const quizFilter = computed(() => { const quizFilter = computed(() => {
@@ -114,6 +106,14 @@ const quizzes = createListResource({
orderBy: 'modified desc', orderBy: 'modified desc',
}) })
const getQuizCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Quiz',
}).then((data) => {
quizCount.value = data
})
}
const quizColumns = computed(() => { const quizColumns = computed(() => {
return [ return [
{ {

View File

@@ -1,9 +1,10 @@
import { toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz' import { Quiz } from '@/utils/quiz'
import { Assignment } from '@/utils/assignment' import { Assignment } from '@/utils/assignment'
import { Upload } from '@/utils/upload' import { Upload } from '@/utils/upload'
import { Markdown } from '@/utils/markdownParser' import { Markdown } from '@/utils/markdownParser'
import { useSettings } from '@/stores/settings'
import { usersStore } from '@/stores/user'
import Header from '@editorjs/header' import Header from '@editorjs/header'
import Paragraph from '@editorjs/paragraph' import Paragraph from '@editorjs/paragraph'
import { CodeBox } from '@/utils/code' import { CodeBox } from '@/utils/code'
@@ -14,19 +15,11 @@ import dayjs from '@/utils/dayjs'
import Embed from '@editorjs/embed' import Embed from '@editorjs/embed'
import SimpleImage from '@editorjs/simple-image' import SimpleImage from '@editorjs/simple-image'
import Table from '@editorjs/table' import Table from '@editorjs/table'
import { usersStore } from '../stores/user'
import Plyr from 'plyr' import Plyr from 'plyr'
import 'plyr/dist/plyr.css' import 'plyr/dist/plyr.css'
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
export function createToast(options) {
toast({
position: 'bottom-right',
...options,
})
}
export function timeAgo(date) { export function timeAgo(date) {
return useTimeAgo(date).value return useTimeAgo(date).value
} }
@@ -97,26 +90,6 @@ export function getFileSize(file_size) {
return value return value
} }
export function showToast(title, text, icon, iconClasses = null) {
if (!iconClasses) {
if (icon == 'check') {
iconClasses = 'bg-surface-green-3 text-ink-white rounded-md p-px'
} else if (icon == 'alert-circle') {
iconClasses = 'bg-yellow-600 text-ink-white rounded-md p-px'
} else {
iconClasses = 'bg-surface-red-5 text-ink-white rounded-md p-px'
}
}
createToast({
title: title,
text: htmlToText(text),
icon: icon,
iconClasses: iconClasses,
position: icon == 'check' ? 'bottom-right' : 'top-center',
timeout: icon != 'check' ? 10 : 5,
})
}
export function getImgDimensions(imgSrc) { export function getImgDimensions(imgSrc) {
return new Promise((resolve) => { return new Promise((resolve) => {
let img = new Image() let img = new Image()
@@ -579,3 +552,10 @@ export const enablePlyr = () => {
}) })
}, 500) }, 500)
} }
export const openSettings = (category, close) => {
const settingsStore = useSettings()
close()
settingsStore.activeTab = category
settingsStore.isSettingsOpen = true
}

View File

@@ -1,5 +1,7 @@
module.exports = { import frappeUIPreset from 'frappe-ui/src/tailwind/preset'
presets: [require('frappe-ui/src/tailwind/preset')],
export default {
presets: [frappeUIPreset],
content: [ content: [
'./index.html', './index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}', './src/**/*.{vue,js,ts,jsx,tsx}',

View File

@@ -25,7 +25,7 @@ export default defineConfig({
}), }),
], ],
server: { server: {
allowedHosts: ['fs', 'persona'], allowedHosts: ['fs', 'per2'],
}, },
resolve: { resolve: {
alias: { alias: {

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ app_license = "AGPL"
# include js, css files in header of web template # include js, css files in header of web template
web_include_css = "lms.bundle.css" web_include_css = "lms.bundle.css"
# web_include_css = "/assets/lms/css/lms.css" # web_include_css = "/assets/lms/css/lms.css"
web_include_js = ["website.bundle.js"] web_include_js = []
# include custom scss in every website theme (without file extension ".scss") # include custom scss in every website theme (without file extension ".scss")
# website_theme_scss = "lms/public/scss/website" # website_theme_scss = "lms/public/scss/website"

View File

@@ -21,9 +21,9 @@ from lms.lms.utils import (
class LMSBatch(Document): class LMSBatch(Document):
def validate(self): def validate(self):
if self.seat_count: self.validate_seats_left()
self.validate_seats_left()
self.validate_batch_end_date() self.validate_batch_end_date()
self.validate_batch_time()
self.validate_duplicate_courses() self.validate_duplicate_courses()
self.validate_payments_app() self.validate_payments_app()
self.validate_amount_and_currency() self.validate_amount_and_currency()
@@ -40,6 +40,11 @@ class LMSBatch(Document):
if self.end_date < self.start_date: if self.end_date < self.start_date:
frappe.throw(_("Batch end date cannot be before the batch start date")) frappe.throw(_("Batch end date cannot be before the batch start date"))
def validate_batch_time(self):
if self.start_time and self.end_time:
if get_time(self.start_time) >= get_time(self.end_time):
frappe.throw(_("Batch start time cannot be greater than or equal to end time."))
def validate_duplicate_courses(self): def validate_duplicate_courses(self):
courses = [row.course for row in self.courses] courses = [row.course for row in self.courses]
duplicates = {course for course in courses if courses.count(course) > 1} duplicates = {course for course in courses if courses.count(course) > 1}
@@ -94,6 +99,9 @@ class LMSBatch(Document):
enrollment.save() enrollment.save()
def validate_seats_left(self): def validate_seats_left(self):
if cint(self.seat_count) < 0:
frappe.throw(_("Seat count cannot be negative."))
students = frappe.db.count("LMS Batch Enrollment", {"batch": self.name}) students = frappe.db.count("LMS Batch Enrollment", {"batch": self.name})
if cint(self.seat_count) < students: if cint(self.seat_count) < students:
frappe.throw(_("There are no seats available in this batch.")) frappe.throw(_("There are no seats available in this batch."))
@@ -208,86 +216,6 @@ def authenticate():
return response.json()["access_token"] return response.json()["access_token"]
@frappe.whitelist()
def create_batch(
title,
start_date,
end_date,
description=None,
batch_details=None,
batch_details_raw=None,
meta_image=None,
seat_count=0,
start_time=None,
end_time=None,
medium="Online",
category=None,
paid_batch=0,
amount=0,
currency=None,
amount_usd=0,
name=None,
published=0,
evaluation_end_date=None,
):
frappe.only_for("Moderator")
if name:
doc = frappe.get_doc("LMS Batch", name)
else:
doc = frappe.get_doc({"doctype": "LMS Batch"})
doc.update(
{
"title": title,
"start_date": start_date,
"end_date": end_date,
"description": description,
"batch_details": batch_details,
"batch_details_raw": batch_details_raw,
"meta_image": meta_image,
"seat_count": seat_count,
"start_time": start_time,
"end_time": end_time,
"medium": medium,
"category": category,
"paid_batch": paid_batch,
"amount": amount,
"currency": currency,
"amount_usd": amount_usd,
"published": published,
"evaluation_end_date": evaluation_end_date,
}
)
doc.save()
return doc
@frappe.whitelist()
def add_course(course, parent, name=None, evaluator=None):
frappe.only_for("Moderator")
if frappe.db.exists("Batch Course", {"course": course, "parent": parent}):
frappe.throw(_("Course already added to the batch."))
if name:
doc = frappe.get_doc("Batch Course", name)
else:
doc = frappe.new_doc("Batch Course")
doc.update(
{
"course": course,
"evaluator": evaluator,
"parent": parent,
"parentfield": "courses",
"parenttype": "LMS Batch",
}
)
doc.save()
return doc.name
@frappe.whitelist() @frappe.whitelist()
def get_batch_timetable(batch): def get_batch_timetable(batch):
timetable = frappe.get_all( timetable = frappe.get_all(

View File

@@ -403,7 +403,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-04-22 16:05:27.914422", "modified": "2025-05-14 12:43:22.749850",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",
@@ -425,6 +425,16 @@
"read": 1, "read": 1,
"role": "LMS Student", "role": "LMS Student",
"share": 1 "share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Moderator",
"share": 1,
"write": 1
} }
], ],
"row_format": "Dynamic", "row_format": "Dynamic",

View File

@@ -1,450 +0,0 @@
frappe.ready(() => {
setup_file_size();
pin_header();
$(".enroll-in-course").click((e) => {
enroll_in_course(e);
});
$(".notify-me").click((e) => {
notify_user(e);
});
$(".nav-link").click((e) => {
change_hash(e);
});
if (window.location.hash) {
open_tab();
}
if (window.location.pathname == "/statistics") {
generate_graph("New Signups", "#new-signups");
generate_graph("Course Enrollments", "#course-enrollments");
generate_graph("Lesson Completion", "#lesson-completion");
generate_course_completion_graph();
}
expand_the_active_chapter();
$(".chapter-title")
.unbind()
.click((e) => {
rotate_chapter_icon(e);
});
$(".no-preview").click((e) => {
show_no_preview_dialog(e);
});
$("#create-batch").click((e) => {
open_batch_dialog(e);
});
$("#course-filter").change((e) => {
filter_courses(e);
});
});
const pin_header = () => {
const el = document.querySelector(".sticky");
if (el) {
const observer = new IntersectionObserver(
([e]) =>
e.target.classList.toggle("is-pinned", e.intersectionRatio < 1),
{ threshold: [1] }
);
observer.observe(el);
}
};
const setup_file_size = () => {
frappe.provide("frappe.form.formatters");
frappe.form.formatters.FileSize = file_size;
};
const file_size = (value) => {
if (value > 1048576) {
value = flt(flt(value) / 1048576, 1) + "M";
} else if (value > 1024) {
value = flt(flt(value) / 1024, 1) + "K";
}
return value;
};
const enroll_in_course = (e) => {
e.preventDefault();
let course = $(e.currentTarget).attr("data-course");
if (frappe.session.user == "Guest") {
window.location.href = `/login?redirect-to=/courses/${course}`;
return;
}
let batch = $(e.currentTarget).attr("data-batch");
batch = batch ? decodeURIComponent(batch) : "";
frappe.call({
method: "lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership",
args: {
batch: batch ? batch : "",
course: course,
},
callback: (data) => {
if (data.message == "OK") {
$(".no-preview-modal").modal("hide");
frappe.show_alert(
{
message: __("Enrolled successfully"),
indicator: "green",
},
3
);
setTimeout(function () {
window.location.href = `/courses/${course}/learn/1.1`;
}, 1000);
}
},
});
};
const notify_user = (e) => {
e.preventDefault();
var course = decodeURIComponent($("#outline-heading").attr("data-course"));
if (frappe.session.user == "Guest") {
window.location.href = `/login?redirect-to=/courses/${course}`;
return;
}
frappe.call({
method: "lms.lms.doctype.lms_course_interest.lms_course_interest.capture_interest",
args: {
course: course,
},
callback: (data) => {
$(".no-preview-modal").modal("hide");
frappe.show_alert(
{
message: __(
"You have opted to be notified for this course. You will receive an email when the course becomes available."
),
indicator: "green",
},
3
);
setTimeout(() => {
window.location.reload();
}, 3000);
},
});
};
const generate_graph = (chart_name, element, type = "line") => {
let date = frappe.datetime;
frappe.call({
method: "lms.lms.utils.get_chart_data",
args: {
chart_name: chart_name,
timespan: "Select Date Range",
timegrain: "Daily",
from_date: date.add_days(date.get_today(), -30),
to_date: date.add_days(date.get_today(), +1),
},
callback: (data) => {
render_chart(data.message, chart_name, element, type);
},
});
};
const render_chart = (data, chart_name, element, type) => {
const chart = new frappe.Chart(element, {
title: chart_name,
data: data,
type: type,
height: 250,
colors: ["#4563f1"],
axisOptions: {
xIsSeries: 1,
},
lineOptions: {
regionFill: 1,
},
});
};
const generate_course_completion_graph = () => {
frappe.call({
method: "lms.lms.utils.get_course_completion_data",
callback: (data) => {
render_chart(
data.message,
"Course Completion",
"#course-completion",
"pie"
);
},
});
};
const change_hash = (e) => {
window.location.hash = $(e.currentTarget).attr("href");
};
const open_tab = () => {
$(`a[href="${window.location.hash}"]`).click();
};
const expand_the_first_chapter = () => {
let elements = $(".course-home-outline .collapse");
elements.each((i, element) => {
if (i < 1) {
show_section(element);
return false;
}
});
};
const expand_the_active_chapter = () => {
let selector = $(".course-home-headings.title");
if (selector.length && $(".course-details-page").length) {
expand_for_course_details(selector);
} else if ($(".active-lesson").length) {
/* For course home page */
selector = $(".active-lesson");
show_section(selector.parent().parent());
} else {
/* If no active chapter then exapand the first chapter */
expand_the_first_chapter();
}
};
const expand_for_course_details = (selector) => {
$(".lesson-info").removeClass("active-lesson");
$(".lesson-info").each((i, elem) => {
if ($(elem).data("lesson") == selector.data("lesson")) {
$(elem).addClass("active-lesson");
show_section($(elem).parent().parent());
}
});
};
const show_section = (element) => {
$(element).addClass("show");
$(element)
.siblings(".chapter-title")
.children(".chapter-icon")
.css("transform", "rotate(90deg)");
$(element).siblings(".chapter-title").attr("aria-expanded", true);
};
const rotate_chapter_icon = (e) => {
let icon = $(e.currentTarget).children(".chapter-icon");
if (icon.css("transform") == "none") {
icon.css("transform", "rotate(90deg)");
} else {
icon.css("transform", "none");
}
};
const show_no_preview_dialog = (e) => {
$("#no-preview-modal").modal("show");
};
const open_batch_dialog = () => {
this.batch_dialog = new frappe.ui.Dialog({
title: __("New Batch"),
fields: [
{
fieldtype: "Data",
label: __("Title"),
fieldname: "title",
reqd: 1,
default: batch_info && batch_info.title,
},
{
fieldtype: "Check",
label: __("Published"),
fieldname: "published",
default: batch_info && batch_info.published,
},
{
fieldtype: "Section Break",
},
{
fieldtype: "Date",
label: __("Start Date"),
fieldname: "start_date",
reqd: 1,
default: batch_info && batch_info.start_date,
},
{
fieldtype: "Date",
label: __("End Date"),
fieldname: "end_date",
reqd: 1,
default: batch_info && batch_info.end_date,
},
{
fieldtype: "Column Break",
},
{
fieldtype: "Time",
label: __("Start Time"),
fieldname: "start_time",
default: batch_info && batch_info.start_time,
reqd: 1,
},
{
fieldtype: "Time",
label: __("End Time"),
fieldname: "end_time",
default: batch_info && batch_info.end_time,
reqd: 1,
},
{
fieldtype: "Section Break",
},
{
fieldtype: "Select",
label: __("Medium"),
fieldname: "medium",
options: ["Online", "Offline"],
default: (batch_info && batch_info.medium) || "Online",
},
{
fieldtype: "Link",
label: __("Category"),
fieldname: "category",
options: "LMS Category",
only_select: 1,
default: batch_info && batch_info.category,
},
{
fieldtype: "Column Break",
},
{
fieldtype: "Int",
label: __("Seat Count"),
fieldname: "seat_count",
default: batch_info && batch_info.seat_count,
},
{
fieldtype: "Date",
label: __("Evaluation End Date"),
fieldname: "evaluation_end_date",
default: batch_info && batch_info.evaluation_end_date,
},
{
fieldtype: "Section Break",
},
{
fieldtype: "Small Text",
label: __("Description"),
fieldname: "description",
default: batch_info && batch_info.description,
reqd: 1,
},
{
fieldtype: "Text Editor",
label: __("Batch Details"),
fieldname: "batch_details",
default: batch_info && batch_info.batch_details,
reqd: 1,
},
{
fieldtype: "HTML Editor",
label: __("Batch Details Raw"),
fieldname: "batch_details_raw",
default: batch_info && batch_info.batch_details_raw,
},
{
fieldtype: "Attach Image",
label: __("Meta Image"),
fieldname: "meta_image",
default: batch_info && batch_info.meta_image,
},
{
fieldtype: "Section Break",
label: __("Pricing"),
fieldname: "pricing",
},
{
fieldtype: "Check",
label: __("Paid Batch"),
fieldname: "paid_batch",
default: batch_info && batch_info.paid_batch,
},
{
fieldtype: "Currency",
label: __("Amount"),
fieldname: "amount",
default: batch_info && batch_info.amount,
mandatory_depends_on: "paid_batch",
depends_on: "paid_batch",
},
{
fieldtype: "Link",
label: __("Currency"),
fieldname: "currency",
options: "Currency",
default: batch_info && batch_info.currency,
mandatory_depends_on: "paid_batch",
depends_on: "paid_batch",
only_select: 1,
},
{
fieldtype: "Currency",
label: __("Amount (USD)"),
fieldname: "amount_usd",
depends_on: "paid_batch",
description: __(
"If you set an amount here, then the USD equivalent setting will not get applied."
),
},
],
primary_action_label: __("Save"),
primary_action: (values) => {
save_batch(values);
},
});
this.batch_dialog.show();
};
const save_batch = (values) => {
let args = {};
if (batch_info) {
args = Object.assign(batch_info, values);
} else {
args = values;
}
frappe.call({
method: "lms.lms.doctype.lms_batch.lms_batch.create_batch",
args: args,
callback: (r) => {
if (r.message) {
frappe.show_alert({
message: batch_info
? __("Batch Updated")
: __("Batch Created"),
indicator: "green",
});
this.batch_dialog.hide();
window.location.href = `/batches/details/${r.message.name}`;
}
},
});
};
const filter_courses = (e) => {
const course_lists = $(".course-cards-parent");
const filter = $(e.currentTarget).val();
course_lists.each((i, list) => {
const course_cards = $(list).children(".course-card");
course_cards.sort((a, b) => {
var value1 = $(a).data(filter);
var value2 = $(b).data(filter);
return value1 > value2 ? -1 : value1 < value2 ? 1 : 0;
});
$(list).append(course_cards);
});
};

View File

@@ -1,14 +0,0 @@
frappe.ready(() => {
hide_profile_and_dashboard_for_guest_users();
});
const hide_profile_and_dashboard_for_guest_users = () => {
if (frappe.session.user == "Guest") {
let links = $(".nav-link").filter(
(i, elem) =>
$(elem).text().trim() === "My Profile" ||
$(elem).text().trim() === "Dashboard"
);
links.length && links.each((i, elem) => $(elem).addClass("hide"));
}
};

View File

@@ -1,5 +0,0 @@
import "./profile.js";
import "./common_functions.js";
import "../../../../frappe/frappe/public/js/frappe/ui/chart.js";
import "../../../../frappe/frappe/public/js/frappe/ui/keyboard.js";
import "../../../../frappe/frappe/public/js/frappe/event_emitter.js";

View File

@@ -11,9 +11,11 @@
<b>{{ _("Batch Start Date:") }}</b> {{ frappe.utils.format_date(start_date, "medium") }} <b>{{ _("Batch Start Date:") }}</b> {{ frappe.utils.format_date(start_date, "medium") }}
</p> </p>
{% if medium %}
<p> <p>
<b>{{ _("Medium:") }}</b> {{ medium }} <b>{{ _("Medium:") }}</b> {{ medium }}
</p> </p>
{% endif %}
<p> <p>
<b>{{ _("Timings:") }}</b> {{ frappe.utils.format_time(start_time, "hh:mm a") }} <b>{{ _("Timings:") }}</b> {{ frappe.utils.format_time(start_time, "hh:mm a") }}

View File

@@ -1,31 +0,0 @@
{% extends "templates/base.html" %}
{% block content %}
{% include "public/icons/symbol-defs.svg" %}
{% include "lms/templates/onboarding_header.html" %}
{% block page_content %}
Hello, world!
{% endblock %}
{% endblock %}
{%- block script -%}
{{ super() }}
{% if frappe.get_system_settings("enable_telemetry") %}
{% set telemetry_boot_info = get_telemetry_boot_info() %}
<script>
const telemetry_boot_info = {{ get_telemetry_boot_info() }}
if (telemetry_boot_info && Object.keys(telemetry_boot_info).length)
Object.assign(frappe.boot, telemetry_boot_info)
</script>
{% endif %}
<script>
frappe.router = {
slug(name) {
return name.toLowerCase().replace(/ /g, "-");
},
};
frappe.utils.make_event_emitter(frappe.router)
</script>
{{ include_script("telemetry.bundle.js") }}
{%- endblock -%}

View File

@@ -2,10 +2,11 @@
"name": "frappe_lms", "name": "frappe_lms",
"version": "1.0.0", "version": "1.0.0",
"description": "Easy to use, open-source, Learning Management System", "description": "Easy to use, open-source, Learning Management System",
"workspaces1": [ "workspaces": [
"frappe-ui", "frappe-ui",
"frontend" "frontend"
], ],
"type": "module",
"scripts": { "scripts": {
"test-local": "cypress open --e2e --browser chrome", "test-local": "cypress open --e2e --browser chrome",
"postinstall": "cd frontend && yarn install --check-files", "postinstall": "cd frontend && yarn install --check-files",

879
yarn.lock

File diff suppressed because it is too large Load Diff