Compare commits

...

112 Commits

Author SHA1 Message Date
Frappe PR Bot
3025ea9a7b chore(release): Bumped to Version 2.29.0 2025-05-26 10:05:36 +00:00
Jannat Patel
e4f1e7b093 Merge pull request #1536 from pateljannat/telemetry-fixes
chore: fix posthog init condition
2025-05-26 15:21:34 +05:30
Jannat Patel
d0a0597087 chore: removed unused imports 2025-05-26 15:08:41 +05:30
Jannat Patel
c9ccf9a1b5 chore: fix posthog init condition 2025-05-26 15:02:51 +05:30
Jannat Patel
69107d4441 Merge pull request #1535 from pateljannat/refactor-batch-charts
refactor: use frappe-ui for batch progress charts
2025-05-26 12:43:15 +05:30
Jannat Patel
e25afc1ef7 chore: fixed formating 2025-05-26 12:32:34 +05:30
Jannat Patel
9babfd150e fix: course count on batch dashboard 2025-05-26 12:25:16 +05:30
Jannat Patel
532dbbea4a fix: restricted minimum chart interval to 1 2025-05-26 11:34:52 +05:30
Jannat Patel
0d284d05d9 Merge pull request #1534 from pateljannat/issues-110
fix: misc issues
2025-05-26 11:22:16 +05:30
Jannat Patel
28fccae3ac Merge pull request #1532 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-26 11:05:01 +05:30
Jannat Patel
3a4a6da69c Merge pull request #1531 from frappe/pot_develop_2025-05-23
chore: update POT file
2025-05-26 11:04:50 +05:30
Jannat Patel
4ea07a95e7 fix: show batch CTA's on mobile 2025-05-26 11:04:00 +05:30
Jannat Patel
80ceb49358 fix: login menu now works on all browsers and devices 2025-05-26 10:58:17 +05:30
Jannat Patel
589337116a fix: added dependencies for onboarding steps 2025-05-26 09:59:05 +05:30
Jannat Patel
cb50067223 chore: Chinese Simplified translations 2025-05-24 23:16:12 +05:30
Jannat Patel
4d63266d88 chore: Serbian (Latin) translations 2025-05-24 23:16:11 +05:30
Jannat Patel
90dd33ce21 chore: Serbian (Latin) translations 2025-05-23 23:13:03 +05:30
frappe-pr-bot
763b849ddf chore: update POT file 2025-05-23 16:04:27 +00:00
Jannat Patel
9c76c54283 Merge pull request #1528 from pateljannat/email-template-list
feat: email template in settings
2025-05-23 15:35:11 +05:30
Md Hussain Nagaria
5cb17b3a36 Merge pull request #1529 from frappe/misc-fixes 2025-05-23 11:01:25 +02:00
Hussain Nagaria
2f7b5d1cbb fix: use unavailabilityMessage if set 2025-05-23 14:28:47 +05:30
Hussain Nagaria
4fe14eb2e9 fix: early return cleanup 2025-05-23 14:26:22 +05:30
Jannat Patel
eb089f2b58 fix: return payment fields data after transform 2025-05-23 14:25:29 +05:30
Hussain Nagaria
4f0ac98eea fix: toast used but not imported 2025-05-23 14:02:04 +05:30
Hussain Nagaria
af19940fa1 fix: some code semantics 2025-05-23 14:00:11 +05:30
Jannat Patel
5635d2a325 feat: email template update and deletion 2025-05-23 13:28:18 +05:30
Jannat Patel
5e2de35693 refactor: layout of payment fields 2025-05-22 21:39:49 +05:30
Jannat Patel
ef7180f23f Merge pull request #1523 from pateljannat/issues-109
refactor: misc enhancements
2025-05-22 12:12:57 +05:30
Jannat Patel
f939973d4f fix: don't validate number of students if seat count is 0 in batch 2025-05-22 12:03:07 +05:30
Jannat Patel
63f327733e refactor: category list in settings 2025-05-22 11:54:54 +05:30
Jannat Patel
c1fb807fe4 fix: show onboarding banner when redirected from other pages 2025-05-21 17:52:24 +05:30
Jannat Patel
b7ddf44267 test: close onboaring popover before creating course 2025-05-21 17:35:48 +05:30
Jannat Patel
6d4c72ea5e fix: rating input style on course details page 2025-05-21 16:28:04 +05:30
Jannat Patel
3db11b9372 refactor: moved batch feedback to sidebar 2025-05-21 16:08:49 +05:30
Jannat Patel
b8714f4abe refactor: batch progress chart will now use frappe-ui components 2025-05-21 13:13:42 +05:30
Jannat Patel
7ccbe74bbe chore: fixed conflicts 2025-05-20 19:09:19 +05:30
Jannat Patel
ea3ae3516b Merge pull request #1513 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-20 19:05:38 +05:30
Jannat Patel
d33af3ca52 chore: Esperanto translations 2025-05-19 21:33:54 +05:30
Jannat Patel
291c3fa908 chore: Serbian (Latin) translations 2025-05-19 21:33:53 +05:30
Jannat Patel
a51fa58122 chore: Bosnian translations 2025-05-19 21:33:52 +05:30
Jannat Patel
65a3967abd chore: Croatian translations 2025-05-19 21:33:50 +05:30
Jannat Patel
e1e5c94a43 chore: Thai translations 2025-05-19 21:33:49 +05:30
Jannat Patel
f15127eceb chore: Chinese Simplified translations 2025-05-19 21:33:47 +05:30
Jannat Patel
071a238b71 chore: Persian translations 2025-05-19 21:33:46 +05:30
Jannat Patel
050b052156 chore: Portuguese, Brazilian translations 2025-05-19 21:33:44 +05:30
Jannat Patel
8f65cca776 chore: Turkish translations 2025-05-19 21:33:43 +05:30
Jannat Patel
66624a8c47 chore: Swedish translations 2025-05-19 21:33:41 +05:30
Jannat Patel
c8b9a415e6 chore: Russian translations 2025-05-19 21:33:40 +05:30
Jannat Patel
a1dcb4c203 chore: Portuguese translations 2025-05-19 21:33:39 +05:30
Jannat Patel
d4edc3e622 chore: Polish translations 2025-05-19 21:33:37 +05:30
Jannat Patel
e2b8c3ee0e chore: Hungarian translations 2025-05-19 21:33:35 +05:30
Jannat Patel
c37816e90d chore: German translations 2025-05-19 21:33:34 +05:30
Jannat Patel
a35cfcdca7 chore: Arabic translations 2025-05-19 21:33:32 +05:30
Jannat Patel
d381646226 chore: Spanish translations 2025-05-19 21:33:31 +05:30
Jannat Patel
285e7afec2 chore: French translations 2025-05-19 21:33:30 +05:30
Jannat Patel
df7d678c32 Merge pull request #1510 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-19 14:24:21 +05:30
Jannat Patel
f36f7e58de Merge pull request #1511 from frappe/pot_develop_2025-05-16
chore: update POT file
2025-05-19 14:24:10 +05:30
Jannat Patel
0e16c834d8 chore: Serbian (Latin) translations 2025-05-18 21:37:27 +05:30
frappe-pr-bot
31a3256128 chore: update POT file 2025-05-16 16:04:20 +00:00
Jannat Patel
aa8f70da28 chore: Swedish translations 2025-05-16 21:09:45 +05:30
Frappe PR Bot
f375ffb8f8 chore(release): Bumped to Version 2.28.1 2025-05-16 06:36:08 +00:00
Jannat Patel
7d30aea07f Merge pull request #1509 from pateljannat/issues-108
fix: misc issues
2025-05-16 11:55:31 +05:30
Jannat Patel
04a7361d0d fix: verify if score_out_of is not 0 before calculating percentage 2025-05-16 11:40:03 +05:30
Jannat Patel
7b19618eca fix: basic cleanup of quiz submission form 2025-05-16 11:25:43 +05:30
Jannat Patel
bd9600cc08 fix: list index error on quiz submission 2025-05-16 11:07:47 +05:30
Jannat Patel
32172bc791 chore: fixed redis issue faced during docker setup 2025-05-16 09:53:45 +05:30
Jannat Patel
c92f57fb07 Merge pull request #1508 from pateljannat/issues-107
fix: ask for role in persona form
2025-05-15 21:51:05 +05:30
Jannat Patel
8fbdea7f36 fix: ask for role in persona form 2025-05-15 19:57:43 +05:30
Jannat Patel
df15da5145 Merge branch 'develop' of https://github.com/frappe/lms into develop 2025-05-15 09:40:09 +05:30
Jannat Patel
846fe53c0f fix: show persona form after course count has been fetched 2025-05-15 09:38:22 +05:30
Jannat Patel
c454c3f0f2 Merge pull request #1505 from pateljannat/issues-106
fix: plyr will now work on all videos of a lesson
2025-05-14 17:33:14 +05:30
Jannat Patel
77b1a546e8 fix: plyr will now work on all videos of a lesson 2025-05-14 17:12:20 +05:30
Jannat Patel
7c7f063204 Merge pull request #1502 from pateljannat/issues-105
fix: misc fixes
2025-05-14 14:10:16 +05:30
Jannat Patel
0a0fcb305c fix: settings modal size 2025-05-14 13:19:03 +05:30
Jannat Patel
da8028784d chore: changed cypress config to esm 2025-05-14 11:52:48 +05:30
Jannat Patel
48edd888a6 chore: changed file to esm 2025-05-14 11:30:11 +05:30
Jannat Patel
da4f134095 fix: misc ui issues 2025-05-13 20:04:39 +05:30
Jannat Patel
0a71620046 fix: misc ui issues 2025-05-13 20:04:06 +05:30
Jannat Patel
1b5a762578 Merge pull request #1500 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-13 14:10:58 +05:30
Jannat Patel
d9d031ed2b refactor: new toast api 2025-05-13 14:08:04 +05:30
Jannat Patel
403e56b4ef fix: misc batch issues 2025-05-13 11:46:00 +05:30
Jannat Patel
499b06e300 chore: Esperanto translations 2025-05-12 20:56:10 +05:30
Jannat Patel
cb69540bdd chore: Chinese Simplified translations 2025-05-12 20:56:08 +05:30
Jannat Patel
1f27fa419a chore: Serbian (Latin) translations 2025-05-12 20:56:06 +05:30
Jannat Patel
a561b2bd91 chore: Bosnian translations 2025-05-12 20:56:05 +05:30
Jannat Patel
eeec85d1de chore: Croatian translations 2025-05-12 20:56:04 +05:30
Jannat Patel
e01484f854 chore: Thai translations 2025-05-12 20:56:02 +05:30
Jannat Patel
fb996ded88 chore: Persian translations 2025-05-12 20:56:01 +05:30
Jannat Patel
a11bfca15a chore: Portuguese, Brazilian translations 2025-05-12 20:55:59 +05:30
Jannat Patel
6262e1c9e6 chore: Turkish translations 2025-05-12 20:55:57 +05:30
Jannat Patel
4e318af7cc chore: Swedish translations 2025-05-12 20:55:55 +05:30
Jannat Patel
d587b7867e chore: Russian translations 2025-05-12 20:55:54 +05:30
Jannat Patel
bd03ead9c3 chore: Portuguese translations 2025-05-12 20:55:53 +05:30
Jannat Patel
c1685b7128 chore: Polish translations 2025-05-12 20:55:51 +05:30
Jannat Patel
7625e79574 chore: Hungarian translations 2025-05-12 20:55:50 +05:30
Jannat Patel
c5bf7875b9 chore: German translations 2025-05-12 20:55:48 +05:30
Jannat Patel
da026293bc chore: Arabic translations 2025-05-12 20:55:46 +05:30
Jannat Patel
86e5677574 chore: Spanish translations 2025-05-12 20:55:45 +05:30
Jannat Patel
a48636604f chore: French translations 2025-05-12 20:55:43 +05:30
Jannat Patel
e6945ac076 fix: all empty states now come from a common component 2025-05-12 17:46:23 +05:30
Jannat Patel
9107d76522 fix: batch form cleanup 2025-05-12 15:04:35 +05:30
Jannat Patel
52b925b306 fix: course form cleanup 2025-05-12 13:07:06 +05:30
Jannat Patel
49d3dc0aa0 Merge pull request #1498 from frappe/pot_develop_2025-05-09
chore: update POT file
2025-05-12 10:53:20 +05:30
Jannat Patel
0d41a1ae70 refactor: use frappe-ui for batch progress charts 2025-05-12 10:37:26 +05:30
frappe-pr-bot
49e22d790a chore: update POT file 2025-05-09 16:04:15 +00:00
Jannat Patel
12e5eedd6b Merge pull request #1494 from pateljannat/issues-104
fix: misc issues
2025-05-08 16:01:14 +05:30
Jannat Patel
159b651871 fix: dark mode for upcoming evaluations 2025-05-08 15:29:04 +05:30
Jannat Patel
080be7a885 fix: tooltips for number cards on statistics page 2025-05-08 15:10:51 +05:30
Jannat Patel
e526627eb9 fix: only show published certificate on the statistics page 2025-05-08 15:05:07 +05:30
Jannat Patel
67fc37c76c fix: ui of job details 2025-05-08 14:53:34 +05:30
Jannat Patel
d54ac37403 Merge pull request #1491 from pateljannat/issues-103
fix: only assign lms roles to admin
2025-05-07 21:49:38 +05:30
Jannat Patel
eedb3d3dd8 fix: only assign lms roles to admin 2025-05-07 21:40:43 +05:30
121 changed files with 10808 additions and 14239 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

