Compare commits

...

273 Commits

Author SHA1 Message Date
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
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
Frappe PR Bot
015aff9c4b chore(release): Bumped to Version 2.28.0 2025-05-07 07:48:16 +00:00
Jannat Patel
567bfc41e0 Merge pull request #1489 from pateljannat/issues-102
fix: misc fixes
2025-05-07 12:51:11 +05:30
Jannat Patel
90d77e9ffb fix: improved question form for quiz 2025-05-07 12:34:38 +05:30
Jannat Patel
2b33ba1984 fix: dark mode issues 2025-05-07 11:50:34 +05:30
Jannat Patel
1918f0c5d5 Merge branch 'develop' of https://github.com/frappe/lms into issues-102 2025-05-07 11:50:07 +05:30
Jannat Patel
91d79de723 fix: submission list access from assignment form 2025-05-06 20:02:34 +05:30
Jannat Patel
62b05f2377 fix: route to course from the course card widget 2025-05-06 19:25:53 +05:30
Jannat Patel
b628ec4c57 Merge pull request #1488 from pateljannat/simplify-persona-form
chore: simplified the persona form
2025-05-06 19:23:28 +05:30
Jannat Patel
494394f084 Merge pull request #1487 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-06 19:15:24 +05:30
Jannat Patel
e99b4b183c chore: simplified the persona form 2025-05-06 19:14:41 +05:30
Jannat Patel
9186353654 chore: Esperanto translations 2025-05-06 18:24:57 +05:30
Jannat Patel
bd2a7b9095 chore: Chinese Simplified translations 2025-05-06 18:24:55 +05:30
Jannat Patel
42b70e7a94 chore: Serbian (Latin) translations 2025-05-06 18:24:54 +05:30
Jannat Patel
7f913203a1 chore: Bosnian translations 2025-05-06 18:24:53 +05:30
Jannat Patel
9b94958840 chore: Croatian translations 2025-05-06 18:24:51 +05:30
Jannat Patel
2070e93379 chore: Thai translations 2025-05-06 18:24:50 +05:30
Jannat Patel
772f4d938f chore: Persian translations 2025-05-06 18:24:48 +05:30
Jannat Patel
531f3af203 chore: Portuguese, Brazilian translations 2025-05-06 18:24:47 +05:30
Jannat Patel
ed522341c1 chore: Turkish translations 2025-05-06 18:24:46 +05:30
Jannat Patel
ee59c5068e chore: Swedish translations 2025-05-06 18:24:44 +05:30
Jannat Patel
ebe3abd05b chore: Russian translations 2025-05-06 18:24:43 +05:30
Jannat Patel
358dd4dddc chore: Portuguese translations 2025-05-06 18:24:41 +05:30
Jannat Patel
3d924d3631 chore: Polish translations 2025-05-06 18:24:40 +05:30
Jannat Patel
0bed316a40 chore: Hungarian translations 2025-05-06 18:24:39 +05:30
Jannat Patel
24b5937793 chore: German translations 2025-05-06 18:24:37 +05:30
Jannat Patel
c5b5876700 chore: Arabic translations 2025-05-06 18:24:36 +05:30
Jannat Patel
0f969e952d chore: Spanish translations 2025-05-06 18:24:34 +05:30
Jannat Patel
43ba512fd5 chore: French translations 2025-05-06 18:24:33 +05:30
Jannat Patel
8aadbffe8c Merge pull request #1483 from pateljannat/issues-101
fix: misc fixes
2025-05-06 10:49:47 +05:30
Jannat Patel
be7e7bc6fd refactor: extracted function that enables plyr as a utility 2025-05-06 10:35:05 +05:30
Jannat Patel
3a10d4bdc0 fix: alignment of information on batch details 2025-05-06 10:17:07 +05:30
Jannat Patel
fc03ecd1b3 fix: made programs breadcrumb translatable 2025-05-06 09:27:48 +05:30
Jannat Patel
c7b10f0e83 Merge pull request #1482 from frappe/pot_develop_2025-05-02
chore: update POT file
2025-05-06 08:50:56 +05:30
Jannat Patel
6a94ce5e1c Merge pull request #1480 from pateljannat/evaluator-list-issue
fix: evaluator list in settings
2025-05-06 08:50:44 +05:30
frappe-pr-bot
59859a8e2f chore: update POT file 2025-05-02 16:04:11 +00:00
Jannat Patel
f51a8aae39 fix: evaluator list in settings 2025-05-02 15:03:54 +05:30
Jannat Patel
bd5b8c5e0e Merge pull request #1478 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-05-02 15:02:35 +05:30
Jannat Patel
67e7744566 Merge pull request #1479 from pateljannat/read-only
feat: read only mode
2025-04-30 18:33:22 +05:30
Jannat Patel
65a6663c31 fix: when moving between lessons in zen mode, ensure discussions remain hidden 2025-04-30 18:19:45 +05:30
Jannat Patel
603e80fd26 feat: read only mode 2025-04-30 18:03:00 +05:30
Jannat Patel
de4ee6bbe6 chore: Persian translations 2025-04-30 15:28:31 +05:30
Jannat Patel
a8aa242280 chore: Portuguese, Brazilian translations 2025-04-30 15:28:30 +05:30
Jannat Patel
0d32c2a9d9 Merge branch 'develop' of https://github.com/frappe/lms into read-only 2025-04-29 18:40:42 +05:30
Jannat Patel
6d5a02e2a8 feat: read only mode 2025-04-29 18:39:22 +05:30
Jannat Patel
67f3cbaaa8 Merge pull request #1475 from pateljannat/issues-100
fix: hide start learning button if self enrollment is disabled
2025-04-29 16:23:51 +05:30
Jannat Patel
f17504e1a0 fix: hide start learning button if self enrollment is disabled 2025-04-29 15:51:03 +05:30
Jannat Patel
b1a9af5de8 Merge pull request #1474 from pateljannat/issues-99
fix: check parenttype when fetching instructors
2025-04-29 11:27:56 +05:30
Jannat Patel
913bf553ae refactor: simplified the condition to check is user is instructor 2025-04-29 11:03:58 +05:30
Jannat Patel
356dcc42bf fix: check parenttype when fetching instructors 2025-04-29 10:48:52 +05:30
Jannat Patel
8c006f24ce Merge pull request #1465 from nextchamp-saqib/refactor-charts
refactor: use charts from `frappe-ui`
2025-04-28 18:19:16 +05:30
Jannat Patel
6f2f0092f0 refactor: dynamic data for statistics charts 2025-04-28 18:05:43 +05:30
Jannat Patel
56afc4c614 Merge pull request #1471 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-28 15:34:43 +05:30
Jannat Patel
0a3b9f8f9a chore: Esperanto translations 2025-04-28 14:01:56 +05:30
Jannat Patel
9b0623f4a4 chore: Bosnian translations 2025-04-28 14:01:55 +05:30
Jannat Patel
c13ef17a86 chore: Croatian translations 2025-04-28 14:01:54 +05:30
Jannat Patel
d5ac2f521f chore: Thai translations 2025-04-28 14:01:52 +05:30
Jannat Patel
037af18114 chore: Persian translations 2025-04-28 14:01:51 +05:30
Jannat Patel
92299458f5 chore: Portuguese, Brazilian translations 2025-04-28 14:01:49 +05:30
Jannat Patel
3272f2a4cf chore: Turkish translations 2025-04-28 14:01:48 +05:30
Jannat Patel
6a6dfdd82c chore: Swedish translations 2025-04-28 14:01:46 +05:30
Jannat Patel
fa27452983 chore: Russian translations 2025-04-28 14:01:45 +05:30
Jannat Patel
8df5ec41d5 chore: Portuguese translations 2025-04-28 14:01:43 +05:30
Jannat Patel
55aad3a742 chore: Polish translations 2025-04-28 14:01:42 +05:30
Jannat Patel
e46890d87e chore: Hungarian translations 2025-04-28 14:01:41 +05:30
Jannat Patel
3a36e10fce chore: German translations 2025-04-28 14:01:39 +05:30
Jannat Patel
cc30c6d271 chore: Arabic translations 2025-04-28 14:01:38 +05:30
Jannat Patel
5e75ff7fb7 chore: Spanish translations 2025-04-28 14:01:36 +05:30
Jannat Patel
80681a1f8b chore: French translations 2025-04-28 14:01:34 +05:30
Jannat Patel
5954e10155 chore: Serbian (Latin) translations 2025-04-28 14:01:32 +05:30
Jannat Patel
78c43b7a10 chore: Chinese Simplified translations 2025-04-28 14:01:31 +05:30
Jannat Patel
8c6f8bf97b Merge pull request #1470 from pateljannat/issues-98
fix: don't allow billing page access if batch has started
2025-04-28 12:38:13 +05:30
Jannat Patel
f220438257 fix: remove borders for iframe on lesson form 2025-04-28 11:16:04 +05:30
Jannat Patel
bbd06752d3 Merge pull request #1468 from frappe/pot_develop_2025-04-25
chore: update POT file
2025-04-28 10:36:31 +05:30
Jannat Patel
e34df2ce95 fix: don't allow billing page access if batch has started 2025-04-28 10:35:14 +05:30
frappe-pr-bot
b197c08716 chore: update POT file 2025-04-25 16:04:29 +00:00
Jannat Patel
aeb6c0f433 Merge branch 'develop' of https://github.com/frappe/lms into refactor-charts 2025-04-25 18:27:50 +05:30
Jannat Patel
8f32767267 Merge pull request #1461 from frappe/zen-mode
feat: Zen Mode
2025-04-25 18:21:31 +05:30
Jannat Patel
afd43b9a9a Merge branch 'develop' of https://github.com/frappe/lms into zen-mode 2025-04-25 17:49:13 +05:30
Jannat Patel
5893e02c48 fix: reduced the size of play button in video block 2025-04-25 17:49:07 +05:30
Jannat Patel
66d3325e3c Merge pull request #1467 from harshpwctech/develop
feat: Embedding for CloudflareStream
2025-04-25 17:29:35 +05:30
Jannat Patel
e513993a0d feat: show and hide discussions in zen mode 2025-04-25 17:28:54 +05:30
safe user
ddbdf42265 feat: Embedding Cloudflare Stream 2025-04-25 09:28:44 +00:00
safe user
badaa33ddb feat: Embedding for CloudflareStream 2025-04-25 08:38:00 +00:00
safe user
befa3d7a6d feat: Added embedding for CloudflareStream 2025-04-25 08:38:00 +00:00
Jannat Patel
513f1e8b86 fix: improved lesson locked state 2025-04-25 08:38:00 +00:00
Jannat Patel
4128f0fb73 chore: fixed settings 2025-04-25 08:38:00 +00:00
Jannat Patel
3d81a63410 ci: skip persona form for ui tests 2025-04-25 08:38:00 +00:00
Jannat Patel
c0ba44cacc fix: check persona_captured after details get saved 2025-04-25 08:38:00 +00:00
Jannat Patel
deba027457 chore: identify user persona 2025-04-25 08:38:00 +00:00
Jannat Patel
47089d286e chore: Serbian (Latin) translations 2025-04-25 08:38:00 +00:00
Jannat Patel
6c50292a66 fix: tags spacing on course cards 2025-04-25 08:38:00 +00:00
Jannat Patel
1f23f06926 fix: allow fullscreen on vimeo and adjust video height on mobile devices 2025-04-25 08:38:00 +00:00
Jannat Patel
63319d32e8 fix: detect editor change to enable plyr on newly added videos 2025-04-25 11:18:19 +05:30
Jannat Patel
66f28ef7a6 Merge branch 'develop' of https://github.com/frappe/lms into zen-mode 2025-04-25 10:20:38 +05:30
Jannat Patel
4e4eccd909 Merge pull request #1466 from pateljannat/issues-97
fix: country details in job page and form
2025-04-25 10:19:32 +05:30
Jannat Patel
c21fe99368 fix: country details in job page and form 2025-04-25 10:13:17 +05:30
Jannat Patel
53ea91e945 feat: plyr for vimeo 2025-04-25 10:05:32 +05:30
Jannat Patel
7cde05b58a Merge branch 'develop' of https://github.com/frappe/lms into zen-mode 2025-04-24 18:42:44 +05:30
Jannat Patel
0fc9b35307 Merge pull request #1464 from pateljannat/issues-96
feat: redesigned job list
2025-04-24 18:35:20 +05:30
Jannat Patel
4a36826af0 fix: if student applied for a job show that on the details page 2025-04-24 18:28:06 +05:30
Jannat Patel
26a278c5f4 feat: country filter in job list 2025-04-24 18:22:00 +05:30
Saqib Ansari
66a4d79730 refactor: use charts from frappe-ui 2025-04-24 16:19:12 +05:30
Jannat Patel
097d541391 feat: redesigned job list 2025-04-24 14:20:51 +05:30
Jannat Patel
788ef9b106 Merge pull request #1463 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-24 14:12:16 +05:30
Jannat Patel
a38e1163af chore: Chinese Simplified translations 2025-04-24 13:37:52 +05:30
Jannat Patel
a633ff5174 fix: check if youtube or vimeo video exists before enabling plyr 2025-04-24 12:06:57 +05:30
Jannat Patel
6b412106de feat: redesigned video block 2025-04-23 17:06:29 +05:30
Jannat Patel
93b5cb6161 feat: zen mode 2025-04-23 11:44:39 +05:30
Jannat Patel
4b80fbe5eb Merge pull request #1460 from pateljannat/issues-95
fix: improved lesson locked state
2025-04-22 18:13:01 +05:30
Jannat Patel
52775aae60 fix: improved lesson locked state 2025-04-22 18:05:07 +05:30
Jannat Patel
0430178b3e Merge pull request #1459 from pateljannat/user-persona
chore: identify user persona
2025-04-22 17:47:20 +05:30
Jannat Patel
470123c77a chore: fixed settings 2025-04-22 16:17:29 +05:30
Jannat Patel
66d4798db3 Merge branch 'develop' of https://github.com/frappe/lms into user-persona 2025-04-22 15:54:55 +05:30
Jannat Patel
cc39395a12 Merge pull request #1458 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-22 15:54:42 +05:30
Jannat Patel
3aeb9cf0b1 ci: skip persona form for ui tests 2025-04-22 15:32:01 +05:30
Jannat Patel
f1b383f0b7 fix: check persona_captured after details get saved 2025-04-22 15:02:21 +05:30
Jannat Patel
e2896b7bf0 chore: Serbian (Latin) translations 2025-04-22 12:52:49 +05:30
Jannat Patel
780dfb8966 Merge branch 'develop' of https://github.com/frappe/lms into user-persona 2025-04-21 18:07:12 +05:30
Jannat Patel
ac47ab3f8a Merge pull request #1456 from pateljannat/issues-94
fix: allow fullscreen on vimeo and adjust video height on mobile devices
2025-04-21 16:36:06 +05:30
Jannat Patel
bfc1488860 fix: tags spacing on course cards 2025-04-21 16:16:54 +05:30
Jannat Patel
726f733434 fix: allow fullscreen on vimeo and adjust video height on mobile devices 2025-04-21 15:41:51 +05:30
Jannat Patel
0c97e31101 Merge pull request #1455 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-21 15:07:47 +05:30
Jannat Patel
ec2b0718e6 chore: Portuguese translations 2025-04-21 12:09:04 +05:30
Jannat Patel
720056268c chore: Serbian (Latin) translations 2025-04-21 12:09:03 +05:30
Jannat Patel
345992eda4 chore: Esperanto translations 2025-04-21 12:09:02 +05:30
Jannat Patel
e3e6b35eb7 chore: Croatian translations 2025-04-21 12:09:00 +05:30
Jannat Patel
701ea950de chore: Thai translations 2025-04-21 12:08:59 +05:30
Jannat Patel
4b78865823 chore: Portuguese, Brazilian translations 2025-04-21 12:08:58 +05:30
Jannat Patel
5b2bdf4cf6 chore: Bosnian translations 2025-04-21 12:08:57 +05:30
Jannat Patel
a677b7fd3a chore: Persian translations 2025-04-21 12:08:55 +05:30
Jannat Patel
9cbd3db022 chore: Chinese Simplified translations 2025-04-21 12:08:54 +05:30
Jannat Patel
5f52d2c2c7 chore: Turkish translations 2025-04-21 12:08:53 +05:30
Jannat Patel
b8c403aa5d chore: Swedish translations 2025-04-21 12:08:52 +05:30
Jannat Patel
2c6863e18e chore: Russian translations 2025-04-21 12:08:50 +05:30
Jannat Patel
e7a462c685 chore: Polish translations 2025-04-21 12:08:49 +05:30
Jannat Patel
0cf671ae3b chore: Hungarian translations 2025-04-21 12:08:48 +05:30
Jannat Patel
dfc6f5bfb4 chore: German translations 2025-04-21 12:08:47 +05:30
Jannat Patel
64b9be7e42 chore: Arabic translations 2025-04-21 12:08:45 +05:30
Jannat Patel
7412a8761c chore: Spanish translations 2025-04-21 12:08:44 +05:30
Jannat Patel
65cdeabc77 chore: French translations 2025-04-21 12:08:42 +05:30
Jannat Patel
a507d4464d Merge pull request #1454 from pateljannat/issues-93
feat: meta image and keywords from settings
2025-04-21 11:05:39 +05:30
Jannat Patel
9143cc39d9 test: find the course image label and attach course image to its sibling input 2025-04-21 10:53:22 +05:30
Jannat Patel
e821755721 test: attach course image if selector is hidden 2025-04-21 10:33:24 +05:30
Jannat Patel
d081688fc9 Merge pull request #1453 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-21 10:16:30 +05:30
Jannat Patel
cdc7ee698c Merge pull request #1452 from frappe/pot_develop_2025-04-18
chore: update POT file
2025-04-21 09:55:00 +05:30
Jannat Patel
0d0a9c872c chore: Chinese Simplified translations 2025-04-20 11:58:27 +05:30
Jannat Patel
30953cce66 chore: German translations 2025-04-20 11:58:22 +05:30
Jannat Patel
f6008cf46a fix: don't show course count on batch details if there are no courses 2025-04-19 12:46:31 +05:30
Jannat Patel
eb0587f726 feat: meta image and keywords from settings 2025-04-19 12:37:24 +05:30
frappe-pr-bot
ba56ac87c5 chore: update POT file 2025-04-18 16:04:27 +00:00
Jannat Patel
5800ac67c4 chore: identify user persona 2025-04-18 18:05:57 +05:30
Jannat Patel
73941a159a Merge pull request #1451 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-18 16:16:35 +05:30
Jannat Patel
d1fe8b203a chore: Thai translations 2025-04-18 11:52:30 +05:30
Jannat Patel
8b8dbc1053 chore: Chinese Simplified translations 2025-04-18 11:52:28 +05:30
Jannat Patel
57e477b17c Merge pull request #1450 from pateljannat/issues-92
fix: moved powered by learning to the bottom of sidebar
2025-04-17 22:41:59 +05:30
Jannat Patel
1a1924de3e fix: removed styles on attachments as they were overriding the styles on webform 2025-04-17 22:32:25 +05:30
Jannat Patel
3bea19c8ad fix: moved powered by learning to the bottom of sidebar 2025-04-17 22:31:47 +05:30
Jannat Patel
cd47b62765 Merge pull request #1449 from pateljannat/assignment-issues
refactor: enhanced assignment form
2025-04-17 22:09:01 +05:30
Jannat Patel
ffeaad324e fix: removed assignment submission email notification 2025-04-17 22:03:07 +05:30
Jannat Patel
4504dd810d fix: make assignment modal scrollable if the questions is very long 2025-04-17 21:52:55 +05:30
Jannat Patel
60ad86f79c refactor: enhanced assignment form 2025-04-17 21:42:43 +05:30
Jannat Patel
f63294699a Merge pull request #1446 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-17 17:37:12 +05:30
Jannat Patel
650594d9ea Merge pull request #1447 from pateljannat/certification-redesign
fix: improved UI of certified participants page
2025-04-17 12:48:27 +05:30
Jannat Patel
7c22d5c774 chore: changed release workflow to run on the 15th of every month 2025-04-17 12:38:24 +05:30
Jannat Patel
73a501908d fix: mobile view for certified members page 2025-04-17 12:34:38 +05:30
Jannat Patel
31836e5c9e chore: Persian translations 2025-04-17 11:49:28 +05:30
Jannat Patel
31adab94b3 chore: merged conflicts 2025-04-16 21:38:00 +05:30
Jannat Patel
4e02044eb4 Merge pull request #1441 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-16 18:47:37 +05:30
Jannat Patel
f245cf2c5d fix: meta title for pages that don't have a default title 2025-04-16 17:09:09 +05:30
Jannat Patel
1b49cc1408 fix: meta title for pages that don't have a title 2025-04-16 17:02:52 +05:30
Jannat Patel
bd384a9b59 chore: Portuguese translations 2025-04-16 11:50:58 +05:30
Jannat Patel
48eb2ff405 fix: registration button should be visible if batch starts today but a few hours are left 2025-04-16 11:18:17 +05:30
Jannat Patel
dcacda984f fix: mobile view for certified member list 2025-04-16 10:59:06 +05:30
Jannat Patel
8186e9e1d2 Merge pull request #1438 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-15 20:14:15 +05:30
Jannat Patel
b5b93917d1 chore: Croatian translations 2025-04-15 11:50:27 +05:30
Jannat Patel
1ffdadbde3 chore: Bosnian translations 2025-04-15 11:50:25 +05:30
Jannat Patel
4506603ea1 chore: Swedish translations 2025-04-15 11:50:23 +05:30
Jannat Patel
fdf8b85f88 Merge branch 'develop' of https://github.com/frappe/lms into certification-redesign 2025-04-14 22:50:47 +05:30
Jannat Patel
340264ce41 Merge pull request #1436 from pateljannat/issues-91
fix: misc issues
2025-04-14 22:49:25 +05:30
Jannat Patel
d6187b3d63 fix: corrected grammar in payments app error message 2025-04-14 22:39:07 +05:30
Jannat Patel
b6577133a9 fix: misc issues 2025-04-14 22:28:06 +05:30
Jannat Patel
2d410eac37 Merge pull request #1430 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-14 16:24:43 +05:30
Jannat Patel
e63e71f2bf chore: Portuguese translations 2025-04-14 11:31:24 +05:30
Jannat Patel
ba743e0480 chore: Serbian (Latin) translations 2025-04-14 11:31:23 +05:30
Jannat Patel
2f26b15524 chore: Esperanto translations 2025-04-14 11:31:21 +05:30
Jannat Patel
5841ed0e70 chore: Croatian translations 2025-04-14 11:31:20 +05:30
Jannat Patel
d217dff4b9 chore: Thai translations 2025-04-14 11:31:18 +05:30
Jannat Patel
2746606db1 chore: Portuguese, Brazilian translations 2025-04-14 11:31:17 +05:30
Jannat Patel
2d321780d0 chore: Bosnian translations 2025-04-14 11:31:16 +05:30
Jannat Patel
c26108586f chore: Persian translations 2025-04-14 11:31:14 +05:30
Jannat Patel
7f30d9c3dc chore: Chinese Simplified translations 2025-04-14 11:31:12 +05:30
Jannat Patel
816b40bdc6 chore: Turkish translations 2025-04-14 11:31:11 +05:30
Jannat Patel
09688315cb chore: Swedish translations 2025-04-14 11:31:10 +05:30
Jannat Patel
c709535442 chore: Russian translations 2025-04-14 11:31:08 +05:30
Jannat Patel
08e2d804fa chore: Polish translations 2025-04-14 11:31:07 +05:30
Jannat Patel
b4fb07b435 chore: Hungarian translations 2025-04-14 11:31:06 +05:30
Jannat Patel
d119ae6409 chore: German translations 2025-04-14 11:31:04 +05:30
Jannat Patel
cf26fc4530 chore: Arabic translations 2025-04-14 11:31:03 +05:30
Jannat Patel
f50a7704c9 chore: Spanish translations 2025-04-14 11:31:02 +05:30
Jannat Patel
facec8393c chore: French translations 2025-04-14 11:31:00 +05:30
Jannat Patel
172e8872ef Merge pull request #1428 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-14 10:57:06 +05:30
Jannat Patel
b7755b844a Merge pull request #1427 from frappe/pot_develop_2025-04-11
chore: update POT file
2025-04-14 10:56:54 +05:30
Jannat Patel
7e77d29edb chore: Portuguese translations 2025-04-13 11:08:49 +05:30
Jannat Patel
3b84ef6968 fix: seo description for job openings 2025-04-13 10:26:59 +05:30
Jannat Patel
2dd8192dcb feat: list of certified participants 2025-04-13 10:14:00 +05:30
Jannat Patel
cafb499a79 chore: Portuguese translations 2025-04-12 10:51:50 +05:30
Jannat Patel
f952267396 chore: Arabic translations 2025-04-12 10:51:38 +05:30
frappe-pr-bot
6913b71c69 chore: update POT file 2025-04-11 16:04:23 +00:00
Jannat Patel
c485b03b83 chore: merged conflicts 2025-04-11 19:01:46 +05:30
Jannat Patel
e1f35c86db fix: empty meta info issue 2025-04-11 18:43:34 +05:30
Jannat Patel
cfbe60b731 fix: improved UI of certified participants page 2025-04-11 18:36:12 +05:30
Jannat Patel
a21020e226 Merge pull request #1425 from frappe/l10n_develop2
chore: sync translations from crowdin
2025-04-11 13:21:11 +05:30
Jannat Patel
28d18102f0 chore: Serbian (Latin) translations 2025-04-11 10:37:08 +05:30
Jannat Patel
f5e78b7fdb chore: Persian translations 2025-04-11 10:37:04 +05:30
Jannat Patel
d420b2dae5 chore: Turkish translations 2025-04-11 10:37:01 +05:30
Jannat Patel
3cce9107d0 chore: Polish translations 2025-04-11 10:36:59 +05:30
Jannat Patel
a5248eb92b chore: Hungarian translations 2025-04-11 10:36:57 +05:30
Jannat Patel
1acf734229 chore: German translations 2025-04-11 10:36:56 +05:30
Jannat Patel
cc170ecb20 chore: French translations 2025-04-11 10:36:53 +05:30
Jannat Patel
b7f40d16a4 Merge pull request #1424 from pateljannat/seo-description
feat: SEO Meta Description
2025-04-10 17:42:58 +05:30
Jannat Patel
7e6cb727bd feat: seo description field 2025-04-10 17:19:38 +05:30
Jannat Patel
eeaa835bef fix: redirect to course list from course from if user is not moderator and instructor 2025-04-10 16:36:22 +05:30
141 changed files with 21025 additions and 12334 deletions