@@ -1,12 +1,15 @@
describe("Course Creation", () => { describe("Course Creation", () => {
it("creates a new course", () => { it("creates a new course", () => {
cy.login(); cy.login();
cy.wait(1000); cy.wait(500);
cy.visit("/lms/courses"); cy.visit("/lms/courses");
// Close onboarding modal
cy.closeOnboardingModal();
// Create a course // Create a course
cy.get("button").contains("New").click(); cy.get("button").contains("New").click();
cy.wait(1000); cy.wait(500);
cy.url().should("include", "/courses/new/edit"); cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course"); cy.get("label").contains("Title").type("Test Course");
@@ -96,7 +99,8 @@ describe("Course Creation", () => {
// View Course // View Course
cy.wait(1000); cy.wait(1000);
cy.visit("/lms"); cy.visit("/lms");
cy.wait(500); cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses"); cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => { cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course"); cy.get("div").contains("Test Course");

View File

@@ -25,6 +25,7 @@
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "cypress-file-upload"; import "cypress-file-upload";
import "cypress-real-events";
Cypress.Commands.add("login", (email, password) => { Cypress.Commands.add("login", (email, password) => {
if (!email) { if (!email) {
@@ -68,3 +69,11 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
element.dispatchEvent(event); element.dispatchEvent(event);
}); });
}); });
Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500);
cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
});

View File

@@ -16,9 +16,9 @@ cd frappe-bench
# Use containers instead of localhost # Use containers instead of localhost
bench set-mariadb-host mariadb bench set-mariadb-host mariadb
bench set-redis-cache-host redis:6379 bench set-redis-cache-host redis://redis:6379
bench set-redis-queue-host redis:6379 bench set-redis-queue-host redis://redis:6379
bench set-redis-socketio-host redis:6379 bench set-redis-socketio-host redis://redis:6379
# Remove redis, watch from Procfile # Remove redis, watch from Procfile
sed -i '/redis/d' ./Procfile sed -i '/redis/d' ./Procfile

View File

@@ -47,10 +47,14 @@ 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']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplates: typeof import('./src/components/EmailTemplates.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']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default'] ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default'] FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default'] IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default'] IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.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.147",
"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,25 +1,22 @@
<template> <template>
<FrappeUIProvider>
<Layout> <Layout>
<router-view /> <router-view />
</Layout> </Layout>
<Dialogs /> <Dialogs />
<Toasts /> </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, onUnmounted, ref } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
import DesktopLayout from './components/DesktopLayout.vue' import DesktopLayout from './components/DesktopLayout.vue'
import MobileLayout from './components/MobileLayout.vue' import MobileLayout from './components/MobileLayout.vue'
import NoSidebarLayout from './components/NoSidebarLayout.vue' import NoSidebarLayout from './components/NoSidebarLayout.vue'
import { stopSession } from '@/telemetry'
import { init as initTelemetry } from '@/telemetry'
import { usersStore } from '@/stores/user'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const screenSize = useScreenSize() const screenSize = useScreenSize()
let { userResource } = usersStore()
const router = useRouter() const router = useRouter()
const noSidebar = ref(false) const noSidebar = ref(false)
@@ -38,13 +35,9 @@ const Layout = computed(() => {
} }
if (screenSize.width < 640) { if (screenSize.width < 640) {
return MobileLayout return MobileLayout
} else {
return DesktopLayout
} }
})
onMounted(async () => { return DesktopLayout
if (userResource.data) await initTelemetry()
}) })
onUnmounted(() => { onUnmounted(() => {

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="
@@ -181,7 +181,6 @@
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue' import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
@@ -244,6 +243,7 @@ const iconProps = {
onMounted(() => { onMounted(() => {
addNotifications() addNotifications()
setSidebarLinks() setSidebarLinks()
setUpOnboarding()
socket.on('publish_lms_notifications', (data) => { socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload() unreadNotifications.reload()
}) })
@@ -388,10 +388,6 @@ const deletePage = (link) => {
) )
} }
const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false)
}
const toggleSidebar = () => { const toggleSidebar = () => {
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
localStorage.setItem( localStorage.setItem(
@@ -438,6 +434,7 @@ const steps = reactive([
title: __('Add your first chapter'), title: __('Add your first chapter'),
icon: markRaw(h(FolderTree, iconProps)), icon: markRaw(h(FolderTree, iconProps)),
completed: false, completed: false,
dependsOn: 'create_first_course',
onClick: async () => { onClick: async () => {
minimize.value = true minimize.value = true
let course = await getFirstCourse() let course = await getFirstCourse()
@@ -453,6 +450,7 @@ const steps = reactive([
title: __('Add your first lesson'), title: __('Add your first lesson'),
icon: markRaw(h(FileText, iconProps)), icon: markRaw(h(FileText, iconProps)),
completed: false, completed: false,
dependsOn: 'create_first_chapter',
onClick: async () => { onClick: async () => {
minimize.value = true minimize.value = true
let course = await getFirstCourse() let course = await getFirstCourse()
@@ -471,6 +469,7 @@ const steps = reactive([
title: __('Create your first quiz'), title: __('Create your first quiz'),
icon: markRaw(h(CircleHelp, iconProps)), icon: markRaw(h(CircleHelp, iconProps)),
completed: false, completed: false,
dependsOn: 'create_first_course',
onClick: () => { onClick: () => {
minimize.value = true minimize.value = true
router.push({ name: 'Quizzes' }) router.push({ name: 'Quizzes' })
@@ -502,6 +501,7 @@ const steps = reactive([
title: __('Add students to your batch'), title: __('Add students to your batch'),
icon: markRaw(h(UserPlus, iconProps)), icon: markRaw(h(UserPlus, iconProps)),
completed: false, completed: false,
dependsOn: 'create_first_batch',
onClick: async () => { onClick: async () => {
minimize.value = true minimize.value = true
let batch = await getFirstBatch() let batch = await getFirstBatch()
@@ -522,6 +522,7 @@ const steps = reactive([
title: __('Add courses to your batch'), title: __('Add courses to your batch'),
icon: markRaw(h(BookText, iconProps)), icon: markRaw(h(BookText, iconProps)),
completed: false, completed: false,
dependsOn: 'create_first_batch',
onClick: async () => { onClick: async () => {
minimize.value = true minimize.value = true
let batch = await getFirstBatch() let batch = await getFirstBatch()

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

@@ -1,21 +1,22 @@
<template> <template>
<div v-if="user.data?.is_student"> <div v-if="user.data?.is_student">
<div <div>
v-if="feedbackList.data?.length" <div class="leading-5 mb-4">
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5" <div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
> >
{{ __('Thank you for providing your feedback!') }} {{ __('to view your feedback.') }}
</div> </div>
<div v-else class="flex justify-between items-center mb-5"> <div v-else>
<div class="text-lg font-semibold"> {{ __('Help us improve by providing your feedback.') }}
{{ __('Help Us Improve') }}
</div> </div>
<Button @click="submitFeedback()">
{{ __('Submit') }}
</Button>
</div> </div>
<div class="space-y-8"> <div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="flex items-center justify-between"> <div class="space-y-4">
<Rating <Rating
v-for="key in ratingKeys" v-for="key in ratingKeys"
v-model="feedback[key]" v-model="feedback[key]"
@@ -27,18 +28,22 @@
v-model="feedback.feedback" v-model="feedback.feedback"
type="textarea" type="textarea"
:label="__('Feedback')" :label="__('Feedback')"
:rows="7" :rows="9"
:readonly="readOnly" :readonly="readOnly"
/> />
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
</div> </div>
</div> </div>
<div v-else-if="feedbackList.data?.length"> <div v-else-if="feedbackList.data?.length">
<div class="text-lg font-semibold mb-5"> <div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average of Feedback Received') }} {{ __('Average Feedback Received') }}
</div> </div>
<div class="flex items-center justify-between mb-10"> <div class="space-y-4">
<Rating <Rating
v-for="key in ratingKeys" v-for="key in ratingKeys"
v-model="average[key]" v-model="average[key]"
@@ -47,81 +52,32 @@
/> />
</div> </div>
<div class="text-lg font-semibold mb-5"> <Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('All Feedback') }} {{ __('View all feedback') }}
</Button>
</div> </div>
<ListView <div v-else class="text-ink-gray-7 mt-5 leading-5">
:columns="feedbackColumns"
:rows="feedbackList.data"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList.data"
class="group cursor-pointer feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
<div v-else class="text-sm italic text-center text-ink-gray-7 mt-5">
{{ __('No feedback received yet.') }} {{ __('No feedback received yet.') }}
</div> </div>
<FeedbackModal
v-if="feedbackList.data?.length"
v-model="showAllFeedback"
:feedbackList="feedbackList.data"
/>
</template> </template>
<script setup> <script setup>
import { computed, inject, onMounted, reactive, ref, watch } from 'vue' import { inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils' import { convertToTitleCase } from '@/utils'
import { import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
Avatar, import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
Button,
createListResource,
FormControl,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
const user = inject('$user') const user = inject('$user')
const ratingKeys = ['content', 'instructors', 'value'] const ratingKeys = ['content', 'instructors', 'value']
const readOnly = ref(false) const readOnly = ref(false)
const average = reactive({}) const average = reactive({})
const feedback = reactive({}) const feedback = reactive({})
const showFeedbackForm = ref(true)
const showAllFeedback = ref(false)
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -167,6 +123,7 @@ watch(
if (feedbackList.data.length) { if (feedbackList.data.length) {
let data = feedbackList.data let data = feedbackList.data
readOnly.value = true readOnly.value = true
showFeedbackForm.value = false
ratingKeys.forEach((key) => { ratingKeys.forEach((key) => {
average[key] = 0 average[key] = 0
@@ -201,40 +158,11 @@ const submitFeedback = () => {
{ {
onSuccess: () => { onSuccess: () => {
feedbackList.reload() feedbackList.reload()
showFeedbackForm.value = false
}, },
} }
) )
} }
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script> </script>
<style> <style>
.feedback-list > button > div { .feedback-list > button > div {

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,108 +1,64 @@
<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') }}
</div> </div>
</div> </div>
<div class="grid grid-cols-4 gap-5 mb-8"> <div class="grid grid-cols-4 gap-5 mb-8">
<div <NumberChart
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" class="border rounded-md"
> :config="{ title: __('Students'), value: students.data?.length || 0 }"
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> />
<User class="w-5 h-5 stroke-1.5" />
</div> <NumberChart
<div class="flex items-center space-x-2"> class="border rounded-md"
<span class="font-semibold"> :config="{
{{ students.data?.length }} title: __('Certified'),
</span> value: certificationCount.data || 0,
<span class=""> }"
{{ __('Students') }} />
</span>
</div> <NumberChart
</div> class="border rounded-md"
:config="{
<div title: __('Courses'),
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" value: batch.data.courses?.length || 0,
> }"
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> />
<GraduationCap class="w-5 h-5 stroke-1.5" />
</div> <NumberChart
<div class="flex items-center space-x-2"> class="border rounded-md"
<span class="font-semibold"> :config="{ title: __('Assessments'), value: assessmentCount || 0 }"
{{ certificationCount.data }}
</span>
<span class="">
{{ __('Certified') }}
</span>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<BookOpen class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ batch.courses?.length }}
</span>
<span>
{{ __('Courses') }}
</span>
</div>
</div>
<div
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
>
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
<ShieldCheck class="w-5 h-5 stroke-1.5" />
</div>
<div class="flex items-center space-x-2">
<span class="font-semibold">
{{ assessmentCount }}
</span>
<span>
{{ __('Assessments') }}
</span>
</div>
</div>
</div>
<div v-if="showProgressChart" class="mb-8">
<div class="text-ink-gray-7 font-medium">
{{ __('Progress') }}
</div>
<ApexChart
:options="chartOptions"
:series="chartData"
type="bar"
:height="chartData[0].data.length * 30 + 100"
/> />
<div
class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4"
>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.green[600] }"
></div>
<div>
{{ __('Courses') }}
</div>
</div>
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.blue[600] }"
></div>
<div>
{{ __('Assessments') }}
</div>
</div>
</div>
</div> </div>
<AxisChart
v-if="showProgressChart"
:config="{
data: chartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div> </div>
<div> <div>
@@ -201,9 +157,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"
@@ -213,6 +170,7 @@
<script setup> <script setup>
import { import {
Avatar, Avatar,
AxisChart,
Button, Button,
createResource, createResource,
FeatherIcon, FeatherIcon,
@@ -223,6 +181,8 @@ import {
ListRows, ListRows,
ListView, ListView,
ListRowItem, ListRowItem,
NumberChart,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { import {
BookOpen, BookOpen,
@@ -234,7 +194,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'
@@ -244,7 +203,6 @@ const showStudentModal = ref(false)
const showStudentProgressModal = ref(false) const showStudentProgressModal = ref(false)
const selectedStudent = ref(null) const selectedStudent = ref(null)
const chartData = ref(null) const chartData = ref(null)
const chartOptions = ref(null)
const showProgressChart = ref(false) const showProgressChart = ref(false)
const assessmentCount = ref(0) const assessmentCount = ref(0)
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
@@ -258,15 +216,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 +281,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()
}, },
} }
@@ -331,96 +290,49 @@ const removeStudents = (selections, unselectAll) => {
} }
const getChartData = () => { const getChartData = () => {
let categories = {} let tasks = []
let data = []
if (!students.data?.length) return [] students.data.forEach((row) => {
tasks = countAssessments(row, tasks)
Object.keys(students.data[0].courses).forEach((course) => { tasks = countCourses(row, tasks)
categories[course] = {
value: 0,
type: 'course',
label: course,
}
}) })
Object.keys(students.data?.[0].assessments).forEach((assessment) => { tasks.forEach((task) => {
categories[assessment] = { data.push({
value: 0, task: task.label,
type: 'assessment', value: task.value,
label: assessment,
}
})
students.data.forEach((student) => {
Object.keys(student.courses).forEach((course) => {
if (student.courses[course] === 100) {
categories[course].value += 1
}
})
Object.keys(student.assessments).forEach((assessment) => {
if (student.assessments[assessment].result === 'Pass') {
categories[assessment].value += 1
}
}) })
}) })
return data
chartOptions.value = getChartOptions(categories)
return [
{
name: __('Completed by Students'),
data: Object.values(categories).map((item) => item.value),
},
]
} }
const getChartOptions = (categories) => { const countAssessments = (row, tasks) => {
const courseColor = theme.colors.green[700] Object.keys(row.assessments).forEach((assessment) => {
const assessmentColor = theme.colors.blue[700] if (row.assessments[assessment].result === 'Pass') {
const maxY = tasks.filter((task) => task.label === assessment).length
students.data?.length % 5 ? tasks.filter((task) => task.label === assessment)[0].value++
? students.data?.length + (5 - (students.data?.length % 5)) : tasks.push({
: students.data?.length value: 1,
label: assessment,
return { })
chart: {
type: 'bar',
toolbar: {
show: false,
},
},
plotOptions: {
bar: {
distributed: true,
borderRadius: 3,
borderRadiusApplication: 'end',
horizontal: true,
barHeight: '40%',
},
},
colors: Object.values(categories).map((item) =>
item.type === 'course' ? courseColor : assessmentColor
),
xaxis: {
categories: Object.values(categories).map((item) => item.label),
labels: {
style: {
fontSize: '10px',
},
rotate: 0,
formatter: function (value) {
return value.length > 30 ? `${value.substring(0, 30)}...` : value
},
},
},
yaxis: {
max: maxY,
min: 0,
stepSize: 10,
tickAmount: maxY / 5,
/* reversed: true */
},
} }
})
return tasks
}
const countCourses = (row, tasks) => {
Object.keys(row.courses).forEach((course) => {
if (row.courses[course] === 100) {
tasks.filter((task) => task.label === course).length
? tasks.filter((task) => task.label === course)[0].value++
: tasks.push({
value: 1,
label: course,
})
}
})
return tasks
} }
watch(students, () => { watch(students, () => {
@@ -434,14 +346,9 @@ 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,
}) })
</script> </script>
<style>
.apexcharts-legend {
display: none !important;
}
</style>

View File

@@ -18,13 +18,13 @@
</div> </div>
<div class="overflow-y-auto"> <div class="overflow-y-auto">
<SettingFields :fields="fields" :data="data.data" /> <SettingFields :fields="fields" :data="data.data" />
</div>
<div class="flex flex-row-reverse mt-auto"> <div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update"> <Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }} {{ __('Update') }}
</Button> </Button>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { createResource, Button, Badge } from 'frappe-ui' import { createResource, Button, Badge } from 'frappe-ui'

View File

@@ -1,16 +1,33 @@
<template> <template>
<div class="flex flex-col min-h-0"> <div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between mb-5">
<div class="text-xl font-semibold mb-5 text-ink-gray-9"> <div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }} {{ label }}
</div> </div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<div
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
v-if="saving"
>
<LoadingIndicator class="size-2" />
<span class="text-xs">
{{ __('saving...') }}
</span>
</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>
</div>
<div <div
v-if="showForm" v-if="showForm"
@@ -28,32 +45,63 @@
</div> </div>
<div class="overflow-y-scroll"> <div class="overflow-y-scroll">
<div class="text-base divide-y space-y-2"> <div class="divide-y space-y-2">
<div
v-for="(cat, index) in categories.data"
:key="cat.name"
class="pt-2"
>
<div
v-if="editing?.name !== cat.name"
class="flex items-center justify-between group text-sm"
>
<div @dblclick="allowEdit(cat, index)">
{{ cat.category }}
</div>
<Button
variant="ghost"
theme="red"
class="invisible group-hover:visible"
@click="deleteCategory(cat.name)"
>
<template #icon>
<Trash2 class="size-4 stroke-1.5 text-ink-red-4" />
</template>
</Button>
</div>
<FormControl <FormControl
:value="cat.category" v-else
:ref="(el) => (editInputRef[index] = el)"
v-model="editedValue"
type="text" type="text"
v-for="cat in categories.data" class="w-full"
class="" @keyup.enter="saveChanges(cat.name, editedValue)"
@change.stop="(e) => update(cat.name, e.target.value)"
/> />
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
Button, Button,
FormControl, FormControl,
LoadingIndicator,
createListResource, createListResource,
createResource, createResource,
debounce, toast,
} from 'frappe-ui' } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next' import { Plus, Trash2, X } from 'lucide-vue-next'
import { ref } from 'vue' import { ref } from 'vue'
import { cleanError } from '@/utils'
const showForm = ref(false) const showForm = ref(false)
const category = ref(null) const category = ref(null)
const categoryInput = ref(null) const categoryInput = ref(null)
const saving = ref(false)
const editing = ref(null)
const editedValue = ref('')
const editInputRef = ref([])
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -72,25 +120,20 @@ const categories = createListResource({
auto: true, auto: true,
}) })
const newCategory = createResource({ const addCategory = () => {
url: 'frappe.client.insert', categories.insert.submit(
makeParams(values) { {
return {
doc: {
doctype: 'LMS Category',
category: category.value, category: category.value,
}, },
}
},
})
const addCategory = () => {
newCategory.submit(
{},
{ {
onSuccess(data) { onSuccess(data) {
categories.reload() categories.reload()
category.value = null category.value = null
showForm.value = false
toast.success(__('Category added successfully'))
},
onError(err) {
toast.error(__(cleanError(err.messages[0]) || 'Unable to add category'))
}, },
} }
) )
@@ -115,6 +158,7 @@ const updateCategory = createResource({
}) })
const update = (name, value) => { const update = (name, value) => {
saving.value = true
updateCategory.submit( updateCategory.submit(
{ {
name: name, name: name,
@@ -122,9 +166,51 @@ const update = (name, value) => {
}, },
{ {
onSuccess() { onSuccess() {
saving.value = false
categories.reload() categories.reload()
editing.value = null
editedValue.value = ''
toast.success(__('Category updated successfully'))
},
onError(err) {
saving.value = false
editing.value = null
editedValue.value = ''
toast.error(
__(cleanError(err.messages[0]) || 'Unable to update category')
)
}, },
} }
) )
} }
const deleteCategory = (name) => {
saving.value = true
categories.delete.submit(name, {
onSuccess() {
saving.value = false
categories.reload()
toast.success(__('Category deleted successfully'))
},
onError(err) {
saving.value = false
toast.error(
__(cleanError(err.messages[0]) || 'Unable to delete category')
)
},
})
}
const saveChanges = (name, value) => {
saving.value = true
update(name, value)
}
const allowEdit = (cat, index) => {
editing.value = cat
editedValue.value = cat.category
setTimeout(() => {
editInputRef.value[index].$el.querySelector('input').focus()
}, 0)
}
</script> </script>

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,22 +4,7 @@
{{ 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
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded-md word-break-all"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
</template>
</Button>
<div class="">
<Combobox v-model="selectedValue" nullable> <Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions"> <Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
@@ -39,13 +24,13 @@
@keydown.delete.capture.stop="removeLastValue" @keydown.delete.capture.stop="removeLastValue"
/> />
</template> </template>
<template #body="{ isOpen }"> <template #body="{ isOpen, close }">
<div v-show="isOpen"> <div v-show="isOpen">
<div <div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2" class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
> >
<ComboboxOptions <ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5" class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
static static
> >
<ComboboxOption <ComboboxOption
@@ -70,6 +55,21 @@
</div> </div>
</li> </li>
</ComboboxOption> </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> </ComboboxOptions>
</div> </div>
</div> </div>
@@ -77,6 +77,19 @@
</Popover> </Popover>
</Combobox> </Combobox>
</div> </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>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> --> <!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
</div> </div>
@@ -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

@@ -116,7 +116,7 @@
v-if="parseInt(course.data.rating) > 0" v-if="parseInt(course.data.rating) > 0"
class="flex items-center text-ink-gray-9" class="flex items-center text-ink-gray-9"
> >
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" /> <Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2"> <span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }} {{ course.data.rating }} {{ __('Rating') }}
</span> </span>
@@ -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

@@ -35,14 +35,14 @@
<span class="text-ink-gray-7"> <span class="text-ink-gray-7">
{{ review.creation }} {{ review.creation }}
</span> </span>
<div class="flex mt-2"> <div class="flex mt-2 space-x-1">
<Star <Star
v-for="index in 5" v-for="index in 5"
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2" class="size-4 text-transparent rounded-sm"
:class=" :class="
index <= Math.ceil(review.rating) index <= Math.ceil(review.rating)
? 'fill-orange-500' ? 'fill-yellow-500'
: 'fill-gray-600' : 'fill-gray-300'
" "
/> />
</div> </div>

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,160 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<Button @click="openTemplateForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="emailTemplates.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openTemplateForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in emailTemplates.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeTemplate(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<EmailTemplateModal
v-model="showForm"
v-model:emailTemplates="emailTemplates"
:templateID="selectedTemplate"
/>
</template>
<script setup lang="ts">
import {
Button,
call,
createListResource,
ListView,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRows,
ListRow,
ListRowItem,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
const props = defineProps({
label: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
const showForm = ref(false)
const readOnlyMode = window.read_only_mode
const selectedTemplate = ref(null)
const emailTemplates = createListResource({
doctype: 'Email Template',
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
auto: true,
orderBy: 'modified desc',
cache: 'email-templates',
})
const removeTemplate = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'Email Template',
documents: Array.from(selections),
})
.then(() => {
emailTemplates.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const openTemplateForm = (templateID) => {
if (readOnlyMode) {
return
}
selectedTemplate.value = templateID
showForm.value = true
}
const columns = computed(() => {
return [
{
label: 'Name',
key: 'name',
width: '20rem',
},
{
label: 'Subject',
key: 'subject',
width: '25rem',
},
]
})
</script>

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

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9"> <div class="text-xl font-semibold mb-1 text-ink-gray-9">
@@ -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>
@@ -38,6 +39,7 @@
</Button> </Button>
</div> </div>
<div class="overflow-y-scroll">
<div class="divide-y"> <div class="divide-y">
<div <div
v-for="evaluator in evaluators.data" v-for="evaluator in evaluators.data"
@@ -64,6 +66,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui' import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'

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

@@ -1,16 +1,34 @@
<template> <template>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col relative">
<div class="h-full pb-10" id="scrollContainer"> <div class="h-full pb-10" id="scrollContainer">
<slot /> <slot />
</div> </div>
<div class="relative z-20">
<!-- Dropdown menu -->
<div
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
v-if="showMenu"
ref="menu"
>
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2 cursor-pointer"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
/>
<div>{{ link.label }}</div>
</div>
</div>
<!-- Fixed menu -->
<div <div
v-if="sidebarSettings.data" v-if="sidebarSettings.data"
class="fixed flex items-center justify-around border-t border-outline-gray-2 bottom-0 z-10 w-full bg-surface-white standalone:pb-4" class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
:style="{
gridTemplateColumns: `repeat(${
sidebarLinks.length + 1
}, minmax(0, 1fr))`,
}"
> >
<button <button
v-for="tab in sidebarLinks" v-for="tab in sidebarLinks"
@@ -25,38 +43,15 @@
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']" :class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
/> />
</button> </button>
<Popover <button @click="toggleMenu">
trigger="hover"
popoverClass="bottom-28 mx-2"
placement="top-start"
>
<template #target>
<component <component
:is="icons['List']" :is="icons['List']"
class="h-6 w-6 stroke-1.5 text-ink-gray-5" class="h-6 w-6 stroke-1.5 text-ink-gray-5"
/> />
</template> </button>
<template #body-main>
<div class="text-base p-5 space-y-4">
<div
v-for="link in otherLinks"
:key="link.label"
class="flex items-center space-x-2"
@click="handleClick(link)"
>
<component
:is="icons[link.icon]"
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
/>
<div>
{{ link.label }}
</div> </div>
</div> </div>
</div> </div>
</template>
</Popover>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
@@ -64,7 +59,6 @@ import { useRouter } from 'vue-router'
import { watch, ref, onMounted } from 'vue' import { watch, ref, onMounted } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { Popover } from 'frappe-ui'
import * as icons from 'lucide-vue-next' import * as icons from 'lucide-vue-next'
const { logout, user, sidebarSettings } = sessionStore() const { logout, user, sidebarSettings } = sessionStore()
@@ -73,12 +67,38 @@ const router = useRouter()
let { userResource } = usersStore() let { userResource } = usersStore()
const sidebarLinks = ref(getSidebarLinks()) const sidebarLinks = ref(getSidebarLinks())
const otherLinks = ref([]) const otherLinks = ref([])
const showMenu = ref(false)
const menu = ref(null)
onMounted(() => { onMounted(() => {
sidebarSettings.reload( sidebarSettings.reload(
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
filterLinksToShow(data)
addOtherLinks()
},
}
)
})
const handleOutsideClick = (e) => {
if (menu.value && !menu.value.contains(e.target)) {
showMenu.value = false
}
}
watch(showMenu, (val) => {
if (val) {
setTimeout(() => {
document.addEventListener('click', handleOutsideClick)
}, 0)
} else {
document.removeEventListener('click', handleOutsideClick)
}
})
const filterLinksToShow = (data) => {
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
if (!parseInt(data[key])) { if (!parseInt(data[key])) {
sidebarLinks.value = sidebarLinks.value.filter( sidebarLinks.value = sidebarLinks.value.filter(
@@ -86,12 +106,7 @@ onMounted(() => {
) )
} }
}) })
}
addOtherLinks()
},
}
)
})
const addOtherLinks = () => { const addOtherLinks = () => {
if (user) { if (user) {
@@ -122,6 +137,7 @@ watch(userResource, () => {
(userResource.data.is_moderator || userResource.data.is_instructor) (userResource.data.is_moderator || userResource.data.is_instructor)
) { ) {
addQuizzes() addQuizzes()
addAssignments()
} }
}) })
@@ -133,6 +149,14 @@ const addQuizzes = () => {
}) })
} }
const addAssignments = () => {
otherLinks.value.push({
label: 'Assignments',
icon: 'Pencil',
to: 'Assignments',
})
}
let isActive = (tab) => { let isActive = (tab) => {
return tab.activeFor?.includes(router.currentRoute.value.name) return tab.activeFor?.includes(router.currentRoute.value.name)
} }
@@ -158,4 +182,8 @@ const isVisible = (tab) => {
else if (tab.label == 'Log out') return isLoggedIn else if (tab.label == 'Log out') return isLoggedIn
else return true else return true
} }
const toggleMenu = () => {
showMenu.value = !showMenu.value
}
</script> </script>

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

@@ -6,7 +6,7 @@
}" }"
> >
<template #body> <template #body>
<div class="p-5 text-base max-h-[75vh] overflow-y-auto"> <div class="p-5 text-base">
<div class="text-lg text-ink-gray-9 font-semibold mb-5"> <div class="text-lg text-ink-gray-9 font-semibold mb-5">
{{ {{
assignmentID === 'new' assignmentID === 'new'
@@ -14,7 +14,7 @@
: __('Edit Assignment') : __('Edit Assignment')
}} }}
</div> </div>
<div class="space-y-4"> <div class="space-y-4 max-h-[75vh] overflow-y-auto">
<FormControl <FormControl
v-model="assignment.title" v-model="assignment.title"
:label="__('Title')" :label="__('Title')"
@@ -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

@@ -0,0 +1,192 @@
<template>
<Dialog
v-model="show"
:options="{
title:
templateID == 'new'
? __('New Email Template')
: __('Edit Email Template'),
size: 'lg',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveTemplate(close)
},
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
:label="__('Name')"
v-model="template.name"
type="text"
:required="true"
:placeholder="__('Batch Enrollment Confirmation')"
/>
<FormControl
:label="__('Subject')"
v-model="template.subject"
type="text"
:required="true"
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
/>
<FormControl
:label="__('Use HTML')"
v-model="template.use_html"
type="checkbox"
/>
<FormControl
v-if="template.use_html"
:label="__('Content')"
v-model="template.response_html"
type="textarea"
:required="true"
:rows="10"
:placeholder="
__(
'<p>Dear {{ member_name }},</p>\n\n<p>You have been enrolled in our upcoming batch {{ batch_name }}.</p>\n\n<p>Thanks,</p>\n<p>Frappe Learning</p>'
)
"
/>
<div v-else>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Content') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="template.response"
@change="(val) => (template.response = val)"
:editable="true"
:fixedMenu="true"
:placeholder="
__(
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
)
"
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>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { reactive, watch } from 'vue'
import { cleanError } from '@/utils'
const props = defineProps({
templateID: {
type: String,
default: 'new',
},
})
const show = defineModel()
const emailTemplates = defineModel('emailTemplates')
const template = reactive({
name: '',
subject: '',
use_html: false,
response: '',
response_html: '',
})
const saveTemplate = (close) => {
if (props.templateID == 'new') {
createNewTemplate(close)
} else {
updateTemplate(close)
}
}
const createNewTemplate = (close) => {
emailTemplates.value.insert.submit(
{
__newname: template.name,
...template,
},
{
onSuccess() {
emailTemplates.value.reload()
refreshForm(close)
toast.success(__('Email Template created successfully'))
},
onError(err) {
refreshForm(close)
toast.error(
cleanError(err.messages[0]) || __('Error creating email template')
)
},
}
)
}
const updateTemplate = async (close) => {
if (props.templateID != template.name) {
await renameDoc()
}
setValue(close)
}
const setValue = (close) => {
emailTemplates.value.setValue.submit(
{
...template,
name: template.name,
},
{
onSuccess() {
emailTemplates.value.reload()
refreshForm(close)
toast.success(__('Email Template updated successfully'))
},
onError(err) {
refreshForm(close)
toast.error(
cleanError(err.messages[0]) || __('Error updating email template')
)
},
}
)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'Email Template',
old_name: props.templateID,
new_name: template.name,
})
}
watch(
() => props.templateID,
(val) => {
if (val !== 'new') {
emailTemplates.value?.data.forEach((row) => {
if (row.name === val) {
template.name = row.name
template.subject = row.subject
template.use_html = row.use_html
template.response = row.response
template.response_html = row.response_html
}
})
}
},
{ flush: 'post' }
)
const refreshForm = (close) => {
close()
template.name = ''
template.subject = ''
template.use_html = false
template.response = ''
template.response_html = ''
}
</script>

View File

@@ -42,10 +42,11 @@
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div v-for="slot in slots.data"> <div v-for="slot in slots.data">
<div <div
class="text-base text-center border rounded-md bg-surface-gray-3 p-2 cursor-pointer" class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
@click="saveSlot(slot)" @click="saveSlot(slot)"
:class="{ :class="{
'border-gray-900': evaluation.start_time == slot.start_time, 'border-outline-gray-4':
evaluation.start_time == slot.start_time,
}" }"
> >
{{ formatTime(slot.start_time) }} - {{ formatTime(slot.start_time) }} -
@@ -65,9 +66,9 @@
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, createResource, Select, FormControl } from 'frappe-ui' import { Dialog, createResource, Select, FormControl, toast } 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')
@@ -89,7 +90,7 @@ const props = defineProps({
}, },
}) })
let evaluation = reactive({ const evaluation = reactive({
course: '', course: '',
date: '', date: '',
start_time: '', start_time: '',
@@ -138,7 +139,7 @@ function submitEvaluation(close) {
close() close()
}, },
onError(err) { onError(err) {
let message = err.messages?.[0] || err const message = err.messages?.[0] || err
let unavailabilityMessage let unavailabilityMessage
if (typeof message === 'string') { if (typeof message === 'string') {
@@ -147,20 +148,13 @@ function submitEvaluation(close) {
unavailabilityMessage = false unavailabilityMessage = false
} }
createToast({ toast.warning(__(unavailabilityMessage || '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,
})
}, },
}) })
} }
const getCourses = () => { const getCourses = () => {
let courses = [] const courses = []
for (const course of props.courses) { for (const course of props.courses) {
if (course.evaluator) { if (course.evaluator) {
courses.push({ courses.push({
@@ -170,7 +164,7 @@ const getCourses = () => {
} }
} }
if (courses.length == 1) { if (courses.length === 1) {
evaluation.course = courses[0].value evaluation.course = courses[0].value
} }

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

@@ -0,0 +1,115 @@
<template>
<Dialog
v-model="show"
:options="{
size: '4xl',
}"
>
<template #body>
<div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4">
{{ __('Training Feedback') }}
</div>
<ListView
:columns="feedbackColumns"
:rows="feedbackList"
row-key="name"
:options="{
showTooltip: false,
rowHeight: 'h-16',
selectable: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
></ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in feedbackList"
class="group feedback-list"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="ratingKeys.includes(column.key)">
<Rating v-model="row[column.key]" :readonly="true" />
</div>
<div v-else class="leading-5">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
ListView,
Avatar,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Rating,
} from 'frappe-ui'
import { reactive, computed } from 'vue'
const show = defineModel()
const ratingKeys = ['content', 'instructors', 'value']
const props = defineProps({
feedbackList: {
type: Array,
required: true,
},
})
const feedbackColumns = computed(() => {
return [
{
label: 'Member',
key: 'member_name',
width: '10rem',
},
{
label: 'Feedback',
key: 'feedback',
width: '15rem',
},
{
label: 'Content',
key: 'content',
width: '9rem',
},
{
label: 'Instructors',
key: 'instructors',
width: '9rem',
},
{
label: 'Value',
key: 'value',
width: '9rem',
},
]
})
</script>

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

@@ -15,27 +15,20 @@
> >
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <Rating v-model="review.rating" :label="__('Rating')" />
<div class="mb-1.5 text-sm text-ink-gray-5"> <FormControl
{{ __('Rating') }} :label="__('Review')"
</div> type="textarea"
<Rating v-model="review.rating" /> v-model="review.review"
</div> :rows="5"
<div> />
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Review') }}
</div>
<Textarea type="text" size="md" rows="5" v-model="review.review" />
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, Textarea, createResource } from 'frappe-ui' import { Dialog, FormControl, createResource, toast, Rating } from 'frappe-ui'
import { reactive } from 'vue' import { reactive } from '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 +71,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">
@@ -51,6 +51,11 @@
:label="activeTab.label" :label="activeTab.label"
:description="activeTab.description" :description="activeTab.description"
/> />
<EmailTemplates
v-else-if="activeTab.label === 'Email Templates'"
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings <PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'" v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label" :label="activeTab.label"
@@ -86,6 +91,7 @@ import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue' import Members from '@/components/Members.vue'
import Evaluators from '@/components/Evaluators.vue' import Evaluators from '@/components/Evaluators.vue'
import Categories from '@/components/Categories.vue' import Categories from '@/components/Categories.vue'
import EmailTemplates from '@/components/EmailTemplates.vue'
import BrandSettings from '@/components/BrandSettings.vue' import BrandSettings from '@/components/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue' import PaymentSettings from '@/components/PaymentSettings.vue'
@@ -122,7 +128,7 @@ const tabsStructure = computed(() => {
label: 'Enable Learning Paths', label: 'Enable Learning Paths',
name: 'enable_learning_paths', name: 'enable_learning_paths',
description: description:
'This will enforce students to go through programs assigned to them in the correct order.', 'This will ensure students follow the assigned programs in order.',
type: 'checkbox', type: 'checkbox',
}, },
{ {
@@ -139,11 +145,26 @@ const tabsStructure = computed(() => {
'If enabled, it sends google calendar invite to the student for evaluations.', 'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox', type: 'checkbox',
}, },
{
type: 'Column Break',
},
{
label: 'Batch Confirmation Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
{ {
label: 'Unsplash Access Key', label: 'Unsplash Access Key',
name: 'unsplash_access_key', name: 'unsplash_access_key',
description: description:
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.', 'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
type: 'password', type: 'password',
}, },
], ],
@@ -160,6 +181,12 @@ const tabsStructure = computed(() => {
description: description:
'Configure the payment gateway and other payment related settings', 'Configure the payment gateway and other payment related settings',
fields: [ fields: [
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
},
{ {
label: 'Payment Gateway', label: 'Payment Gateway',
name: 'payment_gateway', name: 'payment_gateway',
@@ -167,10 +194,7 @@ const tabsStructure = computed(() => {
doctype: 'Payment Gateway', doctype: 'Payment Gateway',
}, },
{ {
label: 'Default Currency', type: 'Column Break',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
}, },
{ {
label: 'Apply GST for India', label: 'Apply GST for India',
@@ -207,9 +231,14 @@ const tabsStructure = computed(() => {
}, },
{ {
label: 'Categories', label: 'Categories',
description: 'Manage the members of your learning system', description: 'Double click to edit the category',
icon: 'Network', icon: 'Network',
}, },
{
label: 'Email Templates',
description: 'Manage the email templates for your learning system',
icon: 'MailPlus',
},
], ],
}, },
{ {
@@ -235,28 +264,6 @@ const tabsStructure = computed(() => {
name: 'favicon', name: 'favicon',
type: 'Upload', type: 'Upload',
}, },
{
label: 'Footer Logo',
name: 'footer_logo',
type: 'Upload',
},
{
label: 'Address',
name: 'address',
type: 'textarea',
rows: 2,
},
{
label: 'Footer "Powered By"',
name: 'footer_powered',
type: 'textarea',
rows: 4,
},
{
label: 'Copyright',
name: 'copyright',
type: 'text',
},
], ],
}, },
{ {
@@ -299,24 +306,6 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'Email Templates',
icon: 'MailPlus',
fields: [
{
label: 'Batch Confirmation Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
],
},
{ {
label: 'Signup', label: 'Signup',
icon: 'LogIn', icon: 'LogIn',

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

@@ -12,13 +12,13 @@
/> --> /> -->
</div> </div>
<div class="overflow-y-scroll"> <div class="overflow-y-scroll">
<div class="flex space-x-4"> <div class="flex flex-col divide-y">
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" /> <SettingFields :fields="fields" :data="data.doc" />
<SettingFields <SettingFields
v-if="paymentGateway.data" v-if="paymentGateway.data"
:fields="paymentGateway.data.fields" :fields="paymentGateway.data.fields"
:data="paymentGateway.data.data" :data="paymentGateway.data.data"
class="w-1/2" class="pt-5 my-0"
/> />
</div> </div>
</div> </div>
@@ -60,9 +60,28 @@ const paymentGateway = createResource({
payment_gateway: props.data.doc.payment_gateway, payment_gateway: props.data.doc.payment_gateway,
} }
}, },
transform(data) {
arrangeFields(data.fields)
return data
},
auto: true, auto: true,
}) })
const arrangeFields = (fields) => {
fields = fields.sort((a, b) => {
if (a.type === 'Upload' && b.type !== 'Upload') {
return 1
} else if (a.type !== 'Upload' && b.type === 'Upload') {
return -1
}
return 0
})
fields.splice(3, 0, {
type: 'Column Break',
})
}
const saveSettings = createResource({ const saveSettings = createResource({
url: 'frappe.client.set_value', url: 'frappe.client.set_value',
makeParams(values) { makeParams(values) {

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.warning(__('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

@@ -6,7 +6,7 @@
<div v-for="(column, index) in columns" :key="index"> <div v-for="(column, index) in columns" :key="index">
<div <div
class="flex flex-col space-y-5" class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-72' : 'w-full'" :class="columns.length > 1 ? 'w-[21rem]' : 'w-1/2'"
> >
<div v-for="field in column"> <div v-for="field in column">
<Link <Link
@@ -14,6 +14,7 @@
v-model="data[field.name]" v-model="data[field.name]"
:doctype="field.doctype" :doctype="field.doctype"
:label="__(field.label)" :label="__(field.label)"
:description="__(field.description)"
/> />
<div v-else-if="field.type == 'Code'"> <div v-else-if="field.type == 'Code'">
@@ -54,11 +55,11 @@
<div v-else> <div v-else>
<div class="flex items-center text-sm space-x-2"> <div class="flex items-center text-sm space-x-2">
<div <div
class="flex items-center justify-center rounded border border-outline-gray-modals bg-white w-[10rem] py-2" class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2 px-20 py-5"
> >
<img <img
:src="data[field.name]?.file_url || data[field.name]" :src="data[field.name]?.file_url || data[field.name]"
class="w-[80%] rounded" class="size-6 rounded"
/> />
</div> </div>
<div class="flex flex-col flex-wrap"> <div class="flex flex-col flex-wrap">

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Upcoming Evaluations') }} {{ __('Upcoming Evaluations') }}
</div> </div>
<Button <Button
@@ -17,9 +17,9 @@
<div v-if="upcoming_evals.data?.length"> <div v-if="upcoming_evals.data?.length">
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div v-for="evl in upcoming_evals.data"> <div v-for="evl in upcoming_evals.data">
<div class="border rounded-md p-3"> <div class="border text-ink-gray-7 rounded-md p-3">
<div class="flex justify-between mb-3"> <div class="flex justify-between mb-3">
<span class="font-semibold leading-5"> <span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }} {{ evl.course_title }}
</span> </span>
<Menu <Menu
@@ -42,7 +42,7 @@
leave-to-class="transform scale-95 opacity-0" leave-to-class="transform scale-95 opacity-0"
> >
<MenuItems <MenuItems
class="absolute mt-2 w-32 rounded-md bg-white shadow-lg p-1.5" class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
> >
<MenuItem v-slot="{ active }"> <MenuItem v-slot="{ active }">
<Button <Button
@@ -82,12 +82,11 @@
{{ evl.evaluator_name }} {{ evl.evaluator_name }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between space-x-2 mt-4"> <div
<Button
v-if="evl.google_meet_link" v-if="evl.google_meet_link"
@click="openEvalCall(evl)" class="flex items-center justify-between space-x-2 mt-4"
class="w-full"
> >
<Button @click="openEvalCall(evl)" class="w-full">
<template #prefix> <template #prefix>
<HeadsetIcon class="w-4 h-4 stroke-1.5" /> <HeadsetIcon class="w-4 h-4 stroke-1.5" />
</template> </template>

View File

@@ -21,8 +21,21 @@
</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
v-if="assignmentCount"
class="text-xl font-semibold text-ink-gray-7 mb-4"
>
{{ __('{0} Assignments').format(assignmentCount) }}
</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 <FormControl
v-model="typeFilter" v-model="typeFilter"
type="select" type="select"
@@ -30,6 +43,7 @@
:placeholder="__('Type')" :placeholder="__('Type')"
/> />
</div> </div>
</div>
<ListView <ListView
v-if="assignments.data?.length" v-if="assignments.data?.length"
:columns="assignmentColumns" :columns="assignmentColumns"
@@ -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" />
@@ -88,16 +88,14 @@
:scrollToBottom="false" :scrollToBottom="false"
/> />
</div> </div>
<div v-else-if="tab.label == 'Feedback'">
<BatchFeedback :batch="batch.data.name" />
</div>
</div> </div>
</template> </template>
</Tabs> </Tabs>
</div> </div>
<div class="p-5"> <div class="p-5">
<div class="text-ink-gray-7 font-semibold mb-4"> <div class="mb-10">
{{ __('About this batch') }}: <div class="text-ink-gray-7 font-semibold mb-2">
{{ __('About this batch') }}
</div> </div>
<div <div
v-html="batch.data.description" v-html="batch.data.description"
@@ -140,6 +138,13 @@
</span> </span>
</div> </div>
</div> </div>
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('Feedback') }}
</div>
<BatchFeedback :batch="batch.data?.name" />
</div>
</div>
<AnnouncementModal <AnnouncementModal
v-model="showAnnouncementModal" v-model="showAnnouncementModal"
:batch="batch.data.name" :batch="batch.data.name"
@@ -234,6 +239,7 @@ import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue' import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue' import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue' import BatchFeedback from '@/components/BatchFeedback.vue'
import dayjs from 'dayjs/esm'
const user = inject('$user') const user = inject('$user')
const showAnnouncementModal = ref(false) const showAnnouncementModal = ref(false)
@@ -277,11 +283,6 @@ const tabs = computed(() => {
label: 'Discussions', label: 'Discussions',
icon: MessageCircle, icon: MessageCircle,
}) })
batchTabs.push({
label: 'Feedback',
icon: ClipboardPen,
})
return batchTabs return batchTabs
}) })
@@ -357,6 +358,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,42 +6,15 @@
<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="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9"> <div class="text-3xl font-semibold text-ink-gray-9">
{{ batch.data.title }} {{ batch.data.title }}
</div> </div>
<div class="my-3 leading-6 text-ink-gray-7"> <div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }} {{ batch.data.description }}
</div> </div>
<div <div class="flex avatar-group overlap">
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>
<span v-if="batch.data?.courses?.length" class="hidden lg:block"
>&middot;</span
>
<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 class="flex avatar-group overlap mt-3">
<div <div
class="h-6 mr-1" class="h-6 mr-1"
:class="{ :class="{
@@ -55,18 +28,16 @@
</div> </div>
<CourseInstructors :instructors="batch.data.instructors" /> <CourseInstructors :instructors="batch.data.instructors" />
</div> </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>
<div class="order-1 lg:order-none"> <div class="hidden md:block">
<BatchOverlay :batch="batch" /> <BatchOverlay :batch="batch" />
</div> </div>
</div> </div>
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
<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,12 +26,26 @@
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">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5">
<FormControl <FormControl
v-model="batch.published" v-model="batch.published"
type="checkbox" type="checkbox"
@@ -48,9 +62,131 @@
:label="__('Certification')" :label="__('Certification')"
/> />
</div> </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> <div>
<div class="text-xs text-ink-gray-5 mb-2"> <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"
:onCreate="
(value, close) => {
openSettings('Email Templates', close)
}
"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.medium"
type="select"
:options="[
{
label: 'Online',
value: 'Online',
},
{
label: 'Offline',
value: 'Offline',
},
]"
:label="__('Medium')"
class="mb-4"
/>
<Link
doctype="LMS Category"
:label="__('Category')"
v-model="batch.category"
:onCreate="(value, close) => openSettings('Categories', close)"
/>
</div>
<div class="space-y-5">
<div>
<div class="text-xs text-ink-gray-5">
{{ __('Meta Image') }} {{ __('Meta Image') }}
</div> </div>
<FileUploader <FileUploader
@@ -70,11 +206,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 +240,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 +263,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 +283,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 +464,7 @@ const createNewBatch = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Message', err.messages?.[0] || err, 'alert-circle') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -478,7 +483,7 @@ const editBatchDetails = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Message', err.messages?.[0] || err, 'alert-circle') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -70,22 +70,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 +86,7 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
Select, Select,
@@ -107,9 +94,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')

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

@@ -20,7 +20,7 @@
:text="__('Average Rating')" :text="__('Average Rating')"
class="flex items-center" class="flex items-center"
> >
<Star class="h-5 w-5 text-gray-100 fill-orange-500" /> <Star class="size-4 text-transparent fill-yellow-500" />
<span class="ml-1 text-ink-gray-7"> <span class="ml-1 text-ink-gray-7">
{{ course.data.rating }} {{ course.data.rating }}
</span> </span>

View File

@@ -19,41 +19,72 @@
</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>
<div class="grid grid-cols-2 gap-5">
<FormControl <FormControl
v-model="course.title" v-model="course.title"
:label="__('Title')" :label="__('Title')"
class="mb-4"
:required="true" :required="true"
/> />
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings('Categories', close)"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<MultiSelect
v-model="instructors"
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>
<div class="grid grid-cols-2 gap-5">
<FormControl <FormControl
v-model="course.short_introduction" v-model="course.short_introduction"
type="textarea"
:rows="4"
:label="__('Short Introduction')" :label="__('Short Introduction')"
:placeholder=" :placeholder="
__( __(
'A one line introduction to the course that appears on the course card' 'A one line introduction to the course that appears on the course card'
) )
" "
class="mb-4"
:required="true" :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 class="mb-4"> <div class="mb-4">
<div class="text-xs text-ink-gray-5 mb-2"> <div class="text-xs text-ink-gray-5 mb-2">
{{ __('Course Image') }} {{ __('Course Image') }}
@@ -76,7 +107,7 @@
<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 on the course card in the course list') __('Appears on the course card in the course list')
}} }}
@@ -96,72 +127,23 @@
{{ __('Remove') }} {{ __('Remove') }}
</Button> </Button>
<div class="mt-2 text-ink-gray-5 text-sm"> <div class="mt-2 text-ink-gray-5 text-sm">
{{ __('Appears on the course card in the course list') }} {{
__('Appears on the course card in the course list')
}}
</div> </div>
</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> </div>
<div class="w-1/2 mb-4">
<Link <div class="px-10 pb-5 mb-5 space-y-5 border-b">
doctype="LMS Category" <div class="text-lg font-semibold">
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 class="container border-t">
<div class="text-lg font-semibold mt-5 mb-4">
{{ __('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,20 +222,32 @@
: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> </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 class="border-l"> <div class="border-l">
@@ -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

@@ -66,22 +66,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 +89,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 +107,12 @@ 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()
setFiltersFromQuery() setFiltersFromQuery()
updateCourses() updateCourses()
getCourseCount()
categories.value = [ categories.value = [
{ {
label: '', label: '',
@@ -175,19 +161,25 @@ 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', {
doctype: 'LMS Course',
}).then((data) => {
if (!data) {
router.push({ router.push({
name: 'PersonaForm', name: 'PersonaForm',
}) })
} }
})
} }
} }
const getCourseCount = () => {
if (!user.data) return
call('frappe.client.get_count', {
doctype: 'LMS Course',
}).then((data) => {
courseCount.value = data
identifyUserPersona()
})
}
const updateCourses = () => { const updateCourses = () => {
updateFilters() updateFilters()
courses.update({ courses.update({

View File

@@ -67,86 +67,61 @@
</header> </header>
<div v-if="job.data" class="max-w-3xl mx-auto pt-5"> <div v-if="job.data" class="max-w-3xl mx-auto pt-5">
<div class="p-4"> <div class="p-4">
<div class="space-y-5 mb-10"> <div class="space-y-5 mb-12">
<div class="flex items-center"> <div class="flex">
<img <img
:src="job.data.company_logo" :src="job.data.company_logo"
class="size-10 rounded-lg object-contain cursor-pointer mr-4" class="size-10 rounded-lg object-contain cursor-pointer mr-4"
:alt="job.data.company_name" :alt="job.data.company_name"
@click="redirectToWebsite(job.data.company_website)" @click="redirectToWebsite(job.data.company_website)"
/> />
<div class="text-2xl text-ink-gray-9 font-semibold"> <div class="">
<div class="text-2xl text-ink-gray-9 font-semibold mb-1">
{{ job.data.job_title }} {{ job.data.job_title }}
</div> </div>
</div> <div class="text-sm text-ink-gray-5 font-semibold">
<div> {{ job.data.company_name }} - {{ job.data.location }},
<div {{ job.data.country }}
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5"
>
<div class="flex items-center space-x-4">
<Building2 class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Organisation') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.company_name }}
</span>
</div> </div>
</div> </div>
<div class="flex items-center space-x-4">
<MapPin class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Location') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.location }}, {{ job.data.country }}
</span>
</div> </div>
</div>
<div class="flex items-center space-x-4"> <div class="space-x-5">
<ClipboardType class="size-4 stroke-1.5 text-ink-gray-7" /> <Badge size="lg">
<div class="flex flex-col space-y-1 text-ink-gray-7"> <template #prefix>
<span class="text-xs text-ink-gray-5 font-medium uppercase"> <CalendarDays class="size-3 stroke-2 text-ink-gray-7" />
{{ __('Category') }} </template>
</span> {{ dayjs(job.data.creation).fromNow() }}
<span class="text-sm font-semibold"> </Badge>
<Badge size="lg">
<template #prefix>
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ job.data.type }} {{ job.data.type }}
</span> </Badge>
</div> <Badge v-if="applicationCount.data" size="lg">
</div> <template #prefix>
<div class="flex items-center space-x-4"> <SquareUserRound class="size-3 stroke-2 text-ink-gray-7" />
<CalendarDays class="size-4 stroke-1.5 text-ink-gray-7" /> </template>
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Posted on') }}
</span>
<span class="text-sm font-semibold">
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
</span>
</div>
</div>
<div
v-if="applicationCount.data"
class="flex items-center space-x-4"
>
<SquareUserRound class="size-4 stroke-1.5 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs text-ink-gray-5 font-medium uppercase">
{{ __('Applications Received') }}
</span>
<span class="text-sm font-semibold">
{{ applicationCount.data }} {{ applicationCount.data }}
</span> {{
</div> applicationCount.data == 1 ? __('applicant') : __('applicants')
}}
</Badge>
</div> </div>
</div> </div>
<div class="flex items-center justify-between">
<div class="bg-surface-gray-2 h-px m-1 w-1/2"></div>
<div>
<FileText class="size-3 stroke-1 text-ink-gray-5" />
</div> </div>
<div class="bg-surface-gray-2 h-px m-1 w-1/2"></div>
</div> </div>
<p <p
v-html="job.data.description" v-html="job.data.description"
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-12"
></p> ></p>
</div> </div>
<JobApplicationModal <JobApplicationModal
@@ -169,15 +144,14 @@ import { inject, ref } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue' import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
import { import {
MapPin,
Check, Check,
SendHorizonal, SendHorizonal,
Pencil, Pencil,
Building2,
CalendarDays, CalendarDays,
ClipboardType,
SquareUserRound, SquareUserRound,
SquareArrowOutUpRight, SquareArrowOutUpRight,
FileText,
ClipboardType,
} from 'lucide-vue-next' } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
@@ -252,3 +226,12 @@ usePageMeta(() => {
} }
}) })
</script> </script>
<style>
p {
margin-bottom: 0.5rem !important;
line-height: 1.5;
}
p span {
line-height: 1.5;
}
</style>

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