View File

@@ -1,8 +1,7 @@
name: Create weekly release name: Create weekly release
on: on:
schedule: schedule:
# 13:00 UTC -> 7pm IST on every Wednesday - cron: '30 4 15 * *'
- cron: '30 4 * * 3'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@@ -100,11 +100,12 @@ jobs:
bench --site lms.test execute frappe.utils.install.complete_setup_wizard bench --site lms.test execute frappe.utils.install.complete_setup_wizard
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
bench --site lms.test set-password frappe@example.com admin bench --site lms.test set-password frappe@example.com admin
bench --site lms.test execute lms.lms.utils.persona_captured
- 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",
@@ -13,6 +13,6 @@ module.exports = defineConfig({
openMode: 0, openMode: 0,
}, },
e2e: { e2e: {
baseUrl: "http://testui:8000", baseUrl: "http://pertest:8000",
}, },
}); });

View File

@@ -19,12 +19,16 @@ describe("Course Creation", () => {
); );
cy.fixture("profile.png", "base64").then((fileContent) => { cy.fixture("profile.png", "base64").then((fileContent) => {
cy.get('input[type="file"]').attachFile({ cy.get("div")
fileContent, .contains("Course Image")
fileName: "profile.png", .siblings("div")
mimeType: "image/png", .children('input[type="file"]')
encoding: "base64", .attachFile({
}); fileContent,
fileName: "profile.png",
mimeType: "image/png",
encoding: "base64",
});
}); });
cy.get("label") cy.get("label")

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

Submodule frappe-ui deleted from 29307e4fff

View File

@@ -16,6 +16,7 @@ declare module 'vue' {
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default'] AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default'] Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default'] Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default'] AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default'] Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default'] BatchCard: typeof import('./src/components/BatchCard.vue')['default']
@@ -46,6 +47,7 @@ declare module 'vue' {
Discussions: typeof import('./src/components/Discussions.vue')['default'] Discussions: typeof import('./src/components/Discussions.vue')['default']
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default'] EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default'] EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default'] EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Evaluators.vue')['default'] Evaluators: typeof import('./src/components/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default'] Event: typeof import('./src/components/Modals/Event.vue')['default']
@@ -68,9 +70,9 @@ declare module 'vue' {
NoPermission: typeof import('./src/components/NoPermission.vue')['default'] NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default'] NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default'] NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
OnboardingBanner: typeof import('./src/components/OnboardingBanner.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default'] PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default'] PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default'] ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default'] Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default'] Quiz: typeof import('./src/components/Quiz.vue')['default']

View File

@@ -26,12 +26,8 @@
<a href="{{ meta.link }}">Know More</a> <a href="{{ meta.link }}">Know More</a>
</div> </div>
</div> </div>
<div id="modals"></div>
<div id="popovers"></div>
<script> <script>
document.getElementById('seo-content').style.display = 'none'; document.getElementById('seo-content').style.display = 'none';
window.csrf_token = '{{ csrf_token }}'
</script> </script>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>

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,11 +27,12 @@
"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.122", "frappe-ui": "^0.1.143",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-vue-next": "^0.383.0", "lucide-vue-next": "^0.383.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"plyr": "^3.7.8",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"tailwindcss": "3.4.15", "tailwindcss": "3.4.15",
"typescript": "^5.7.2", "typescript": "^5.7.2",

View File

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

View File

@@ -1,12 +1,13 @@
<template> <template>
<Layout> <FrappeUIProvider>
<router-view /> <Layout>
</Layout> <router-view />
<Dialogs /> </Layout>
<Toasts /> <Dialogs />
</FrappeUIProvider>
</template> </template>
<script setup> <script setup>
import { Toasts } from 'frappe-ui' import { FrappeUIProvider } from 'frappe-ui'
import { Dialogs } from '@/utils/dialogs' import { Dialogs } from '@/utils/dialogs'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useScreenSize } from './utils/composables' import { useScreenSize } from './utils/composables'
@@ -24,7 +25,7 @@ const router = useRouter()
const noSidebar = ref(false) const noSidebar = ref(false)
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.query.fromLesson) { if (to.query.fromLesson || to.path === '/persona') {
noSidebar.value = true noSidebar.value = true
} else { } else {
noSidebar.value = false noSidebar.value = false

View File

@@ -39,7 +39,11 @@
{{ __('More') }} {{ __('More') }}
</span> </span>
</div> </div>
<Button v-if="isModerator" variant="ghost" @click="openPageModal()"> <Button
v-if="isModerator && !readOnlyMode"
variant="ghost"
@click="openPageModal()"
>
<template #icon> <template #icon>
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" /> <Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template> </template>
@@ -63,6 +67,16 @@
</div> </div>
</div> </div>
<div class="m-2 flex flex-col gap-1"> <div class="m-2 flex flex-col gap-1">
<div
v-if="readOnlyMode && !sidebarStore.isSidebarCollapsed"
class="z-10 m-2 bg-surface-modal py-2.5 px-3 text-xs text-ink-gray-7 leading-5 rounded-md"
>
{{
__(
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
)
}}
</div>
<TrialBanner <TrialBanner
v-if=" v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site userResource.data?.is_system_manager && userResource.data?.is_fc_site
@@ -74,43 +88,69 @@
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed" :isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
appName="learning" appName="learning"
/> />
<SidebarLink
v-if="isOnboardingStepsCompleted" <div
:link="{ class="flex items-center mt-4"
label: __('Help'), :class="
}" sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
:isCollapsed="sidebarStore.isSidebarCollapsed"
@click="
() => {
showHelpModal = minimize ? true : !showHelpModal
minimize = !showHelpModal
}
" "
> >
<template #icon> <div
<span class="grid h-5 w-6 flex-shrink-0 place-items-center"> class="flex items-center flex-1"
<CircleHelp class="h-4 w-4 stroke-1.5" /> :class="
</span> sidebarStore.isSidebarCollapsed
</template> ? 'flex-col space-y-3'
</SidebarLink> : 'flex-row space-x-3'
<SidebarLink "
:link="{ >
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse', <Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
}" <CircleAlert
:isCollapsed="sidebarStore.isSidebarCollapsed" class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="toggleSidebar()"
>
<template #icon>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<CollapseSidebar
class="h-4 w-4 text-ink-gray-7 duration-300 ease-in-out"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
/> />
</span> <template #body>
</template> <div
</SidebarLink> class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl"
>
{{
__(
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
)
}}
</div>
</template>
</Tooltip>
<Tooltip :text="__('Powered by Learning')">
<Zap
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
/>
</Tooltip>
<Tooltip v-if="showOnboarding" :text="__('Help')">
<CircleHelp
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="
() => {
showHelpModal = minimize ? true : !showHelpModal
minimize = !showHelpModal
}
"
/>
</Tooltip>
</div>
<Tooltip
:text="
sidebarStore.isSidebarCollapsed ? __('Expand') : __('Collapse')
"
>
<CollapseSidebar
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
:class="{
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
}"
@click="toggleSidebar()"
/>
</Tooltip>
</div>
</div> </div>
<HelpModal <HelpModal
v-if="showOnboarding && showHelpModal" v-if="showOnboarding && showHelpModal"
@@ -148,7 +188,7 @@ import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { useSidebar } from '@/stores/sidebar' import { useSidebar } from '@/stores/sidebar'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { Button, createResource } from 'frappe-ui' import { Button, createResource, Tooltip } from 'frappe-ui'
import PageModal from '@/components/Modals/PageModal.vue' import PageModal from '@/components/Modals/PageModal.vue'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import LMSLogo from '@/components/Icons/LMSLogo.vue' import LMSLogo from '@/components/Icons/LMSLogo.vue'
@@ -156,6 +196,7 @@ import { useRouter } from 'vue-router'
import InviteIcon from './Icons/InviteIcon.vue' import InviteIcon from './Icons/InviteIcon.vue'
import { import {
BookOpen, BookOpen,
CircleAlert,
ChevronRight, ChevronRight,
Plus, Plus,
CircleHelp, CircleHelp,
@@ -164,6 +205,7 @@ import {
UserPlus, UserPlus,
Users, Users,
BookText, BookText,
Zap,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
TrialBanner, TrialBanner,
@@ -192,6 +234,7 @@ const currentStep = ref({})
const router = useRouter() const router = useRouter()
let onboardingDetails let onboardingDetails
let isOnboardingStepsCompleted = false let isOnboardingStepsCompleted = false
const readOnlyMode = window.read_only_mode
const iconProps = { const iconProps = {
strokeWidth: 1.5, strokeWidth: 1.5,
width: 16, width: 16,
@@ -578,4 +621,8 @@ watch(userResource, () => {
setUpOnboarding() setUpOnboarding()
} }
}) })
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
</script> </script>

View File

@@ -3,7 +3,7 @@
<template #target="{ togglePopover }"> <template #target="{ togglePopover }">
<button <button
:class="[ :class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-8 hover:bg-surface-gray-2', 'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
]" ]"
@click.prevent="togglePopover()" @click.prevent="togglePopover()"
> >

View File

@@ -4,7 +4,7 @@
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Assessments') }} {{ __('Assessments') }}
</div> </div>
<Button v-if="canSeeAddButton()" @click="showModal = true"> <Button v-if="canAddAssessments()" @click="showModal = true">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -100,6 +100,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const showModal = ref(false) const showModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -181,7 +182,8 @@ const getRowRoute = (row) => {
} }
} }
const canSeeAddButton = () => { const canAddAssessments = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }

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

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border-2 hover:bg-surface-gray-2 rounded-md p-4 h-full" class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9"> <div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">

View File

@@ -86,9 +86,10 @@ 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 showCourseModal = ref(false) const showCourseModal = ref(false)
const user = inject('$user') const user = inject('$user')
@@ -151,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()
}, },
} }
@@ -159,6 +160,9 @@ const removeCourses = (selections, unselectAll) => {
} }
const canSeeAddButton = () => { const canSeeAddButton = () => {
if (readOnlyMode) {
return false
}
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -111,7 +111,6 @@ import {
FormControl, FormControl,
ListView, ListView,
ListHeader, ListHeader,
ListHeaderItem,
ListRows, ListRows,
ListRow, ListRow,
ListRowItem, ListRowItem,

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">
@@ -24,7 +29,10 @@
> >
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }} {{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div> </div>
<div class="flex items-center mb-3 text-ink-gray-7"> <div
v-if="batch.data.courses.length"
class="flex items-center mb-3 text-ink-gray-7"
>
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" /> <BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span> <span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
</div> </div>
@@ -46,81 +54,83 @@
{{ batch.data.timezone }} {{ batch.data.timezone }}
</span> </span>
</div> </div>
<router-link <div v-if="!readOnlyMode">
v-if="isModerator || isStudent" <router-link
:to="{ v-if="isModerator || isStudent"
name: 'Batch', :to="{
params: { name: 'Batch',
batchName: batch.data.name, params: {
}, batchName: batch.data.name,
}" },
> }"
<Button variant="solid" class="w-full mt-4"> >
<span> <Button variant="solid" class="w-full mt-4">
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }} <span>
</span> {{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
params: {
type: 'batch',
name: batch.data.name,
},
}"
v-else-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button> </Button>
</router-link> <router-link
<router-link v-if="isModerator"
:to="{ :to="{
name: 'Billing', name: 'BatchForm',
params: { params: {
type: 'batch', batchName: batch.data.name,
name: batch.data.name, },
}, }"
}" >
v-else-if=" <Button class="w-full mt-2">
batch.data.paid_batch && <span>
batch.data.seats_left > 0 && {{ __('Edit') }}
batch.data.accept_enrollments </span>
" </Button>
> </router-link>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid"> </div>
<span>
{{ __('Register Now') }}
</span>
</Button>
</router-link>
<Button
variant="solid"
class="w-full mt-2"
v-else-if="
batch.data.allow_self_enrollment &&
batch.data.seats_left &&
batch.data.accept_enrollments
"
@click="enrollInBatch()"
>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="isModerator"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div> </div>
</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'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -146,11 +156,7 @@ const enrollInBatch = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
showToast( toast.success(__('You have been enrolled in this batch'))
__('Success'),
__('You have been enrolled in this batch'),
'check'
)
router.push({ router.push({
name: 'Batch', name: 'Batch',
params: { params: {

View File

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

View File

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

View File

@@ -28,9 +28,7 @@
</template> </template>
<template #body="{ isOpen }"> <template #body="{ isOpen }">
<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 shadow-2xl"
>
<div class="relative px-1.5 pt-0.5"> <div class="relative px-1.5 pt-0.5">
<ComboboxInput <ComboboxInput
ref="search" ref="search"
@@ -49,7 +47,7 @@
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center" class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null" @click="selectedValue = null"
> >
<X class="h-4 w-4 stroke-1.5" /> <X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button> </button>
</div> </div>
<ComboboxOptions <ComboboxOptions
@@ -89,12 +87,15 @@
name="item-label" name="item-label"
v-bind="{ active, selected, option }" v-bind="{ active, selected, option }"
> >
<div class="flex flex-col space-y-1"> <div class="flex flex-col space-y-1 text-ink-gray-8">
<div> <div>
{{ option.label }} {{ option.label }}
</div> </div>
<div <div
v-if="option.description" v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7" class="text-xs text-ink-gray-7"
v-html="option.description" v-html="option.description"
></div> ></div>

View File

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

View File

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

View File

@@ -9,16 +9,20 @@
:class="{ 'default-image': !course.image }" :class="{ 'default-image': !course.image }"
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }" :style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
> >
<div <div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit" <Badge
> v-if="course.featured"
<Badge v-if="course.featured" variant="subtle" theme="green" size="md"> variant="subtle"
theme="green"
size="md"
class="mb-1 mr-1"
>
{{ __('Featured') }} {{ __('Featured') }}
</Badge> </Badge>
<div <div
v-if="course.tags" v-if="course.tags"
v-for="tag in course.tags?.split(', ')" v-for="tag in course.tags?.split(', ')"
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md" class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md mb-1 mr-1"
> >
{{ tag }} {{ tag }}
</div> </div>

View File

@@ -9,88 +9,94 @@
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3"> <div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
{{ course.data.price }} {{ course.data.price }}
</div> </div>
<div v-if="course.data.membership" class="space-y-2"> <div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<router-link
:to="{
name: 'Lesson',
params: {
courseName: course.name,
chapterNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
},
}"
>
<Button variant="solid" size="md" class="w-full">
<span>
{{ __('Continue Learning') }}
</span>
</Button>
</router-link>
<CertificationLinks :courseName="course.data.name" class="w-full" />
</div>
<router-link <router-link
v-else-if="course.data.paid_course"
:to="{ :to="{
name: 'Lesson', name: 'Billing',
params: { params: {
courseName: course.name, type: 'course',
chapterNumber: course.data.current_lesson name: course.data.name,
? course.data.current_lesson.split('-')[0]
: 1,
lessonNumber: course.data.current_lesson
? course.data.current_lesson.split('-')[1]
: 1,
}, },
}" }"
> >
<Button variant="solid" size="md" class="w-full"> <Button variant="solid" size="md" class="w-full">
<span> <span>
{{ __('Continue Learning') }} {{ __('Buy this course') }}
</span> </span>
</Button> </Button>
</router-link> </router-link>
<CertificationLinks :courseName="course.data.name" class="w-full" /> <Badge
</div> v-else-if="course.data.disable_self_learning"
<router-link theme="blue"
v-else-if="course.data.paid_course" size="lg"
:to="{ >
name: 'Billing', {{ __('Contact the Administrator to enroll for this course.') }}
params: { </Badge>
type: 'course', <Button
name: course.data.name, v-else
}, @click="enrollStudent()"
}" variant="solid"
> class="w-full"
<Button variant="solid" size="md" class="w-full"> size="md"
>
<span> <span>
{{ __('Buy this course') }} {{ __('Start Learning') }}
</span> </span>
</Button> </Button>
</router-link> <Button
<div v-if="canGetCertificate"
v-else-if="course.data.disable_self_learning" @click="fetchCertificate()"
class="bg-surface-blue-2 text-blue-900 text-sm rounded-md py-1 px-3" variant="subtle"
> class="w-full mt-2"
{{ __('Contact the Administrator to enroll for this course.') }} size="md"
</div> >
<Button {{ __('Get Certificate') }}
v-else
@click="enrollStudent()"
variant="solid"
class="w-full"
size="md"
>
<span>
{{ __('Start Learning') }}
</span>
</Button>
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
</Button> </Button>
</router-link> <router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
<div class="space-y-4"> <div class="space-y-4">
<div class="mt-8 font-medium text-ink-gray-9"> <div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
>
{{ __('This course has:') }} {{ __('This course has:') }}
</div> </div>
<div class="flex items-center text-ink-gray-9"> <div class="flex items-center text-ink-gray-9">
@@ -140,14 +146,15 @@
<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 { Button, createResource, Tooltip } 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'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
course: { course: {
@@ -165,14 +172,10 @@ 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}`
}, 2000) }, 1000)
} else { } else {
const enrollStudentResource = createResource({ const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
@@ -185,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

@@ -1,5 +1,5 @@
<template> <template>
<div class="h-full"> <div class="">
<div <div
v-if="title && (outline.data?.length || allowEdit)" v-if="title && (outline.data?.length || allowEdit)"
class="flex items-center justify-between space-x-2 mb-4 px-2" class="flex items-center justify-between space-x-2 mb-4 px-2"
@@ -17,9 +17,6 @@
<Button size="sm" v-if="allowEdit" @click="openChapterModal()"> <Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }} {{ __('Add Chapter') }}
</Button> </Button>
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
</span> -->
</div> </div>
<div <div
:class="{ :class="{
@@ -150,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'
@@ -165,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()
@@ -218,7 +214,7 @@ const deleteLesson = createResource({
}, },
onSuccess() { onSuccess() {
outline.reload() outline.reload()
showToast('Success', 'Lesson deleted successfully', 'check') toast.success(__('Lesson deleted successfully'))
}, },
}) })
@@ -233,7 +229,7 @@ const updateLessonIndex = createResource({
} }
}, },
onSuccess() { onSuccess() {
showToast('Success', 'Lesson moved successfully', 'check') toast.success(__('Lesson moved successfully'))
}, },
}) })
@@ -291,7 +287,7 @@ const deleteChapter = createResource({
}, },
onSuccess() { onSuccess() {
outline.reload() outline.reload()
showToast('Success', 'Chapter deleted successfully', 'check') toast.success(__('Chapter deleted successfully'))
}, },
}) })
@@ -320,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

@@ -27,7 +27,9 @@
</span> </span>
</div> </div>
<Dropdown <Dropdown
v-if="user.data.name == reply.owner && !reply.editable" v-if="
user.data.name == reply.owner && !reply.editable && !readOnlyMode
"
:options="[ :options="[
{ {
label: 'Edit', label: 'Edit',
@@ -71,7 +73,7 @@
</div> </div>
<TextEditor <TextEditor
v-if="renderEditor" v-if="renderEditor && !readOnlyMode"
class="mt-5" class="mt-5"
:content="newReply" :content="newReply"
:mentions="mentionUsers" :mentions="mentionUsers"
@@ -80,7 +82,7 @@
:fixedMenu="true" :fixedMenu="true"
editorClass="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 border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2" editorClass="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 border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2"
/> />
<div class="flex justify-between mt-2"> <div v-if="!readOnlyMode" class="flex justify-between mt-2">
<span> </span> <span> </span>
<Button @click="postReply()"> <Button @click="postReply()">
<span> <span>
@@ -91,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('')
@@ -105,6 +106,7 @@ const user = inject('$user')
const allUsers = inject('$allUsers') const allUsers = inject('$allUsers')
const mentionUsers = ref([]) const mentionUsers = ref([])
const renderEditor = ref(false) const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
topic: { topic: {
@@ -189,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

@@ -1,6 +1,10 @@
<template> <template>
<div> <div>
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()"> <Button
v-if="!singleThread && !readOnlyMode"
class="float-right"
@click="openTopicModal()"
>
{{ __('New {0}').format(singularize(title)) }} {{ __('New {0}').format(singularize(title)) }}
</Button> </Button>
<div class="text-xl font-semibold text-ink-gray-9"> <div class="text-xl font-semibold text-ink-gray-9">
@@ -77,6 +81,7 @@ const currentTopic = ref(null)
const socket = inject('$socket') const socket = inject('$socket')
const user = inject('$user') const user = inject('$user')
const showTopicModal = ref(false) const showTopicModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
title: { title: {

View File

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

View File

@@ -17,10 +17,11 @@
:debounce="300" :debounce="300"
/> />
<Button @click="() => (showForm = !showForm)"> <Button @click="() => (showForm = !showForm)">
<template #icon> <template #prefix>
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" /> <Plus v-if="!showForm" class="size-4 stroke-1.5" />
<X v-else class="h-3 w-3 stroke-1.5" /> <X v-else class="size-4 stroke-1.5" />
</template> </template>
{{ showForm ? __('Close') : __('New') }}
</Button> </Button>
</div> </div>
</div> </div>
@@ -97,7 +98,7 @@ const evaluators = createResource({
return { return {
doctype: 'Course Evaluator', doctype: 'Course Evaluator',
fields: ['evaluator', 'full_name', 'user_image', 'username'], fields: ['evaluator', 'full_name', 'user_image', 'username'],
filters: search.value ? [['evaluator', 'like', search.value]] : [], filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
} }
}, },
auto: true, auto: true,

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="20"
height="20"
viewBox="0 0 68 75"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 6.78182C0 1.60212 5.5742 -1.65958 10.09 0.879521L64.09 31.2545C68.6916 33.8443 68.6916 40.4693 64.09 43.0595L10.09 73.4345C5.5744 75.9736 0 72.7119 0 67.5322V6.78182ZM26.2695 38.5201C26.2695 37.3248 25.2265 37.9342 26.2695 38.5201C27.332 39.1178 27.332 37.9225 26.2695 38.5201Z"
fill="white"
/>
</svg>
</template>

View File

@@ -1,25 +1,35 @@
<template> <template>
<div class="border rounded-md p-4"> <div
<div class="flex space-x-4"> class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
<img >
:src="job.company_logo" <div class="flex space-x-4 mb-4">
class="size-10 rounded-full object-contain" <div class="flex flex-col space-y-2 flex-1">
/> <div class="text-lg font-semibold text-ink-gray-9">
<div class="flex flex-col space-y-1 flex-1">
<div class="flex items-center justify-between">
<span class="text-lg font-semibold text-ink-gray-9">
{{ job.job_title }}
</span>
</div>
<div class="text-xs text-ink-gray-5">
{{ job.company_name }} {{ job.company_name }}
</div> </div>
<span class="font-medium text-ink-gray-7 leading-5">
{{ job.job_title }}
</span>
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
<MapPin class="size-3" />
<span>
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
</span>
</div>
<div
v-if="job.applicants"
class="flex items-center space-x-1 text-sm text-ink-gray-7"
>
<User class="size-3" />
<span>
{{ job.applicants }}
{{ job.applicants > 1 ? __('applicants') : __('applicant') }}
</span>
</div>
</div> </div>
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
</div> </div>
<div class="space-x-4 mt-2"> <div class="space-x-2 mt-auto">
<Badge>
{{ job.location }}
</Badge>
<Badge> <Badge>
{{ job.type }} {{ job.type }}
</Badge> </Badge>
@@ -27,11 +37,16 @@
{{ dayjs(job.creation).fromNow() }} {{ dayjs(job.creation).fromNow() }}
</Badge> </Badge>
</div> </div>
<!-- <div
class="description text-ink-gray-9 text-sm"
v-html="job.description"
></div> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { inject } from 'vue' import { inject } from 'vue'
import { Badge } from 'frappe-ui' import { Badge } from 'frappe-ui'
import { MapPin, User } from 'lucide-vue-next'
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
@@ -41,3 +56,15 @@ const props = defineProps({
}, },
}) })
</script> </script>
<style>
.description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin-top: auto;
line-height: 1.5;
}
</style>

View File

@@ -3,7 +3,7 @@
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }} {{ __('Live Class') }}
</div> </div>
<Button v-if="user.data.is_moderator" @click="openLiveClassModal"> <Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -87,6 +87,7 @@ import { formatTime } from '@/utils/'
const user = inject('$user') const user = inject('$user')
const showLiveClassModal = ref(false) const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
batch: { batch: {
@@ -116,6 +117,11 @@ const liveClasses = createListResource({
const openLiveClassModal = () => { const openLiveClassModal = () => {
showLiveClassModal.value = true showLiveClassModal.value = true
} }
const canCreateClass = () => {
if (readOnlyMode) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
</script> </script>
<style> <style>
.short-introduction { .short-introduction {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'lg',
}"
>
<template #body>
<div class="p-5 text-base max-h-[75vh] overflow-y-auto">
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
{{
assignmentID === 'new'
? __('Create an Assignment')
: __('Edit Assignment')
}}
</div>
<div class="space-y-4">
<FormControl
v-model="assignment.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="assignment.type"
type="select"
:options="assignmentOptions"
:label="__('Submission Type')"
:required="true"
/>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="assignment.question"
@change="(val) => (assignment.question = 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-[18rem] overflow-y-auto"
/>
</div>
</div>
<div class="flex justify-end space-x-2 mt-5">
<router-link
:to="{
name: 'AssignmentSubmissionList',
query: {
assignmentID: assignmentID,
},
}"
>
<Button v-if="assignmentID !== 'new'" variant="subtle">
{{ __('Check Submissions') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
interface Assignment {
title: string
type: string
question: string
}
interface Assignments {
data: Assignment[]
get: (params: { doctype: string; name: string }) => Promise<Assignment>
insert: {
submit: (params: Assignment, options: { onSuccess: () => void }) => void
}
}
const assignment = reactive({
title: '',
type: '',
question: '',
})
const props = defineProps({
assignmentID: {
type: String,
default: 'new',
},
})
watch(
() => props.assignmentID,
(val) => {
if (val !== 'new') {
assignments.value?.data.forEach((row) => {
if (row.name === val) {
assignment.title = row.title
assignment.type = row.type
assignment.question = row.question
}
})
}
},
{ flush: 'post' }
)
const saveAssignment = () => {
if (props.assignmentID == 'new') {
assignments.value.insert.submit(
{
...assignment,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment created successfully'))
},
}
)
} else {
assignments.value.setValue.submit(
{
...assignment,
name: props.assignmentID,
},
{
onSuccess() {
show.value = false
toast.success(__('Assignment updated successfully'))
},
}
)
}
}
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
</script>

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

@@ -38,7 +38,7 @@
<div class="mb-4"> <div class="mb-4">
<Button @click="openFileSelector" :loading="uploading"> <Button @click="openFileSelector" :loading="uploading">
{{ {{
uploading ? `Uploading ${progress}%` : 'Upload an zip file' uploading ? `Uploading ${progress}%` : 'Upload an ZIP file'
}} }}
</Button> </Button>
</div> </div>
@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +1,27 @@
<template> <template>
<Dialog v-model="show" :options="dialogOptions"> <Dialog
<template #body-content> v-model="show"
<div class="space-y-4"> :options="{
size: '3xl',
}"
>
<template #body>
<div class="p-5 space-y-5">
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
{{ __(props.title) }}
</div>
<div <div
v-if="!editMode" v-if="!editMode"
class="flex items-center text-xs text-ink-gray-7 space-x-5" class="flex items-center text-xs text-ink-gray-7 space-x-5"
> >
<div class="flex items-center space-x-2"> <Switch
<input size="sm"
type="radio" :label="__('Choose an existing question')"
id="existing" v-model="chooseFromExisting"
value="existing" class="!p-0"
v-model="questionType" />
class="w-3 h-3 cursor-pointer"
/>
<label for="existing" class="cursor-pointer">
{{ __('Add an existing question') }}
</label>
</div>
<div class="flex items-center space-x-2">
<input
type="radio"
id="new"
value="new"
v-model="questionType"
class="w-3 h-3 cursor-pointer"
/>
<label for="new" class="cursor-pointer">
{{ __('Create a new question') }}
</label>
</div>
</div> </div>
<div v-if="questionType == 'new' || editMode" class="space-y-2"> <div v-if="!chooseFromExisting || editMode" class="space-y-2">
<div> <div>
<label class="block text-xs text-ink-gray-5 mb-1"> <label class="block text-xs text-ink-gray-5 mb-1">
{{ __('Question') }} {{ __('Question') }}
@@ -45,20 +34,34 @@
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]"
/> />
</div> </div>
<FormControl <div class="grid grid-cols-2 gap-4">
v-model="question.marks" <FormControl
:label="__('Marks')" v-model="question.marks"
type="number" :label="__('Marks')"
/> type="number"
<FormControl />
:label="__('Type')" <FormControl
v-model="question.type" :label="__('Type')"
type="select" v-model="question.type"
:options="['Choices', 'User Input', 'Open Ended']" type="select"
class="pb-2" :options="['Choices', 'User Input', 'Open Ended']"
:required="true" class="pb-2"
/> :required="true"
<div v-if="question.type == 'Choices'" class="divide-y border-t"> />
</div>
<div
v-if="question.type == 'Choices'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
>
{{ __('Options') }}
</div>
<div
v-else-if="question.type == 'User Input'"
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
>
{{ __('Possibilities') }}
</div>
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
<div v-for="n in 4" class="space-y-4 py-2"> <div v-for="n in 4" class="space-y-4 py-2">
<FormControl <FormControl
:label="__('Option') + ' ' + n" :label="__('Option') + ' ' + n"
@@ -78,17 +81,18 @@
</div> </div>
<div <div
v-else-if="question.type == 'User Input'" v-else-if="question.type == 'User Input'"
v-for="n in 4" class="grid grid-cols-2 gap-4 py-2"
class="space-y-2"
> >
<FormControl <div v-for="n in 4">
:label="__('Possibility') + ' ' + n" <FormControl
v-model="question[`possibility_${n}`]" :label="__('Possibility') + ' ' + n"
:required="n == 1 ? true : false" v-model="question[`possibility_${n}`]"
/> :required="n == 1 ? true : false"
/>
</div>
</div> </div>
</div> </div>
<div v-else-if="questionType == 'existing'" class="space-y-2"> <div v-else-if="chooseFromExisting" class="space-y-2">
<Link <Link
v-model="existingQuestion.question" v-model="existingQuestion.question"
:label="__('Select a question')" :label="__('Select a question')"
@@ -100,20 +104,32 @@
type="number" type="number"
/> />
</div> </div>
<div class="flex items-center justify-end space-x-2 mt-5">
<Button variant="solid" @click="submitQuestion()">
{{ __('Submit') }}
</Button>
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui' import {
Dialog,
FormControl,
TextEditor,
createResource,
Switch,
Button,
toast,
} 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()
const quiz = defineModel('quiz') const quiz = defineModel('quiz')
const questionType = ref(null) const chooseFromExisting = ref(false)
const editMode = ref(false) const editMode = ref(false)
const user = inject('$user') const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
@@ -182,11 +198,12 @@ watch(show, () => {
editMode.value = false editMode.value = false
if (props.questionDetail.question) questionData.fetch() if (props.questionDetail.question) questionData.fetch()
else { else {
;(question.question = ''), (question.marks = 0) question.question = ''
question.marks = 1
question.type = 'Choices' question.type = 'Choices'
existingQuestion.question = '' existingQuestion.question = ''
existingQuestion.marks = 0 existingQuestion.marks = 1
questionType.value = null chooseFromExisting.value = false
populateFields() populateFields()
} }
@@ -221,42 +238,36 @@ const questionCreation = createResource({
}, },
}) })
const submitQuestion = (close) => { const submitQuestion = () => {
if (props.questionDetail?.question) updateQuestion(close) if (props.questionDetail?.question) updateQuestion()
else addQuestion(close) else addQuestion()
} }
const addQuestion = (close) => { const addQuestion = () => {
if (questionType.value == 'existing') { if (chooseFromExisting.value) {
addQuestionRow( addQuestionRow({
{ question: existingQuestion.question,
question: existingQuestion.question, marks: existingQuestion.marks,
marks: existingQuestion.marks, })
},
close
)
} else { } else {
questionCreation.submit( questionCreation.submit(
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
addQuestionRow( addQuestionRow({
{ question: data.name,
question: data.name, marks: question.marks,
marks: question.marks, })
},
close
)
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
} }
} }
const addQuestionRow = (question, close) => { const addQuestionRow = (question) => {
questionRow.submit( questionRow.submit(
{ {
...question, ...question,
@@ -267,13 +278,13 @@ const addQuestionRow = (question, close) => {
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()
close() show.value = false
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
close() show.value = false
}, },
} }
) )
@@ -307,7 +318,7 @@ const marksUpdate = createResource({
}, },
}) })
const updateQuestion = (close) => { const updateQuestion = () => {
questionUpdate.submit( questionUpdate.submit(
{}, {},
{ {
@@ -317,39 +328,18 @@ const updateQuestion = (close) => {
{ {
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()
close()
}, },
} }
) )
}, },
onError(err) { onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
} }
const dialogOptions = computed(() => {
return {
title: __(props.title),
size: 'xl',
actions: [
{
label: __('Submit'),
variant: 'solid',
onClick: (close) => {
submitQuestion(close)
},
},
],
}
})
</script> </script>
<style> <style>
input[type='radio']:checked { input[type='radio']:checked {

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<Dialog v-model="show" :options="{ size: '4xl' }"> <Dialog v-model="show" :options="{ size: '5xl' }">
<template #body> <template #body>
<div class="flex h-[calc(100vh_-_8rem)]"> <div class="flex h-[calc(100vh_-_8rem)]">
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2"> <div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
@@ -315,12 +315,6 @@ const tabsStructure = computed(() => {
doctype: 'Email Template', doctype: 'Email Template',
type: 'Link', type: 'Link',
}, },
{
label: 'Assignment Submission Template',
name: 'assignment_submission_template',
doctype: 'Email Template',
type: 'Link',
},
], ],
}, },
{ {
@@ -328,11 +322,11 @@ const tabsStructure = computed(() => {
icon: 'LogIn', icon: 'LogIn',
fields: [ fields: [
{ {
label: 'Identify User Persona', label: 'Identify User Category',
name: 'user_category', name: 'user_category',
type: 'checkbox', type: 'checkbox',
description: description:
'Enable this option to identify the user persona during signup.', 'Enable this option to identify the user category during signup.',
}, },
{ {
label: 'Disable signup', label: 'Disable signup',
@@ -350,6 +344,33 @@ const tabsStructure = computed(() => {
}, },
], ],
}, },
{
label: 'SEO',
icon: 'Search',
fields: [
{
label: 'Meta Description',
name: 'meta_description',
type: 'textarea',
rows: 4,
description:
"This description will be shown on lists and pages that don't have meta description",
},
{
label: 'Meta Keywords',
name: 'meta_keywords',
type: 'textarea',
rows: 4,
description:
'Keywords for search engines to find your website. Separated by commas.',
},
{
label: 'Meta Image',
name: 'meta_image',
type: 'Upload',
},
],
},
], ],
}, },
] ]

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

@@ -1,159 +0,0 @@
<template>
<div v-if="showOnboardingBanner && onboardingDetails.data">
<Tooltip :text="__('Skip Onboarding')" placement="left">
<X
class="w-4 h-4 stroke-1 absolute top-2 right-2 cursor-pointer mr-1"
@click="skipOnboarding.reload()"
/>
</Tooltip>
<div class="flex items-center justify-evenly bg-surface-gray-2 p-10">
<div
@click="redirectToCourseForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.course_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span
v-else
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
>
1
</span>
<span class="text-lg font-semibold">
{{ __('Create a course') }}
</span>
</div>
<div
@click="redirectToChapterForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
!onboardingDetails.data.chapter_created?.length,
'text-ink-gray-3': !onboardingDetails.data.course_created?.length,
}"
>
<span
v-if="onboardingDetails.data.chapter_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span
v-else
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
>
2
</span>
<span class="text-lg font-semibold">
{{ __('Add a chapter') }}
</span>
</div>
<div
@click="redirectToLessonForm()"
class="flex items-center space-x-2"
:class="{
'cursor-pointer':
onboardingDetails.data.course_created?.length &&
onboardingDetails.data.chapter_created?.length,
'text-ink-gray-3':
!onboardingDetails.data.course_created?.length ||
!onboardingDetails.data.chapter_created?.length,
}"
>
<span
v-if="onboardingDetails.data.lesson_created?.length"
class="py-1 px-1 bg-surface-white rounded-full"
>
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
</span>
<span class="font-semibold bg-surface-white px-2 py-1 rounded-full">
3
</span>
<span class="text-lg font-semibold">
{{ __('Add a lesson') }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Check, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useSettings } from '@/stores/settings'
import { createResource, Tooltip } from 'frappe-ui'
const showOnboardingBanner = ref(false)
const settings = useSettings()
const onboardingDetails = settings.onboardingDetails
const router = useRouter()
watch(onboardingDetails, () => {
if (!onboardingDetails.data?.is_onboarded) {
showOnboardingBanner.value = true
} else {
showOnboardingBanner.value = false
}
})
const redirectToCourseForm = () => {
if (onboardingDetails.data?.course_created.length) {
return
} else {
router.push({ name: 'CourseForm', params: { courseName: 'new' } })
}
}
const redirectToChapterForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else {
router.push({
name: 'CourseForm',
params: {
courseName: onboardingDetails.data?.first_course,
},
})
}
}
const redirectToLessonForm = () => {
if (!onboardingDetails.data?.course_created.length) {
return
} else if (!onboardingDetails.data?.chapter_created.length) {
return
} else {
router.push({
name: 'LessonForm',
params: {
courseName: onboardingDetails.data?.first_course,
chapterNumber: 1,
lessonNumber: 1,
},
})
}
}
const skipOnboarding = createResource({
url: 'frappe.client.set_value',
makeParams() {
return {
doctype: 'LMS Settings',
name: 'LMS Settings',
fieldname: 'is_onboarding_complete',
value: 1,
}
},
onSuccess(data) {
onboardingDetails.reload()
},
})
</script>

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)
@@ -653,3 +648,8 @@ const getSubmissionColumns = () => {
] ]
} }
</script> </script>
<style>
p {
line-height: 1.5rem;
}
</style>

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: {
@@ -51,7 +50,9 @@ const props = defineProps({
const update = () => { const update = () => {
props.fields.forEach((f) => { props.fields.forEach((f) => {
if (f.type != 'Column Break') { if (f.type == 'Upload') {
props.data.doc[f.name] = f.value ? f.value.file_url : null
} else if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value props.data.doc[f.name] = f.value
} }
}) })
@@ -59,7 +60,7 @@ const update = () => {
{}, {},
{ {
onError(err) { onError(err) {
showToast(__('Error'), err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

@@ -54,21 +54,30 @@
<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 w-[10rem] py-5" class="flex items-center justify-center rounded border border-outline-gray-modals bg-white w-[10rem] py-2"
> >
<img :src="data[field.name]?.file_url" class="h-6 rounded" /> <img
:src="data[field.name]?.file_url || data[field.name]"
class="w-[80%] rounded"
/>
</div> </div>
<div class="flex flex-col flex-wrap"> <div class="flex flex-col flex-wrap">
<span class="break-all text-ink-gray-9"> <span class="break-all text-ink-gray-9">
{{ data[field.name]?.file_name }} {{
data[field.name]?.file_name ||
data[field.name].split('/').pop()
}}
</span> </span>
<span class="text-sm text-ink-gray-5 mt-1"> <span
v-if="data[field.name]?.file_size"
class="text-sm text-ink-gray-5 mt-1"
>
{{ getFileSize(data[field.name]?.file_size) }} {{ getFileSize(data[field.name]?.file_size) }}
</span> </span>
</div> </div>
<X <X
@click="data[field.name] = null" @click="data[field.name] = null"
class="bg-surface-gray-5 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4" class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/> />
</div> </div>
</div> </div>
@@ -99,7 +108,7 @@
</template> </template>
<script setup> <script setup>
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui' import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed, onMounted } from 'vue' import { computed } from 'vue'
import { getFileSize, validateFile } from '@/utils' import { getFileSize, validateFile } from '@/utils'
import { X } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'

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" class="flex items-center justify-between space-x-2 mt-4"
@click="openEvalCall(evl)" >
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

@@ -194,18 +194,6 @@ const userDropdownOptions = computed(() => {
) )
}, },
}, },
],
},
{
group: '',
items: [
{
icon: Zap,
label: 'Powered by Learning',
onClick: () => {
window.open('https://frappe.io/learning', '_blank')
},
},
{ {
icon: LogOut, icon: LogOut,
label: 'Log out', label: 'Log out',

View File

@@ -1,32 +1,53 @@
<template> <template>
<div ref="videoContainer" class="video-block group relative"> <div ref="videoContainer" class="video-block relative group">
<video <video
@timeupdate="updateTime" @timeupdate="updateTime"
@ended="videoEnded" @ended="videoEnded"
@click="togglePlay" @click="togglePlay"
oncontextmenu="return false" oncontextmenu="return false"
class="rounded-lg border border-gray-100 group cursor-pointer" class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef" ref="videoRef"
> >
<source :src="fileURL" :type="type" /> <source :src="fileURL" :type="type" />
</video> </video>
<div <div
class="flex items-center space-x-2 bg-surface-gray-3 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto invisible group-hover:visible" v-if="!playing"
class="absolute inset-0 flex items-center justify-center cursor-pointer"
@click="playVideo"
>
<div
class="rounded-full p-4 pl-4.5"
style="
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.4) 50%
);
"
>
<Play />
</div>
</div>
<div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
:class="{
'invisible group-hover:visible': playing,
}"
> >
<Button variant="ghost"> <Button variant="ghost">
<template #icon> <template #icon>
<Play <Play
v-if="!playing" v-if="!playing"
@click="playVideo" @click="playVideo"
class="w-4 h-4 text-ink-gray-9" class="size-4 text-ink-gray-9"
/> />
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-ink-gray-9" /> <Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
<Button variant="ghost" @click="toggleMute"> <Button variant="ghost" @click="toggleMute">
<template #icon> <template #icon>
<Volume2 v-if="!muted" class="w-4 h-4 text-ink-gray-9" /> <Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="w-4 h-4 text-ink-gray-9" /> <VolumeX v-else class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
<input <input
@@ -38,12 +59,12 @@
@input="changeCurrentTime" @input="changeCurrentTime"
class="duration-slider w-full h-1" class="duration-slider w-full h-1"
/> />
<span class="text-xs font-medium"> <span class="text-sm font-semibold">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span> </span>
<Button variant="ghost" @click="toggleFullscreen"> <Button variant="ghost" @click="toggleFullscreen">
<template #icon> <template #icon>
<Maximize class="w-4 h-4 text-ink-gray-9" /> <Maximize class="size-5 text-ink-white" />
</template> </template>
</Button> </Button>
</div> </div>
@@ -51,8 +72,9 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next' import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui' import { Button } from 'frappe-ui'
import Play from '@/components/Icons/Play.vue'
const videoRef = ref(null) const videoRef = ref(null)
const videoContainer = ref(null) const videoContainer = ref(null)
@@ -147,7 +169,6 @@ const toggleFullscreen = () => {
<style scoped> <style scoped>
.video-block { .video-block {
width: 100%; width: 100%;
max-width: 900px;
margin: 0 auto; margin: 0 auto;
} }
@@ -165,15 +186,16 @@ iframe {
flex: 1; flex: 1;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background-color: theme('colors.gray.400'); border-radius: 10px;
background-color: theme('colors.gray.100');
cursor: pointer; cursor: pointer;
} }
.duration-slider::-webkit-slider-thumb { .duration-slider::-webkit-slider-thumb {
height: 10px; width: 2px;
width: 10px; border-radius: 50%;
-webkit-appearance: none; -webkit-appearance: none;
background-color: theme('colors.gray.900'); background-color: theme('colors.gray.500');
} }
@media screen and (-webkit-min-device-pixel-ratio: 0) { @media screen and (-webkit-min-device-pixel-ratio: 0) {
@@ -186,7 +208,7 @@ iframe {
input[type='range']::-webkit-slider-thumb { input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
cursor: pointer; cursor: pointer;
box-shadow: -500px 0 0 500px theme('colors.gray.900'); box-shadow: -500px 0 0 500px theme('colors.gray.600');
} }
} }
</style> </style>

View File

@@ -26,5 +26,6 @@ app.mount('#app')
const { userResource, allUsers } = usersStore() const { userResource, allUsers } = usersStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.provide('$allUsers', allUsers) app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource app.config.globalProperties.$user = userResource
app.config.globalProperties.$dialog = createDialog app.config.globalProperties.$dialog = createDialog

View File

@@ -1,201 +0,0 @@
<template>
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2">
<router-link
v-if="assignment.doc?.name"
:to="{
name: 'AssignmentSubmissionList',
query: {
assignmentID: assignment.doc.name,
},
}"
>
<Button>
{{ __('Submission List') }}
</Button>
</router-link>
<Button variant="solid" @click="saveAssignment()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="w-3/4 mx-auto py-5">
<div class="font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-2 gap-5 mt-4 mb-8">
<FormControl
v-model="model.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="model.type"
type="select"
:options="assignmentOptions"
:label="__('Type')"
:required="true"
/>
</div>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="model.question"
@change="(val) => (model.question = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
</template>
<script setup>
import {
Breadcrumbs,
Button,
createDocumentResource,
createResource,
FormControl,
TextEditor,
usePageMeta,
} from 'frappe-ui'
import {
computed,
inject,
onMounted,
onBeforeUnmount,
reactive,
watch,
} from 'vue'
import { sessionStore } from '../stores/session'
import { showToast } from '@/utils'
import { useRouter } from 'vue-router'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const props = defineProps({
assignmentID: {
type: String,
required: true,
},
})
const model = reactive({
title: '',
type: 'PDF',
question: '',
})
onMounted(() => {
if (
props.assignmentID == 'new' &&
!user.data?.is_moderator &&
!user.data?.is_instructor
) {
router.push({ name: 'Courses' })
}
if (props.assignmentID !== 'new') {
assignment.reload()
}
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
saveAssignment()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const assignment = createDocumentResource({
doctype: 'LMS Assignment',
name: props.assignmentID,
auto: false,
})
const newAssignment = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Assignment',
...values,
},
}
},
onSuccess(data) {
router.push({ name: 'AssignmentForm', params: { assignmentID: data.name } })
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
})
const saveAssignment = () => {
if (props.assignmentID == 'new') {
newAssignment.submit({
...model,
})
} else {
assignment.setValue.submit(
{
...model,
},
{
onSuccess(data) {
showToast(__('Success'), __('Assignment saved successfully'), 'check')
assignment.reload()
},
onError(err) {
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
},
}
)
}
}
watch(assignment, () => {
Object.keys(assignment.doc).forEach((key) => {
model[key] = assignment.doc[key]
})
})
const breadcrumbs = computed(() => [
{
label: __('Assignments'),
route: { name: 'Assignments' },
},
{
label: assignment.doc ? assignment.doc.title : __('New Assignment'),
},
])
const assignmentOptions = computed(() => {
return [
{ label: 'PDF', value: 'PDF' },
{ label: 'Image', value: 'Image' },
{ label: 'Document', value: 'Document' },
{ label: 'Text', value: 'Text' },
{ label: 'URL', value: 'URL' },
]
})
usePageMeta(() => {
return {
title: assignment.doc ? assignment.doc.title : __('New Assignment'),
icon: brand.favicon,
}
})
</script>

View File

@@ -3,32 +3,46 @@
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 :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <Button
:to="{ v-if="!readOnlyMode"
name: 'AssignmentForm', variant="solid"
params: { @click="
assignmentID: 'new', () => {
}, assignmentID = 'new'
}" showAssignmentForm = true
}
"
> >
<Button variant="solid"> <template #prefix>
<template #prefix> <Plus class="w-4 h-4" />
<Plus class="w-4 h-4" /> </template>
</template> {{ __('New') }}
{{ __('New') }} </Button>
</Button>
</router-link>
</header> </header>
<div class="md:w-3/4 md:mx-auto py-5 mx-5"> <div class="md:w-3/4 md:mx-auto py-5 mx-5">
<div class="grid grid-cols-3 gap-5 mb-5"> <div class="flex items-center justify-between mb-5">
<FormControl v-model="titleFilter" :placeholder="__('Search by title')" /> <div
<FormControl v-if="assignmentCount"
v-model="typeFilter" class="text-xl font-semibold text-ink-gray-7 mb-4"
type="select" >
:options="assignmentTypes" {{ __('{0} Assignments').format(assignmentCount) }}
:placeholder="__('Type')" </div>
/> <div
v-if="assignments.data?.length || assigmentCount > 0"
class="grid grid-cols-2 gap-5"
>
<FormControl
v-model="titleFilter"
:placeholder="__('Search by title')"
/>
<FormControl
v-model="typeFilter"
type="select"
:options="assignmentTypes"
:placeholder="__('Type')"
/>
</div>
</div> </div>
<ListView <ListView
v-if="assignments.data?.length" v-if="assignments.data?.length"
@@ -38,31 +52,15 @@
:options="{ :options="{
showTooltip: false, showTooltip: false,
selectable: false, selectable: false,
getRowRoute: (row) => ({ onRowClick: (row) => {
name: 'AssignmentForm', if (readOnlyMode) return
params: { assignmentID = row.name
assignmentID: row.name, showAssignmentForm = true
}, },
}),
}" }"
> >
</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"
@@ -72,33 +70,45 @@
</Button> </Button>
</div> </div>
</div> </div>
<AssignmentForm
v-model="showAssignmentForm"
v-model:assignments="assignments"
:assignmentID="assignmentID"
/>
</template> </template>
<script setup> <script setup>
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 EmptyState from '@/components/EmptyState.vue'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const titleFilter = ref('') const titleFilter = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const showAssignmentForm = ref(false)
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
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' })
} }
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
}) })
@@ -136,7 +146,7 @@ const assignmentFilter = computed(() => {
const assignments = createListResource({ const assignments = createListResource({
doctype: 'LMS Assignment', doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation'], fields: ['name', 'title', 'type', 'creation', 'question'],
orderBy: 'modified desc', orderBy: 'modified desc',
cache: ['assignments'], cache: ['assignments'],
transform(data) { transform(data) {
@@ -166,11 +176,19 @@ const assignmentColumns = computed(() => {
label: __('Created'), label: __('Created'),
key: 'creation', key: 'creation',
width: 1, width: 1,
align: 'center', align: 'right',
}, },
] ]
}) })
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

@@ -11,7 +11,7 @@
> >
{{ __('Generate Certificates') }} {{ __('Generate Certificates') }}
</Button> </Button>
<Button v-if="user.data?.is_moderator" @click="openAnnouncementModal()"> <Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
<span> <span>
{{ __('Make an Announcement') }} {{ __('Make an Announcement') }}
</span> </span>
@@ -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" />
@@ -242,6 +242,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { brand } = sessionStore() const { brand } = sessionStore()
const tabIndex = ref(0) const tabIndex = ref(0)
const readOnlyMode = window.read_only_mode
const tabs = computed(() => { const tabs = computed(() => {
let batchTabs = [] let batchTabs = []
@@ -354,6 +355,14 @@ watch(tabIndex, () => {
} }
}) })
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
usePageMeta(() => { usePageMeta(() => {
return { return {
title: batch?.data?.title, title: batch?.data?.title,

View File

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

View File

@@ -8,99 +8,68 @@
{{ __('Save') }} {{ __('Save') }}
</Button> </Button>
</header> </header>
<div class="w-1/2 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 font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<div class="space-y-4 mb-4"> <div class="grid grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Members', close)"
:filters="{ ignore_user_type: 1 }"
/>
</div>
<FormControl <FormControl
v-model="batch.title" v-model="batch.description"
:label="__('Title')" :label="__('Short Description')"
type="textarea"
:rows="8"
:placeholder="__('Short description of the batch')"
:required="true" :required="true"
class="w-full"
/> />
<div class="flex items-center space-x-5">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
</div> </div>
</div> </div>
<div class="mb-4">
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Meta Image') }}
</div>
<FileUploader
v-if="!batch.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template v-slot="{ file, progress, uploading, openFileSelector }">
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img :src="batch.image.file_url" class="border rounded-md w-40" />
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</div>
</div>
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
:filters="{ ignore_user_type: 1 }"
/>
<div class="my-10"> <div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-3 gap-5">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
</div>
<div class="px-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') }} {{ __('Date and Time') }}
</div> </div>
<div class="grid grid-cols-2 gap-10"> <div class="grid grid-cols-3 gap-10">
<div> <div class="space-y-5">
<FormControl <FormControl
v-model="batch.start_date" v-model="batch.start_date"
:label="__('Start Date')" :label="__('Start Date')"
@@ -115,16 +84,8 @@
class="mb-4" class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
</div> </div>
<div> <div class="space-y-5">
<FormControl <FormControl
v-model="batch.start_time" v-model="batch.start_time"
:label="__('Start Time')" :label="__('Start Time')"
@@ -140,21 +101,14 @@
:required="true" :required="true"
/> />
</div> </div>
</div> <div class="space-y-5">
</div>
<div class="mb-10">
<div class="text-lg font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-2 gap-10">
<div>
<FormControl <FormControl
v-model="batch.seat_count" v-model="batch.timezone"
:label="__('Seat Count')" :label="__('Timezone')"
type="number" type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4" class="mb-4"
:placeholder="__('Number of seats available')" :required="true"
/> />
<FormControl <FormControl
v-model="batch.evaluation_end_date" v-model="batch.evaluation_end_date"
@@ -162,13 +116,46 @@
type="date" type="date"
class="mb-4" class="mb-4"
/> />
</div>
</div>
</div>
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
/>
</div>
</div>
<div class="px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Configurations') }}
</div>
<div class="grid grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<Link <Link
doctype="Email Template" doctype="Email Template"
:label="__('Email Template')" :label="__('Email Template')"
v-model="batch.confirmation_email_template" v-model="batch.confirmation_email_template"
/> />
</div> </div>
<div> <div class="space-y-5">
<FormControl <FormControl
v-model="batch.medium" v-model="batch.medium"
type="select" type="select"
@@ -189,26 +176,79 @@
doctype="LMS Category" doctype="LMS Category"
:label="__('Category')" :label="__('Category')"
v-model="batch.category" v-model="batch.category"
:onCreate="(value, close) => openSettings('Categories', close)"
/> />
</div> </div>
<div class="space-y-5">
<div>
<div class="text-xs text-ink-gray-5">
{{ __('Meta Image') }}
</div>
<FileUploader
v-if="!batch.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="flex items-center">
<div class="border rounded-md w-fit py-5 px-20">
<Image class="size-5 stroke-1 text-ink-gray-7" />
</div>
<div class="ml-4">
<Button @click="openFileSelector">
{{ __('Upload') }}
</Button>
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
{{
__('Appears when the batch URL is shared on socials')
}}
</div>
</div>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="batch.image.file_url"
class="border rounded-md w-40"
/>
<div class="ml-4">
<Button @click="removeImage()">
{{ __('Remove') }}
</Button>
<div class="mt-2 text-ink-gray-5 text-sm">
{{
__(
'Appears when the batch URL is shared on any online platform'
)
}}
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class=""> <div class="px-20 pb-5 space-y-5">
<div class="text-lg font-semibold mb-4"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Payment') }} {{ __('Pricing') }}
</div> </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 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')"
type="number" type="number"
class="my-4"
/> />
<Link <Link
doctype="Currency" doctype="Currency"
@@ -218,33 +258,6 @@
/> />
</div> </div>
</div> </div>
<div class="my-10">
<div class="text-lg 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>
@@ -265,15 +278,16 @@ import {
TextEditor, TextEditor,
createResource, createResource,
usePageMeta, usePageMeta,
toast,
} from 'frappe-ui' } from 'frappe-ui'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from '@/utils'
import { Image } from 'lucide-vue-next' import { Image } from 'lucide-vue-next'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { openSettings } from '@/utils'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
@@ -445,7 +459,7 @@ const createNewBatch = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -464,7 +478,7 @@ const editBatchDetails = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5" class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link :to="{ name: 'Batches' }"> <router-link :to="{ name: 'Batches', query: { certification: true } }">
<Button> <Button>
<template #prefix> <template #prefix>
<GraduationCap class="h-4 w-4 stroke-1.5" /> <GraduationCap class="h-4 w-4 stroke-1.5" />
@@ -12,12 +12,13 @@
</Button> </Button>
</router-link> </router-link>
</header> </header>
<div class="p-5 lg:w-3/4 mx-auto"> <div
<div v-if="participants.data?.length"
class="flex flex-col lg:flex-row lg:items-center space-y-4 lg:space-y-0 justify-between mb-5" class="mx-auto w-full max-w-4xl pt-6 pb-10"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="flex flex-col md:flex-row justify-between mb-4 px-3">
{{ __('All Certified Participants') }} <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }}
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<FormControl <FormControl
@@ -40,79 +41,90 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="participants.data?.length"> <div class="divide-y">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"> <template v-for="participant in participants.data">
<router-link <router-link
v-for="participant in participants.data"
:to="{ :to="{
name: 'ProfileCertificates', name: 'ProfileCertificates',
params: { username: participant.username }, params: {
username: participant.username,
},
}" }"
class="flex sm:rounded px-3 py-2 sm:h-15 hover:bg-surface-gray-2"
> >
<div <div class="flex items-center w-full space-x-3">
class="flex items-center space-x-2 border rounded-md hover:bg-surface-menu-bar p-2 text-ink-gray-7"
>
<Avatar <Avatar
:image="participant.user_image" :image="participant.user_image"
class="size-8 rounded-full object-contain"
:label="participant.full_name" :label="participant.full_name"
size="2xl" size="2xl"
/> />
<div class="flex flex-col space-y-2"> <div class="flex flex-col md:flex-row w-full">
<div class="font-medium"> <div class="flex-1">
{{ participant.full_name }} <div class="text-base font-medium text-ink-gray-8">
{{ participant.full_name }}
</div>
<div
v-if="participant.headline"
class="mt-1.5 text-base text-ink-gray-5"
>
{{ participant.headline }}
</div>
</div> </div>
<div <div
v-if="participant.headline" class="flex items-center space-x-3 md:space-x-24 text-sm md:text-base mt-1.5"
class="headline text-sm text-ink-gray-7"
> >
{{ participant.headline }} <div class="text-ink-gray-5">
{{ participant.certificate_count }}
{{
participant.certificate_count > 1
? __('certificates')
: __('certificate')
}}
</div>
<span class="text-ink-gray-4 md:hidden">·</span>
<div class="text-ink-gray-5">
{{ dayjs(participant.issue_date).format('DD MMM YYYY') }}
</div>
</div> </div>
</div> </div>
</div> </div>
</router-link> </router-link>
</div> </template>
<div
v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5"
>
<Button @click="participants.next()">
{{ __('Load More') }}
</Button>
</div>
</div> </div>
<div <div
v-else-if="!participants.list.loading" v-if="!participants.list.loading && participants.hasNextPage"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48" class="flex justify-center mt-5"
> >
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" /> <Button @click="participants.next()">
<div class="text-lg font-medium mb-1"> {{ __('Load More') }}
{{ __('No participants found') }} </Button>
</div>
<div class="leading-5 w-2/5 text-center">
{{ __('There are no participants matching this criteria.') }}
</div>
</div> </div>
</div> </div>
<EmptyState v-else type="Certified Members" />
</template> </template>
<script setup> <script setup>
import { import {
Avatar, Avatar,
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
Select, Select,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { computed, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { updateDocumentTitle } from '@/utils' import { GraduationCap } from 'lucide-vue-next'
import { BookOpen, 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({})
const nameFilter = ref('') const nameFilter = ref('')
const { brand } = sessionStore() const { brand } = sessionStore()
const memberCount = ref(0)
const dayjs = inject('$dayjs')
onMounted(() => { onMounted(() => {
updateParticipants() updateParticipants()
@@ -126,6 +138,12 @@ const participants = createListResource({
pageLength: 30, pageLength: 30,
}) })
const count = call('lms.lms.api.get_count_of_certified_members').then(
(data) => {
memberCount.value = data
}
)
const categories = createListResource({ const categories = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories', url: 'lms.lms.api.get_certification_categories',
@@ -161,14 +179,14 @@ const updateFilters = () => {
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ {
label: __('Certified Participants'), label: __('Certified Members'),
route: { name: 'CertifiedParticipants' }, route: { name: 'CertifiedParticipants' },
}, },
]) ])
usePageMeta(() => { usePageMeta(() => {
return { return {
title: __('Certified Participants'), title: __('Certified Members'),
icon: brand.favicon, icon: brand.favicon,
} }
}) })

View File

@@ -56,7 +56,7 @@
<CourseInstructors :instructors="course.data.instructors" /> <CourseInstructors :instructors="course.data.instructors" />
</div> </div>
</div> </div>
<div v-if="course.data.tags" class="flex mt-4 w-fit"> <div v-if="course.data.tags" class="flex my-4 w-fit">
<Badge <Badge
theme="gray" theme="gray"
size="lg" size="lg"
@@ -69,7 +69,7 @@
<CourseCardOverlay :course="course" class="md:hidden mb-4" /> <CourseCardOverlay :course="course" class="md:hidden mb-4" />
<div <div
v-html="course.data.description" v-html="course.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-4" 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"
></div> ></div>
<div class="mt-10"> <div class="mt-10">
<CourseOutline <CourseOutline

View File

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

View File

@@ -20,12 +20,14 @@
</header> </header>
<div class="p-5 pb-10"> <div class="p-5 pb-10">
<div <div
v-if="courseCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
> >
<div class="text-lg text-ink-gray-9 font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('All Courses') }} {{ __('All Courses') }}
</div> </div>
<div <div
v-if="courses.data?.length || courseCount"
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4" class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
> >
<TabButtons :buttons="courseTabs" v-model="currentTab" /> <TabButtons :buttons="courseTabs" v-model="currentTab" />
@@ -57,7 +59,7 @@
</div> </div>
<div <div
v-if="courses.data?.length" v-if="courses.data?.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
> >
<router-link <router-link
v-for="course in courses.data" v-for="course in courses.data"
@@ -66,22 +68,7 @@
<CourseCard :course="course" /> <CourseCard :course="course" />
</router-link> </router-link>
</div> </div>
<div <EmptyState v-else-if="!courses.list.loading" type="Courses" />
v-else-if="!courses.list.loading"
class="flex flex-col items-center justify-center text-sm text-ink-gray-5 italic mt-48"
>
<BookOpen class="size-10 mx-auto stroke-1 text-ink-gray-4" />
<div class="text-lg font-medium mb-1">
{{ __('No courses found') }}
</div>
<div class="leading-5 w-2/5 text-center">
{{
__(
'There are no courses matching the criteria. Keep an eye out, fresh learning experiences are on the way soon!'
)
}}
</div>
</div>
<div <div
v-if="!courses.list.loading && courses.hasNextPage" v-if="!courses.list.loading && courses.hasNextPage"
class="flex justify-center mt-5" class="flex justify-center mt-5"
@@ -96,6 +83,7 @@
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
call,
createListResource, createListResource,
FormControl, FormControl,
Select, Select,
@@ -103,10 +91,12 @@ 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'
const user = inject('$user') const user = inject('$user')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
@@ -119,10 +109,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 courseCount = ref(0)
onMounted(() => { onMounted(() => {
setFiltersFromQuery() setFiltersFromQuery()
updateCourses() updateCourses()
getCourseCount()
categories.value = [ categories.value = [
{ {
label: '', label: '',
@@ -145,16 +137,49 @@ const courses = createListResource({
pageLength: pageLength.value, pageLength: pageLength.value,
start: start.value, start: start.value,
onSuccess(data) { onSuccess(data) {
let allCategories = data.map((course) => course.category) setCategories(data)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}, },
}) })
const setCategories = (data) => {
let allCategories = data.map((course) => course.category)
allCategories = allCategories.filter(
(category, index) => allCategories.indexOf(category) === index && category
)
if (categories.value.length <= allCategories.length) {
updateCategories(data)
}
}
const isPersonaCaptured = async () => {
let persona = await call('frappe.client.get_single_value', {
doctype: 'LMS Settings',
field: 'persona_captured',
})
return persona
}
const identifyUserPersona = async () => {
if (user.data?.is_system_manager && !user.data?.developer_mode) {
let personaCaptured = await isPersonaCaptured()
if (personaCaptured) return
if (!courseCount.value) {
router.push({
name: 'PersonaForm',
})
}
}
}
const getCourseCount = () => {
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

@@ -16,11 +16,14 @@
}, },
]" ]"
/> />
<div v-if="user.data?.name" class="flex space-x-2"> <div
v-if="user.data?.name && !readOnlyMode"
class="flex items-center space-x-2"
>
<router-link <router-link
v-if="user.data.name == job.data?.owner" v-if="user.data.name == job.data?.owner"
:to="{ :to="{
name: 'JobCreation', name: 'JobForm',
params: { jobName: job.data?.name }, params: { jobName: job.data?.name },
}" }"
> >
@@ -47,8 +50,14 @@
</template> </template>
{{ __('Apply') }} {{ __('Apply') }}
</Button> </Button>
<Badge v-else variant="subtle" theme="green" size="lg">
<template #prefix>
<Check class="h-4 w-4" />
</template>
{{ __('You have applied') }}
</Badge>
</div> </div>
<div v-else> <div v-else-if="!readOnlyMode">
<Button @click="redirectToLogin(job.data?.name)"> <Button @click="redirectToLogin(job.data?.name)">
<span> <span>
{{ __('Login to apply') }} {{ __('Login to apply') }}
@@ -56,88 +65,63 @@
</Button> </Button>
</div> </div>
</header> </header>
<div v-if="job.data" class="max-w-3xl mx-auto"> <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="w-16 h-16 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="">
{{ job.data.job_title }} <div class="text-2xl text-ink-gray-9 font-semibold mb-1">
{{ job.data.job_title }}
</div>
<div class="text-sm text-ink-gray-5 font-semibold">
{{ job.data.company_name }} - {{ job.data.location }},
{{ job.data.country }}
</div>
</div> </div>
</div> </div>
<div>
<div <div class="space-x-5">
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-5 md:gap-y-5" <Badge size="lg">
> <template #prefix>
<div class="flex items-center space-x-4"> <CalendarDays class="size-3 stroke-2 text-ink-gray-7" />
<Building2 class="h-4 w-4 text-ink-green-2" /> </template>
<div class="flex flex-col space-y-1 text-ink-gray-7"> {{ dayjs(job.data.creation).fromNow() }}
<span class="text-xs text-ink-gray-5 font-medium uppercase"> </Badge>
{{ __('Organisation') }} <Badge size="lg">
</span> <template #prefix>
<span class="text-sm font-semibold"> <ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
{{ job.data.company_name }} </template>
</span> {{ job.data.type }}
</div> </Badge>
</div> <Badge v-if="applicationCount.data" size="lg">
<div class="flex items-center space-x-4"> <template #prefix>
<MapPin class="size-4 text-ink-red-3" /> <SquareUserRound class="size-3 stroke-2 text-ink-gray-7" />
<div class="flex flex-col space-y-1 text-ink-gray-7"> </template>
<span class="text-xs font-medium uppercase"> {{ applicationCount.data }}
{{ __('Location') }} {{
</span> applicationCount.data == 1 ? __('applicant') : __('applicants')
<span class="text-sm font-semibold"> }}
{{ job.data.location }} </Badge>
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<ClipboardType class="h-4 w-4 text-yellow-500" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase">
{{ __('Category') }}
</span>
<span class="text-sm font-semibold">
{{ job.data.type }}
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<CalendarDays class="h-4 w-4 text-ink-blue-2" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs 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="h-4 w-4 text-purple-500" />
<div class="flex flex-col space-y-1 text-ink-gray-7">
<span class="text-xs font-medium uppercase">
{{ __('Applications Received') }}
</span>
<span class="text-sm font-semibold">
{{ applicationCount.data }}
</span>
</div>
</div>
</div>
</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 class="bg-surface-gray-2 h-px m-1 w-1/2"></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
@@ -149,25 +133,32 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Button, Breadcrumbs, createResource, usePageMeta } from 'frappe-ui' import {
Badge,
Button,
Breadcrumbs,
createResource,
usePageMeta,
} from 'frappe-ui'
import { inject, ref } from 'vue' 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,
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')
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const { brand } = sessionStore() const { brand } = sessionStore()
const showApplicationModal = ref(false) const showApplicationModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
job: { job: {
@@ -235,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,34 +9,39 @@
</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>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-5">
<div> <div class="space-y-4">
<FormControl <FormControl
v-model="job.job_title" v-model="job.job_title"
:label="__('Title')" :label="__('Title')"
class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="job.location"
:label="__('Location')"
:required="true"
/>
</div>
<div>
<FormControl <FormControl
v-model="job.type" v-model="job.type"
:label="__('Type')" :label="__('Type')"
type="select" type="select"
:options="jobTypes" :options="jobTypes"
class="mb-4" :required="true"
/>
</div>
<div class="space-y-4">
<FormControl
v-model="job.location"
:label="__('City')"
:required="true"
/>
<Link
v-model="job.country"
doctype="Country"
:label="__('Country')"
:required="true" :required="true"
/> />
<FormControl <FormControl
v-if="jobName != 'new'"
v-model="job.status" v-model="job.status"
:label="__('Status')" :label="__('Status')"
type="select" type="select"
@@ -45,25 +50,12 @@
/> />
</div> </div>
</div> </div>
<div class="mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.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] mb-4"
/>
</div>
</div> </div>
<div class="container 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>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-5">
<div> <div>
<FormControl <FormControl
v-model="job.company_name" v-model="job.company_name"
@@ -128,6 +120,19 @@
</div> </div>
</div> </div>
</div> </div>
<div class="container mt-4">
<label class="block text-ink-gray-5 text-xs mb-1">
{{ __('Description') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="job.description"
@change="(val) => (job.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] mb-4"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -140,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()
@@ -217,6 +223,7 @@ const imageResource = createResource({
const job = reactive({ const job = reactive({
job_title: '', job_title: '',
location: '', location: '',
country: '',
type: 'Full Time', type: 'Full Time',
status: 'Open', status: 'Open',
company_name: '', company_name: '',
@@ -253,7 +260,7 @@ const createNewJob = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -272,7 +279,7 @@ const editJobDetails = () => {
}) })
}, },
onError(err) { onError(err) {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}, },
} }
) )
@@ -317,7 +324,7 @@ const breadcrumbs = computed(() => {
}, },
{ {
label: props.jobName == 'new' ? 'New Job' : 'Edit Job', label: props.jobName == 'new' ? 'New Job' : 'Edit Job',
route: { name: 'JobCreation' }, route: { name: 'JobForm' },
}, },
] ]
return crumbs return crumbs

View File

@@ -10,13 +10,13 @@
<router-link <router-link
v-if="user.data?.name" v-if="user.data?.name"
:to="{ :to="{
name: 'JobCreation', name: 'JobForm',
params: { params: {
jobName: 'new', jobName: 'new',
}, },
}" }"
> >
<Button variant="solid"> <Button v-if="!readOnlyMode" variant="solid">
<template #prefix> <template #prefix>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</template> </template>
@@ -25,40 +25,50 @@
</router-link> </router-link>
</header> </header>
<div> <div>
<div v-if="jobs.data?.length" class="p-5"> <div
<div v-if="jobCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
> >
<div class="text-xl text-ink-gray-9 font-semibold"> <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ __('Find the perfect job for you') }} {{ __('{0} Open Jobs').format(jobCount) }}
</div>
<div class="grid grid-cols-2 gap-2">
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5"> <div
v-if="jobs.data?.length || jobCount > 0"
class="grid grid-cols-1 md:grid-cols-3 gap-2"
>
<FormControl
type="text"
:placeholder="__('Search')"
v-model="searchQuery"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
@input="updateJobs"
>
<template #prefix>
<Search
class="w-4 h-4 stroke-1.5 text-ink-gray-5"
name="search"
/>
</template>
</FormControl>
<Link
doctype="Country"
v-model="country"
:placeholder="__('Country')"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
/>
<FormControl
v-model="jobType"
type="select"
:options="jobTypes"
class="min-w-40 lg:min-w-0 lg:w-32 xl:w-40"
:placeholder="__('Type')"
@change="updateJobs"
/>
</div>
</div>
<div v-if="jobs.data?.length" class="w-full md:w-4/5 mx-auto p-5 pt-0">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<router-link <router-link
v-for="job in jobs.data" v-for="job in jobs.data"
:to="{ :to="{
@@ -71,22 +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-48"
>
<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. Open a job opportunity or check here again later.'
)
}}
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -94,21 +89,27 @@
import { import {
Button, Button,
Breadcrumbs, Breadcrumbs,
call,
createResource, createResource,
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 } 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 EmptyState from '@/components/EmptyState.vue'
const user = inject('$user') const user = inject('$user')
const jobType = ref(null) const jobType = ref(null)
const { brand } = sessionStore() const { brand } = sessionStore()
const searchQuery = ref('') const searchQuery = ref('')
const country = ref(null)
const filters = ref({}) const filters = ref({})
const orFilters = ref({}) const orFilters = ref({})
const jobCount = ref(0)
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
let queries = new URLSearchParams(location.search) let queries = new URLSearchParams(location.search)
@@ -116,6 +117,7 @@ onMounted(() => {
jobType.value = queries.get('type') jobType.value = queries.get('type')
} }
updateJobs() updateJobs()
getJobCount()
}) })
const jobs = createResource({ const jobs = createResource({
@@ -153,8 +155,30 @@ const updateFilters = () => {
} else { } else {
orFilters.value = {} orFilters.value = {}
} }
if (country.value) {
filters.value.country = country.value
} else {
delete filters.value.country
}
} }
const getJobCount = () => {
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: {
status: 'Open',
disabled: 0,
},
}).then((data) => {
jobCount.value = data
})
}
watch(country, (val) => {
updateJobs()
})
const jobTypes = computed(() => { const jobTypes = computed(() => {
return [ return [
'', '',

View File

@@ -4,166 +4,237 @@
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 class="h-7" :items="breadcrumbs" /> <Breadcrumbs class="h-7" :items="breadcrumbs" />
<CertificationLinks :courseName="courseName" /> <div class="flex items-center space-x-2">
<Tooltip v-if="canGoZen()" :text="__('Zen Mode')">
<Button @click="goFullScreen()">
<template #icon>
<Focus class="w-4 h-4 stroke-2" />
</template>
</Button>
</Tooltip>
<CertificationLinks :courseName="courseName" />
</div>
</header> </header>
<div class="grid md:grid-cols-[70%,30%] h-screen"> <div class="grid md:grid-cols-[70%,30%] h-screen">
<div <div v-if="lesson.data.no_preview" class="border-r">
v-if="lesson.data.no_preview" <div class="shadow rounded-md w-3/4 mt-10 mx-auto text-center p-4">
class="border-r text-center pt-10 px-5 md:px-0 pb-10" <div class="flex items-center justify-center mt-4 space-x-2">
> <LockKeyholeIcon class="size-4 stroke-2 text-ink-gray-5" />
<p class="mb-4"> <div class="text-lg font-semibold text-ink-gray-7">
{{ {{ __('This lesson is locked') }}
__( </div>
'This lesson is not available for preview. Please enroll in the course to access it.'
)
}}
</p>
<Button v-if="user.data" @click="enrollStudent()" variant="solid">
{{ __('Start Learning') }}
</Button>
<Button v-else @click="redirectToLogin()">
{{ __('Login') }}
</Button>
</div>
<div v-else class="border-r container pt-5 pb-10 px-5">
<div class="flex flex-col md:flex-row md:items-center justify-between">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ lesson.data.title }}
</div> </div>
<div class="flex items-center mt-2 md:mt-0"> <div class="mt-1 mb-4 text-ink-gray-7">
<router-link {{
v-if="lesson.data.prev" __(
:to="{ 'This lesson is not available for preview. Please enroll in the course to access it.'
name: 'Lesson', )
params: { }}
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button class="mr-2">
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<router-link
v-if="allowEdit()"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button class="mr-2">
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
</div> </div>
</div> <Button
v-if="user.data && !lesson.data.disable_self_learning"
<div class="flex items-center mt-2"> @click="enrollStudent()"
<span variant="solid"
class="h-6 mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors?.length > 1,
}"
> >
<UserAvatar {{ __('Start Learning') }}
v-for="instructor in lesson.data.instructors" </Button>
:user="instructor" <Badge
/> theme="blue"
</span> size="lg"
<CourseInstructors v-else-if="lesson.data.disable_self_learning"
v-if="lesson.data?.instructors" class="mt-2"
:instructors="lesson.data.instructors" >
/> {{ __('Contact the Administrator to enroll for this course.') }}
</Badge>
<Button v-else @click="redirectToLogin()">
<template #prefix>
<LogIn class="w-4 h-4 stroke-1" />
</template>
{{ __('Login') }}
</Button>
</div> </div>
</div>
<div
v-else
ref="lessonContainer"
class="bg-surface-white"
:class="{
'overflow-y-auto': zenModeEnabled,
}"
>
<div <div
v-if=" class="border-r container pt-5 pb-10 px-5 h-full"
lesson.data.instructor_content && :class="{
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 && 'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
allowInstructorContent() }"
"
class="bg-surface-gray-2 p-3 rounded-md mt-6"
> >
<div class="text-ink-gray-5 font-medium"> <div
{{ __('Instructor Notes') }} class="flex flex-col md:flex-row md:items-center justify-between"
>
<div class="flex flex-col">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ lesson.data.title }}
</div>
<div
v-if="zenModeEnabled"
class="relative flex items-center space-x-2 text-sm mt-1 text-ink-gray-7 group w-fit mt-2"
>
<span>
{{ lesson.data.chapter_title }} -
{{ lesson.data.course_title }}
</span>
<Info class="size-3" />
<div
class="hidden group-hover:block rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-xl absolute left-0 top-full mt-2"
>
{{ Math.ceil(lesson.data.membership.progress) }}%
{{ __('completed') }}
</div>
</div>
</div>
<div class="flex items-center space-x-2 mt-2 md:mt-0">
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode()">
<template #icon>
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<router-link
v-if="lesson.data.prev"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.prev.split('.')[0],
lessonNumber: lesson.data.prev.split('.')[1],
},
}"
>
<Button>
<template #prefix>
<ChevronLeft class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Previous') }}
</span>
</Button>
</router-link>
<router-link
v-if="allowEdit()"
:to="{
name: 'LessonForm',
params: {
courseName: courseName,
chapterNumber: props.chapterNumber,
lessonNumber: props.lessonNumber,
},
}"
>
<Button>
{{ __('Edit') }}
</Button>
</router-link>
<router-link
v-if="lesson.data.next"
:to="{
name: 'Lesson',
params: {
courseName: courseName,
chapterNumber: lesson.data.next.split('.')[0],
lessonNumber: lesson.data.next.split('.')[1],
},
}"
>
<Button>
<template #suffix>
<ChevronRight class="w-4 h-4 stroke-1" />
</template>
<span>
{{ __('Next') }}
</span>
</Button>
</router-link>
<router-link
v-else
:to="{
name: 'CourseDetail',
params: { courseName: courseName },
}"
>
<Button>
{{ __('Back to Course') }}
</Button>
</router-link>
</div>
</div>
<div v-if="!zenModeEnabled" class="flex items-center mt-2">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': lesson.data.instructors?.length > 1,
}"
>
<UserAvatar
v-for="instructor in lesson.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors
v-if="lesson.data?.instructors"
:instructors="lesson.data.instructors"
/>
</div>
<div
v-if="
lesson.data.instructor_content &&
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
allowInstructorContent()
"
class="bg-surface-gray-2 p-3 rounded-md mt-6"
>
<div class="text-ink-gray-5 font-medium">
{{ __('Instructor Notes') }}
</div>
<div
id="instructor-content"
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"
></div>
</div> </div>
<div <div
id="instructor-content" v-else-if="lesson.data.instructor_notes"
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" 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-8"
></div> >
</div> <LessonContent :content="lesson.data.instructor_notes" />
<div </div>
v-else-if="lesson.data.instructor_notes" <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" v-if="lesson.data.content"
> 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-8"
<LessonContent :content="lesson.data.instructor_notes" /> >
</div> <div id="editor"></div>
<div </div>
v-if="lesson.data.content" <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-5" v-else
> 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-8"
<div id="editor"></div> >
</div> <LessonContent
<div v-if="lesson.data?.body"
v-else :content="lesson.data.body"
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-5" :youtube="lesson.data.youtube"
> :quizId="lesson.data.quiz_id"
<LessonContent />
v-if="lesson.data?.body" </div>
:content="lesson.data.body" <div class="mt-20" ref="discussionsContainer">
:youtube="lesson.data.youtube" <Discussions
:quizId="lesson.data.quiz_id" v-if="allowDiscussions"
/> :title="'Questions'"
</div> :doctype="'Course Lesson'"
<div class="mt-20"> :docname="lesson.data.name"
<Discussions :key="lesson.data.name"
v-if="allowDiscussions" />
:title="'Questions'" </div>
:doctype="'Course Lesson'"
:docname="lesson.data.name"
:key="lesson.data.name"
/>
</div> </div>
</div> </div>
<div class="sticky top-10"> <div class="sticky top-10">
@@ -193,14 +264,37 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Breadcrumbs, Button, usePageMeta } from 'frappe-ui' import {
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue' createResource,
Badge,
Breadcrumbs,
Button,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import {
computed,
watch,
inject,
ref,
onMounted,
onBeforeUnmount,
nextTick,
} from 'vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next' import {
ChevronLeft,
ChevronRight,
LockKeyholeIcon,
LogIn,
Focus,
Info,
MessageCircleQuestion,
} from 'lucide-vue-next'
import Discussions from '@/components/Discussions.vue' import Discussions from '@/components/Discussions.vue'
import { getEditorTools } from '../utils' import { getEditorTools, enablePlyr } from '@/utils'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import EditorJS from '@editorjs/editorjs' import EditorJS from '@editorjs/editorjs'
import LessonContent from '@/components/LessonContent.vue' import LessonContent from '@/components/LessonContent.vue'
@@ -215,6 +309,10 @@ const allowDiscussions = ref(false)
const editor = ref(null) const editor = ref(null)
const instructorEditor = ref(null) const instructorEditor = ref(null)
const lessonProgress = ref(0) const lessonProgress = ref(0)
const lessonContainer = ref(null)
const zenModeEnabled = ref(false)
const hasQuiz = ref(false)
const discussionsContainer = ref(null)
const timer = ref(0) const timer = ref(0)
const { brand } = sessionStore() const { brand } = sessionStore()
let timerInterval let timerInterval
@@ -236,11 +334,27 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
startTimer() startTimer()
document.addEventListener('fullscreenchange', attachFullscreenEvent)
})
const attachFullscreenEvent = () => {
if (document.fullscreenElement) {
zenModeEnabled.value = true
allowDiscussions.value = false
} else {
zenModeEnabled.value = false
if (!hasQuiz.value) {
allowDiscussions.value = true
}
}
}
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
}) })
const lesson = createResource({ const lesson = createResource({
url: 'lms.lms.utils.get_lesson', url: 'lms.lms.utils.get_lesson',
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
makeParams(values) { makeParams(values) {
return { return {
course: props.courseName, course: props.courseName,
@@ -249,36 +363,37 @@ const lesson = createResource({
} }
}, },
auto: true, auto: true,
onSuccess(data) {
if (Object.keys(data).length === 0) {
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
)
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
if (!editor.value && data.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
const hasQuiz = quizRegex.test(data.body)
if (!hasQuiz) allowDiscussions.value = true
}
},
}) })
const setupLesson = (data) => {
if (Object.keys(data).length === 0) {
router.push({
name: 'CourseDetail',
params: { courseName: props.courseName },
})
return
}
lessonProgress.value = data.membership?.progress
if (data.content) editor.value = renderEditor('editor', data.content)
if (
data.instructor_content &&
JSON.parse(data.instructor_content)?.blocks?.length > 1
)
instructorEditor.value = renderEditor(
'instructor-content',
data.instructor_content
)
editor.value?.isReady.then(() => {
checkIfDiscussionsAllowed()
})
if (!editor.value && data.body) {
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
hasQuiz.value = quizRegex.test(data.body)
if (!hasQuiz.value && !zenModeEnabled) allowDiscussions.value = true
}
}
const renderEditor = (holder, content) => { const renderEditor = (holder, content) => {
// empty the holder // empty the holder
if (document.getElementById(holder)) if (document.getElementById(holder))
@@ -348,10 +463,19 @@ watch(
clearInterval(timerInterval) clearInterval(timerInterval)
timer.value = 0 timer.value = 0
startTimer() startTimer()
enablePlyr()
} }
} }
) )
watch(
() => lesson.data,
(data) => {
setupLesson(data)
enablePlyr()
}
)
const startTimer = () => { const startTimer = () => {
timerInterval = setInterval(() => { timerInterval = setInterval(() => {
timer.value++ timer.value++
@@ -367,13 +491,13 @@ onBeforeUnmount(() => {
}) })
const checkIfDiscussionsAllowed = () => { const checkIfDiscussionsAllowed = () => {
let quizPresent = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => { JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
if (block.type === 'quiz') quizPresent = true if (block.type === 'quiz') hasQuiz.value = true
}) })
if ( if (
!quizPresent && !hasQuiz.value &&
!zenModeEnabled.value &&
(lesson.data?.membership || (lesson.data?.membership ||
user.data?.is_moderator || user.data?.is_moderator ||
user.data?.is_instructor) user.data?.is_instructor)
@@ -382,6 +506,7 @@ const checkIfDiscussionsAllowed = () => {
} }
const allowEdit = () => { const allowEdit = () => {
if (window.read_only_mode) return false
if (user.data?.is_moderator) return true if (user.data?.is_moderator) return true
if (lesson.data?.instructors?.includes(user.data?.name)) return true if (lesson.data?.instructors?.includes(user.data?.name)) return true
return false return false
@@ -417,6 +542,48 @@ const enrollStudent = () => {
) )
} }
const canGoZen = () => {
if (
user.data?.is_moderator ||
user.data?.is_instructor ||
user.data?.is_evaluator
)
return false
if (lesson.data?.membership) return true
return false
}
const goFullScreen = () => {
if (lessonContainer.value.requestFullscreen) {
lessonContainer.value.requestFullscreen()
} else if (lessonContainer.value.mozRequestFullScreen) {
lessonContainer.value.mozRequestFullScreen()
} else if (lessonContainer.value.webkitRequestFullscreen) {
lessonContainer.value.webkitRequestFullscreen()
} else if (lessonContainer.value.msRequestFullscreen) {
lessonContainer.value.msRequestFullscreen()
}
}
const showDiscussionsInZenMode = () => {
if (allowDiscussions.value) {
allowDiscussions.value = false
} else {
allowDiscussions.value = true
scrollDiscussionsIntoView()
}
}
const scrollDiscussionsIntoView = () => {
nextTick(() => {
discussionsContainer.value?.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
})
})
}
const redirectToLogin = () => { const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}` window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
} }
@@ -590,4 +757,30 @@ usePageMeta(() => {
.tc-table { .tc-table {
border-left: 1px solid #e8e8eb; border-left: 1px solid #e8e8eb;
} }
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style> </style>

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 } 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'
@@ -133,6 +134,7 @@ onMounted(() => {
editor.value = renderEditor('content') editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes') instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
enablePlyr()
}) })
const renderEditor = (holder) => { const renderEditor = (holder) => {
@@ -141,6 +143,9 @@ const renderEditor = (holder) => {
tools: getEditorTools(true), tools: getEditorTools(true),
autofocus: true, autofocus: true,
defaultBlock: 'markdown', defaultBlock: 'markdown',
onChange: async (api, event) => {
enablePlyr()
},
}) })
} }
@@ -406,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)
}, },
} }
) )
@@ -430,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)
}, },
} }
) )
@@ -449,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 = [
{ {
@@ -624,8 +615,7 @@ usePageMeta(() => {
} }
iframe { iframe {
border-top: 3px solid theme('colors.gray.700'); border: none !important;
border-bottom: 3px solid theme('colors.gray.700');
} }
.tc-table { .tc-table {
@@ -639,4 +629,30 @@ iframe {
.ce-popover-item[data-item-name='markdown'] { .ce-popover-item[data-item-name='markdown'] {
display: none !important; display: none !important;
} }
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style> </style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="flex h-screen overflow-hidden sm:bg-gray-50">
<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">
<LMSLogo class="size-7" />
<span
class="select-none text-xl font-semibold tracking-tight text-gray-900"
>
Learning
</span>
</div>
<div
class="mx-auto w-full h-fit bg-white py-8 sm:mt-6 sm:w-96 sm:rounded-lg sm:px-8 sm:shadow-xl"
>
<div class="font-medium text-center mb-8">
{{ __('Help us understand your needs') }}
</div>
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('What is your use case for Frappe Learning?') }}
</div>
<FormControl
v-model="persona.useCase"
type="select"
:options="useCaseOptions"
/>
</div>
<div class="mb-5">
<div class="text-sm text-gray-700 mb-2">
{{ __('What best describes your role?') }}
</div>
<FormControl
v-model="persona.role"
type="select"
:options="roleOptions"
/>
</div>
<div class="flex w-full">
<Button variant="solid" class="mx-auto" @click="submitPersona()">
{{ __('Submit and Continue') }}
</Button>
</div>
</div>
<div
class="text-center absolute bottom-0 right-0 left-0 mx-auto cursor-pointer text-sm pb-4"
@click="skipPersonaForm()"
>
{{ __('Skip') }}
</div>
</div>
</div>
</template>
<script setup>
import LMSLogo from '@/components/Icons/LMSLogo.vue'
import { Button, call, FormControl, usePageMeta } from 'frappe-ui'
import { computed, inject, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { sessionStore } from '@/stores/session'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const persona = reactive({
role: null,
useCase: null,
})
const submitPersona = () => {
let responses = {
site: user.data?.sitename,
no_of_students: persona.noOfStudents,
use_case: persona.useCase,
}
call('lms.lms.api.capture_user_persona', {
responses: JSON.stringify(responses),
}).then(() => {
router.push({
name: 'Courses',
})
})
}
const skipPersonaForm = () => {
call('frappe.client.set_value', {
doctype: 'LMS Settings',
name: null,
fieldname: 'persona_captured',
value: 1,
}).then(() => {
router.push({
name: 'Courses',
})
})
}
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 options = [
'Less than 50',
'50-200',
'200-1000',
'1000+',
'Not sure yet',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
const useCaseOptions = computed(() => {
const options = [
'Teaching students in a school/university',
'Training employees in my company',
'Onboarding and educating my users/community',
'Selling courses and earning income',
'Other',
]
return options.map((option) => ({
label: option,
value: option,
}))
})
usePageMeta(() => {
return {
title: 'Persona',
icon: brand.favicon,
}
})
</script>

View File

@@ -25,7 +25,11 @@
@select="(imageUrl) => coverImage.submit({ url: imageUrl })" @select="(imageUrl) => coverImage.submit({ url: imageUrl })"
> >
<template v-slot="{ togglePopover }"> <template v-slot="{ togglePopover }">
<Button variant="outline" @click="togglePopover()"> <Button
v-if="!readOnlyMode"
variant="outline"
@click="togglePopover()"
>
<template #prefix> <template #prefix>
<Edit class="w-4 h-4 stroke-1.5 text-ink-gray-7" /> <Edit class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template> </template>
@@ -58,7 +62,7 @@
</div> </div>
</div> </div>
<Button <Button
v-if="isSessionUser()" v-if="isSessionUser() && !readOnlyMode"
class="mt-3 sm:mt-0 md:ml-auto" class="mt-3 sm:mt-0 md:ml-auto"
@click="editProfile()" @click="editProfile()"
> >
@@ -95,7 +99,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue' import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
import { Edit, icons } from 'lucide-vue-next' import { Edit } from 'lucide-vue-next'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import NoPermission from '@/components/NoPermission.vue' import NoPermission from '@/components/NoPermission.vue'
@@ -109,6 +113,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const activeTab = ref('') const activeTab = ref('')
const showProfileModal = ref(false) const showProfileModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
username: { username: {

View File

@@ -4,134 +4,150 @@
{{ __('My availability') }} {{ __('My availability') }}
</h2> </h2>
<div class=""> <div
<div v-if="readOnlyMode"
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4" class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
> >
<div> <CircleAlert class="size-4 stroke-1.5" />
{{ __('Day') }} <span>
</div> {{
<div> __(
{{ __('Start Time') }} 'You cannot change the availability when the site is being updated.'
</div> )
<div> }}
{{ __('End Time') }} </span>
</div>
</div>
<div
v-if="evaluator.data"
v-for="slot in evaluator.data.slots.schedule"
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
>
<FormControl
type="select"
:options="days"
v-model="slot.day"
@focusout.stop="update(slot.name, 'day', slot.day)"
/>
<FormControl
type="time"
v-model="slot.start_time"
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
/>
<FormControl
type="time"
v-model="slot.end_time"
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
/>
<X
@click="deleteRow(slot.name)"
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
/>
</div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
v-model="newSlot.day"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.start_time"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.end_time"
@focusout.stop="add()"
/>
</div>
<Button @click="showSlotsTemplate = 1">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template>
{{ __('Add Slot') }}
</Button>
</div> </div>
<div class="my-10"> <div v-else>
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9"> <div>
{{ __('I am unavailable') }} <div
</h2> class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-ink-gray-7 mb-4"
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> >
<FormControl <div>
type="date" {{ __('Day') }}
:label="__('From')" </div>
v-model="from" <div>
@blur=" {{ __('Start Time') }}
() => { </div>
updateUnavailability.submit({ <div>
field: 'unavailable_from', {{ __('End Time') }}
value: from, </div>
}) </div>
}
" <div
/> v-if="evaluator.data"
<FormControl v-for="slot in evaluator.data.slots.schedule"
type="date" class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
:label="__('To')" >
v-model="to" <FormControl
@blur=" type="select"
() => { :options="days"
updateUnavailability.submit({ v-model="slot.day"
field: 'unavailable_to', @focusout.stop="update(slot.name, 'day', slot.day)"
value: to, />
}) <FormControl
} type="time"
" v-model="slot.start_time"
/> @focusout.stop="update(slot.name, 'start_time', slot.start_time)"
/>
<FormControl
type="time"
v-model="slot.end_time"
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
/>
<X
@click="deleteRow(slot.name)"
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-surface-red-2 hidden group-hover:block"
/>
</div>
<div
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
v-show="showSlotsTemplate"
>
<FormControl
type="select"
:options="days"
v-model="newSlot.day"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.start_time"
@focusout.stop="add()"
/>
<FormControl
type="time"
v-model="newSlot.end_time"
@focusout.stop="add()"
/>
</div>
<Button @click="showSlotsTemplate = 1">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5 text-ink-gray-7" />
</template>
{{ __('Add Slot') }}
</Button>
</div> </div>
</div> <div class="my-10">
<div> <h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9"> {{ __('I am unavailable') }}
{{ __('My calendar') }} </h2>
</h2> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div <FormControl
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized" type="date"
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit" :label="__('From')"
> v-model="from"
<Check class="h-4 w-4 stroke-1.5 mr-2" /> @blur="
{{ __('Your calendar is set.') }} () => {
updateUnavailability.submit({
field: 'unavailable_from',
value: from,
})
}
"
/>
<FormControl
type="date"
:label="__('To')"
v-model="to"
@blur="
() => {
updateUnavailability.submit({
field: 'unavailable_to',
value: to,
})
}
"
/>
</div>
</div>
<div>
<h2 class="mb-4 text-lg font-semibold text-ink-gray-9">
{{ __('My calendar') }}
</h2>
<div
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
class="flex items-center bg-surface-green-2 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
>
<Check class="h-4 w-4 stroke-1.5 mr-2" />
{{ __('Your calendar is set.') }}
</div>
<Button @click="() => authorizeCalendar.submit()">
{{ __('Authorize Google Calendar Access') }}
</Button>
</div> </div>
<Button @click="() => authorizeCalendar.submit()">
{{ __('Authorize Google Calendar Access') }}
</Button>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, FormControl, Button } 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 } from 'lucide-vue-next' import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
const user = inject('$user') const user = inject('$user')
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
profile: { profile: {
@@ -182,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 = ''
@@ -190,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)
}, },
}) })
@@ -205,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)
}, },
}) })
@@ -221,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)
}, },
}) })
@@ -240,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