@@ -334,7 +334,6 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
startTimer() startTimer()
enablePlyr()
document.addEventListener('fullscreenchange', attachFullscreenEvent) document.addEventListener('fullscreenchange', attachFullscreenEvent)
}) })
@@ -473,6 +472,7 @@ watch(
() => lesson.data, () => lesson.data,
(data) => { (data) => {
setupLesson(data) setupLesson(data)
enablePlyr()
} }
) )

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

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex h-screen overflow-hidden sm:bg-gray-50"> <div class="flex h-screen overflow-hidden sm:bg-gray-50">
<div class="relative h-full z-10 mx-auto pt-8 sm:w-max sm:pt-32"> <div class="relative h-full z-10 mx-auto sm:w-max pt-40">
<div class="mx-auto flex items-center justify-center space-x-2"> <div class="mx-auto flex items-center justify-center space-x-2">
<LMSLogo class="size-7" /> <LMSLogo class="size-7" />
<span <span
@@ -18,7 +18,7 @@
<div class="mb-5"> <div class="mb-5">
<div class="text-sm text-gray-700 mb-2"> <div class="text-sm text-gray-700 mb-2">
{{ __('What is your main use case for Frappe Learning?') }} {{ __('What is your use case for Frappe Learning?') }}
</div> </div>
<FormControl <FormControl
v-model="persona.useCase" v-model="persona.useCase"
@@ -29,12 +29,12 @@
<div class="mb-5"> <div class="mb-5">
<div class="text-sm text-gray-700 mb-2"> <div class="text-sm text-gray-700 mb-2">
{{ __('How many students are you planning to teach?') }} {{ __('What best describes your role?') }}
</div> </div>
<FormControl <FormControl
v-model="persona.noOfStudents" v-model="persona.role"
type="select" type="select"
:options="noOfStudentsOptions" :options="roleOptions"
/> />
</div> </div>
@@ -65,7 +65,7 @@ const router = useRouter()
const { brand } = sessionStore() const { brand } = sessionStore()
const persona = reactive({ const persona = reactive({
noOfStudents: null, role: null,
useCase: null, useCase: null,
}) })
@@ -97,6 +97,24 @@ const skipPersonaForm = () => {
}) })
} }
const roleOptions = computed(() => {
const options = [
'Trainer / Instructor',
'Freelancer / Consultant',
'HR / L&D Professional',
'School / University Admin',
'Software Developer',
'Community Manager',
'Business Owner / Team Lead',
'Other',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
const noOfStudentsOptions = computed(() => { const noOfStudentsOptions = computed(() => {
const options = [ const options = [
'Less than 50', 'Less than 50',

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

@@ -2,10 +2,10 @@
<header <header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs v-if="submisisonDetails.doc" :items="breadcrumbs" /> <Breadcrumbs v-if="submissionDetails.doc" :items="breadcrumbs" />
<div class="space-x-2"> <div class="space-x-2">
<Badge <Badge
v-if="submisisonDetails.isDirty" v-if="submissionDetails.isDirty"
:label="__('Not Saved')" :label="__('Not Saved')"
variant="subtle" variant="subtle"
theme="orange" theme="orange"
@@ -15,19 +15,19 @@
</Button> </Button>
</div> </div>
</header> </header>
<div v-if="submisisonDetails.doc" class="w-1/2 mx-auto py-5 space-y-5"> <div v-if="submissionDetails.doc" class="w-2/3 border-x mx-auto py-5">
<div class="text-xl font-semibold text-ink-gray-9"> <div class="text-xl px-10 font-semibold text-ink-gray-9 mb-5">
{{ submisisonDetails.doc.member_name }} {{ submissionDetails.doc.member_name }}
</div> </div>
<div class="space-y-4 border p-5 rounded-md"> <div class="space-y-4 border-b pb-5 px-10">
<div class="grid grid-cols-2 gap-5"> <div class="grid grid-cols-2 gap-5">
<FormControl <FormControl
v-model="submisisonDetails.doc.quiz_title" v-model="submissionDetails.doc.quiz_title"
:label="__('Quiz')" :label="__('Quiz')"
:disabled="true" :disabled="true"
/> />
<FormControl <FormControl
v-model="submisisonDetails.doc.member_name" v-model="submissionDetails.doc.member_name"
:label="__('Member')" :label="__('Member')"
:disabled="true" :disabled="true"
/> />
@@ -35,31 +35,30 @@
<div class="grid grid-cols-2 gap-5"> <div class="grid grid-cols-2 gap-5">
<FormControl <FormControl
v-model="submisisonDetails.doc.score" v-model="submissionDetails.doc.score"
:label="__('Score')" :label="__('Score')"
:disabled="true" :disabled="true"
/> />
<FormControl <FormControl
v-model="submisisonDetails.doc.percentage" v-model="submissionDetails.doc.percentage"
:label="__('Percentage')" :label="__('Percentage')"
:disabled="true" :disabled="true"
/> />
</div> </div>
</div> </div>
<div class="divide-y">
<div <div
v-for="(row, index) in submisisonDetails.doc.result" v-for="(row, index) in submissionDetails.doc.result"
class="border p-5 rounded-md space-y-4" class="py-5 px-10 space-y-4"
> >
<div class="flex items-start space-x-1 font-semibold text-ink-gray-9"> <div class="text-ink-gray-9">
<!-- <span> <span class="font-semibold"> {{ __('Question') }}: </span>
{{ index + 1 }}.
</span> -->
<span class="leading-5" v-html="row.question"> </span> <span class="leading-5" v-html="row.question"> </span>
</div> </div>
<div class="leading-5 text-ink-gray-7 space-x-1"> <div class="">
<span> {{ __('Answer') }}: </span> <span class="font-semibold"> {{ __('Answer') }} </span>
<span v-html="row.answer"></span> <span class="leading-5" v-html="row.answer"></span>
</div> </div>
<div class="grid grid-cols-2 gap-5"> <div class="grid grid-cols-2 gap-5">
<FormControl v-model="row.marks" :label="__('Marks')" /> <FormControl v-model="row.marks" :label="__('Marks')" />
@@ -71,6 +70,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { import {
@@ -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()
@@ -119,7 +119,7 @@ const props = defineProps({
}, },
}) })
const submisisonDetails = createDocumentResource({ const submissionDetails = createDocumentResource({
doctype: 'LMS Quiz Submission', doctype: 'LMS Quiz Submission',
name: props.submission, name: props.submission,
auto: true, auto: true,
@@ -132,22 +132,22 @@ const breadcrumbs = computed(() => {
route: { route: {
name: 'QuizSubmissionList', name: 'QuizSubmissionList',
params: { params: {
quizID: submisisonDetails.doc.quiz, quizID: submissionDetails.doc.quiz,
}, },
}, },
}, },
{ {
label: submisisonDetails.doc.quiz_title, label: submissionDetails.doc.quiz_title,
}, },
] ]
}) })
const saveSubmission = () => { const saveSubmission = () => {
submisisonDetails.save.submit( submissionDetails.save.submit(
{}, {},
{ {
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -155,7 +155,7 @@ const saveSubmission = () => {
usePageMeta(() => { usePageMeta(() => {
return { return {
title: `${submisisonDetails.doc.quiz_title}`, title: `${submissionDetails.doc?.quiz_title}`,
icon: brand.favicon, icon: brand.favicon,
} }
}) })

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

@@ -7,14 +7,19 @@
</header> </header>
<div v-if="chartDetails.data" class="p-5"> <div v-if="chartDetails.data" class="p-5">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<Tooltip :text="__('Published Courses')">
<NumberChart <NumberChart
class="border rounded-md" class="border rounded-md"
:config="{ title: 'Courses', value: chartDetails.data.courses }" :config="{ title: 'Courses', value: chartDetails.data.courses }"
/> />
</Tooltip>
<Tooltip :text="__('Active Members')">
<NumberChart <NumberChart
class="border rounded-md" class="border rounded-md"
:config="{ title: 'Signups', value: chartDetails.data.users }" :config="{ title: 'Signups', value: chartDetails.data.users }"
/> />
</Tooltip>
<Tooltip :text="__('Course Enrollments')">
<NumberChart <NumberChart
class="border rounded-md" class="border rounded-md"
:config="{ :config="{
@@ -22,6 +27,8 @@
value: chartDetails.data.enrollments, value: chartDetails.data.enrollments,
}" }"
/> />
</Tooltip>
<Tooltip :text="__('Course Completions')">
<NumberChart <NumberChart
class="border rounded-md" class="border rounded-md"
:config="{ :config="{
@@ -29,6 +36,8 @@
value: chartDetails.data.completions, value: chartDetails.data.completions,
}" }"
/> />
</Tooltip>
<Tooltip :text="__('Certified Members')">
<NumberChart <NumberChart
class="border rounded-md" class="border rounded-md"
:config="{ :config="{
@@ -36,6 +45,7 @@
value: chartDetails.data.certifications, value: chartDetails.data.certifications,
}" }"
/> />
</Tooltip>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
<div class="border rounded-md min-h-72"> <div class="border rounded-md min-h-72">
@@ -129,6 +139,7 @@ import {
createResource, createResource,
DonutChart, DonutChart,
NumberChart, NumberChart,
Tooltip,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'

View File

@@ -1,98 +1,96 @@
import { useStorage } from "@vueuse/core"; import '../../../frappe/frappe/public/js/lib/posthog.js'
import { call } from "frappe-ui"; import { createResource } from 'frappe-ui'
import "../../../frappe/frappe/public/js/lib/posthog.js";
const APP = "lms";
const SITENAME = window.location.hostname;
declare global { declare global {
interface Window { interface Window {
posthog: any; posthog: any
} }
} }
type PosthogSettings = {
const telemetry = useStorage("telemetry", { posthog_project_id: string
enabled: false, posthog_host: string
project_id: "", enable_telemetry: boolean
host: "", telemetry_site_age: number
});
export async function init() {
await set_enabled();
if (!telemetry.value.enabled) return;
try {
await set_credentials();
window.posthog.init(telemetry.value.project_id, {
api_host: telemetry.value.host,
autocapture: false,
person_profiles: "always",
capture_pageview: true,
capture_pageleave: true,
disable_session_recording: false,
session_recording: {
maskAllInputs: false,
maskInputOptions: {
password: true,
},
},
loaded: (posthog) => {
window.posthog = posthog;
window.posthog.identify(SITENAME);
},
});
} catch (e) {
console.trace("Failed to initialize telemetry", e);
telemetry.value.enabled = false;
}
} }
async function set_enabled() {
if (telemetry.value.enabled) return;
await call("lms.lms.telemetry.is_enabled").then((res) => {
telemetry.value.enabled = res;
});
}
async function set_credentials() {
if (!telemetry.value.enabled) return;
if (telemetry.value.project_id && telemetry.value.host) return;
await call("lms.lms.telemetry.get_credentials").then((res) => {
telemetry.value.project_id = res.project_id;
telemetry.value.host = res.telemetry_host;
});
}
interface CaptureOptions { interface CaptureOptions {
data: { data: {
user: string; user: string
[key: string]: string | number | boolean | object; [key: string]: string | number | boolean | object
}; }
} }
export function capture( let posthog: typeof window.posthog = window.posthog
// Posthog Settings
let posthogSettings = createResource({
url: 'lms.lms.telemetry.get_posthog_settings',
cache: 'posthog_settings',
onSuccess: (ps: PosthogSettings) => initPosthog(ps),
})
let isTelemetryEnabled = () => {
if (!posthogSettings.data) return false
return (
posthogSettings.data.enable_telemetry &&
posthogSettings.data.posthog_project_id &&
posthogSettings.data.posthog_host
)
}
// Posthog Initialization
function initPosthog(ps: PosthogSettings) {
if (!isTelemetryEnabled()) return
posthog.init(ps.posthog_project_id, {
api_host: ps.posthog_host,
person_profiles: 'identified_only',
autocapture: false,
capture_pageview: true,
capture_pageleave: true,
enable_heatmaps: false,
disable_session_recording: false,
loaded: (ph: typeof posthog) => {
window.posthog = ph
ph.identify(window.location.hostname)
},
})
}
// Posthog Functions
function capture(
event: string, event: string,
options: CaptureOptions = { data: { user: "" } } options: CaptureOptions = { data: { user: '' } },
) { ) {
if (!telemetry.value.enabled) return; if (!isTelemetryEnabled()) return
window.posthog.capture(`${APP}_${event}`, options); window.posthog.capture(`lms_${event}`, options)
} }
export function recordSession() { function startRecording() {
if (!telemetry.value.enabled) return; if (!isTelemetryEnabled()) return
if (window.posthog && window.posthog.__loaded) { if (window.posthog?.__loaded) {
window.posthog.startSessionRecording(); window.posthog.startSessionRecording()
} }
} }
export function stopSession() { function stopRecording() {
if (!telemetry.value.enabled) return; if (!isTelemetryEnabled()) return
if ( if (window.posthog?.__loaded && window.posthog.sessionRecordingStarted()) {
window.posthog && window.posthog.stopSessionRecording()
window.posthog.__loaded &&
window.posthog.sessionRecordingStarted()
) {
window.posthog.stopSessionRecording();
} }
} }
// Posthog Plugin
function posthogPlugin(app: any) {
app.config.globalProperties.posthog = posthog
if (!window.posthog?.length) posthogSettings.fetch()
}
export {
posthog,
posthogSettings,
posthogPlugin,
capture,
startRecording,
stopRecording,
}

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()
@@ -558,12 +531,13 @@ export const enablePlyr = () => {
const videoElement = document.getElementsByClassName('video-player') const videoElement = document.getElementsByClassName('video-player')
if (videoElement.length === 0) return if (videoElement.length === 0) return
const src = videoElement[0].getAttribute('src') Array.from(videoElement).forEach((video) => {
const src = video.getAttribute('src')
if (src) { if (src) {
let videoID = src.split('/').pop() let videoID = src.split('/').pop()
videoElement[0].setAttribute('data-plyr-embed-id', videoID) video.setAttribute('data-plyr-embed-id', videoID)
} }
new Plyr('.video-player', { new Plyr(video, {
youtube: { youtube: {
noCookie: true, noCookie: true,
}, },
@@ -578,4 +552,33 @@ export const enablePlyr = () => {
], ],
}) })
}, 500) }, 500)
})
}
export const openSettings = (category, close) => {
const settingsStore = useSettings()
close()
settingsStore.activeTab = category
settingsStore.isSettingsOpen = true
}
export const cleanError = (message) => {
// Remove HTML tags but keep the text within the tags
const cleanMessage = message.replace(/<[^>]+>/g, (match) => {
return match.replace(/<\/?[^>]+(>|$)/g, '')
})
return cleanMessage
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#x60;/g, '`')
.replace(/&#x3D;/g, '=')
.replace(/&#x2F;/g, '/')
.replace(/&#x2C;/g, ',')
.replace(/&#x3B;/g, ';')
.replace(/&#x3A;/g, ':')
} }

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

@@ -1 +1 @@
__version__ = "2.28.0" __version__ = "2.29.0"

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

@@ -11,7 +11,7 @@ def after_install():
def after_sync(): def after_sync():
create_lms_roles() create_lms_roles()
set_default_certificate_print_format() set_default_certificate_print_format()
add_all_roles_to("Administrator") give_lms_roles_to_admin()
def before_uninstall(): def before_uninstall():
@@ -172,3 +172,15 @@ def create_batch_source():
doc = frappe.new_doc("LMS Source") doc = frappe.new_doc("LMS Source")
doc.source = source doc.source = source
doc.save() doc.save()
def give_lms_roles_to_admin():
roles = ["Course Creator", "Moderator", "Batch Evaluator"]
for role in roles:
if not frappe.db.exists("Has Role", {"parent": "Administrator", "role": role}):
doc = frappe.new_doc("Has Role")
doc.parent = "Administrator"
doc.parenttype = "User"
doc.parentfield = "roles"
doc.role = role
doc.save()

View File

@@ -356,6 +356,7 @@
"fieldtype": "Section Break" "fieldtype": "Section Break"
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [ "links": [
{ {
@@ -371,8 +372,8 @@
"link_fieldname": "batch_name" "link_fieldname": "batch_name"
} }
], ],
"modified": "2025-02-18 15:43:18.512504", "modified": "2025-05-21 13:30:28.904260",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",
"owner": "Administrator", "owner": "Administrator",
@@ -412,8 +413,18 @@
"role": "Batch Evaluator", "role": "Batch Evaluator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",

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,8 +99,11 @@ 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) and 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."))
def validate_timetable(self): def validate_timetable(self):
@@ -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

@@ -73,10 +73,11 @@
"read_only": 1 "read_only": 1
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-01-13 19:02:58.259908", "modified": "2025-05-21 15:58:51.667270",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch Feedback", "name": "LMS Batch Feedback",
"owner": "Administrator", "owner": "Administrator",
@@ -106,7 +107,9 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"title_field": "member"
} }

View File

@@ -96,9 +96,7 @@ def set_total_marks(questions):
@frappe.whitelist() @frappe.whitelist()
def quiz_summary(quiz, results): def quiz_summary(quiz, results):
score = 0
results = results and json.loads(results) results = results and json.loads(results)
is_open_ended = False
percentage = 0 percentage = 0
quiz_details = frappe.db.get_value( quiz_details = frappe.db.get_value(
@@ -108,7 +106,32 @@ def quiz_summary(quiz, results):
as_dict=1, as_dict=1,
) )
data = process_results(results, quiz)
results = data["results"]
score = data["score"]
is_open_ended = data["is_open_ended"]
score_out_of = quiz_details.total_marks score_out_of = quiz_details.total_marks
percentage = (score / score_out_of) * 100 if score_out_of else 0
submission = create_submission(
quiz, results, score_out_of, quiz_details.passing_percentage
)
save_progress_after_quiz(quiz_details, percentage)
return {
"score": score,
"score_out_of": score_out_of,
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
"percentage": percentage,
"is_open_ended": is_open_ended,
}
def process_results(results, quiz):
score = 0
is_open_ended = False
for result in results: for result in results:
question_details = frappe.db.get_value( question_details = frappe.db.get_value(
@@ -123,55 +146,28 @@ def quiz_summary(quiz, results):
result["marks_out_of"] = question_details.marks result["marks_out_of"] = question_details.marks
if question_details.type != "Open Ended": if question_details.type != "Open Ended":
if len(result["is_correct"]) > 0:
correct = result["is_correct"][0] correct = result["is_correct"][0]
for point in result["is_correct"]: for point in result["is_correct"]:
correct = correct and point correct = correct and point
result["is_correct"] = correct result["is_correct"] = correct
else:
result["is_correct"] = 0
marks = question_details.marks if correct else 0 marks = question_details.marks if correct else 0
result["marks"] = marks result["marks"] = marks
score += marks score += marks
else: else:
result["is_correct"] = 0
is_open_ended = True is_open_ended = True
result["is_correct"] = 0
percentage = (score / score_out_of) * 100
result["answer"] = re.sub( result["answer"] = re.sub(
r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"] r'<img[^>]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, result["answer"]
) )
submission = frappe.new_doc("LMS Quiz Submission")
# Score and percentage are calculated by the controller function
submission.update(
{
"doctype": "LMS Quiz Submission",
"quiz": quiz,
"result": results,
"score": 0,
"score_out_of": score_out_of,
"member": frappe.session.user,
"percentage": 0,
"passing_percentage": quiz_details.passing_percentage,
}
)
submission.save(ignore_permissions=True)
if (
percentage >= quiz_details.passing_percentage
and quiz_details.lesson
and quiz_details.course
):
save_progress(quiz_details.lesson, quiz_details.course)
elif not quiz_details.passing_percentage:
save_progress(quiz_details.lesson, quiz_details.course)
return { return {
"results": results,
"score": score, "score": score,
"score_out_of": score_out_of,
"submission": submission.name,
"pass": percentage == quiz_details.passing_percentage,
"percentage": percentage,
"is_open_ended": is_open_ended, "is_open_ended": is_open_ended,
} }
@@ -219,6 +215,36 @@ def get_corrupted_image_msg():
return _("Image: Corrupted Data Stream") return _("Image: Corrupted Data Stream")
def create_submission(quiz, results, score_out_of, passing_percentage):
submission = frappe.new_doc("LMS Quiz Submission")
# Score and percentage are calculated by the controller function
submission.update(
{
"doctype": "LMS Quiz Submission",
"quiz": quiz,
"result": results,
"score": 0,
"score_out_of": score_out_of,
"member": frappe.session.user,
"percentage": 0,
"passing_percentage": passing_percentage,
}
)
submission.save(ignore_permissions=True)
return submission
def save_progress_after_quiz(quiz_details, percentage):
if (
percentage >= quiz_details.passing_percentage
and quiz_details.lesson
and quiz_details.course
):
save_progress(quiz_details.lesson, quiz_details.course)
elif not quiz_details.passing_percentage:
save_progress(quiz_details.lesson, quiz_details.course)
@frappe.whitelist() @frappe.whitelist()
def get_question_details(question): def get_question_details(question):
if frappe.db.exists("LMS Quiz Question", question): if frappe.db.exists("LMS Quiz Question", question):

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,18 +1,12 @@
import frappe import frappe
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
@frappe.whitelist() @frappe.whitelist()
def is_enabled(): def get_posthog_settings():
return bool(
frappe.get_system_settings("enable_telemetry")
and frappe.conf.get("posthog_host")
and frappe.conf.get("posthog_project_id")
)
@frappe.whitelist()
def get_credentials():
return { return {
"project_id": frappe.conf.get("posthog_project_id"), "posthog_project_id": frappe.conf.get(POSTHOG_PROJECT_FIELD),
"telemetry_host": frappe.conf.get("posthog_host"), "posthog_host": frappe.conf.get(POSTHOG_HOST_FIELD),
"enable_telemetry": frappe.get_system_settings("enable_telemetry"),
"telemetry_site_age": frappe.utils.telemetry.site_age(),
} }

View File

@@ -772,14 +772,17 @@ def get_chart_data(
from_date = add_months(getdate(), -1) from_date = add_months(getdate(), -1)
if not to_date: if not to_date:
to_date = getdate() to_date = getdate()
chart = frappe.get_doc("Dashboard Chart", chart_name)
filters = [([chart.document_type, "docstatus", "<", 2, False])]
doctype = chart.document_type
datefield = chart.based_on
value_field = chart.value_based_on or "1"
from_date = get_datetime(from_date).strftime("%Y-%m-%d") from_date = get_datetime(from_date).strftime("%Y-%m-%d")
to_date = get_datetime(to_date) to_date = get_datetime(to_date)
chart = frappe.get_doc("Dashboard Chart", chart_name)
doctype = chart.document_type
datefield = chart.based_on
value_field = chart.value_based_on or "1"
filters = [([chart.document_type, "docstatus", "<", 2, False])]
filters = filters + json.loads(chart.filters_json)
filters.append([doctype, datefield, ">=", from_date, False]) filters.append([doctype, datefield, ">=", from_date, False])
filters.append([doctype, datefield, "<=", to_date, False]) filters.append([doctype, datefield, "<=", to_date, False])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More