@@ -4,6 +4,16 @@
{{ __('Settings') }} {{ __('Settings') }}
</h2> </h2>
<div <div
v-if="readOnlyMode"
class="flex items-center space-x-2 text-sm text-ink-gray-7 bg-surface-gray-1 px-3 py-2 rounded-md w-full text-center"
>
<CircleAlert class="size-4 stroke-1.5" />
<span>
{{ __('You cannot change the roles in read-only mode.') }}
</span>
</div>
<div
v-else
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5" class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
> >
<FormControl <FormControl
@@ -34,14 +44,16 @@
</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'
const moderator = ref(false) const moderator = ref(false)
const course_creator = ref(false) const course_creator = ref(false)
const batch_evaluator = ref(false) const batch_evaluator = ref(false)
const lms_student = ref(false) const lms_student = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
profile: { profile: {
@@ -90,7 +102,7 @@ const changeRole = (role) => {
}, },
{ {
onSuccess(data) { onSuccess(data) {
showToast('Success', 'Role updated successfully', 'check') toast.success(__('Role updated successfully'))
}, },
} }
) )

View File

@@ -13,7 +13,7 @@
<!-- Courses --> <!-- Courses -->
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="text-lg font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Program Courses') }} {{ __('Program Courses') }}
</div> </div>
<Button <Button
@@ -75,7 +75,7 @@
<!-- Members --> <!-- Members -->
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="text-lg font-semibold"> <div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Program Members') }} {{ __('Program Members') }}
</div> </div>
<Button <Button
@@ -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

@@ -4,7 +4,7 @@
> >
<Breadcrumbs :items="breadbrumbs" /> <Breadcrumbs :items="breadbrumbs" />
<Button <Button
v-if="user.data?.is_moderator || user.data?.is_instructor" v-if="canCreateProgram()"
@click="showDialog = true" @click="showDialog = true"
variant="solid" variant="solid"
> >
@@ -46,7 +46,7 @@
params: { programName: program.name }, params: { programName: program.name },
}" }"
> >
<Button> <Button v-if="!readOnlyMode">
<template #prefix> <template #prefix>
<Edit class="h-4 w-4 stroke-1.5" /> <Edit class="h-4 w-4 stroke-1.5" />
</template> </template>
@@ -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()
@@ -142,6 +128,7 @@ const showDialog = ref(false)
const router = useRouter() const router = useRouter()
const title = ref('') const title = ref('')
const settings = useSettings() const settings = useSettings()
const readOnlyMode = window.read_only_mode
onMounted(() => { onMounted(() => {
if ( if (
@@ -197,7 +184,7 @@ const enrollMember = (program, course) => {
} }
}) })
.catch((err) => { .catch((err) => {
showToast('Error', err.messages?.[0] || err, 'x') toast.error(err.messages?.[0] || err)
}) })
} }
@@ -208,9 +195,15 @@ const lockCourse = (course) => {
return true return true
} }
const canCreateProgram = () => {
if (readOnlyMode) return false
if (user.data?.is_moderator || user.data?.is_instructor) return true
return false
}
const breadbrumbs = computed(() => [ const breadbrumbs = computed(() => [
{ {
label: 'Programs', label: __('Programs'),
}, },
]) ])

View File

@@ -3,7 +3,7 @@
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 :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<div class="space-x-2"> <div v-if="!readOnlyMode" class="space-x-2">
<router-link <router-link
v-if="quizDetails.data?.name" v-if="quizDetails.data?.name"
:to="{ :to="{
@@ -38,7 +38,7 @@
<div class="w-3/4 mx-auto py-5"> <div class="w-3/4 mx-auto py-5">
<!-- Details --> <!-- Details -->
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Details') }} {{ __('Details') }}
</div> </div>
<FormControl <FormControl
@@ -75,7 +75,7 @@
<!-- Settings --> <!-- Settings -->
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Settings') }} {{ __('Settings') }}
</div> </div>
<div class="grid grid-cols-3 gap-5 my-4"> <div class="grid grid-cols-3 gap-5 my-4">
@@ -93,7 +93,7 @@
</div> </div>
<div class="mb-8"> <div class="mb-8">
<div class="font-semibold mb-4"> <div class="font-semibold text-ink-gray-9 mb-4">
{{ __('Shuffle Settings') }} {{ __('Shuffle Settings') }}
</div> </div>
<div class="grid grid-cols-3"> <div class="grid grid-cols-3">
@@ -113,10 +113,10 @@
<!-- Questions --> <!-- Questions -->
<div> <div>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="font-semibold"> <div class="font-semibold text-ink-gray-9">
{{ __('Questions') }} {{ __('Questions') }}
</div> </div>
<Button @click="openQuestionModal()"> <Button v-if="!readOnlyMode" @click="openQuestionModal()">
<template #prefix> <template #prefix>
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
</template> </template>
@@ -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'
@@ -223,6 +224,7 @@ const currentQuestion = reactive({
}) })
const user = inject('$user') const user = inject('$user')
const router = useRouter() const router = useRouter()
const readOnlyMode = window.read_only_mode
const props = defineProps({ const props = defineProps({
quizID: { quizID: {
@@ -339,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)
}, },
} }
) )
@@ -358,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)
}, },
} }
) )
@@ -427,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()
}, },
@@ -444,11 +446,7 @@ const breadcrumbs = computed(() => {
}, },
}, },
] ]
/* if (quizDetails.data) {
crumbs.push({
label: quiz.title,
})
} */
crumbs.push({ crumbs.push({
label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title, label: props.quizID == 'new' ? __('New Quiz') : quizDetails.data?.title,
route: { name: 'QuizForm', params: { quizID: props.quizID } }, route: { name: 'QuizForm', params: { quizID: props.quizID } },

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,39 +35,39 @@
<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 <div class="divide-y">
v-for="(row, index) in submisisonDetails.doc.result" <div
class="border p-5 rounded-md space-y-4" v-for="(row, index) in submissionDetails.doc.result"
> class="py-5 px-10 space-y-4"
<div class="flex items-start space-x-1 font-semibold text-ink-gray-9"> >
<!-- <span> <div class="text-ink-gray-9">
{{ index + 1 }}. <span class="font-semibold"> {{ __('Question') }}: </span>
</span> --> <span class="leading-5" v-html="row.question"> </span>
<span class="leading-5" v-html="row.question"> </span> </div>
</div> <div class="">
<div class="leading-5 text-ink-gray-7 space-x-1"> <span class="font-semibold"> {{ __('Answer') }} </span>
<span> {{ __('Answer') }}: </span> <span class="leading-5" v-html="row.answer"></span>
<span 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')" /> <FormControl
<FormControl v-model="row.marks_out_of"
v-model="row.marks_out_of" :label="__('Marks out of')"
:label="__('Marks out of')" :disabled="true"
:disabled="true" />
/> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -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

@@ -4,6 +4,7 @@
> >
<Breadcrumbs :items="breadcrumbs" /> <Breadcrumbs :items="breadcrumbs" />
<router-link <router-link
v-if="!readOnlyMode"
:to="{ :to="{
name: 'QuizForm', name: 'QuizForm',
params: { params: {
@@ -20,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"
@@ -52,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,
@@ -82,18 +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
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(() => {
@@ -112,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,109 +7,125 @@
</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">
<div <Tooltip :text="__('Published Courses')">
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" <NumberChart
> class="border rounded-md"
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> :config="{ title: 'Courses', value: chartDetails.data.courses }"
<BookOpen class="w-18 h-18 stroke-1.5" /> />
</div> </Tooltip>
<div> <Tooltip :text="__('Active Members')">
<div class="text-xl font-semibold mb-1"> <NumberChart
{{ formatNumber(chartDetails.data.courses) }} class="border rounded-md"
</div> :config="{ title: 'Signups', value: chartDetails.data.users }"
<div> />
{{ __('Courses') }} </Tooltip>
</div> <Tooltip :text="__('Course Enrollments')">
</div> <NumberChart
</div> class="border rounded-md"
<div :config="{
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" title: 'Enrollments',
> value: chartDetails.data.enrollments,
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> }"
<LogIn class="w-18 h-18 stroke-1.5" /> />
</div> </Tooltip>
<div> <Tooltip :text="__('Course Completions')">
<div class="text-xl font-semibold mb-1"> <NumberChart
{{ formatNumber(chartDetails.data.users) }} class="border rounded-md"
</div> :config="{
<div> title: 'Completions',
{{ __('Signups') }} value: chartDetails.data.completions,
</div> }"
</div> />
</div> </Tooltip>
<div <Tooltip :text="__('Certified Members')">
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7" <NumberChart
> class="border rounded-md"
<div class="p-2 rounded-md bg-surface-gray-2 mr-3"> :config="{
<BookOpenCheck class="w-18 h-18 stroke-1.5" /> title: 'Certifications',
</div> value: chartDetails.data.certifications,
<div> }"
<div class="text-xl font-semibold mb-1"> />
{{ formatNumber(chartDetails.data.enrollments) }} </Tooltip>
</div>
<div>
{{ __('Enrollments') }}
</div>
</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">
<FileCheck class="w-18 h-18 stroke-1.5" />
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.completions) }}
</div>
<div>
{{ __('Completions') }}
</div>
</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">
<FileCheck2 class="w-18 h-18 stroke-1.5" />
</div>
<div>
<div class="text-xl font-semibold mb-1">
{{ formatNumber(chartDetails.data.lesson_completions) }}
</div>
<div class="text-ink-gray-7">
{{ __('Milestones') }}
</div>
</div>
</div>
</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 p-5 min-h-72"> <div class="border rounded-md min-h-72">
<Line <AxisChart
v-if="signupsChart.data" v-if="signupsChart.data"
:data="signupsChart.data" :config="{
:options="signupChartOptions()" data: signupsChart.data,
title: 'Signups',
subtitle: 'Signups per month',
xAxis: {
key: 'date',
type: 'time',
title: 'Date',
timeGrain: 'day',
},
yAxis: {
title: 'Signups',
},
series: [{ name: 'signups', type: 'line', showDataPoints: true }],
}"
/> />
</div> </div>
<div class="border rounded-md p-5 min-h-72"> <div class="border rounded-md min-h-72">
<Line <AxisChart
v-if="enrollmentChart.data" v-if="enrollmentChart.data"
:data="enrollmentChart.data" :config="{
:options="enrollmentChartOptions()" data: enrollmentChart.data,
title: 'Enrollments',
subtitle: 'Enrollments per month',
xAxis: {
key: 'date',
type: 'time',
title: 'Date',
timeGrain: 'day',
},
yAxis: {
title: 'Enrollments',
},
series: [
{ name: 'enrollments', type: 'line', showDataPoints: true },
],
}"
/> />
</div> </div>
<div class="border rounded-md p-5"> <div class="border rounded-md">
<Line <AxisChart
v-if="lessonCompletion.data" v-if="certification.data"
:data="lessonCompletion.data" :config="{
:options="lessonChartOptions()" data: certification.data,
title: 'Certifications',
subtitle: 'Certifications per month',
xAxis: {
key: 'date',
type: 'time',
title: 'Date',
timeGrain: 'day',
},
yAxis: {
title: 'Certifications',
},
series: [
{
name: 'certifications',
type: 'line',
showDataPoints: true,
},
],
}"
/> />
</div> </div>
<div class="border rounded-md p-5"> <div class="border rounded-md">
<Pie <DonutChart
v-if="courseCompletion.data" v-if="courseCompletion.data"
:data="courseCompletion.data" :config="{
:options="courseChartOptions()" data: courseCompletion.data,
title: 'Completions',
subtitle: 'Course Completion',
categoryColumn: 'label',
valueColumn: 'value',
}"
/> />
</div> </div>
</div> </div>
@@ -117,42 +133,17 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { createResource, Breadcrumbs, usePageMeta } from 'frappe-ui' import {
AxisChart,
Breadcrumbs,
createResource,
DonutChart,
NumberChart,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { formatNumber } from '@/utils'
import { Line, Pie } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
ArcElement,
Filler,
} from 'chart.js'
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
ArcElement,
Filler
)
import {
BookOpen,
LogIn,
FileCheck,
FileCheck2,
BookOpenCheck,
} from 'lucide-vue-next'
const { brand } = sessionStore() const { brand } = sessionStore()
@@ -175,11 +166,18 @@ const chartDetails = createResource({
const signupsChart = createResource({ const signupsChart = createResource({
url: 'lms.lms.utils.get_chart_data', url: 'lms.lms.utils.get_chart_data',
cache: ['signups'],
params: { params: {
chart_name: 'New Signups', chart_name: 'New Signups',
}, },
auto: true, auto: true,
transform(data) {
return data.map((item) => {
return {
date: new Date(item.date),
signups: item.count,
}
})
},
}) })
const enrollmentChart = createResource({ const enrollmentChart = createResource({
@@ -189,15 +187,31 @@ const enrollmentChart = createResource({
chart_name: 'Course Enrollments', chart_name: 'Course Enrollments',
}, },
auto: true, auto: true,
transform(data) {
return data.map((item) => {
return {
date: new Date(item.date),
enrollments: item.count,
}
})
},
}) })
const lessonCompletion = createResource({ const certification = createResource({
url: 'lms.lms.utils.get_chart_data', url: 'lms.lms.utils.get_chart_data',
cache: ['lessonCompletion'], cache: ['certifications'],
params: { params: {
chart_name: 'Lesson Completion', chart_name: 'Certification',
}, },
auto: true, auto: true,
transform(data) {
return data.map((item) => {
return {
date: new Date(item.date),
certifications: item.count,
}
})
},
}) })
const courseCompletion = createResource({ const courseCompletion = createResource({
@@ -206,117 +220,6 @@ const courseCompletion = createResource({
cache: ['courseCompletion'], cache: ['courseCompletion'],
}) })
const signupChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Signups'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#4563f0')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
return options
}
const enrollmentChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Enrollments'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#4563f0')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
return options
}
const lessonChartOptions = () => {
let options = chartOptions(false)
options.plugins.title.text = 'Milestones'
options.borderColor = '#4563f0'
options.backgroundColor = (ctx) => {
const canvas = ctx.chart.ctx
const gradient = canvas.createLinearGradient(0, 0, 0, 160)
gradient.addColorStop(0, '#B6DEC5')
gradient.addColorStop(0.5, '#e8ecfe')
gradient.addColorStop(1, '#f6f7ff')
return gradient
}
return options
}
const courseChartOptions = () => {
let options = chartOptions(true)
options.plugins.title.text = 'Completions'
options.backgroundColor = ['#4563f0', '#f683ae']
return options
}
const chartOptions = (isPie) => {
return {
responsive: true,
maintainAspectRatio: false,
fill: true,
borderWidth: 2,
pointRadius: 2,
pointStyle: 'cross',
ticks: {
autoSkip: true,
maxTicksLimit: 5,
},
plugins: {
legend: {
display: isPie ? true : false,
},
title: {
display: true,
align: 'start',
font: {
size: 14,
weight: '500',
},
color: '#171717',
padding: {
bottom: 20,
},
},
tooltip: {
backgroundColor: '#000',
},
},
scales: {
x: {
display: isPie ? false : true,
grid: {
display: false,
},
border: {
display: isPie ? false : true,
},
},
y: {
beginAtZero: true,
display: isPie ? false : true,
grid: {
display: false,
},
border: {
display: isPie ? false : true,
},
},
},
}
}
usePageMeta(() => { usePageMeta(() => {
return { return {
title: __('Statistics'), title: __('Statistics'),

View File

@@ -134,8 +134,8 @@ const routes = [
}, },
{ {
path: '/job-opening/:jobName/edit', path: '/job-opening/:jobName/edit',
name: 'JobCreation', name: 'JobForm',
component: () => import('@/pages/JobCreation.vue'), component: () => import('@/pages/JobForm.vue'),
props: true, props: true,
}, },
{ {
@@ -199,12 +199,6 @@ const routes = [
name: 'Assignments', name: 'Assignments',
component: () => import('@/pages/Assignments.vue'), component: () => import('@/pages/Assignments.vue'),
}, },
{
path: '/assignments/:assignmentID',
name: 'AssignmentForm',
component: () => import('@/pages/AssignmentForm.vue'),
props: true,
},
{ {
path: '/assignment-submission/:assignmentID/:submissionName', path: '/assignment-submission/:assignmentID/:submissionName',
name: 'AssignmentSubmission', name: 'AssignmentSubmission',
@@ -216,6 +210,11 @@ const routes = [
name: 'AssignmentSubmissionList', name: 'AssignmentSubmissionList',
component: () => import('@/pages/AssignmentSubmissionList.vue'), component: () => import('@/pages/AssignmentSubmissionList.vue'),
}, },
{
path: '/persona',
name: 'PersonaForm',
component: () => import('@/pages/PersonaForm.vue'),
},
] ]
let router = createRouter({ let router = createRouter({

View File

@@ -50,8 +50,7 @@ export const sessionStore = defineStore('lms-session', () => {
brand.name = data.app_name brand.name = data.app_name
brand.logo = data.app_logo brand.logo = data.app_logo
brand.favicon = brand.favicon =
data.favicon?.file_url || data.favicon?.file_url || '/assets/lms/frontend/learning.svg'
'/assets/lms/frontend/public/learning.svg'
}, },
}) })

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,14 +15,10 @@ 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/dist/plyr.css'
export function createToast(options) { const readOnlyMode = window.read_only_mode
toast({
position: 'bottom-right',
...options,
})
}
export function timeAgo(date) { export function timeAgo(date) {
return useTimeAgo(date).value return useTimeAgo(date).value
@@ -93,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: 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()
@@ -199,78 +176,50 @@ export function getEditorTools() {
services: { services: {
youtube: { youtube: {
regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/, regex: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtu\.be\/)|(?:youtube\.com)\/(?:v\/|u\/\w\/|embed\/|watch))(?:(?:\?v=)?([^#&?=]*))?((?:[?&]\w*=\w*)*)/,
embedUrl: embedUrl: '<%= remote_id %>',
'https://www.youtube.com/embed/<%= remote_id %>', /* 'https://www.youtube.com/embed/<%= remote_id %>?origin=https://plyr.io&amp;iv_load_policy=3&amp;modestbranding=1&amp;playsinline=1&amp;showinfo=0&amp;rel=0&amp;enablejsapi=1' */
html: '<iframe style="width:100%; height: 30rem;" frameborder="0" allowfullscreen></iframe>', html: `<div class="video-player" data-plyr-provider="youtube"></div>`,
height: 320, id: ([id]) => id,
width: 580, },
id: ([id, params]) => { vimeo: {
if (!params && id) { regex: /(?:http[s]?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)/,
return id embedUrl: '<%= remote_id %>',
} html: `<div class="video-player" data-plyr-provider="vimeo"></div>`,
id: ([id]) => id,
const paramsMap = { },
start: 'start', cloudflareStream: {
end: 'end', regex: /https:\/\/customer-[a-z0-9]+\.cloudflarestream\.com\/([a-f0-9]{32})\/watch/,
t: 'start', embedUrl:
// eslint-disable-next-line camelcase 'https://iframe.videodelivery.net/<%= remote_id %>',
time_continue: 'start', html: `<iframe style="width:100%; height: ${
list: 'list', window.innerWidth < 640 ? '15rem' : '30rem'
} };" frameborder="0" allowfullscreen></iframe>`,
let newParams = params
.slice(1)
.split('&')
.map((param) => {
const [name, value] = param.split('=')
if (!id && name === 'v') {
id = value
return null
}
if (!paramsMap[name]) {
return null
}
if (
value === 'LL' ||
value.startsWith('RDMM') ||
value.startsWith('FL')
) {
return null
}
return `${paramsMap[name]}=${value}`
})
.filter((param) => !!param)
return id + '?' + newParams.join('&')
},
}, },
vimeo: true,
codepen: true, codepen: true,
aparat: { aparat: {
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/, regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
embedUrl: embedUrl:
'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame', 'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame',
html: '<iframe style="margin: 0 auto; width: 100%; height: 25rem;" frameborder="0" scrolling="no" allowtransparency="true"></iframe>', html: `<iframe style="margin: 0 auto; width: 100%; height: ${
height: 300, window.innerWidth < 640 ? '15rem' : '30rem'
width: 600, };" frameborder="0" scrolling="no" allowtransparency="true"></iframe>`,
}, },
github: true, github: true,
slides: { slides: {
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub/, regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/pub/,
embedUrl: embedUrl:
'https://docs.google.com/presentation/d/<%= remote_id %>/embed', 'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0' frameborder='0' allowfullscreen='true'></iframe>", html: `<iframe style='width: 100%; height: ${
window.innerWidth < 640 ? '15rem' : '30rem'
}; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0' frameborder='0' allowfullscreen='true'></iframe>`,
}, },
drive: { drive: {
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/, regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
embedUrl: embedUrl:
'https://drive.google.com/file/d/<%= remote_id %>/preview', 'https://drive.google.com/file/d/<%= remote_id %>/preview',
html: "<iframe style='width: 100%; height: 25rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>", html: `<iframe style='width: 100%; height: ${
window.innerWidth < 640 ? '15rem' : '30rem'
}; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>`,
}, },
docsPublic: { docsPublic: {
regex: /https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/, regex: /https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
@@ -479,7 +428,7 @@ export function getSidebarLinks() {
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'], activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
}, },
{ {
label: 'Certified Participants', label: 'Certified Members',
icon: 'GraduationCap', icon: 'GraduationCap',
to: 'CertifiedParticipants', to: 'CertifiedParticipants',
activeFor: ['CertifiedParticipants'], activeFor: ['CertifiedParticipants'],
@@ -571,5 +520,44 @@ export const escapeHTML = (text) => {
export const canCreateCourse = () => { export const canCreateCourse = () => {
const { userResource } = usersStore() const { userResource } = usersStore()
return userResource.data?.is_instructor || userResource.data?.is_moderator return (
!readOnlyMode &&
(userResource.data?.is_instructor || userResource.data?.is_moderator)
)
}
export const enablePlyr = () => {
setTimeout(() => {
const videoElement = document.getElementsByClassName('video-player')
if (videoElement.length === 0) return
Array.from(videoElement).forEach((video) => {
const src = video.getAttribute('src')
if (src) {
let videoID = src.split('/').pop()
video.setAttribute('data-plyr-embed-id', videoID)
}
new Plyr(video, {
youtube: {
noCookie: true,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
}, 500)
})
}
export const openSettings = (category, close) => {
const settingsStore = useSettings()
close()
settingsStore.activeTab = category
settingsStore.isSettingsOpen = true
} }

View File

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

View File

@@ -25,7 +25,7 @@ export default defineConfig({
}), }),
], ],
server: { server: {
allowedHosts: ['fs', 'onb2'], allowedHosts: ['fs', 'per2'],
}, },
resolve: { resolve: {
alias: { alias: {
@@ -40,6 +40,7 @@ export default defineConfig({
'engine.io-client', 'engine.io-client',
'tailwind.config.js', 'tailwind.config.js',
'highlight.js', 'highlight.js',
'plyr',
], ],
}, },
}) })

View File

@@ -1 +1 @@
__version__ = "2.27.0" __version__ = "2.28.1"

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"
@@ -246,7 +246,7 @@ on_login = "lms.lms.user.on_login"
add_to_apps_screen = [ add_to_apps_screen = [
{ {
"name": "lms", "name": "lms",
"logo": "/assets/lms/images/lms-logo.png", "logo": "/assets/lms/frontend/learning.svg",
"title": "Learning", "title": "Learning",
"route": "/lms", "route": "/lms",
"has_permission": "lms.lms.api.check_app_permission", "has_permission": "lms.lms.api.check_app_permission",

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

@@ -9,18 +9,19 @@
"field_order": [ "field_order": [
"job_title", "job_title",
"location", "location",
"disabled", "country",
"column_break_5", "column_break_5",
"type", "type",
"status", "status",
"disabled",
"section_break_6", "section_break_6",
"description",
"company_details_section",
"company_name", "company_name",
"company_website", "company_website",
"column_break_11", "column_break_phkm",
"company_logo", "company_logo",
"company_email_address" "company_email_address",
"company_details_section",
"description"
], ],
"fields": [ "fields": [
{ {
@@ -36,7 +37,7 @@
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Location", "label": "City",
"reqd": 1 "reqd": 1
}, },
{ {
@@ -62,7 +63,8 @@
}, },
{ {
"fieldname": "section_break_6", "fieldname": "section_break_6",
"fieldtype": "Section Break" "fieldtype": "Section Break",
"label": "Company Details"
}, },
{ {
"fieldname": "description", "fieldname": "description",
@@ -72,8 +74,7 @@
}, },
{ {
"fieldname": "company_details_section", "fieldname": "company_details_section",
"fieldtype": "Section Break", "fieldtype": "Section Break"
"label": "Company Details"
}, },
{ {
"fieldname": "company_name", "fieldname": "company_name",
@@ -89,10 +90,6 @@
"label": "Company Website", "label": "Company Website",
"reqd": 1 "reqd": 1
}, },
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{ {
"fieldname": "company_logo", "fieldname": "company_logo",
"fieldtype": "Attach Image", "fieldtype": "Attach Image",
@@ -111,13 +108,30 @@
"label": "Company Email Address", "label": "Company Email Address",
"options": "Email", "options": "Email",
"reqd": 1 "reqd": 1
},
{
"fieldname": "column_break_phkm",
"fieldtype": "Column Break"
},
{
"fieldname": "country",
"fieldtype": "Link",
"label": "Country",
"options": "Country",
"reqd": 1
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
{
"link_doctype": "LMS Job Application",
"link_fieldname": "job"
}
],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-01-17 12:38:57.134919", "modified": "2025-04-24 14:34:35.920242",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "Job", "module": "Job",
"name": "Job Opportunity", "name": "Job Opportunity",
"owner": "Administrator", "owner": "Administrator",
@@ -157,8 +171,9 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "job_title" "title_field": "job_title"
} }

View File

@@ -19,6 +19,8 @@ from frappe.utils import (
format_date, format_date,
date_diff, date_diff,
) )
from frappe.query_builder import DocType
from pypika.functions import DistinctOptionFunction
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress from lms.lms.doctype.course_lesson.course_lesson import save_progress
@@ -182,9 +184,10 @@ def get_user_info():
) )
user.is_fc_site = is_fc_site() user.is_fc_site = is_fc_site()
user.is_system_manager = "System Manager" in user.roles user.is_system_manager = "System Manager" in user.roles
user.sitename = frappe.local.site
user.developer_mode = frappe.conf.developer_mode
if user.is_fc_site and user.is_system_manager: if user.is_fc_site and user.is_system_manager:
user.site_info = current_site_info() user.site_info = current_site_info()
user.sitename = frappe.local.site
return user return user
@@ -237,6 +240,11 @@ def validate_billing_access(billing_type, name):
access = False access = False
message = _("Batch is sold out.") message = _("Batch is sold out.")
start_date = frappe.get_cached_value("LMS Batch", name, "start_date")
if start_date and date_diff(start_date, now()) < 0:
access = False
message = _("Batch has already started.")
elif access and billing_type == "certificate": elif access and billing_type == "certificate":
purchased_certificate = frappe.db.exists( purchased_certificate = frappe.db.exists(
"LMS Enrollment", "LMS Enrollment",
@@ -278,6 +286,7 @@ def get_job_details(job):
[ [
"job_title", "job_title",
"location", "location",
"country",
"type", "type",
"company_name", "company_name",
"company_logo", "company_logo",
@@ -303,14 +312,20 @@ def get_job_opportunities(filters=None, orFilters=None):
fields=[ fields=[
"job_title", "job_title",
"location", "location",
"country",
"type", "type",
"company_name", "company_name",
"company_logo", "company_logo",
"name", "name",
"creation", "creation",
"description",
], ],
order_by="creation desc", order_by="creation desc",
) )
for job in jobs:
job.description = frappe.utils.strip_html_tags(job.description)
job.applicants = frappe.db.count("LMS Job Application", {"job": job.name})
return jobs return jobs
@@ -331,7 +346,7 @@ def get_chart_details():
details.completions = frappe.db.count( details.completions = frappe.db.count(
"LMS Enrollment", {"progress": ["like", "%100%"]} "LMS Enrollment", {"progress": ["like", "%100%"]}
) )
details.lesson_completions = frappe.db.count("LMS Course Progress") details.certifications = frappe.db.count("LMS Certificate", {"published": 1})
return details return details
@@ -411,29 +426,50 @@ def get_certified_participants(filters=None, start=0, page_length=30):
or_filters["course_title"] = ["like", f"%{category}%"] or_filters["course_title"] = ["like", f"%{category}%"]
or_filters["batch_title"] = ["like", f"%{category}%"] or_filters["batch_title"] = ["like", f"%{category}%"]
participants = frappe.get_all( participants = frappe.db.get_all(
"LMS Certificate", "LMS Certificate",
filters=filters, filters=filters,
or_filters=or_filters, or_filters=or_filters,
fields=["member"], fields=["member", "issue_date"],
group_by="member", group_by="member",
order_by="creation desc", order_by="issue_date desc",
start=start, start=start,
page_length=page_length, page_length=page_length,
) )
for participant in participants: for participant in participants:
count = frappe.db.count("LMS Certificate", {"member": participant.member})
details = frappe.db.get_value( details = frappe.db.get_value(
"User", "User",
participant.member, participant.member,
["full_name", "user_image", "username", "country", "headline"], ["full_name", "user_image", "username", "country", "headline"],
as_dict=1, as_dict=1,
) )
details["certificate_count"] = count
participant.update(details) participant.update(details)
return participants return participants
class CountDistinct(DistinctOptionFunction):
def __init__(self, field):
super().__init__("COUNT", field, distinct=True)
@frappe.whitelist(allow_guest=True)
def get_count_of_certified_members():
Certificate = DocType("LMS Certificate")
query = (
frappe.qb.from_(Certificate)
.select(CountDistinct(Certificate.member).as_("total"))
.where(Certificate.published == 1)
)
result = query.run(as_dict=True)
return result[0]["total"] if result else 0
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_certification_categories(): def get_certification_categories():
categories = [] categories = []
@@ -655,13 +691,13 @@ def get_categories(doctype, filters):
@frappe.whitelist() @frappe.whitelist()
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index. """Get members for the given search term and start index.
Args: start (int): Start index for the query. Args: start (int): Start index for the query.
<<<<<<< HEAD <<<<<<< HEAD
search (str): Search term to filter the results. search (str): Search term to filter the results.
======= =======
search (str): Search term to filter the results. search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577 >>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members. Returns: List of members.
""" """
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
@@ -1366,3 +1402,17 @@ def add_an_evaluator(email):
evaluator.insert() evaluator.insert()
return evaluator return evaluator
@frappe.whitelist()
def capture_user_persona(responses):
frappe.only_for("System Manager")
data = frappe.parse_json(responses)
data = json.dumps(data)
response = frappe.integrations.utils.make_post_request(
"https://school.frappe.io/api/method/capture-persona",
data={"response": data},
)
if response.get("message").get("name"):
frappe.db.set_single_value("LMS Settings", "persona_captured", True)
return response

View File

@@ -0,0 +1,31 @@
{
"based_on": "issue_date",
"chart_name": "Certification",
"chart_type": "Count",
"creation": "2025-04-28 17:47:28.517149",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "LMS Certificate",
"dynamic_filters_json": "[]",
"filters_json": "[[\"LMS Certificate\",\"published\",\"=\",1,false]]",
"group_by_type": "Count",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2025-04-28 17:47:28.517149",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "Certification",
"number_of_groups": 0,
"owner": "sayali@frappe.io",
"parent_document_type": "",
"roles": [],
"source": "",
"time_interval": "Daily",
"timeseries": 1,
"timespan": "Last Month",
"type": "Line",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

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