Compare commits

...

500 Commits

Author SHA1 Message Date
Frappe PR Bot 38d8bbd2d3 chore(release): Bumped to Version 2.50.0 2026-03-26 06:26:37 +00:00
Raizaaa 6ff15117db Merge pull request #2258 from frappe/mergify/bp/main/pr-2247
fix: course progress updated for scorm and video end event (backport #2247)
2026-03-26 11:55:57 +05:30
raizasafeel c831022a31 fix: progress updated on video completion
(cherry picked from commit 400c950bb7)
2026-03-26 06:17:54 +00:00
raizasafeel dcbf91990a fix(scorm): save_progress no longer impended by race condition
(cherry picked from commit 89505eac7d)
2026-03-26 06:17:54 +00:00
raizasafeel 61bd858a69 feat(scorm): show completion in frontend
(cherry picked from commit 99397ad1f4)
2026-03-26 06:17:54 +00:00
Frappe PR Bot 721da94914 chore(release): Bumped to Version 2.49.0 2026-03-25 05:33:07 +00:00
Jannat Patel 08baf1aaaf Merge pull request #2253 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
2026-03-25 11:02:21 +05:30
Jannat Patel 71b96d836a revert: certification course filter to a checkbox 2026-03-19 17:58:31 +05:30
Jannat Patel a6abef224c Merge pull request #2230 from frappe/mergify/bp/main-hotfix/pr-2229
fix: misc issues (backport #2229)
2026-03-19 16:18:31 +05:30
Jannat Patel 6f0c695856 fix: misc ui issues
(cherry picked from commit 8f4bd7afaf)
2026-03-19 10:23:19 +00:00
Jannat Patel 9d71915b7d fix: certification should be visible by default in sidebar
(cherry picked from commit 0d39f1cce1)
2026-03-19 10:23:19 +00:00
Jannat Patel 29faf4d3b8 fix: events permission to moderator and evaluator
(cherry picked from commit e18d27e9de)
2026-03-19 10:23:19 +00:00
Jannat Patel b34a23ec48 Merge pull request #2226 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-03-19 11:36:12 +05:30
Jannat Patel 5511576a65 Merge pull request #2220 from LeoDanielA01/fix/programming-submissions-filter
fix: reload and persist status filters in programming submissions
2026-03-18 11:36:58 +05:30
Frappe PR Bot b40c6fe661 chore(release): Bumped to Version 2.48.0 2026-03-18 05:59:16 +00:00
Jannat Patel 838f20758e Merge pull request #2222 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
2026-03-18 11:28:32 +05:30
Jannat Patel 30632e9b3a Merge pull request #2217 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-18 11:18:31 +05:30
Jannat Patel ee6ee469f4 Merge pull request #2223 from frappe/mergify/bp/main-hotfix/pr-2205
fix: assignment issues (backport #2205)
2026-03-18 11:12:02 +05:30
Jannat Patel f424fe8bbb fix: assignment issues
(cherry picked from commit 77bdc29b3e)
2026-03-18 05:34:20 +00:00
LEO DANIEL A 515ff5662b fix: reload and persist status filters in programming submissions 2026-03-18 00:39:03 +05:30
MochaMind 0cc68cced9 chore: Serbian (Latin) translations 2026-03-17 18:40:47 +05:30
MochaMind bd321dbab4 chore: Serbian (Cyrillic) translations 2026-03-17 18:40:34 +05:30
Jannat Patel 9b01dfaa14 Merge pull request #2216 from frappe/mergify/bp/main-hotfix/pr-2215
chore: updated demo data with latest explainer videos (backport #2215)
2026-03-17 15:15:42 +05:30
Jannat Patel 7bfbbc5926 chore: updated demo data with latest explainer videos
(cherry picked from commit b70b69eb63)
2026-03-17 09:32:06 +00:00
Jannat Patel 8d49252418 Merge pull request #2215 from pateljannat/issues-212
chore: updated demo data with latest explainer videos
2026-03-17 15:01:47 +05:30
Jannat Patel b70b69eb63 chore: updated demo data with latest explainer videos 2026-03-17 14:52:00 +05:30
Raizaaa 8da726a280 Merge pull request #2214 from raizasafeel/fix/ui-teardown
feat: change checkbox to switches, controls styling
2026-03-17 13:51:42 +05:30
Raizaaa 7f95a3eb60 Merge branch 'frappe:develop' into fix/ui-teardown 2026-03-17 13:25:31 +05:30
raizasafeel 7e0bea60ee fix: prevent toast pop up on profiles page mount 2026-03-17 13:09:30 +05:30
raizasafeel 74862c131d feat: replace preview video placeholder with description 2026-03-17 12:48:16 +05:30
raizasafeel f2f042e0fa feat: rename disable self enrollment toggle to self enrollment 2026-03-17 12:48:12 +05:30
raizasafeel b8dab3e54a feat: replace checkboxes with switches 2026-03-17 12:21:58 +05:30
raizasafeel 186cd90d42 style: matched to frappe-ui style for input 2026-03-17 11:48:42 +05:30
Jannat Patel a7598233a7 Merge pull request #2199 from LeoDanielA01/fix/sidebar-scrollview
fix: improve sidebar scrolling with overflow
2026-03-17 09:06:47 +05:30
Jannat Patel 83b6a02e0f Merge pull request #2208 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-17 09:04:55 +05:30
Raizaaa 755b0af9d0 Merge pull request #2211 from raizasafeel/fix/ui-teardown
refactor(multiselect): selected values are shown in input
2026-03-17 03:41:59 +05:30
raizasafeel d821ec56aa refactor(multiselect): selected values are shown in input 2026-03-17 03:32:38 +05:30
Raizaaa e8c9510511 Merge pull request #2210 from raizasafeel/fix/ui-teardown
feat(control): better ux, inline create functionality for link
2026-03-17 03:28:15 +05:30
raizasafeel 674512444e fix: refactor components for create modal 2026-03-17 03:22:20 +05:30
raizasafeel 29d11a42df feat(control): better ux, inline create functionality for link 2026-03-17 02:20:15 +05:30
Raizaaa 613ee475b7 Merge pull request #2209 from raizasafeel/fix/ui-teardown
feat: add member modal, refactor control filters
2026-03-17 02:13:10 +05:30
raizasafeel 5a0bbae746 test: update batch creation cypress test for new member modal 2026-03-17 02:06:08 +05:30
raizasafeel 0c0820a826 feat: update batch, course form to use new member modal 2026-03-17 01:59:40 +05:30
raizasafeel dd8a0d4238 feat: add member modal, evaluator add refactor 2026-03-17 01:55:57 +05:30
raizasafeel 5d8090c0a0 fix(controls): link and multiselect filter handling 2026-03-17 00:16:13 +05:30
Raizaaa 5cc8ef227e Merge pull request #2207 from raizasafeel/fix/ui-teardown
feat(lesson): add support for html (heading, block code, list)
2026-03-16 21:34:47 +05:30
MochaMind d0261d178d chore: Esperanto translations 2026-03-16 18:38:56 +05:30
MochaMind 33635408f5 chore: Serbian (Latin) translations 2026-03-16 18:38:52 +05:30
MochaMind 2fc68d12db chore: Norwegian Bokmal translations 2026-03-16 18:38:49 +05:30
MochaMind 791601f573 chore: Bosnian translations 2026-03-16 18:38:45 +05:30
MochaMind 328804c50e chore: Burmese translations 2026-03-16 18:38:44 +05:30
MochaMind dfc138fa00 chore: Croatian translations 2026-03-16 18:38:41 +05:30
MochaMind c5e0dee764 chore: Thai translations 2026-03-16 18:38:38 +05:30
MochaMind d3c1890ba1 chore: Persian translations 2026-03-16 18:38:36 +05:30
MochaMind 2d840f3c0c chore: Indonesian translations 2026-03-16 18:38:32 +05:30
MochaMind da3cd25880 chore: Vietnamese translations 2026-03-16 18:38:28 +05:30
MochaMind b987fa0e27 chore: Chinese Simplified translations 2026-03-16 18:38:24 +05:30
MochaMind dff5359b08 chore: Turkish translations 2026-03-16 18:38:18 +05:30
MochaMind 6d05a39b74 chore: Swedish translations 2026-03-16 18:38:15 +05:30
MochaMind d6b79b19bc chore: Serbian (Cyrillic) translations 2026-03-16 18:38:13 +05:30
MochaMind a93571d1e1 chore: Slovenian translations 2026-03-16 18:38:12 +05:30
MochaMind 9ace1381c6 chore: Russian translations 2026-03-16 18:38:09 +05:30
MochaMind 4d6aec0bca chore: Portuguese translations 2026-03-16 18:38:06 +05:30
MochaMind d6f2720927 chore: Polish translations 2026-03-16 18:38:04 +05:30
MochaMind c5bd65ab23 chore: Dutch translations 2026-03-16 18:38:03 +05:30
MochaMind cbabe5bce1 chore: Italian translations 2026-03-16 18:38:01 +05:30
MochaMind f718f0aa61 chore: Hungarian translations 2026-03-16 18:37:59 +05:30
MochaMind 76776dbc2f chore: German translations 2026-03-16 18:37:57 +05:30
MochaMind 49bd5e6766 chore: Danish translations 2026-03-16 18:37:53 +05:30
MochaMind cfefb2101e chore: Czech translations 2026-03-16 18:37:52 +05:30
MochaMind 857c7c6a55 chore: Arabic translations 2026-03-16 18:37:49 +05:30
MochaMind b46d5a1f9c chore: Spanish translations 2026-03-16 18:37:47 +05:30
MochaMind e8d8a6feb5 chore: French translations 2026-03-16 18:37:45 +05:30
MochaMind 8c68584fc2 chore: Portuguese, Brazilian translations 2026-03-16 18:37:41 +05:30
raizasafeel e2550cca31 feat(lesson): add support for html (heading, block code, list) 2026-03-16 17:38:05 +05:30
Leo Daniel A 6646a83378 fix: hide evaluators search when list is empty (#2197)
* fix: hide evaluator search when list is empty

* fix(lms): keep search bar visible in Evaluators settings during search
2026-03-16 16:51:00 +05:30
Jannat Patel 1ff071a147 Merge pull request #2206 from pateljannat/mark-for-review
feat: mark questions for review in quiz
2026-03-16 16:44:08 +05:30
Jannat Patel 4684411d09 feat: mark questions for review in quiz 2026-03-16 16:21:21 +05:30
Jannat Patel d6714e6123 Merge pull request #2205 from pateljannat/issues-211
fix: assignment issues
2026-03-16 15:39:34 +05:30
Jannat Patel 77bdc29b3e fix: assignment issues 2026-03-16 15:20:03 +05:30
Jannat Patel 952da4d240 Merge pull request #2193 from harshpwctech/bunnystream-new-player
chore: Added new Embed URL for BunnyStream Player
2026-03-16 12:54:20 +05:30
Jannat Patel 51cf663eb7 Merge pull request #2203 from frappe/mergify/bp/main-hotfix/pr-2202
feat: Frappe appointment booking for trial sites (backport #2202)
2026-03-16 12:52:59 +05:30
Jannat Patel b8ec83c25a feat: Frappe appointment booking for trial sites
(cherry picked from commit fde1c106c5)
2026-03-16 06:59:21 +00:00
Jannat Patel 0d096257c9 Merge pull request #2202 from pateljannat/issues-210
feat: Frappe appointment booking for trial sites
2026-03-16 12:28:57 +05:30
Jannat Patel 86faf86183 Merge pull request #2080 from jagadish-7/fix/video-speed
feat: added speed controls
2026-03-16 12:28:17 +05:30
Jannat Patel c33247e347 Merge pull request #2194 from pateljannat/issues-209
fix: sidebar scroll issue
2026-03-16 12:15:15 +05:30
Jannat Patel a47125d0d1 Merge pull request #2198 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-16 12:13:46 +05:30
Jannat Patel 9bda76f5f5 Merge pull request #2195 from frappe/pot_develop_2026-03-13
chore: update POT file
2026-03-16 12:13:35 +05:30
Jannat Patel fde1c106c5 feat: Frappe appointment booking for trial sites 2026-03-16 12:11:37 +05:30
CA Harsh Agrawal 53f98b2788 chore: Added new Embed URL for BunnyStream Player 2026-03-14 18:28:44 +05:30
LEO DANIEL A 6a467ea8e2 fix(ui): add scroll to sidebar 2026-03-14 14:11:34 +05:30
MochaMind a8575b7ff0 chore: Portuguese, Brazilian translations 2026-03-14 07:13:01 +05:30
frappe-pr-bot 707bbed8d7 chore: update POT file 2026-03-13 16:10:33 +00:00
jagadish madavalkar f26eec09c4 fix: dynamic speed options 2026-03-13 21:31:31 +05:30
jagadish madavalkar 0a056e101f fix: used frappe dropdown
Signed-off-by: jagadish madavalkar <jagadish.me07@gmail.com>
2026-03-13 21:31:31 +05:30
jagadish madavalkar bac875baed feat: added speed controls 2026-03-13 21:31:31 +05:30
Jannat Patel 496f1c0acd Merge pull request #2191 from frappe/mergify/bp/main-hotfix/pr-2190
chore: frappe dependency change (backport #2190)
2026-03-13 19:16:33 +05:30
Jannat Patel 44ff640a0c Merge pull request #2192 from frappe/mergify/bp/main/pr-2190
chore: frappe dependency change (backport #2190)
2026-03-13 19:16:22 +05:30
CA Harsh Agrawal 6085471053 chore: Added new Embed URL for BunnyStream Player 2026-03-13 19:00:31 +05:30
Jannat Patel a72aa1366b fix: sidebar scroll issue 2026-03-13 18:39:21 +05:30
Jannat Patel 108672fe3d chore: frappe dependency change
(cherry picked from commit 7d08a76cff)
2026-03-13 13:06:10 +00:00
Jannat Patel 83b003a303 chore: frappe dependency change
(cherry picked from commit 7d08a76cff)
2026-03-13 13:05:56 +00:00
Jannat Patel 62685b93e2 Merge pull request #2190 from pateljannat/issues-208
chore: frappe dependency change
2026-03-13 18:35:31 +05:30
Jannat Patel 82e5af1dee Merge pull request #2189 from LeoDanielA01/fix/quizzes-dark-mode-text
style: fix text color for dark mode in Quizzes
2026-03-13 18:35:19 +05:30
Jannat Patel 7d08a76cff chore: frappe dependency change 2026-03-13 18:27:58 +05:30
Jannat Patel 61b3bd651d Merge pull request #2188 from pateljannat/quiz-navigation
feat: navigate between questions in quiz
2026-03-13 18:19:00 +05:30
LEO DANIEL A cd17b7dcfb style: fix text color for dark mode in Quizzes 2026-03-13 18:12:29 +05:30
Jannat Patel b6a82c5850 feat: submit the page if the user reloads or closes the quiz window 2026-03-13 17:39:55 +05:30
Jannat Patel 747da123aa test: quiz submission 2026-03-13 11:57:55 +05:30
Raizaaa 7cc2f0c52c Merge pull request #2186 from raizasafeel/fix/ui-teardown
fix: evaluator role synched between doctype and role
2026-03-12 17:11:59 +05:30
Jannat Patel 2f66dd8046 Merge pull request #2187 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-03-12 17:09:12 +05:30
raizasafeel 8458985c28 test: test sync between evaluator doc and role 2026-03-12 17:05:33 +05:30
raizasafeel 6a6b4e0139 fix: add patch to sync batch evaluator role with course evaluator doctype 2026-03-12 17:05:33 +05:30
raizasafeel ba394926c5 fix: synched evaluator across roles and 'course evaluator' doctype 2026-03-12 17:05:26 +05:30
Jannat Patel e29c9354fd Merge branch 'main-hotfix' into develop 2026-03-12 16:59:01 +05:30
Jannat Patel 429d38f771 feat: navigate between questions in quiz 2026-03-12 16:50:02 +05:30
Raizaaa b8283860a7 Merge pull request #2185 from raizasafeel/fix/api
fix(security): prevent stored XSS decoding in _lms.py
2026-03-12 13:15:35 +05:30
Raizaaa 456e1db6c8 Merge branch 'frappe:develop' into fix/api 2026-03-12 13:03:19 +05:30
Jannat Patel 40aae3a2ed Merge pull request #2184 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-12 11:12:28 +05:30
MochaMind 4f27b9b763 chore: Portuguese, Brazilian translations 2026-03-12 06:03:39 +05:30
Raizaaa be4934862e Merge branch 'frappe:develop' into fix/api 2026-03-12 00:38:51 +05:30
raizasafeel efda159191 fix: prevent stored XSS decoding in _lms.py 2026-03-12 00:37:29 +05:30
Jannat Patel a664296fe5 Merge pull request #2182 from pateljannat/issues-207
feat: payment reminder setting
2026-03-11 14:23:11 +05:30
Jannat Patel 189de76a42 Merge pull request #2180 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-11 14:22:52 +05:30
Jannat Patel 1661389b07 test: fixed course image issue 2026-03-11 12:53:01 +05:30
Jannat Patel e90a730a29 test: delete course from cypress test after all operations are complete 2026-03-11 12:34:05 +05:30
Frappe PR Bot 31ba468f91 chore(release): Bumped to Version 2.47.0 2026-03-11 06:43:07 +00:00
Jannat Patel 36028bf36b Merge pull request #2181 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
2026-03-11 12:12:16 +05:30
Jannat Patel 9820db329e fix: course deletion issues 2026-03-11 11:50:05 +05:30
MochaMind d572f54e3b chore: Esperanto translations 2026-03-11 05:55:18 +05:30
MochaMind 97405d4ad8 chore: Serbian (Latin) translations 2026-03-11 05:55:17 +05:30
MochaMind 6beae3496f chore: Norwegian Bokmal translations 2026-03-11 05:55:15 +05:30
MochaMind e295424d1d chore: Bosnian translations 2026-03-11 05:55:14 +05:30
MochaMind 5e96911834 chore: Burmese translations 2026-03-11 05:55:12 +05:30
MochaMind fdd8c083e8 chore: Croatian translations 2026-03-11 05:55:11 +05:30
MochaMind 45298a6f85 chore: Thai translations 2026-03-11 05:55:09 +05:30
MochaMind 00c4d5b878 chore: Persian translations 2026-03-11 05:55:08 +05:30
MochaMind 7343691bb1 chore: Indonesian translations 2026-03-11 05:55:06 +05:30
MochaMind 9aaff97f06 chore: Portuguese, Brazilian translations 2026-03-11 05:55:05 +05:30
MochaMind 226b0fb5d1 chore: Vietnamese translations 2026-03-11 05:55:03 +05:30
MochaMind 549a3281ec chore: Chinese Simplified translations 2026-03-11 05:55:02 +05:30
MochaMind 27f516e383 chore: Turkish translations 2026-03-11 05:55:00 +05:30
MochaMind 62d748b6b3 chore: Swedish translations 2026-03-11 05:54:59 +05:30
MochaMind bef52063c9 chore: Serbian (Cyrillic) translations 2026-03-11 05:54:57 +05:30
MochaMind b0ae913b33 chore: Slovenian translations 2026-03-11 05:54:56 +05:30
MochaMind 57b5240c5c chore: Russian translations 2026-03-11 05:54:55 +05:30
MochaMind 193f014627 chore: Portuguese translations 2026-03-11 05:54:53 +05:30
MochaMind bd005c82c2 chore: Polish translations 2026-03-11 05:54:52 +05:30
MochaMind 0f516a452b chore: Dutch translations 2026-03-11 05:54:50 +05:30
MochaMind 9ebf895733 chore: Italian translations 2026-03-11 05:54:49 +05:30
MochaMind 554e111329 chore: Hungarian translations 2026-03-11 05:54:47 +05:30
MochaMind 2f5010fbe2 chore: German translations 2026-03-11 05:54:46 +05:30
MochaMind e1710eb59e chore: Danish translations 2026-03-11 05:54:44 +05:30
MochaMind d072c6259b chore: Czech translations 2026-03-11 05:54:43 +05:30
MochaMind 80de3ad5e1 chore: Arabic translations 2026-03-11 05:54:42 +05:30
MochaMind db7c8499b4 chore: Spanish translations 2026-03-11 05:54:40 +05:30
MochaMind 005acc2815 chore: French translations 2026-03-11 05:54:39 +05:30
Jannat Patel d68a362115 feat: settings for payment reminders 2026-03-10 18:44:28 +05:30
Jannat Patel c583ad72d1 fix: send payment reminders for incomplete batch payments only 2026-03-10 18:15:36 +05:30
Jannat Patel ef574047fe Merge pull request #2174 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-03-09 17:26:18 +05:30
Jannat Patel a7eaaeda95 Merge pull request #2159 from pateljannat/demo-data
feat: demo data
2026-03-09 16:47:08 +05:30
Jannat Patel 82d9ea7efc fix: delete demo quiz and users when deleting demo data 2026-03-09 16:37:21 +05:30
Jannat Patel a6da65ab99 fix: dont capture progress of demo course for analytics 2026-03-09 16:22:21 +05:30
Jannat Patel ad76fac579 Merge pull request #2172 from frappe/pot_develop_2026-03-09
chore: update POT file
2026-03-09 14:10:41 +05:30
frappe-pr-bot 0fb4e0bc41 chore: update POT file 2026-03-09 08:28:54 +00:00
Jannat Patel 68d69d5ccd Merge pull request #2171 from pateljannat/issues-206
fix: misc issues
2026-03-09 12:20:28 +05:30
Jannat Patel f11059524f fix: lms dynamic path fetching 2026-03-09 11:54:16 +05:30
Jannat Patel 735a3f4b00 fix: misc issues 2026-03-09 11:34:28 +05:30
Jannat Patel 0a587e5598 Merge pull request #2169 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-09 10:51:24 +05:30
Jannat Patel cb273685a7 test: click the div of the test course and not the first card 2026-03-09 10:50:46 +05:30
MochaMind daacbc7faf chore: Russian translations 2026-03-07 06:03:52 +05:30
Jannat Patel 711d89b603 fix: exclude demo course count before showing persona 2026-03-06 17:51:40 +05:30
Jannat Patel 3889893b2f Merge branch 'develop' of https://github.com/frappe/lms into demo-data 2026-03-06 17:44:53 +05:30
Jannat Patel 7cfae5401e Merge pull request #2168 from raizasafeel/fix/persona
fix(persona): redirection route and skip button
2026-03-06 17:44:42 +05:30
Jannat Patel 71f3aca623 Merge pull request #2167 from raizasafeel/fix/payment
fix: use backend field metadata for billing and transaction forms
2026-03-06 17:42:39 +05:30
Jannat Patel 8c54d77740 test(ui): find course with the test title instead of checking the first course card 2026-03-06 17:22:12 +05:30
Jannat Patel 2e0f8e91af fix: pluck name when creating instructor for demo 2026-03-06 17:08:42 +05:30
Jannat Patel 7c20b9c728 ci: get payments app in ui tests 2026-03-06 17:00:03 +05:30
Jannat Patel 2941c4724f ci(revert): don't get payments app 2026-03-06 12:10:34 +05:30
Jannat Patel dcdffc0aac ci: get payments app before installing lms 2026-03-05 21:51:59 +05:30
Jannat Patel 607103e40e feat: demo review and course progress 2026-03-05 21:41:34 +05:30
raizasafeel 8d3485742b revert: 'refactor(persona): made persona labels/options smaller and simpler' 2026-03-05 15:27:02 +05:30
raizasafeel f24b0fd22b fix(persona): skip/continue button redirects to Home page 2026-03-05 15:20:59 +05:30
Raizaaa 3731826ffd Merge branch 'frappe:develop' into fix/persona 2026-03-05 13:49:12 +05:30
raizasafeel 865634ce82 fix(persona): skip button now successfully redirects to courses 2026-03-05 13:45:16 +05:30
raizasafeel 9923b702e0 refactor(persona): made persona labels/options smaller and simpler 2026-03-05 13:37:54 +05:30
raizasafeel 49f4c878d6 fix(persona): moved to homepage from courses page 2026-03-05 13:30:22 +05:30
Jannat Patel 69e2d628d9 Merge pull request #2165 from frappe/develop
merge `develop` into `main-hotifx`
2026-03-05 10:30:31 +05:30
Raizaaa 112cc3ac9d Merge branch 'frappe:develop' into fix/payment 2026-03-05 02:07:41 +05:30
raizasafeel 4a5f16e1bc fix(settings): transaction button now redirects successfully to batch/course 2026-03-05 02:05:21 +05:30
raizasafeel a893c405d1 fix: replace hardcoded meta fields with validation from backend 2026-03-05 02:05:00 +05:30
raizasafeel 5683fd5d7a fix: add get_field_meta function to get doctype field metadata 2026-03-05 02:03:24 +05:30
Jannat Patel f1014e7452 Merge branch 'develop' of https://github.com/frappe/lms into demo-data 2026-03-04 20:53:22 +05:30
Jannat Patel 82f0bb40ef Merge branch 'main-hotfix' into develop 2026-03-04 20:49:45 +05:30
Jannat Patel 71ff6e01d6 Merge pull request #2164 from pateljannat/issues-205
fix: misc issues
2026-03-04 20:43:56 +05:30
Jannat Patel 701814060d fix: delete event after deleting the live class 2026-03-04 20:35:51 +05:30
Jannat Patel 292b48fbac fix: do not link live class to event as event is already linked to live class 2026-03-04 18:08:55 +05:30
Jannat Patel 2e3baff401 refactor: live class controllers for better code reusability 2026-03-04 16:59:32 +05:30
Jannat Patel 7e26bb277f fix: misc ui issues 2026-03-04 16:58:53 +05:30
Jannat Patel 22fb96a00f Merge pull request #2163 from pateljannat/issues-204
fix: assignment conditions for save button visibility
2026-03-04 11:09:37 +05:30
Jannat Patel 8752f8038a Merge pull request #2156 from raizasafeel/fix/payment-gateway
fix(payment gateway): add delete functionality and field details
2026-03-04 11:01:38 +05:30
Jannat Patel c5bb852227 Merge pull request #2122 from ColoredCow/feature/google-meet
feat: Google Meet integration for Live Classes
2026-03-04 11:00:13 +05:30
Jannat Patel 8d8452f8a3 fix: assignment conditions for save button visibility 2026-03-04 10:44:39 +05:30
Frappe PR Bot 5933a59a14 chore(release): Bumped to Version 2.46.0 2026-03-04 05:08:05 +00:00
Jannat Patel 5c3834cbbe Merge pull request #2161 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
2026-03-04 10:37:22 +05:30
Jannat Patel c77fdf55b3 Merge branch 'main' into main-hotfix 2026-03-04 10:22:29 +05:30
Jannat Patel c509da8497 Merge pull request #2160 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-04 10:21:32 +05:30
MochaMind d5bc012c21 chore: Swedish translations 2026-03-04 00:17:20 +05:30
MochaMind 610ec89670 chore: Spanish translations 2026-03-04 00:17:06 +05:30
Vaibhav Rathore a29e1a58a4 fix: resolve server test failures for Google Meet integration
- Mock get_google_calendar_object in live class tests to prevent real
  Google API calls in CI (no OAuth tokens available)
- Fix on_trash to clear event link before deleting Event to avoid
  LinkExistsError
- Fix test query to avoid PostgreSQL-incompatible integer filter
- Add Google Settings setup to Google Meet Settings integration tests
2026-03-03 17:34:34 +05:30
Vaibhav Rathore f0d35ec1d1 fix: enable Google API in test setup to fix CI server tests
Google Calendar validation requires Google API to be enabled in Google
Settings. Without this, all LMS Live Class tests fail in CI with
"Enable Google API in Google Settings" error.
2026-03-03 15:56:34 +05:30
Vaibhav Rathore 3f116a37c2 fix: remove unnecessary "Meet link generating" block from LiveClass 2026-03-03 15:56:34 +05:30
Vaibhav Rathore 1086d2219b fix: add console.error to error handlers in GoogleMeetAccountModal 2026-03-03 15:56:34 +05:30
Vaibhav Rathore c5998f95ee refactor: move GoogleMeetAccountModal to Settings folder
Move GoogleMeetAccountModal.vue from Modals to Settings directory
to colocate it with GoogleMeetSettings.vue. Update import path.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore 507938425c refactor: move conferencing fields to separate section and translate labels
Move Conferencing Provider, Zoom Account, and Google Meet Account
fields into their own "Conferencing" section. Wrap option labels
with __() for i18n translation.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore cd9a6831a7 fix: add type annotations and role validation to create_google_meet_live_class
Add type hints to match create_live_class signature. Add
frappe.only_for role check to prevent unauthorized access.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore 2fab297745 fix: add patch to set conferencing provider for existing Zoom records
Sets conferencing_provider to "Zoom" for all existing LMS Batch and
LMS Live Class records that have a zoom_account linked.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore 4925c5bc45 refactor: extract shared helpers to reduce code duplication in lms_live_class
Extract _get_participants() and _build_event_description() to eliminate
duplicated participant-gathering and description-building logic across
Zoom and Google Meet code paths.
2026-03-03 15:56:33 +05:30
Vaibhav Rathore f5551603a5 fix: resolve ruff-format and prettier linting errors 2026-03-03 15:56:33 +05:30
Vaibhav Rathore 1eb13c9378 chore: remove unused component declaration from components.d.ts 2026-03-03 15:56:33 +05:30
Frappe PR Bot 2c2e8ca112 chore(release): Bumped to Version 2.45.2 2026-03-03 07:19:36 +00:00
Jannat Patel 4771ebbcfd fix: enrollment error during course progress 2026-03-03 12:48:51 +05:30
Jannat Patel 2a2e937876 feat: added course card image for demo course 2026-03-03 12:46:40 +05:30
Jannat Patel 08fbcc963d Merge pull request #2158 from frappe/mergify/bp/main-hotfix/pr-2157
fix: pricing section issue in course form (backport #2157)
2026-03-02 18:32:05 +05:30
Jannat Patel 54e9396fdb fix: pricing section issue in course form
(cherry picked from commit a0d6b2b6b6)
2026-03-02 12:54:01 +00:00
Jannat Patel 2b124de4cb Merge pull request #2157 from pateljannat/issues-203
fix: pricing section issue in course form
2026-03-02 18:23:48 +05:30
Jannat Patel a0d6b2b6b6 fix: pricing section issue in course form 2026-03-02 18:06:44 +05:30
Jannat Patel 93f019a0d0 feat: demo data 2026-03-02 18:02:25 +05:30
Jannat Patel 5180875ab5 Merge pull request #2155 from pateljannat/issues-202
fix: misc issues
2026-03-02 13:43:38 +05:30
Jannat Patel 40d83aca36 fix: course and batch description formatting issue 2026-03-02 13:37:28 +05:30
Jannat Patel d3a4c211db fix: do not accept json as input for certificate request event 2026-03-02 13:29:06 +05:30
raizasafeel 1223ca8f29 fix(payment gateway): default values not pre-filling in new form 2026-03-02 13:21:04 +05:30
Jannat Patel 9af9a7d87f fix: desk redirection and desk sidebar 2026-03-02 13:18:13 +05:30
raizasafeel 5ae5634753 fix(payment gateway): add missing removeAccount function 2026-03-02 13:02:06 +05:30
Jannat Patel f63a4a44a2 fix: support youtube watch links as preview links 2026-03-02 13:01:59 +05:30
raizasafeel b95a308f7a fix(payment gateway): include reqd, options, default, and description in fields 2026-03-02 12:10:31 +05:30
Jannat Patel e8fcd2fa0a Merge pull request #2154 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-03-02 11:09:28 +05:30
Jannat Patel 5c4385aefd Merge pull request #2146 from raizasafeel/chore/upgrade-frappe-ui-0.1.264
chore: upgrade frappe-ui to v0.1.264
2026-03-02 11:09:18 +05:30
MochaMind 3359d4511c chore: Russian translations 2026-03-02 00:03:52 +05:30
raizasafeel 5520f7f083 chore: upgrade frappe-ui to v0.1.264 2026-02-27 17:47:46 +05:30
Jannat Patel 94f0f79404 Merge pull request #2144 from Owaishk08/fix/profile-nudge-collapsed-sidebar
fix: show profile nudge as icon when sidebar is collapsed
2026-02-27 16:21:40 +05:30
Owais Khan 8d03b25331 fix: show profile nudge as icon when sidebar is collapsed 2026-02-27 16:00:11 +05:30
Owais Khan f54b63a2a7 fix: show profile nudge as icon when sidebar is collapsed 2026-02-27 12:56:36 +05:30
Jannat Patel 2dd2c78b88 Merge pull request #2143 from pateljannat/issues-201
fix: misc issues
2026-02-27 12:44:01 +05:30
Jannat Patel 361d1c0fd6 fix: misc issues 2026-02-27 12:31:38 +05:30
Jannat Patel 5c0faa39b7 Merge pull request #2141 from pateljannat/issues-200
fix: if certificate is linked to a batch then don't validate course enrollment
2026-02-27 11:06:56 +05:30
Jannat Patel 78c6bfea83 fix: if certificate is linked to a batch then don't validate course enrollment 2026-02-27 10:56:06 +05:30
Jannat Patel f3eb000c23 Merge pull request #2140 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-27 10:53:50 +05:30
MochaMind aa638e9992 chore: Swedish translations 2026-02-26 23:41:48 +05:30
Vaibhav Rathore 8ea178fcad refactor: remove manual attendance feature for Google Meet classes
Manual attendance marking is not required for Google Meet live classes.
This removes the ManualAttendance modal, the mark_manual_attendance API
endpoint, and associated tests.
2026-02-26 18:38:55 +05:30
Jannat Patel fa3be115d7 Merge pull request #2138 from pateljannat/nudge-profile-completion
feat: nudge students to complete their profile
2026-02-26 17:14:22 +05:30
Jannat Patel 975f06d956 feat: nudge students to complete their profile 2026-02-26 16:59:08 +05:30
Jannat Patel 6b24a23e70 Merge pull request #2137 from pateljannat/issues-199
fix: misc issues
2026-02-26 16:10:12 +05:30
Jannat Patel 87e588cd1f fix: misc permission issues 2026-02-26 15:48:21 +05:30
Jannat Patel 3462d2f251 fix: misc ui issues 2026-02-26 15:06:30 +05:30
Jannat Patel 92e956a9a2 Merge pull request #2136 from pateljannat/issues-198
fix: batch admin conditions
2026-02-26 13:40:09 +05:30
Jannat Patel 0e65c2cf76 fix: batch admin conditions 2026-02-26 13:39:40 +05:30
Jannat Patel 0adda28674 Merge pull request #2135 from pateljannat/issues-197
fix: assignment upload issue
2026-02-26 12:12:45 +05:30
Jannat Patel 69f90fb809 fix: assignment upload issue 2026-02-26 11:31:32 +05:30
Jannat Patel 23cde1761b Merge pull request #2132 from pateljannat/issues-196
fix: student home page issue when not enrolled in any batch
2026-02-25 18:22:11 +05:30
Jannat Patel e8354e9781 fix: student home page issue when not enrolled in any batch 2026-02-25 18:14:45 +05:30
Frappe PR Bot 315ec3d655 chore(release): Bumped to Version 2.45.1 2026-02-25 12:36:10 +00:00
Jannat Patel 484c3d7402 Merge pull request #2128 from frappe/mergify/bp/main/pr-2126
fix: permission issue during quiz submission (backport #2126)
2026-02-25 18:05:27 +05:30
Vaibhav Rathore 08b6a9d091 fix: use str() instead of get_time_str() for time field comparison
get_time_str() expects a timedelta but batch start_time is a
datetime.time object, causing AttributeError on batch details page.
2026-02-25 17:05:38 +05:30
Vaibhav Rathore 4be47af3ef fix: skip conferencing provider validation on batch creation
Frappe sets Select field default to the first option ("Zoom") even
when the field isn't in the creation form. Skip validation for new
batches since conferencing is configured after creation.
2026-02-25 16:59:23 +05:30
Jannat Patel 49e989f39e Merge pull request #2131 from pateljannat/issues-195
fix: verify quiz answers on server side
2026-02-25 16:57:05 +05:30
Vaibhav Rathore 898a872232 fix: make conferencing provider optional during batch creation
The conferencing_provider Select field defaulted to "Zoom" (first option)
when not explicitly set, causing batch creation to fail with
"Please select a Zoom account for this batch" since no zoom_account
is provided at creation time.
2026-02-25 16:56:02 +05:30
Jannat Patel e7ce850691 fix: removed trailing comma at the end of permission 2026-02-25 16:45:02 +05:30
Jannat Patel cb01e17aa7 fix: verify quiz answers on server side 2026-02-25 16:42:55 +05:30
Jannat Patel d7c5ff7098 Merge pull request #2129 from pateljannat/issues-194
fix: sanitize data before creating new course or batch
2026-02-25 14:01:15 +05:30
Jannat Patel 62b5715b98 Merge pull request #2127 from frappe/mergify/bp/main-hotfix/pr-2126
fix: permission issue during quiz submission (backport #2126)
2026-02-25 13:08:14 +05:30
Jannat Patel 593c70affb chore: resolved conflicts 2026-02-25 13:07:05 +05:30
Jannat Patel 3a1a7db386 chore: resolved conflicts 2026-02-25 13:06:37 +05:30
Jannat Patel af611b1603 fix: sanitize data before creating new course or batch 2026-02-25 13:03:47 +05:30
Jannat Patel a5e948bba8 fix: permission issue during quiz submission
(cherry picked from commit af5bce9e34)

# Conflicts:
#	lms/lms/doctype/lms_quiz_submission/lms_quiz_submission.json
2026-02-25 07:21:47 +00:00
Jannat Patel e63d83beb5 fix: permission issue during quiz submission
(cherry picked from commit af5bce9e34)
2026-02-25 07:21:40 +00:00
Jannat Patel 8fa5c899ff Merge pull request #2126 from pateljannat/issues-193
fix: permission issue during quiz submission
2026-02-25 12:44:28 +05:30
Jannat Patel af5bce9e34 fix: permission issue during quiz submission 2026-02-25 12:37:09 +05:30
Jannat Patel 1ea8705552 Merge pull request #2125 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-02-25 11:24:11 +05:30
Jannat Patel 61193b71f4 Merge branch 'main-hotfix' into develop 2026-02-25 11:09:48 +05:30
Jannat Patel 2331ddfc67 Merge pull request #2123 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
2026-02-25 10:55:53 +05:30
Jannat Patel afe9674a6a Merge pull request #2124 from frappe/mergify/bp/main-hotfix/pr-2121
chore: sync translations from crowdin (backport #2121)
2026-02-25 10:47:30 +05:30
Jannat Patel 5b22ef46c0 chore: resolved conflicts 2026-02-25 10:40:06 +05:30
MochaMind 8f1604e237 chore: Persian translations
(cherry picked from commit 63321fe2c8)
2026-02-25 05:08:21 +00:00
MochaMind a9f4eb1291 chore: Spanish translations
(cherry picked from commit 68848fc642)

# Conflicts:
#	lms/locale/es.po
2026-02-25 05:08:20 +00:00
Jannat Patel 26301c26e9 Merge pull request #2121 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-25 10:37:32 +05:30
Vaibhav Rathore 559338da59 fix: include conferencing_provider and google_meet_account in batch details API 2026-02-24 23:55:33 +05:30
Vaibhav Rathore 9a7c77c57b feat: Google Meet integration for Live Classes
Add Google Meet as an alternative conferencing provider for Live Classes
in Frappe LMS, alongside the existing Zoom integration. Leverages
Frappe's built-in Google Calendar sync to generate Meet links.

Changes:
- New DocType: LMS Google Meet Settings (account_name, member, calendar)
- Schema changes to LMS Batch (conferencing_provider, google_meet_account)
- Schema changes to LMS Live Class (conferencing_provider, google_meet_account)
- Participant calendar invites via Google Calendar API
- Event update/reschedule sync (on_update hook)
- Event cancellation/deletion sync (on_trash hook)
- Async Meet link handling with user-facing fallback message
- Frontend empty link guard ("Meet link generating...")
- Batch validation for conferencing provider configuration
- Manual attendance marking for Google Meet classes
- Admin UI for managing Google Meet accounts in LMS Settings
- Unit and integration tests

Upstream Issue: frappe/lms#2027
2026-02-24 23:25:27 +05:30
MochaMind 63321fe2c8 chore: Persian translations 2026-02-24 22:48:48 +05:30
MochaMind 68848fc642 chore: Spanish translations 2026-02-24 22:48:29 +05:30
Jannat Patel aa7ec019bc Merge pull request #2120 from pateljannat/issues-192
fix: misc issues
2026-02-24 18:22:57 +05:30
Jannat Patel eb33155db2 fix: enqueue progress calculation after validating enrollments 2026-02-24 18:13:02 +05:30
Jannat Patel 3088b14d83 fix: recalculate course progress when lesson is inserted or deleted 2026-02-24 17:40:58 +05:30
Jannat Patel bf89f3ba2f fix: show system timezone in certificate request 2026-02-24 13:01:29 +05:30
Jannat Patel 2198adf902 fix: sidebar settings issue if guest access was not allowed 2026-02-24 12:36:46 +05:30
Jannat Patel c5145c6c24 Merge pull request #2119 from pateljannat/issues-191
fix: misc issues
2026-02-24 12:17:41 +05:30
Jannat Patel 499bcd5281 chore: resolved conflicts 2026-02-24 12:05:44 +05:30
Jannat Patel dc4bbdaa55 Merge pull request #2116 from raizasafeel/fix/codesandbox-embed
fix(lesson): render codesandbox
2026-02-24 12:02:52 +05:30
Jannat Patel bf19ebd3a8 fix: assignment submission issue 2026-02-24 12:02:10 +05:30
Jannat Patel fa7e59b4ad Merge pull request #2118 from frappe/mergify/bp/main-hotfix/pr-2117
chore: capture more events for analytics (backport #2117)
2026-02-23 16:59:02 +05:30
Jannat Patel 5fcd3ddabe revert: removed new batch modal changes 2026-02-23 16:45:37 +05:30
Jannat Patel a9dd43d0ea chore: resolved conflicts 2026-02-23 16:44:04 +05:30
Jannat Patel 22e005f19c chore: capture more events for analytics
(cherry picked from commit b3c8fbd833)

# Conflicts:
#	frontend/src/pages/Batches/components/NewBatchModal.vue
#	lms/lms/doctype/lms_course_review/lms_course_review.json
2026-02-23 11:11:44 +00:00
Jannat Patel 5a6a7ff646 Merge pull request #2117 from pateljannat/issues-189
chore: capture more events for analytics
2026-02-23 16:41:23 +05:30
Jannat Patel b3c8fbd833 chore: capture more events for analytics 2026-02-23 16:31:58 +05:30
Jannat Patel f828c76a0f Merge pull request #2115 from pateljannat/issues-188
fix: misc issues
2026-02-23 15:13:02 +05:30
raizasafeel 2634a4e316 fix(lesson): render codesandbox properly 2026-02-23 15:12:08 +05:30
Jannat Patel fb0517caa0 fix: show only instructor tab for admins on home page 2026-02-23 14:54:54 +05:30
Jannat Patel 90151be166 fix: updated app name in workspace and desktop 2026-02-23 12:35:13 +05:30
Jannat Patel 9b0a7f5fa5 Merge pull request #2112 from pateljannat/issues-187
fix: lesson progress issue
2026-02-23 12:03:30 +05:30
Jannat Patel aa93375e6c Merge pull request #2110 from frappe/mergify/bp/main-hotfix/pr-2109
fix: check permission of session user during batch enrollment (backport #2109)
2026-02-23 11:49:25 +05:30
Jannat Patel e8edf33be6 fix: lesson progress issue 2026-02-23 11:49:06 +05:30
Jannat Patel 619f02a74b fix: check permission of session user during batch enrollment
(cherry picked from commit c1260edb00)
2026-02-23 06:08:16 +00:00
Jannat Patel b77c4867e1 Merge pull request #2109 from pateljannat/issues-186
fix: check permission of session user during batch enrollment
2026-02-23 11:36:49 +05:30
Jannat Patel c1260edb00 fix: check permission of session user during batch enrollment 2026-02-23 11:28:58 +05:30
Jannat Patel 41d5ef5fd5 Merge pull request #2108 from pateljannat/issues-185
fix: misc permission issues
2026-02-23 11:22:52 +05:30
Jannat Patel 14937fd4fc fix: check ptype for permission if not admin 2026-02-23 11:06:34 +05:30
Jannat Patel 58826fe30f fix: removed badge page reference from the router 2026-02-23 10:41:13 +05:30
Jannat Patel 0da9eec0af Merge pull request #2105 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-23 10:29:32 +05:30
Jannat Patel bb47fd5ba9 fix: misc permission issues 2026-02-23 10:29:13 +05:30
MochaMind db49cb2d64 chore: Thai translations 2026-02-21 22:13:22 +05:30
MochaMind 58732148e2 chore: Vietnamese translations 2026-02-21 22:13:18 +05:30
MochaMind 08eb7ef17b chore: Dutch translations 2026-02-21 22:13:10 +05:30
MochaMind 8bda7edb7b chore: Spanish translations 2026-02-21 22:13:03 +05:30
Jannat Patel 49d596216d fix: zoom account link issue 2026-02-21 12:58:19 +05:30
Jannat Patel faa9c94970 fix: zoom account link issue 2026-02-21 12:28:55 +05:30
Jannat Patel c596d1e215 fix: misc permission issues 2026-02-21 12:25:47 +05:30
Jannat Patel 235958e432 Merge pull request #2103 from raizasafeel/fix/lesson-body-filter
fix(security): remove ignore_xss_filter to enable HTML sanitization
2026-02-20 14:20:07 +05:30
raizasafeel 7f85dbccec fix: added timestamps for bench migrate 2026-02-20 13:52:13 +05:30
Jannat Patel 5b69ddf9b5 Merge pull request #2099 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-20 13:39:16 +05:30
Jannat Patel dfb7152aa3 fix: video watch time permission and other issues 2026-02-20 13:08:27 +05:30
Raizaaa 2a311bfb6f Merge pull request #2104 from raizasafeel/fix/lms-enrollment
fix: duplicate enrollment validation on update
2026-02-20 12:21:45 +05:30
raizasafeel 0e90627144 fix: duplicate enrollment validation on update 2026-02-20 12:09:08 +05:30
Raizaaa aac1692058 Merge branch 'frappe:develop' into fix/lesson-body-filter 2026-02-20 11:58:57 +05:30
raizasafeel d58d362c72 fix(security): remove ignore_xss_filter to enable HTML sanitization 2026-02-20 11:54:17 +05:30
MochaMind e7f2386d14 chore: Thai translations 2026-02-19 21:23:50 +05:30
MochaMind 79a50d2454 chore: Portuguese, Brazilian translations 2026-02-19 21:23:45 +05:30
MochaMind 936f82c477 chore: Spanish translations 2026-02-19 21:23:24 +05:30
Jannat Patel 133037698c fix: remove read permission on lms settings for lms student 2026-02-19 16:30:13 +05:30
Jannat Patel 07c58251a1 fix: lms certificate request will allow students to read only if they are owner 2026-02-19 16:27:35 +05:30
Jannat Patel c88d36df1e fix: permission issues on badges 2026-02-19 15:59:03 +05:30
Jannat Patel 08373dc2ab fix: refactored job form and permissions 2026-02-19 15:58:44 +05:30
Jannat Patel 44ca59c64a fix: return profile details only if the profile is of an LMS user 2026-02-19 12:51:30 +05:30
Jannat Patel c961923fa0 fix: verify enrollment and admin access before returing batch assessment data 2026-02-19 12:43:50 +05:30
Jannat Patel 72cee75474 fix: only allow lms roles to be modified by moderator 2026-02-19 12:39:55 +05:30
Jannat Patel cb3af6fa63 fix: sanitised badge assignment api 2026-02-19 12:24:47 +05:30
Jannat Patel 0ff14a959d Merge pull request #2096 from pateljannat/issues-184
fix: course form issues
2026-02-19 11:58:09 +05:30
Jannat Patel 35adf49015 fix: course form overflow issue 2026-02-19 11:47:17 +05:30
Jannat Patel e5f0d55ff0 fix: course form permission issue 2026-02-19 11:46:56 +05:30
Jannat Patel ba395fe982 Merge pull request #2081 from pateljannat/batch-dashboard-update
Batch dashboard update
2026-02-18 15:45:01 +05:30
Jannat Patel 8ab6776fa9 fix: redirect from batch/details to batch 2026-02-18 15:36:55 +05:30
Jannat Patel 61d13aeb12 Merge pull request #2094 from frappe/develop
chore: merge `develop` into `main-hotfix`
2026-02-18 12:11:10 +05:30
Jannat Patel 24bfe69985 chore: resolved conflicts 2026-02-18 11:16:00 +05:30
Jannat Patel 7b2a4fe24a Merge pull request #2092 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-18 11:10:40 +05:30
Jannat Patel 6f86b822bf fix: mobile view for batch dashboard 2026-02-18 10:58:49 +05:30
MochaMind c3b2907ebf chore: Portuguese, Brazilian translations 2026-02-17 21:06:06 +05:30
MochaMind 48c5b82c73 chore: Spanish translations 2026-02-17 21:06:04 +05:30
Jannat Patel af273a9a1c refactor: batch student progress 2026-02-17 19:38:19 +05:30
Jannat Patel 3b80ccd8db Merge pull request #2083 from raizasafeel/fix/dark-mode
fix: dark mode ui
2026-02-17 17:41:40 +05:30
Jannat Patel 6484d551d1 Merge pull request #2091 from frappe/mergify/bp/main-hotfix/pr-2087
chore: project URLs (backport #2087)
2026-02-17 17:41:05 +05:30
Ankush Menat 719d7b5e88 chore: project URLs (#2087)
(cherry picked from commit 3cd9d89f0b)
2026-02-17 11:38:09 +00:00
Ankush Menat 3cd9d89f0b chore: project URLs (#2087) 2026-02-17 15:54:23 +05:30
raizasafeel 6e44da1993 test: update batch creation test to match Badge component 2026-02-17 15:13:13 +05:30
Jannat Patel 44b7a210ce chore: resolved conflicts 2026-02-17 15:04:31 +05:30
Jannat Patel 0e382f77ef Merge pull request #2048 from raizasafeel/fix/video-embedding
fix(lesson): vimeo player rendered for private and unsanitized content
2026-02-17 14:57:00 +05:30
raizasafeel 7a1a247113 Merge upstream/develop into fix/dark-mode 2026-02-17 14:28:46 +05:30
raizasafeel 2c6ab3c331 Merge remote-tracking branch 'upstream/develop' into fix/video-embedding 2026-02-17 14:21:22 +05:30
Jannat Patel 79dba165c5 Merge pull request #2084 from pateljannat/issues-183
fix: misc issues
2026-02-17 13:57:09 +05:30
Jannat Patel d83f3464cd fix: permission issue when cancelling evaluation 2026-02-17 13:35:15 +05:30
Jannat Patel e2ef8f732d test: fixed category input selection in course creation test 2026-02-17 12:09:29 +05:30
Jannat Patel 919904a7f1 refactor: autocomplete component 2026-02-17 12:05:40 +05:30
raizasafeel 8453226f29 fix: add vimeo emded URL to extract hash properly 2026-02-17 11:38:26 +05:30
Jannat Patel 03759ca3c3 fix: spacing issue on course overview page 2026-02-16 19:53:33 +05:30
Jannat Patel c1608f8cc4 refactor: Link component 2026-02-16 19:48:58 +05:30
Jannat Patel 73b20653f0 chore: updated release process with main-hotfix 2026-02-16 18:33:45 +05:30
Jannat Patel 7e683f8b44 fix: permission checks for api 2026-02-16 18:20:02 +05:30
Jannat Patel eba1815390 fix: permission issues when adding new members 2026-02-16 18:05:53 +05:30
raizasafeel 7564f0418b fix: dark mode text and divider colors in course dashboard 2026-02-16 17:03:35 +05:30
raizasafeel 7e9cca2782 refactor: replace deprecated input with formcontrol in announcement modal 2026-02-16 17:00:18 +05:30
raizasafeel dbc7e7d6d4 fix: dark mode styling for controls and modals 2026-02-16 16:58:32 +05:30
raizasafeel ae25cfae6e fix: text editor border color in dark mode across forms and modals 2026-02-16 16:58:32 +05:30
raizasafeel 970635430b fix(settings): dark mode divider and text colors 2026-02-16 16:58:32 +05:30
raizasafeel fe869a5988 fix(batch): use frappe-ui badge for dark mode compatibility 2026-02-16 16:58:32 +05:30
raizasafeel 7ea8040790 fix(sidebar): configuration popover and dark mode fixes 2026-02-16 16:58:32 +05:30
Jannat Patel 9f6f717585 fix: discussions reply endpoint permission check 2026-02-16 12:18:41 +05:30
Jannat Patel 641d729bd1 refactor: student batch dashboard 2026-02-16 12:17:13 +05:30
Jannat Patel ee73d8db86 Merge pull request #2077 from pateljannat/issues-182
refactor: MultiSelect field
2026-02-16 09:10:46 +05:30
Jannat Patel c7b5f9a04d Merge pull request #2078 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-16 09:10:28 +05:30
MochaMind fa4c3a8ad7 chore: Persian translations 2026-02-15 19:54:51 +05:30
MochaMind 71318bff04 chore: Portuguese translations 2026-02-15 19:54:42 +05:30
MochaMind 186ddc93c8 chore: Portuguese, Brazilian translations 2026-02-14 19:58:13 +05:30
Jannat Patel 2f1d9a8690 refactor: MultiSelect field 2026-02-13 13:04:02 +05:30
Jannat Patel 5fc7c52bfe Merge pull request #2073 from UmakanthKaspa/fix/categories-dark-mode-text
fix: add text color for category names in dark mode
2026-02-13 10:18:29 +05:30
Jannat Patel d0da6e7401 Merge pull request #2076 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-13 10:17:53 +05:30
UmakanthKaspa a437c197a5 fix: format code with pre-commit 2026-02-12 21:41:19 +05:30
Jannat Patel 944fd6d013 refactor: new batch quick entry modal 2026-02-12 19:52:46 +05:30
MochaMind 80a9f2abe2 chore: Spanish translations 2026-02-12 19:28:48 +05:30
Jannat Patel c0298f0a70 Merge branch 'develop' of https://github.com/frappe/lms into batch-dashboard-update 2026-02-12 11:09:19 +05:30
Jannat Patel c30b21e5ae Merge pull request #2075 from pateljannat/issues-181
fix: issues on home page
2026-02-12 11:07:12 +05:30
Jannat Patel 3e3afa63c2 fix: issues on home page 2026-02-12 10:44:01 +05:30
Jannat Patel 7ef8aad2c8 fix: dirty state of batch form 2026-02-12 10:33:21 +05:30
Jannat Patel c00cb100a9 Merge pull request #2072 from UmakanthKaspa/fix/job-details-missing-type-hint
fix: add missing type annotation to get_job_details
2026-02-11 21:41:28 +05:30
Jannat Patel f824ac3c28 Merge pull request #2070 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-11 21:40:28 +05:30
UmakanthKaspa 2dea096fa0 fix: use semantic bg class for MultiSelect "Create New" button 2026-02-11 21:39:30 +05:30
UmakanthKaspa f1853a3c97 fix: add text color for category names in dark mode 2026-02-11 21:13:48 +05:30
UmakanthKaspa 4995f8e3fd fix: add missing type annotation to get_job_details 2026-02-11 20:38:08 +05:30
Jannat Patel f59eecd34e fix: circular dependency issues 2026-02-11 19:24:50 +05:30
MochaMind 560ac8d5c4 chore: Burmese translations 2026-02-11 18:52:08 +05:30
Jannat Patel eab929da47 refactor: batch form 2026-02-11 18:42:42 +05:30
Jannat Patel d370ca796f Merge pull request #2067 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-10 19:53:48 +05:30
Jannat Patel e9f0b12550 feat: batch page new look 2026-02-10 19:53:26 +05:30
MochaMind a4035168be chore: Italian translations 2026-02-10 17:32:47 +05:30
Jannat Patel 70872857d1 fix: show console error if course creation fails 2026-02-10 10:55:59 +05:30
Jannat Patel 332334b556 Merge pull request #2066 from UmakanthKaspa/fix/course-creation-error-toast
fix: show error toast when course creation fails
2026-02-10 10:48:34 +05:30
UmakanthKaspa 1d91baa9c5 fix: show error toast when course creation fails 2026-02-09 13:41:12 +00:00
Jannat Patel 1e8040ef7b Merge pull request #2036 from Aradhya-Tripathi/setup-fix
fix(setup): Add frappe dependency and build utils
2026-02-09 17:21:05 +05:30
Jannat Patel ad6e0a3b80 Merge pull request #2064 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-09 15:32:59 +05:30
Jannat Patel 8f6810923d fix: only published courses should be added to batch 2026-02-09 14:42:32 +05:30
Jannat Patel 990db83ab3 chore: Italian translations 2026-02-09 13:02:11 +05:30
Jannat Patel 01f08ba449 chore: fixed typing for non mandatory fields of certificate 2026-02-09 12:44:54 +05:30
Jannat Patel 7a3701cc10 Merge pull request #2063 from pateljannat/issues-180
chore: added type hints to all whitelisted functions
2026-02-06 17:29:57 +05:30
Jannat Patel f021ddd84c chore: fixed type annotations for doc_event methods 2026-02-06 17:08:49 +05:30
Jannat Patel 0e3157c57e chore: fixed type check for batch flows 2026-02-06 16:53:25 +05:30
Raizaaa 22eb8b9f3f Merge branch 'frappe:develop' into fix/video-embedding 2026-02-06 16:51:52 +05:30
Jannat Patel 9609398643 chore: fixed type check for batch flows 2026-02-06 16:18:37 +05:30
Jannat Patel cd0d4c413d chore: added type hints to all whitelisted functions 2026-02-06 16:01:49 +05:30
Jannat Patel 1bbdff9aaf Merge pull request #2060 from raizasafeel/translations-fix
fix(profile): translations in tab are now rendered
2026-02-06 15:00:20 +05:30
Jannat Patel 8754d0498c Merge pull request #2062 from pateljannat/issues-179
fix: improved the default print format
2026-02-06 14:59:08 +05:30
raizasafeel 395ac52740 fix(assessments): translation render in list heading 2026-02-06 14:46:02 +05:30
Jannat Patel 29cdbe5b8b Merge pull request #2061 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-06 14:38:40 +05:30
Raizaaa 0677c21dc7 Merge branch 'frappe:develop' into translations-fix 2026-02-06 14:37:22 +05:30
Jannat Patel 1a58e2669f fix: improved the default print format 2026-02-06 14:33:24 +05:30
raizasafeel 3fa27024f9 fix(breadcrumbs): translations are now rendered 2026-02-06 12:53:17 +05:30
Jannat Patel 04c4069c75 chore: Serbian (Latin) translations 2026-02-06 12:08:55 +05:30
Jannat Patel dd77b01ff1 chore: Bosnian translations 2026-02-06 12:08:52 +05:30
Jannat Patel 085614bca6 chore: Croatian translations 2026-02-06 12:08:50 +05:30
Jannat Patel ef2606c41a chore: Swedish translations 2026-02-06 12:08:41 +05:30
Jannat Patel 1d95361587 chore: Serbian (Cyrillic) translations 2026-02-06 12:08:40 +05:30
raizasafeel 6ead16edf0 fix(profile): translations in tab are now rendered 2026-02-06 10:10:29 +05:30
Jannat Patel 31d21bf689 Merge pull request #2056 from frappe/l10n_develop2
chore: sync translations from crowdin
2026-02-05 13:07:15 +05:30
Jannat Patel c5ee140551 Merge pull request #2054 from pateljannat/issues-178
feat: student progress in course dashboard
2026-02-05 12:40:26 +05:30
Jannat Patel 8e97b2f5bb chore: codecov config 2026-02-05 12:33:38 +05:30
Jannat Patel 19171a8019 chore: removed files that are no longer used 2026-02-05 12:24:56 +05:30
Jannat Patel 3f49cf0c9c chore: added type hints to course assessment progress functions 2026-02-05 12:24:36 +05:30
Jannat Patel 8f9cc536e2 chore: Esperanto translations 2026-02-05 12:13:55 +05:30
Jannat Patel 573bc74a41 chore: Serbian (Latin) translations 2026-02-05 12:13:53 +05:30
Jannat Patel bb2552b30c chore: Norwegian Bokmal translations 2026-02-05 12:13:52 +05:30
Jannat Patel 20ac312f57 chore: Bosnian translations 2026-02-05 12:13:50 +05:30
Jannat Patel f58842438b chore: Burmese translations 2026-02-05 12:13:49 +05:30
Jannat Patel a34f99ed49 chore: Croatian translations 2026-02-05 12:13:48 +05:30
Jannat Patel 44b7243f75 chore: Thai translations 2026-02-05 12:13:46 +05:30
Jannat Patel d2f7d80114 chore: Persian translations 2026-02-05 12:13:45 +05:30
Jannat Patel 192b246381 chore: Indonesian translations 2026-02-05 12:13:43 +05:30
Jannat Patel 17d9a3991e chore: Portuguese, Brazilian translations 2026-02-05 12:13:42 +05:30
Jannat Patel 3407a02046 chore: Vietnamese translations 2026-02-05 12:13:41 +05:30
Jannat Patel be3546e79c chore: Chinese Simplified translations 2026-02-05 12:13:39 +05:30
Jannat Patel 556067de7a chore: Turkish translations 2026-02-05 12:13:38 +05:30
Jannat Patel 4ce08af516 chore: Swedish translations 2026-02-05 12:13:36 +05:30
Jannat Patel 8a4477a01f chore: Serbian (Cyrillic) translations 2026-02-05 12:13:35 +05:30
Jannat Patel 21d868a355 chore: Slovenian translations 2026-02-05 12:13:33 +05:30
Jannat Patel 68a2cc1003 chore: Russian translations 2026-02-05 12:13:32 +05:30
Jannat Patel 5a288836e0 chore: Portuguese translations 2026-02-05 12:13:30 +05:30
Jannat Patel 4eab5e2867 chore: Polish translations 2026-02-05 12:13:29 +05:30
Jannat Patel 6082093fb6 chore: Dutch translations 2026-02-05 12:13:27 +05:30
Jannat Patel 66c26c2a2c chore: Italian translations 2026-02-05 12:13:26 +05:30
Jannat Patel f7eaf3faaa chore: Hungarian translations 2026-02-05 12:13:24 +05:30
Jannat Patel 82a43b4f24 chore: German translations 2026-02-05 12:13:23 +05:30
Jannat Patel 5bd33a1536 chore: Danish translations 2026-02-05 12:13:21 +05:30
Jannat Patel a063c0735c chore: Czech translations 2026-02-05 12:13:20 +05:30
Jannat Patel 732db8290d chore: Arabic translations 2026-02-05 12:13:19 +05:30
Jannat Patel 2b1d57f2bc chore: Spanish translations 2026-02-05 12:13:17 +05:30
Jannat Patel c8c051c1de chore: French translations 2026-02-05 12:13:16 +05:30
Jannat Patel 13139bc2de test: course assessment progress 2026-02-05 12:10:24 +05:30
Jannat Patel 9814abf55f fix: dark mode issues of course dashboard 2026-02-04 16:04:28 +05:30
Jannat Patel 249ecb8c4c Merge pull request #2053 from frappe/develop
chore: merge 'develop' into 'main'
2026-02-04 15:24:23 +05:30
Jannat Patel 582540e7f0 feat: student progress on course dashboard 2026-02-03 21:31:31 +05:30
raizasafeel 2f3fa7c295 fix: added regex anchors to embed urls 2026-02-03 16:22:50 +05:30
raizasafeel 3b49aac1b3 refactor: removed unused functions 2026-02-03 16:14:58 +05:30
raizasafeel dc25b408e6 fix(vimeo): video player is rendered for private videos and unsanitized vimeo links 2026-02-03 14:51:17 +05:30
raizasafeel c8d9b97ab7 refactor: reuse function 'escapehtml' from utils 2026-02-03 14:01:48 +05:30
Jannat Patel 754d3cf2ca fix: import permissions 2026-02-03 10:56:22 +05:30
Jannat Patel e4268d0437 fix: allow attaching payment information for batch enrollment 2026-02-03 10:49:48 +05:30
Jannat Patel 8febe21aa8 fix: dont allow contact us email sending to guest users 2026-02-03 10:48:59 +05:30
Jannat Patel 5384b26610 Merge pull request #2042 from raizasafeel/fix/chapter-deletion
test(chapter): added reindexing test on chapter deletion
2026-02-02 19:20:55 +05:30
Jannat Patel 2a4650e5ed Merge pull request #2044 from raizasafeel/fix/quiz-order
fix(batches): order assessments by their index
2026-02-02 19:12:50 +05:30
Jannat Patel 737993c543 Merge pull request #2043 from raizasafeel/fix/multilanguage-filters
fix: filter tabs not working in non-english languages
2026-02-02 18:34:50 +05:30
raizasafeel 58b49e3608 fix(batches): order assessments by their index 2026-02-02 18:28:06 +05:30
Raizaaa 27553464d6 Merge branch 'frappe:develop' into fix/chapter-deletion 2026-02-02 16:28:06 +05:30
raizasafeel be76268c70 fix(batches): use constant value for filter instead of translated label 2026-02-02 16:22:37 +05:30
raizasafeel df2f2e6603 fix(courses): use constant value for filter instead of translated label 2026-02-02 16:21:31 +05:30
Jannat Patel fb1e1ec2e4 Merge pull request #2040 from pateljannat/issues-177
fix: permissions cleanup
2026-02-02 15:55:22 +05:30
Jannat Patel ac81d1817b fix: batch dashboard chart visibility 2026-02-02 15:43:36 +05:30
Jannat Patel da33e1d3bd test: course and batch details 2026-02-02 15:11:16 +05:30
Jannat Patel 24a511f48e fix: do nor return details of unpublished courses and batches via api 2026-02-02 14:44:13 +05:30
Jannat Patel 14e669435f fix: permissions cleanup 2026-02-02 13:14:16 +05:30
Jannat Patel 0407f01016 Merge pull request #2038 from vishwajeet-13/fix/issue-date
fix: issue date not coming from backend
2026-02-02 11:06:53 +05:30
vishwajeet-13 2a63f781ac fix: issue date not coming from backend 2026-01-30 17:37:07 +05:30
raizasafeel a882432702 test: added test for chapter deletion and renumbering 2026-01-29 13:32:37 +05:30
raizasafeel f8b6dfc981 Merge branch 'develop' into fix/chapter-deletion 2026-01-29 13:27:29 +05:30
Jannat Patel e8768d5687 Merge pull request #2026 from frappe/develop
chore: merge 'develop' into 'main'
2026-01-28 11:32:28 +05:30
262 changed files with 56872 additions and 35278 deletions
+1
View File
@@ -11,6 +11,7 @@ cd ./frappe-bench || exit
bench -v setup requirements
echo "Setting Up LMS App..."
bench get-app "https://github.com/frappe/payments"
bench get-app lms "${GITHUB_WORKSPACE}"
echo "Setting Up Sites & Database..."
+6
View File
@@ -3,9 +3,12 @@ on:
push:
branches:
- main
- develop
- main-hotfix
pull_request: {}
jobs:
tests:
name: Server Tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -59,6 +62,9 @@ jobs:
mkdir -p ~/bench-cache
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
fi
- name: add payments app to bench
working-directory: /home/runner/frappe-bench
run: bench get-app https://github.com/frappe/payments
- name: add lms app to bench
working-directory: /home/runner/frappe-bench
run: bench get-app lms $GITHUB_WORKSPACE
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
owner: frappe
repo: lms
title: |-
"chore: merge 'develop' into 'main'"
"chore: merge 'main-hotfix' into 'main'"
body: "Automated weekly release"
base: main
head: develop
head: main-hotfix
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
+4 -2
View File
@@ -4,7 +4,10 @@ on:
pull_request:
workflow_dispatch:
push:
branches: [ main ]
branches:
- main
- develop
- main-hotfix
permissions:
# Do not change this as GITHUB_TOKEN is being used by roulette
@@ -14,7 +17,6 @@ jobs:
test:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'frappe' }}
timeout-minutes: 60
strategy:
+2 -1
View File
@@ -13,4 +13,5 @@ package-lock.json
lms/public/frontend
lms/www/lms.html
lms/www/_lms.html
frappe-ui
frappe-ui
frappe-semgrep-rules
+30
View File
@@ -0,0 +1,30 @@
pull_request_rules:
- name: backport to develop
conditions:
- label="backport develop"
actions:
backport:
branches:
- develop
assignees:
- "{{ author }}"
- name: backport to main-hotfix
conditions:
- label="backport main-hotfix"
actions:
backport:
branches:
- main-hotfix
assignees:
- "{{ author }}"
- name: backport to main
conditions:
- label="backport main"
actions:
backport:
branches:
- main
assignees:
- "{{ author }}"
+2
View File
@@ -0,0 +1,2 @@
ignore:
- "**/test_helper.py"
+25 -27
View File
@@ -27,24 +27,24 @@ describe("Batch Creation", () => {
cy.get("input[placeholder='Jane']").type(randomName);
cy.get("button").contains("Add").click();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add evaluator
// Switch to Evaluators tab
cy.get("[data-dismissable-layer]")
.find("span")
.contains(/^Evaluators$/)
.click();
// Click "New" dropdown and select "New Evaluator"
cy.get("[data-dismissable-layer]")
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("span").contains("New Evaluator").click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
cy.get("input[placeholder='Jane']").type("Evaluator");
cy.get("button").contains("Add").click();
cy.wait(500);
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
@@ -54,25 +54,21 @@ describe("Batch Creation", () => {
cy.get("button").contains("Create").click();
cy.get("span").contains("New Batch").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.contains("Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
@@ -90,13 +86,14 @@ describe("Batch Creation", () => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.button("Save").click();
cy.get("label").contains("Published").click();
cy.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
batchName = url.split("/").pop().split("#")[0];
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
@@ -115,7 +112,7 @@ describe("Batch Creation", () => {
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
@@ -128,14 +125,11 @@ describe("Batch Creation", () => {
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
cy.contains("div:visible", "10 Seats Left").should(
"be.visible"
);
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
cy.get(`a[href='/lms/batches/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
@@ -157,18 +151,22 @@ describe("Batch Creation", () => {
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
)
.should("be.visible");
cy.get("button:visible").contains("Manage Batch").click();
cy.get("button:visible").contains("Dashboard").click();
/* Add student to batch */
cy.get("button").contains("Students").click();
cy.get("button").contains("Add").click();
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get("button").contains("Enroll").click();
cy.get('div[role="dialog"]')
.first()
.find("div[label='Student']")
.find("div")
.first()
.click();
cy.get("input[placeholder='Search']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("button:visible").contains("Overview").click();
cy.contains("div:visible", "9 Seats Left").should("be.visible");
});
});
+41 -27
View File
@@ -21,10 +21,17 @@ describe("Course Creation", () => {
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
cy.fixture("profile.png", "base64").then((fileContent) => {
cy.contains("Course Image")
.should("exist")
.parent()
.find('input[type="file"]')
.attachFile("profile.png", { force: true });
/* cy.fixture("profile.png", "base64").then((fileContent) => {
expect(fileContent).to.exist;
cy.get("div")
.contains("Course Image")
.siblings("div")
.parent()
.children('input[type="file"]')
.attachFile({
fileContent,
@@ -32,7 +39,7 @@ describe("Course Creation", () => {
mimeType: "image/png",
encoding: "base64",
});
});
}); */
/* Instructor */
cy.get("label")
@@ -53,7 +60,7 @@ describe("Course Creation", () => {
});
});
cy.button("Create").last().click();
cy.button("Save").last().click();
// Edit Course Details
cy.wait(500);
@@ -67,20 +74,17 @@ describe("Course Creation", () => {
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
cy.get("div").contains("Business").click();
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click();
// Add Chapter
cy.wait(1000);
cy.wait(500);
cy.button("Add").click();
cy.wait(1000);
cy.wait(500);
cy.get("[data-dismissable-layer]")
.should("be.visible")
.within(() => {
@@ -89,12 +93,10 @@ describe("Course Creation", () => {
});
// Add Lesson
cy.wait(1000);
cy.wait(500);
cy.button("Add Lesson").click();
cy.wait(1000);
cy.wait(500);
cy.url().should("include", "/learn/1-1/edit");
cy.wait(1000);
cy.get("label").contains("Title").type("Test Lesson");
cy.get("#content .ce-block").type(
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
@@ -102,21 +104,23 @@ describe("Course Creation", () => {
cy.button("Save").click();
// View Course
cy.wait(1000);
cy.wait(500);
cy.visit("/lms/courses");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/courses");
cy.get(".grid a:first").within(() => {
cy.get("div").contains("Test Course");
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get(".grid a:first").click();
cy.get("div")
.contains("Test Course")
.closest("a")
.within(() => {
cy.get("div").contains(
"Test Course Short Introduction to test the UI"
);
cy.get(".bg-cover")
.invoke("css", "background-image")
.should("include", "/files/profile");
});
cy.get("div").contains("Test Course").closest("a").click();
cy.url().should("include", "/lms/courses/test-course");
cy.get("div").contains("Test Course");
cy.get("div").contains("Test Course Short Introduction to test the UI");
@@ -134,7 +138,7 @@ describe("Course Creation", () => {
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
cy.get("div").contains("Test Lesson").click();
});
cy.wait(3000);
cy.wait(500);
// View Lesson
cy.url().should("include", "/learn/1-1");
@@ -145,7 +149,6 @@ describe("Course Creation", () => {
);
// Add Discussion
cy.get("span").contains("Community").click();
cy.button("New Question").click();
cy.wait(500);
cy.get("[data-dismissable-layer]").within(() => {
@@ -168,5 +171,16 @@ describe("Course Creation", () => {
cy.get("div").contains(
"This is a test comment. This will check if the UI is working properly."
);
// Delete Course
cy.get("div").contains("Test Course").click();
cy.get("button").contains("Settings").click();
cy.get("header").within(() => {
cy.get("svg.lucide.lucide-trash2-icon").click();
});
cy.get("span").contains("Delete").click();
cy.wait(500);
cy.url().should("include", "/lms/courses");
cy.get("div").contains("Test Course").should("not.exist");
});
});
+4 -13
View File
@@ -8,14 +8,11 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default']
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
AddEvaluatorModal: typeof import('./src/components/Modals/AddEvaluatorModal.vue')['default']
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
@@ -24,16 +21,8 @@ declare module 'vue' {
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
@@ -72,6 +61,8 @@ declare module 'vue' {
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GoogleMeetAccountModal: typeof import('./src/components/Settings/GoogleMeetAccountModal.vue')['default']
GoogleMeetSettings: typeof import('./src/components/Settings/GoogleMeetSettings.vue')['default']
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
@@ -82,13 +73,13 @@ declare module 'vue' {
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
Members: typeof import('./src/components/Settings/Members.vue')['default']
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
NewMemberModal: typeof import('./src/components/Modals/NewMemberModal.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
+3 -5
View File
@@ -27,13 +27,11 @@
"@editorjs/table": "2.4.2",
"@vueuse/core": "^14.1.0",
"ace-builds": "1.36.2",
"apexcharts": "4.3.0",
"chart.js": "4.4.1",
"codemirror": "6.0.1",
"dayjs": "1.11.10",
"dompurify": "3.2.6",
"feather-icons": "4.28.0",
"frappe-ui": "^0.1.261",
"frappe-ui": "^0.1.264",
"highlight.js": "11.11.1",
"lucide-vue-next": "0.383.0",
"markdown-it": "14.0.0",
@@ -51,12 +49,12 @@
"vuedraggable": "4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "5.0.3",
"@vitejs/plugin-vue": "5.0.3",
"autoprefixer": "10.4.2",
"postcss": "8.4.5",
"tailwindcss": "^3.4.15",
"unplugin-auto-import": "^20.3.0",
"vite": "5.0.11",
"vite-plugin-pwa": "0.15.0"
"vite-plugin-pwa": "^1.2.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<FrappeUIProvider>
<Layout class="isolate text-base">
<Layout class="isolate text-p-base">
<router-view />
</Layout>
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
@@ -1,118 +0,0 @@
<template>
<div v-if="batch?.data" class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChart
class="border rounded-md"
:config="{ title: __('Students'), value: studentCount.data || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch?.data?.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount.data || 0 }"
/>
</div>
<AxisChart
v-if="showProgressChart"
class="border"
:config="{
data: filteredChartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div>
</template>
<script setup lang="ts">
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
import { computed } from 'vue'
const props = defineProps<{
batch: { [key: string]: any } | null
}>()
const studentCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_student_count', props.batch?.data?.name],
params: {
doctype: 'LMS Batch Enrollment',
filters: { batch: props.batch?.data?.name },
},
auto: true,
})
const assessmentCount = createResource({
url: 'lms.lms.utils.get_batch_assessment_count',
cache: ['batch_assessment_count', props.batch?.data?.name],
params: {
batch: props.batch?.data?.name,
},
auto: true,
})
const chartData = createResource({
url: 'lms.lms.utils.get_batch_chart_data',
cache: ['batch_chart_data', props.batch?.data?.name],
params: { batch: props.batch?.data?.name },
auto: true,
})
const certificationCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_certificate_count', props.batch?.data?.name],
params: {
doctype: 'LMS Certificate',
filters: { batch_name: props.batch?.data?.name },
},
auto: true,
})
const filteredChartData = computed(() =>
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
)
const showProgressChart = computed(
() =>
studentCount.data &&
(props.batch?.data?.courses?.length || assessmentCount.data)
)
</script>
-53
View File
@@ -1,53 +0,0 @@
<template>
<div v-if="communications.data?.length">
<div v-for="comm in communications.data">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2 text-ink-gray-7">
{{ comm.sender_full_name }}
</div>
</div>
<div class="text-sm">
{{ timeAgo(comm.communication_date) }}
</div>
</div>
<div
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
v-html="comm.content"
></div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No announcements') }}
</div>
</template>
<script setup>
import { createResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const communications = createResource({
url: 'lms.lms.api.get_announcements',
makeParams(value) {
return {
batch: props.batch,
}
},
auto: true,
cache: ['announcement', props.batch],
})
</script>
<style>
.prose-sm p {
margin: 0 0 0.5rem;
}
</style>
+5 -4
View File
@@ -49,8 +49,9 @@
:label="__('Select an Assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
<FormControl
type="checkbox"
<Switch
size="sm"
:description="__('Only show assignments from the current course')"
:label="__('Filter assignments by course')"
v-model="filterAssignmentsByCourse"
/>
@@ -61,11 +62,11 @@
</Dialog>
</template>
<script setup>
import { Dialog, FormControl } from 'frappe-ui'
import { Dialog, Switch } from 'frappe-ui'
import { nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Link } from 'frappe-ui/frappe'
import { getLmsRoute } from '@/utils/basePath'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)
+97 -123
View File
@@ -16,8 +16,8 @@
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
</div>
</div>
<div class="text-sm text-ink-gray-7 font-medium mb-2">
{{ __('Question') }}:
<div class="text-ink-gray-9 font-semibold mb-5">
{{ __('Assignment Question') }}
</div>
<div
v-html="assignment.data.question"
@@ -42,7 +42,11 @@
>
{{ submissionResource.doc?.status }}
</Badge>
<Button variant="solid" @click="submitAssignment()">
<Button
v-if="canModifyAssignment || canGradeSubmission"
variant="solid"
@click="submitAssignment()"
>
{{ __('Save') }}
</Button>
</div>
@@ -73,12 +77,15 @@
}}
</div>
<FileUploader
v-if="!submissionResource.doc?.assignment_attachment"
v-if="!attachment"
:fileTypes="getType()"
:uploadArgs="{
private: true,
}"
:validateFile="validateFile"
:validateFile="
(file) =>
validateFile(file, true, assignment.data.type.toLowerCase())
"
@success="(file) => saveSubmission(file)"
>
<template #default="{ uploading, progress, openFileSelector }">
@@ -94,7 +101,7 @@
<div v-else>
<div class="flex items-center text-ink-gray-7">
<a
:href="submissionResource.doc.assignment_attachment"
:href="attachment"
target="_blank"
class="cursor-pointer !no-underline text-sm leading-5"
>
@@ -103,11 +110,7 @@
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<span>
{{
submissionResource.doc.assignment_attachment
.split('/')
.pop()
}}
{{ attachment.split('/').pop() }}
</span>
</div>
</a>
@@ -138,10 +141,11 @@
@change="(val) => (answer = val)"
:editable="true"
:fixedMenu="true"
:readonly="!canModifyAssignment"
:uploadArgs="{
private: true,
}"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
@@ -150,7 +154,7 @@
user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments
"
class="mt-8 p-3 border rounded-lg"
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
>
<div class="text-ink-gray-5 mb-4">
{{ __('Comments by Evaluator') }}
@@ -190,7 +194,7 @@
:uploadArgs="{
private: true,
}"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
@@ -213,8 +217,10 @@ import {
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { validateFile } from '@/utils'
const answer = ref(null)
const attachment = ref(null)
const comments = ref(null)
const router = useRouter()
const user = inject('$user')
@@ -264,118 +270,97 @@ const assignment = createResource({
},
})
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (!showUploader()) {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
auto: false,
onError(err) {
toast.error(err.messages?.[0] || err)
},
auto: false,
cache: [user.data?.name, props.assignmentID],
})
watch(submissionResource, () => {
if (submissionResource.doc) {
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (
showUploader() &&
!submissionResource.doc.assignment_attachment
) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
} else {
isDirty.value = false
}
if (!submissionResource.doc) return
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.assignment_attachment) {
attachment.value = submissionResource.doc.assignment_attachment
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
})
watch(
() => submissionResource.doc,
() => {
if (
props.submissionName == 'new' &&
submissionResource.doc?.assignment_attachment
) {
isDirty.value = true
}
}
)
const submitAssignment = () => {
if (props.submissionName != 'new') {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
},
{
onSuccess(data) {
isDirty.value = false
toast.success(__('Changes saved successfully'))
},
}
)
updateSubmission()
} else {
addNewSubmission()
}
}
const addNewSubmission = () => {
newSubmission.submit(
{},
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (!showUploader()) {
doc.answer = answer.value
} else {
doc.assignment_attachment = attachment.value
}
call('frappe.client.insert', {
doc: doc,
})
.then((data) => {
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
isDirty.value = false
submissionResource.name = data.name
submissionResource.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const updateSubmission = () => {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
assignment_attachment: attachment.value,
},
{
onSuccess(data) {
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
submissionResource.name = data.name
submissionResource.reload()
isDirty.value = false
toast.success(__('Changes saved successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
@@ -383,7 +368,7 @@ const addNewSubmission = () => {
const saveSubmission = (file) => {
isDirty.value = true
submissionResource.doc.assignment_attachment = file.file_url
attachment.value = file.file_url
}
const markLessonProgress = () => {
@@ -417,24 +402,9 @@ const getType = () => {
}
}
const validateFile = (file) => {
let type = assignment.data?.type
let extension = file.name.split('.').pop().toLowerCase()
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
} else if (
type == 'Document' &&
!['doc', 'docx', 'xml'].includes(extension)
) {
return 'Only document file is allowed.'
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
return 'Only PDF file is allowed.'
}
}
const removeSubmission = () => {
isDirty.value = true
submissionResource.doc.assignment_attachment = ''
attachment.value = null
}
const canGradeSubmission = computed(() => {
@@ -448,11 +418,15 @@ const canGradeSubmission = computed(() => {
})
const canModifyAssignment = computed(() => {
return (
!submissionResource.doc ||
(submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded')
)
if (props.submissionName == 'new') {
return true
} else if (
submissionResource.doc?.owner == user.data?.name &&
submissionResource.doc?.status == 'Not Graded'
) {
return true
}
return false
})
const submissionStatusOptions = computed(() => {
@@ -1,26 +0,0 @@
<template>
<div class="space-y-10">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
</script>
-226
View File
@@ -1,226 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-ink-gray-9 font-medium">
{{ studentCount.data ?? 0 }} {{ __('Students') }}
</div>
<Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
class="max-h-[75vh]"
:columns="studentColumns"
:rows="students.data"
row-key="name"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem
:item="item"
v-for="item in studentColumns"
:title="item.label"
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
<Button @click="students.next()">
{{ __('Load More') }}
</Button>
</div>
</ListView>
</div>
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
{{ __('There are no students in this batch.') }}
</div>
</div>
<StudentModal
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/>
</template>
<script setup>
import {
Avatar,
Button,
createListResource,
createResource,
FeatherIcon,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const studentCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_student_count', props.batch?.data?.name],
params: {
doctype: 'LMS Batch Enrollment',
filters: { batch: props.batch?.data?.name },
},
auto: true,
})
const students = createListResource({
doctype: 'LMS Batch Enrollment',
url: 'lms.lms.utils.get_batch_students',
cache: ['batch_students', props.batch?.data?.name],
pageLength: 50,
filters: {
batch: props.batch?.data?.name,
},
auto: true,
})
const studentColumns = [
{
label: 'Full Name',
key: 'full_name',
width: '25rem',
icon: 'user',
},
{
label: 'Progress',
key: 'progress',
width: '15rem',
icon: 'activity',
},
{
label: 'Last Active',
key: 'last_active',
width: '10rem',
align: 'center',
icon: 'clock',
},
]
const openStudentModal = () => {
showStudentModal.value = true
}
const openStudentProgressModal = (row) => {
showStudentProgressModal.value = true
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Batch Enrollment',
documents: values.students,
}
},
})
const removeStudents = (selections, unselectAll) => {
deleteStudents.submit(
{
students: Array.from(selections),
},
{
onSuccess(data) {
students.reload()
studentCount.reload()
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
)
}
</script>
@@ -9,14 +9,23 @@
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<Popover
class="w-full"
v-model:show="showOptions"
:matchTargetWidth="true"
>
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="() => togglePopover()"
@click="
() => {
showOptions = !showOptions
togglePopover()
}
"
:disabled="attrs.readonly"
>
<div class="flex items-center w-[90%]">
@@ -87,7 +96,8 @@
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
'flex items-center rounded px-2.5 text-base py-1.5',
optionLines(option).secondary ? '' : 'h-7',
{ 'bg-surface-gray-2': active },
]"
>
@@ -99,18 +109,21 @@
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col space-y-1 text-ink-gray-8">
<div>
{{ option.label }}
<div
class="flex flex-col px-1"
:class="
optionLines(option).secondary ? 'gap-0.5' : ''
"
>
<div class="text-base font-medium text-ink-gray-8">
{{ optionLines(option).primary }}
</div>
<div
v-if="
option.description &&
option.description != option.label
"
class="text-xs text-ink-gray-7"
v-html="option.description"
></div>
v-if="optionLines(option).secondary"
class="text-sm text-ink-gray-5"
>
{{ optionLines(option).secondary }}
</div>
</div>
</slot>
</li>
@@ -120,7 +133,7 @@
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
No results found
{{ __('No results found') }}
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
@@ -241,6 +254,17 @@ function filterOptions(options) {
})
}
function optionLines(option) {
const primary = option.label
let secondary = null
if (option.description && option.description !== primary) {
secondary = option.description
} else if (option.value && option.value !== primary) {
secondary = option.value
}
return { primary, secondary }
}
function displayValue(option) {
if (typeof option === 'string') {
let allOptions = groups.value.flatMap((group) => group.items)
@@ -284,9 +308,9 @@ const inputClasses = computed(() => {
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
subtle:
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
'border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
outline:
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
disabled: [
'border bg-surface-menu-bar placeholder-ink-gray-3',
props.variant === 'outline'
@@ -3,10 +3,10 @@
<div class="text-xs text-ink-gray-5 mb-2">
{{ label }}
</div>
<div class="overflow-visible border rounded-md">
<div class="overflow-visible border border-outline-gray-modals rounded-md">
<div class="overflow-x-auto">
<div
class="grid items-center space-x-4 p-2 border-b"
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
>
<div
@@ -28,7 +28,7 @@
<input
v-if="showKey(key)"
v-model="row[key]"
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
class="py-1.5 px-2 w-full border-none bg-transparent text-ink-gray-8 focus:ring-0 focus:border focus:border-outline-gray-3 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
/>
</template>
@@ -47,7 +47,7 @@
<div
v-if="menuOpenIndex === rowIndex"
ref="menuRef"
class="absolute right-0 w-32 z-50 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
:class="
rowIndex == (rows?.length ?? 0) - 1
? 'bottom-full mb-1'
+75 -20
View File
@@ -11,7 +11,6 @@
:size="attrs.size || 'sm'"
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:filterable="false"
:readonly="attrs.readonly"
>
<template #target="{ open, togglePopover }">
@@ -31,28 +30,48 @@
</template>
<template #footer="{ value, close }">
<div v-if="attrs.onCreate">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(value, close)"
<div v-if="creating" class="flex items-center gap-1">
<button
class="p-1 rounded hover:bg-surface-gray-3 text-ink-gray-5"
@click="creating = false"
:aria-label="__('Cancel')"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
<ArrowLeft class="size-4 stroke-1.5" />
</button>
<FormControl
v-model="newItemName"
class="flex-1 min-w-0"
size="sm"
:placeholder="__(props.inlineCreatePlaceholder)"
/>
<Button
variant="solid"
size="sm"
:disabled="!newItemName.trim()"
@click="submitCreate"
:aria-label="__('Create')"
>
{{ __('Create') }}
</Button>
</div>
<div>
<div v-else class="flex justify-between">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Clear')"
@click="() => clearValue(close)"
:aria-label="__('Clear')"
>
{{ __('Clear') }}
</Button>
<Button
v-if="props.onCreate"
variant="ghost"
@click="handleCreate(close)"
:aria-label="__('Create New')"
>
<template #prefix>
<X class="h-4 w-4 stroke-1.5" />
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Create New') }}
</Button>
</div>
</template>
@@ -64,8 +83,8 @@
<script setup>
import Autocomplete from '@/components/Controls/Autocomplete.vue'
import { watchDebounced } from '@vueuse/core'
import { createResource, Button } from 'frappe-ui'
import { Plus, X } from 'lucide-vue-next'
import { createResource, Button, FormControl } from 'frappe-ui'
import { Plus, ArrowLeft } from 'lucide-vue-next'
import { useAttrs, computed, ref } from 'vue'
import { useSettings } from '@/stores/settings'
@@ -86,18 +105,32 @@ const props = defineProps({
type: String,
default: '',
},
inlineCreate: {
type: Boolean,
default: false,
},
inlineCreatePlaceholder: {
type: String,
default: 'Enter...',
},
onCreate: {
type: Function,
default: null,
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs)
const creating = ref(false)
const newItemName = ref('')
const value = computed({
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
set: (val) => {
return (
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
)
},
})
@@ -106,6 +139,26 @@ const autocomplete = ref(null)
const text = ref('')
const settingsStore = useSettings()
function handleCreate(close) {
if (props.inlineCreate) {
creating.value = true
return
}
if (props.onCreate) {
props.onCreate(null, close)
}
}
function submitCreate() {
if (!newItemName.value.trim() || !props.onCreate) return
const value = newItemName.value.trim()
props.onCreate(value, () => {
creating.value = false
newItemName.value = ''
reload()
})
}
watchDebounced(
() => autocomplete.value?.query,
(val) => {
@@ -141,7 +194,7 @@ const options = createResource({
params: {
txt: text.value,
doctype: props.doctype,
filters: props.filters,
filters: JSON.stringify(props.filters),
},
transform: (data) => {
return data.map((option) => {
@@ -154,12 +207,12 @@ const options = createResource({
},
})
const reload = (val) => {
const reload = (val = '') => {
options.update({
params: {
txt: val,
doctype: props.doctype,
filters: props.filters,
filters: JSON.stringify(props.filters),
},
})
options.reload()
@@ -179,4 +232,6 @@ const labelClasses = computed(() => {
'text-ink-gray-5',
]
})
defineExpose({ reload })
</script>
+162 -197
View File
@@ -1,177 +1,144 @@
<template>
<div>
<label class="block mb-1" :class="labelClasses" v-if="label">
<label v-if="label" class="block mb-1" :class="labelClasses">
{{ label }}
<span class="text-ink-red-3" v-if="required">*</span>
<span v-if="required" class="text-ink-red-3">*</span>
</label>
<div class="w-full">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full focus-visible:!ring-0"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
@click="
(e) => {
showOptions = true
nextTick(() => {
setFocus()
})
}
"
@focus="
() => {
if (!filterOptions.data || filterOptions.data.length === 0) {
reload('')
}
}
"
autocomplete="off"
/>
</template>
<template #body="{ isOpen, close }">
<div v-show="isOpen">
<div
class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
<div class="relative w-full">
<div
class="flex flex-wrap items-center gap-1.5 w-full rounded-lg border border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1.5 cursor-text transition-colors focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3"
@click="focusInput"
>
<button
v-for="value in values"
:key="value"
type="button"
class="inline-flex items-center gap-1 bg-surface-white border border-outline-gray-2 text-ink-gray-7 pl-2 pr-1.5 py-0.5 rounded text-base leading-5"
@click.stop="removeValue(value)"
>
<span>{{ value }}</span>
<X class="size-3.5 stroke-1.5 shrink-0" />
</button>
<ComboboxInput
ref="search"
class="flex-1 min-w-[4rem] border-none outline-none bg-transparent p-0 text-base focus:ring-0"
type="text"
:placeholder="!values?.length ? __('Search...') : ''"
@change="
(e) => {
query = e.target.value
}
"
autocomplete="off"
@focus="onFocus"
/>
</div>
<ComboboxButton ref="trigger" class="hidden" />
<ComboboxOptions
v-show="open"
static
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal border-2 border-outline-gray-modals max-h-[13rem] flex flex-col"
>
<div
class="flex-1 my-1 overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[1rem]'"
>
<template v-if="options.length">
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<ComboboxOptions
class="flex-1 my-1 overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
static
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<ComboboxOption
v-if="options.length"
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value == option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
<div v-else class="text-ink-gray-7 px-4">
{{ __('No results found') }}
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value === option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</ComboboxOptions>
<div v-if="attrs.onCreate" class="px-1 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>
</div>
</li>
</ComboboxOption>
</template>
<div v-else class="text-ink-gray-7 px-4 py-2">
{{ __('No results found') }}
</div>
</template>
</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
v-if="attrs.onCreate"
class="p-1 bg-surface-white border-t rounded-b-lg"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate()"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
</div>
</div>
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
</Combobox>
</div>
</template>
<script setup>
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick, useAttrs } from 'vue'
import { set, watchDebounced } from '@vueuse/core'
import { createResource, Button, toast } from 'frappe-ui'
import { ref, computed, useAttrs, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { X, Plus } from 'lucide-vue-next'
const props = defineProps({
label: {
type: String,
},
size: {
type: String,
default: 'sm',
},
doctype: {
type: String,
required: true,
},
filters: {
type: Object,
default: () => ({}),
},
validate: {
type: Function,
default: null,
},
label: String,
size: { type: String, default: 'sm' },
doctype: { type: String, required: true },
filters: { type: [Object, Array], default: () => ({}) },
url: { type: String, default: 'frappe.desk.search.search_link' },
searchParams: { type: Object, default: () => ({}) },
validate: Function,
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
required: {
type: Boolean,
},
required: Boolean,
})
const values = defineModel()
const values = defineModel({ default: () => [] })
const attrs = useAttrs()
const search = ref(null)
const error = ref(null)
const trigger = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const emit = defineEmits(['update:modelValue'])
const selectedValue = ref(null)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
val?.value && addValue(val.value)
showOptions.value = false
emit('update:modelValue', values.value)
},
watch(selectedValue, (val) => {
if (!val?.value) return
query.value = ''
addValue(val.value)
selectedValue.value = null
})
watchDebounced(
@@ -185,79 +152,77 @@ watchDebounced(
{ debounce: 300, immediate: true }
)
const filterOptions = createResource({
url: 'frappe.desk.search.search_link',
method: 'POST',
cache: [text.value, props.doctype],
auto: true,
params: {
txt: text.value,
doctype: props.doctype,
// Refetch when filters or searchParams change
watch(
() => [props.filters, props.searchParams],
() => {
reload(text.value)
},
{ deep: true }
)
function getParams(txt) {
return {
txt,
doctype: props.doctype,
filters: JSON.stringify(props.filters),
...props.searchParams,
}
}
const filterOptions = createResource({
url: props.url,
method: 'POST',
})
const options = computed(() => {
setFocus()
const allOptions = filterOptions.data || []
return allOptions.filter((option) => !values.value?.includes(option.value))
})
function reload(val) {
filterOptions.update({
params: {
txt: val,
doctype: props.doctype,
},
params: getParams(val),
})
filterOptions.reload()
}
const addValue = (value) => {
error.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
})
!error.value && (value = '')
function onFocus() {
if (!filterOptions.data?.length) {
reload('')
}
trigger.value?.$el.click()
}
const removeValue = (value) => {
function addValue(value) {
if (!value) return
const splitValues = value.split(',')
let newValues = [...(values.value || [])]
splitValues.forEach((val) => {
val = val.trim()
if (!val) return
if (newValues.includes(val)) return
if (props.validate && !props.validate(val)) {
toast.error(props.errorMessage(val))
return
}
newValues.push(val)
})
values.value = newValues
}
function removeValue(value) {
values.value = values.value.filter((v) => v !== value)
emit('update:modelValue', values.value)
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[props.size || 'sm'],
'text-ink-gray-5',
]
})
const labelClasses = computed(() => [
{ sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
'text-ink-gray-5',
])
</script>
@@ -38,7 +38,7 @@
'border object-cover',
shape === 'circle'
? 'w-20 h-20 rounded-full'
: 'w-44 h-auto min-h-20 rounded-md',
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
]"
/>
<video v-else controls class="border rounded-md w-44 h-auto">
+13 -11
View File
@@ -10,7 +10,7 @@
{{ course.data.price }}
</div>
<div v-if="!readOnlyMode">
<div v-if="course.data.membership" class="space-y-2">
<div v-if="course.data.membership" class="space-y-2 mb-8">
<router-link
:to="{
name: 'Lesson',
@@ -46,7 +46,7 @@
},
}"
>
<Button variant="solid" size="md" class="w-full">
<Button variant="solid" size="md" class="w-full mb-8">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
@@ -67,7 +67,7 @@
v-else-if="!isAdmin"
@click="enrollStudent()"
variant="solid"
class="w-full"
class="w-full mb-8"
size="md"
>
<template #prefix>
@@ -90,24 +90,26 @@
{{ __('Get Certificate') }}
</Button>
</div>
<div class="space-y-4">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
>
<div class="space-y-3">
<div class="font-medium text-ink-gray-9">
{{ __('This course has:') }}
</div>
<div class="flex items-center text-ink-gray-9">
<BookOpen class="h-4 w-4 stroke-1.5" />
<span class="ml-2">
{{ course.data.lessons }} {{ __('Lessons') }}
{{ course.data.lessons }}
{{ course.data.lessons > 1 ? __('lessons') : __('lesson') }}
</span>
</div>
<div class="flex items-center text-ink-gray-9">
<Users class="h-4 w-4 stroke-1.5" />
<span class="ml-2">
{{ formatAmount(course.data.enrollments) }}
{{ __('Enrolled Students') }}
{{
course.data.enrollments > 1
? __('enrolled students')
: __('enrolled student')
}}
</span>
</div>
<div
@@ -116,7 +118,7 @@
>
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
<span class="ml-2">
{{ course.data.rating }} {{ __('Rating') }}
{{ course.data.rating }} {{ __('average rating') }}
</span>
</div>
<div
+20
View File
@@ -75,6 +75,12 @@
/>
</Tooltip>
</div>
<Check
v-if="
chapter.is_scorm_package && isScormChapterComplete(chapter)
"
class="h-4 w-4 text-green-700"
/>
</DisclosureButton>
<DisclosurePanel v-if="!chapter.is_scorm_package">
<Draggable
@@ -112,6 +118,14 @@
v-else-if="lesson.icon === 'icon-quiz'"
class="h-4 w-4 stroke-1 mr-2"
/>
<NotebookPen
v-else-if="lesson.icon === 'icon-assignment'"
class="h-4 w-4 stroke-1 mr-2"
/>
<SquareCode
v-else-if="lesson.icon === 'icon-code'"
class="h-4 w-4 stroke-1 mr-2"
/>
<FileText
v-else-if="lesson.icon === 'icon-list'"
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
@@ -177,7 +191,9 @@ import {
FilePenLine,
HelpCircle,
MonitorPlay,
NotebookPen,
Plus,
SquareCode,
Trash2,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
@@ -391,6 +407,10 @@ const redirectToChapter = (chapter) => {
})
}
const isScormChapterComplete = (chapter) => {
return chapter.lessons?.length && chapter.lessons.every((l) => l.is_complete)
}
const isActiveLesson = (lessonNumber) => {
return (
route.params.chapterNumber == lessonNumber.split('-')[0] &&
+5 -5
View File
@@ -12,7 +12,7 @@
</div>
<div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data">
<div class="flex items-center">
<div class="flex">
<router-link
:to="{
name: 'Profile',
@@ -46,11 +46,11 @@
"
/>
</div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div>
</div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div>
</div>
</div>
@@ -80,7 +80,7 @@ const props = defineProps({
required: true,
},
membership: {
type: Object,
type: Object || null,
required: false,
},
})
+59 -80
View File
@@ -93,11 +93,19 @@
</div>
</template>
<script setup>
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import {
call,
createResource,
TextEditor,
Button,
Dropdown,
toast,
} from 'frappe-ui'
import { timeAgo } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted, onUnmounted } from 'vue'
import { useTelemetry } from 'frappe-ui/frappe'
const showTopics = defineModel('showTopics')
const newReply = ref('')
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
const mentionUsers = ref([])
const renderEditor = ref(false)
const readOnlyMode = window.read_only_mode
const { capture } = useTelemetry()
const props = defineProps({
topic: {
@@ -143,19 +152,6 @@ const replies = createResource({
auto: true,
})
const newReplyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
reply: newReply.value,
topic: props.topic.name,
},
}
},
})
const fetchMentionUsers = () => {
if (user.data?.is_student) {
renderEditor.value = true
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
}
const postReply = () => {
newReplyResource.submit(
{},
{
validate() {
if (!newReply.value) {
return 'Reply cannot be empty'
}
},
onSuccess() {
newReply.value = ''
replies.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const editReplyResource = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
if (!newReply.value) {
toast.error(__('Reply cannot be empty.'))
return
}
call('frappe.client.insert', {
doc: {
doctype: 'Discussion Reply',
name: values.name,
fieldname: 'reply',
value: values.reply,
}
},
})
reply: newReply.value,
topic: props.topic.name,
},
})
.then((data) => {
newReply.value = ''
replies.reload()
capture('discussion_reply_created')
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const postEdited = (reply) => {
editReplyResource.submit(
{
name: reply.name,
reply: reply.reply,
},
{
validate() {
if (!reply.reply) {
return 'Reply cannot be empty'
}
},
onSuccess() {
reply.editable = false
replies.reload()
},
}
)
if (!reply.reply) {
toast.error(__('Reply cannot be empty.'))
return
}
call('frappe.client.set_value', {
doctype: 'Discussion Reply',
name: reply.name,
fieldname: 'reply',
value: reply.reply,
})
.then(() => {
reply.editable = false
replies.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const deleteReplyResource = createResource({
url: 'frappe.client.delete',
makeParams(values) {
return {
doctype: 'Discussion Reply',
name: values.name,
}
},
})
const deleteReply = (reply) => {
deleteReplyResource.submit(
{
name: reply.name,
},
{
onSuccess() {
replies.reload()
},
}
)
call('frappe.client.delete', {
doctype: 'Discussion Reply',
name: reply.name,
})
.then(() => {
replies.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
onUnmounted(() => {
+1 -3
View File
@@ -4,9 +4,7 @@
<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-full md:w-2/5 text-base text-center text-ink-gray-7"
>
<div class="text-p-base w-full md:w-2/5 text-center text-ink-gray-7">
{{
__(
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
+2 -2
View File
@@ -3,13 +3,13 @@
<div class="space-y-2">
<div class="flex items-center text-sm font-medium space-x-2">
<span>
{{ __('What does include in preview mean?') }}
{{ __('What are Instructor Notes?') }}
</span>
</div>
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
{{
__(
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
'Instructor Notes are private notes that only instructors can see. They can be used to provide additional context or guidance for the lesson.'
)
}}
</div>
-221
View File
@@ -1,221 +0,0 @@
<template>
<div
v-if="hasPermission() && !props.zoomAccount"
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3 text-xs"
>
<AlertCircle class="size-4 stroke-1.5" />
<span>
{{ __('Please add a zoom account to the batch to create live classes.') }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }}
</div>
<Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div
v-if="liveClasses.data?.length"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
>
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': hasPermission() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
>
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }}
</div>
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }}
</div>
<LiveClassModal
:batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance
v-if="showAttendance"
v-model="showAttendance"
:live_class="attendanceFor"
/>
</template>
<script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: String,
required: true,
},
zoomAccount: String,
})
const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch,
},
fields: [
'title',
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
],
orderBy: 'date',
auto: true,
})
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassStart = (cls) => {
return new Date(`${cls.date}T${cls.time}`)
}
const getClassEnd = (cls) => {
const classStart = getClassStart(cls)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!hasPermission()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.5rem;
line-height: 1.5;
}
</style>
@@ -0,0 +1,65 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add Existing User as Evaluator'),
size: 'md',
actions: [
{
label: __('Add'),
variant: 'solid',
loading: submitting,
onClick: ({ close }: any) => addEvaluator(close),
},
],
}"
>
<template #body-content>
<Link doctype="User" v-model="selectedUser" :label="__('Select User')" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, toast } from 'frappe-ui'
import { ref, watch } from 'vue'
import { cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel<boolean>({ default: false })
const selectedUser = ref('')
const submitting = ref(false)
const emit = defineEmits<{
added: []
}>()
watch(show, (isOpen) => {
if (isOpen) {
selectedUser.value = ''
}
})
const addEvaluator = async (close?: () => void) => {
if (!selectedUser.value?.trim()) {
toast.error(__('Please select a user'))
return
}
submitting.value = true
try {
await call('lms.lms.api.save_role', {
user: selectedUser.value,
role: 'Batch Evaluator',
value: 1,
})
toast.success(__('Evaluator added successfully'))
emit('added')
close?.()
} catch (err: any) {
toast.error(cleanError(err.messages?.[0]) || __('Unable to add evaluator'))
} finally {
submitting.value = false
}
}
</script>
@@ -20,11 +20,15 @@
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
placeholder=" "
@update:modelValue="() => (assessment = null)"
/>
<Link
v-if="assessmentType"
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
placeholder=" "
:onCreate="
(value, close) => {
close()
@@ -49,9 +53,9 @@
</template>
<script setup>
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const assessmentType = ref(null)
@@ -14,7 +14,7 @@
: __('Edit Assignment')
}}
</div>
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
<div class="space-y-4 max-h-[75vh] overflow-y-auto p-1">
<FormControl
v-model="assignment.title"
:label="__('Title')"
@@ -43,7 +43,7 @@
@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"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
@@ -73,7 +73,7 @@
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { escapeHTML, sanitizeHTML } from '@/utils'
import { Link } from 'frappe-ui/frappe'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
@@ -2,8 +2,8 @@
<Dialog
v-model="show"
:options="{
title: __('Add a course'),
size: 'sm',
title: __('Add a course to the batch'),
size: 'lg',
actions: [
{
label: __('Submit'),
@@ -19,6 +19,7 @@
v-model="course"
:label="__('Course')"
:required="true"
:filters="{ published: 1 }"
:onCreate="
(value, close) => {
close()
@@ -40,7 +41,7 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, toast } from 'frappe-ui'
import { Dialog, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { useOnboarding } from 'frappe-ui/frappe'
@@ -62,37 +63,28 @@ const props = defineProps({
},
})
const createBatchCourse = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Batch Course',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
course: course.value,
evaluator: evaluator.value,
},
}
},
})
const addCourse = (close) => {
createBatchCourse.submit(
{},
courses.value.insert.submit(
{
course: course.value,
evaluator: evaluator.value,
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
},
{
onSuccess() {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_course')
close()
courses.value.reload()
course.value = null
evaluator.value = null
toast.success(__('Course added to batch successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.log(err)
},
}
)
@@ -1,146 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ student.full_name }}
</div>
<Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ student.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<div
v-if="Object.keys(student.assessments).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Assessment') }}
</span>
<span>
{{ __('Percentage/Status') }}
</span>
</div>
<router-link
v-for="assessment in Object.keys(student.assessments)"
class="flex items-center text-ink-gray-7 font-medium"
:to="{
name:
student.assessments[assessment].type == 'LMS Assignment'
? 'AssignmentSubmission'
: '',
params:
student.assessments[assessment].type == 'LMS Assignment'
? {
assignmentID:
student.assessments[assessment].assessment,
submissionName:
student.assessments[assessment].submission,
}
: {},
}"
>
<span class="flex-1">
{{ assessment }}
</span>
<span v-if="isAssignment(student.assessments[assessment].status)">
<Badge
:theme="
getStatusTheme(student.assessments[assessment].status)
"
>
{{ student.assessments[assessment].status }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment].status }}
</span>
</router-link>
</div>
<!-- Courses -->
<div
v-if="Object.keys(student.courses).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-ink-gray-7 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div>
<!-- Heatmap -->
<StudentHeatmap :member="student.email" :days="120" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel()
const props = defineProps({
student: {
type: Object,
default: null,
},
})
const isAssignment = (value) => {
return isNaN(value)
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>
@@ -16,7 +16,12 @@
>
<template #body-content>
<div class="space-y-4 text-base">
<FormControl label="Title" v-model="chapter.title" :required="true" />
<FormControl
label="Title"
v-model="chapter.title"
:required="true"
autocomplete="off"
/>
<Switch
size="sm"
:label="__('SCORM Package')"
@@ -26,7 +26,7 @@
@change="(val) => (topic.reply = 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
</div>
@@ -34,17 +34,13 @@
</Dialog>
</template>
<script setup>
import {
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { reactive } from 'vue'
import { singularize } from '@/utils'
import { useTelemetry } from 'frappe-ui/frappe'
const topics = defineModel('reloadTopics')
const { capture } = useTelemetry()
const props = defineProps({
title: {
@@ -66,64 +62,50 @@ const topic = reactive({
reply: '',
})
const topicResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Topic',
reference_doctype: props.doctype,
reference_docname: props.docname,
title: topic.title,
},
}
},
})
const replyResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Discussion Reply',
topic: values.topic,
reply: topic.reply,
},
}
},
})
const submitTopic = (close) => {
topicResource.submit(
{},
{
validate() {
if (!topic.title) {
return 'Title cannot be empty.'
}
if (!topic.reply) {
return 'Reply cannot be empty.'
}
},
onSuccess(data) {
replyResource.submit(
{
topic: data.name,
},
{
onSuccess() {
topic.title = ''
topic.reply = ''
topics.value.reload()
close()
},
}
)
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
if (!topic.title) {
toast.error(__('Title cannot be empty.'))
return
}
if (!topic.reply) {
toast.error(__('Details cannot be empty.'))
return
}
call('frappe.client.insert', {
doc: {
doctype: 'Discussion Topic',
reference_doctype: props.doctype,
reference_docname: props.docname,
title: topic.title,
},
})
.then((data) => {
createReply(data.name, close)
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const createReply = (topicName, close) => {
call('frappe.client.insert', {
doc: {
doctype: 'Discussion Reply',
topic: topicName,
reply: topic.reply,
},
})
.then((data) => {
topic.title = ''
topic.reply = ''
topics.value.reload()
capture('discussion_topic_created')
close()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
</script>
@@ -37,10 +37,12 @@
<FormControl
v-model="profile.first_name"
:label="__('First Name')"
:required="true"
/>
<FormControl
v-model="profile.last_name"
:label="__('Last Name')"
:required="true"
/>
<FormControl v-model="profile.headline" :label="__('Headline')" />
@@ -141,7 +143,25 @@ const updateProfile = createResource({
},
})
const validateMandatoryFields = () => {
let missingFields = []
if (!profile.first_name) missingFields.push(__('First Name'))
if (!profile.last_name) missingFields.push(__('Last Name'))
if (!profile.image) missingFields.push(__('Profile Image'))
if (missingFields.length) {
toast.error(
__('Please fill the mandatory fields: {0}').format(
missingFields.join(', ')
)
)
console.error('Missing mandatory fields:', missingFields)
}
return missingFields.length
}
const saveProfile = () => {
let missingMandatoryFields = validateMandatoryFields()
if (missingMandatoryFields) return
profile.bio = sanitizeHTML(profile.bio)
updateProfile.submit(
{},
@@ -34,10 +34,11 @@
:required="true"
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
/>
<FormControl
<Switch
size="sm"
:description="__('Use HTML content for the email response')"
:label="__('Use HTML')"
v-model="template.use_html"
type="checkbox"
/>
<FormControl
v-if="template.use_html"
@@ -67,7 +68,7 @@
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
)
"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
/>
</div>
</div>
@@ -75,7 +76,7 @@
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { call, Dialog, FormControl, TextEditor, toast, Switch } from 'frappe-ui'
import { reactive, watch } from 'vue'
import { cleanError } from '@/utils'
@@ -88,6 +89,7 @@ const props = defineProps({
const show = defineModel()
const emailTemplates = defineModel('emailTemplates')
const emit = defineEmits(['created'])
const template = reactive({
name: '',
subject: '',
@@ -113,6 +115,7 @@ const createNewTemplate = (close) => {
{
onSuccess() {
emailTemplates.value.reload()
emit('created', template.name)
refreshForm(close)
toast.success(__('Email Template created successfully'))
},
@@ -55,6 +55,9 @@
</div>
</div>
</div>
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
{{ __('Please select a course to view available slots.') }}
</div>
<div v-else class="text-ink-red-3">
{{ __('No slots available for the selected course.') }}
</div>
+6 -2
View File
@@ -122,10 +122,13 @@
</Button>
</div>
<div v-else class="flex flex-col space-y-4 p-5">
<FormControl
type="checkbox"
<Switch
size="sm"
v-model="certificate.published"
:label="__('Published')"
:description="
__('Make this certificate visible to the participant.')
"
:disabled="!userIsEvaluator()"
/>
<Link
@@ -169,6 +172,7 @@ import {
Button,
FormControl,
createResource,
Switch,
Tabs,
Tooltip,
Textarea,
@@ -7,7 +7,7 @@
>
<template #body>
<div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Training Feedback') }}
</div>
<ListView
@@ -29,14 +29,12 @@
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
:required="true"
/>
</Tooltip>
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration (in minutes)')"
:required="true"
/>
</div>
<div class="space-y-4">
<Tooltip
@@ -67,6 +65,7 @@
/>
</div>
<FormControl
v-if="props.conferencingProvider === 'Zoom'"
v-model="liveClass.auto_recording"
type="select"
:options="getRecordingOptions()"
@@ -84,16 +83,10 @@
</Dialog>
</template>
<script setup>
import {
Dialog,
createResource,
Tooltip,
FormControl,
Autocomplete,
toast,
} from 'frappe-ui'
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
import { reactive, inject, onMounted } from 'vue'
import { getTimezones, getUserTimezone } from '@/utils/'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()
@@ -105,10 +98,9 @@ const props = defineProps({
type: String,
required: true,
},
zoomAccount: {
type: String,
required: true,
},
zoomAccount: String,
googleMeetAccount: String,
conferencingProvider: String,
})
let liveClass = reactive({
@@ -165,8 +157,23 @@ const createLiveClass = createResource({
},
})
const createGoogleMeetLiveClass = createResource({
url: 'lms.lms.doctype.lms_batch.lms_batch.create_google_meet_live_class',
makeParams(values) {
return {
batch_name: values.batch,
google_meet_account: props.googleMeetAccount,
...values,
}
},
})
const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, {
const resource =
props.conferencingProvider === 'Google Meet'
? createGoogleMeetLiveClass
: createLiveClass
return resource.submit(liveClass, {
validate() {
validateFormFields()
},
@@ -177,6 +184,7 @@ const submitLiveClass = (close) => {
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
})
}
@@ -0,0 +1,173 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add New Member'),
size: 'lg',
actions: [
{
label: __('Add'),
variant: 'solid',
loading: submitting,
onClick: ({ close }: any) => addMember(close),
},
],
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="member.email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
:required="true"
@keyup.enter="addMember()"
/>
<div class="flex items-center gap-3">
<FormControl
v-model="member.first_name"
:label="__('First Name')"
placeholder="Jane"
type="text"
class="w-full"
/>
<FormControl
v-model="member.last_name"
:label="__('Last Name')"
placeholder="Doe"
type="text"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-ink-gray-5">
{{ __('Roles') }}
</div>
<div class="grid md:grid-cols-2 gap-x-6 gap-y-3">
<Switch
size="sm"
:label="__('Student')"
v-model="roles.lms_student"
/>
<Switch
size="sm"
:label="__('Course Creator')"
v-model="roles.course_creator"
/>
<Switch
size="sm"
:label="__('Evaluator')"
v-model="roles.batch_evaluator"
/>
<Switch
size="sm"
:label="__('Moderator')"
v-model="roles.moderator"
/>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast, Switch } from 'frappe-ui'
import { reactive, ref, watch } from 'vue'
import { cleanError } from '@/utils'
const show = defineModel<boolean>({ default: false })
const submitting = ref(false)
const props = defineProps<{
defaultRoles?: string[]
}>()
const emit = defineEmits<{
created: [user: any]
}>()
const ROLE_MAP: Record<string, string> = {
moderator: 'Moderator',
course_creator: 'Course Creator',
batch_evaluator: 'Batch Evaluator',
lms_student: 'LMS Student',
}
const member = reactive({
email: '',
first_name: '',
last_name: '',
})
const roles = reactive({
moderator: false,
course_creator: false,
batch_evaluator: false,
lms_student: false,
})
const resetForm = () => {
member.email = ''
member.first_name = ''
member.last_name = ''
applyDefaultRoles()
}
const applyDefaultRoles = () => {
roles.moderator = props.defaultRoles?.includes('moderator') ?? false
roles.course_creator = props.defaultRoles?.includes('course_creator') ?? false
roles.batch_evaluator =
props.defaultRoles?.includes('batch_evaluator') ?? false
roles.lms_student = props.defaultRoles?.includes('lms_student') ?? false
}
watch(show, (isOpen) => {
if (isOpen) {
resetForm()
}
})
const assignRoles = async (userEmail: string) => {
const selectedRoles = Object.entries(roles).filter(([_, checked]) => checked)
for (const [key, _] of selectedRoles) {
await call('lms.lms.api.save_role', {
user: userEmail,
role: ROLE_MAP[key],
value: 1,
})
}
}
const addMember = async (close?: () => void) => {
if (!member.email?.trim()) {
toast.error(__('Email is required'))
return
}
submitting.value = true
try {
const user = await call('frappe.client.insert', {
doc: {
doctype: 'User',
email: member.email.trim(),
first_name: member.first_name.trim() || undefined,
last_name: member.last_name.trim() || undefined,
},
})
await assignRoles(user.name)
toast.success(__('Member added successfully'))
emit('created', user)
resetForm()
close?.()
} catch (err: any) {
toast.error(cleanError(err.messages?.[0]) || __('Unable to add member'))
} finally {
submitting.value = false
}
}
</script>
+13 -15
View File
@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
size: '5xl',
size: '3xl',
}"
>
<template #body>
@@ -10,17 +10,14 @@
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
{{ __(props.title) }}
</div>
<div
<Switch
v-if="!editMode"
class="flex items-center text-xs text-ink-gray-7 space-x-5"
>
<Switch
size="sm"
:label="__('Choose an existing question')"
v-model="chooseFromExisting"
class="!p-0"
/>
</div>
size="sm"
:label="__('Choose an existing question')"
:description="__('Select from questions you have already created')"
v-model="chooseFromExisting"
class="!p-0"
/>
<div v-if="!chooseFromExisting || editMode">
<div>
<label class="block text-xs text-ink-gray-5 mb-1">
@@ -31,7 +28,7 @@
@change="(val) => (question.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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="grid grid-cols-2 gap-8 mt-4">
@@ -75,10 +72,11 @@
:label="__('Explanation')"
v-model="question[`explanation_${n}`]"
/>
<FormControl
<Switch
size="sm"
:label="__('Correct Answer')"
:description="__('Mark this option as a correct answer.')"
v-model="question[`is_correct_${n}`]"
type="checkbox"
/>
</div>
</div>
@@ -164,7 +162,7 @@ populateFields()
const props = defineProps({
title: {
type: String,
default: __('Add a new question'),
default: __('Add new question'),
},
questionDetail: {
type: [Object, null],
+37 -28
View File
@@ -2,8 +2,8 @@
<Dialog
v-model="show"
:options="{
title: __('Add a Student'),
size: 'sm',
title: __('Enroll a Student'),
size: 'lg',
actions: [
{
label: 'Submit',
@@ -18,10 +18,24 @@
<Link
doctype="User"
v-model="student"
:filters="{ ignore_user_type: 1 }"
placeholder=" "
:label="__('Student')"
:onCreate="
(value, close) => {
openSettings('Members', close)
() => {
openSettings('Members')
show = false
}
"
:required="true"
/>
<Link
doctype="LMS Payment"
v-model="payment"
placeholder=" "
:label="__('Payment')"
:onCreate="
() => {
openSettings('Transactions')
show = false
}
"
@@ -31,54 +45,49 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, toast } from 'frappe-ui'
import { call, Dialog, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const students = defineModel('reloadStudents')
const batchModal = defineModel('batchModal')
const student = ref()
const student = ref(null)
const payment = ref(null)
const user = inject('$user')
const { updateOnboardingStep } = useOnboarding('learning')
const show = defineModel()
const props = defineProps({
batch: {
type: String,
type: Object,
default: null,
},
students: {
type: Object,
default: null,
},
})
const studentResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Batch Enrollment',
batch: props.batch,
member: student.value,
},
}
},
})
const addStudent = (close) => {
studentResource.submit(
{},
props.students.insert.submit(
{
member: student.value,
payment: payment.value,
batch: props.batch.data?.name,
},
{
onSuccess() {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student')
students.value.reload()
batchModal.value.reload()
student.value = null
payment.value = null
props.batch.reload()
close()
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
@@ -3,7 +3,7 @@
v-model="show"
:options="{
size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle),
title: __('Video Statistics'),
}"
>
<template #body-content>
@@ -21,17 +21,22 @@
class="mt-2 mr-5 w-[25%]"
/> -->
</div>
<div v-if="currentTab" class="mt-4">
<div
v-if="currentTab"
:class="{
'mt-5': tabs.length > 1,
}"
>
<div class="grid grid-cols-[55%,40%] gap-5">
<div
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
class="space-y-5 border rounded-md p-2 pt-4 max-h-[70vh] overflow-y-auto"
>
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4">
{{ __('Member') }}
</div>
<div class="text-center">
{{ __('Watch Time') }}
{{ __('Watch Time (mins)') }}
</div>
</div>
<div
@@ -68,15 +73,16 @@
</div>
</div>
<div class="space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Watch Time'),
value: averageWatchTime,
}"
<NumberChartGraph
:title="__('Average Watch Time (mins)')"
:value="averageWatchTime"
/>
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
<div
class="video-player"
:data-plyr-provider="provider"
:src="currentTab"
></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
@@ -101,6 +107,7 @@ import {
import { computed, ref, watch } from 'vue'
import { enablePlyr, formatTimestamp } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
@@ -171,7 +178,7 @@ watch(show, () => {
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
statistics.data?.forEach((item: { source: string }) => {
if (!grouped[item.source]) {
grouped[item.source] = []
}
@@ -18,10 +18,13 @@
>
<template #body-content>
<div class="mb-4">
<FormControl
<Switch
size="sm"
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
:description="
__('Activate this Zoom account for scheduling meetings.')
"
/>
</div>
<div class="grid grid-cols-2 gap-5">
@@ -61,7 +64,7 @@
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
@@ -109,16 +112,14 @@ const account = reactive({
client_secret: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
const props = defineProps<{
accountID: string | null
}>()
watch(
() => props.accountID,
(val) => {
console.log(props.accountID)
if (val === 'new') {
account.name = ''
account.enabled = false
+1 -1
View File
@@ -5,7 +5,7 @@
</div>
<div class="flex items-center space-x-2">
<slot name="prefix" />
<div class="font-semibold text-2xl">
<div class="font-semibold text-ink-gray-9 text-2xl">
{{ value }}
</div>
<slot name="suffix" />
+352 -90
View File
@@ -1,56 +1,72 @@
<template>
<div v-if="quiz.data">
<div
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
class="bg-surface-blue-2 text-ink-blue-3 space-y-2 p-3 mb-4 rounded-lg leading-5"
>
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</div>
<div class="leading-5">
{{
__('This quiz consists of {0} questions.').format(questions.length)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
<div class="font-medium">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</div>
<div v-if="quiz.data?.duration" class="leading-5">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</div>
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
{{
__(
'You will have to get {0}% correct answers in order to pass the quiz.'
).format(quiz.data.passing_percentage)
}}
</div>
<div v-if="quiz.data.max_attempts" class="leading-5">
{{
__('You can attempt this quiz {0}.').format(
quiz.data.max_attempts == 1
? '1 time'
: `${quiz.data.max_attempts} times`
)
}}
</div>
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
{{
__(
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
).format(
quiz.data.marks_to_cut,
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
'Please read the following instructions carefully before starting the quiz'
)
}}
</div>
<ol class="list-decimal list-inside space-y-2">
<li v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</li>
<li>
{{
__(
'Do not refresh the page or close this window. If you do, the quiz will be submitted automatically.'
)
}}
</li>
<li>
{{
__('This quiz consists of {0} questions.').format(questions.length)
}}
</li>
<li v-if="quiz.data?.duration">
{{
__(
'Please ensure that you complete all the questions in {0} minutes.'
).format(quiz.data.duration)
}}
</li>
<li v-if="quiz.data?.duration">
{{
__(
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
)
}}
</li>
<li v-if="quiz.data.passing_percentage">
{{
__(
'You will have to get {0}% correct answers in order to pass the quiz.'
).format(quiz.data.passing_percentage)
}}
</li>
<li v-if="quiz.data.max_attempts">
{{
__('You can attempt this quiz {0}.').format(
quiz.data.max_attempts == 1
? '1 time'
: `${quiz.data.max_attempts} times`
)
}}
</li>
<li v-if="quiz.data.enable_negative_marking">
{{
__(
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
).format(
quiz.data.marks_to_cut,
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
)
}}
</li>
</ol>
</div>
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
@@ -104,16 +120,12 @@
<div v-for="(question, qtidx) in questions">
<div
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
class="border rounded-md p-5"
class="border rounded-lg p-5"
>
<div class="flex justify-between">
<div class="text-sm text-ink-gray-5">
<span class="mr-2">
{{ __('Question {0}').format(activeQuestion) }}:
</span>
<span>
{{ getInstructions(questionDetails.data) }}
</span>
{{ __('Question {0}').format(activeQuestion) }} -
{{ getInstructions(questionDetails.data) }}
</div>
<div class="text-ink-gray-9 text-sm font-semibold item-left">
{{ question.marks }}
@@ -135,6 +147,7 @@
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-ink-gray-9 focus:ring-outline-gray-modals"
@change="markAnswer(index)"
:checked="selectedOptions[index - 1]"
/>
<input
@@ -143,6 +156,7 @@
:name="encodeURIComponent(questionDetails.data.question)"
class="w-3.5 h-3.5 text-ink-gray-9 rounded-sm focus:ring-outline-gray-modals"
@change="markAnswer(index)"
:checked="selectedOptions[index - 1]"
/>
<div
v-else-if="quiz.data.show_answers"
@@ -205,17 +219,64 @@
@change="(val) => (possibleAnswer = 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]"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
/>
</div>
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-ink-gray-5">
<div class="flex items-center justify-between mt-8">
<Checkbox
:label="__('Mark for review')"
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
@change="markForReview($event, activeQuestion)"
/>
<!-- <div class="text-sm text-ink-gray-5">
{{
__('Question {0} of {1}').format(
activeQuestion,
questions.length
)
}}
</div> -->
<div
v-if="!quiz.data.show_answers"
class="flex items-center space-x-2"
>
<Button
@click="switchQuestion(activeQuestion - 1)"
:disabled="activeQuestion == 1"
class="rounded-full"
>
<template #icon>
<ChevronLeft class="size-4 stroke-1.5" />
</template>
</Button>
<span
v-for="item in paginationWindow"
:key="item"
class="w-6 h-6 rounded-full flex items-center justify-center text-sm"
:class="{
'cursor-pointer': item !== '...',
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
activeQuestion == item,
'bg-surface-gray-3 text-ink-gray-6':
activeQuestion != item && item !== '...',
'text-ink-gray-5': item === '...',
'bg-surface-blue-3 text-ink-white':
attemptedQuestions.includes(item) && activeQuestion != item,
}"
@click="item !== '...' && switchQuestion(item)"
>
{{ item }}
</span>
<Button
@click="switchQuestion(activeQuestion + 1)"
:disabled="activeQuestion == questions.length"
class="rounded-full"
>
<template #icon>
<ChevronRight class="size-4 stroke-1.5" />
</template>
</Button>
</div>
<Button
v-if="
@@ -230,14 +291,16 @@
</span>
</Button>
<Button
v-else-if="activeQuestion != questions.length"
v-else-if="
activeQuestion != questions.length && quiz.data.show_answers
"
@click="nextQuestion()"
>
<span>
{{ __('Next') }}
</span>
</Button>
<Button v-else @click="submitQuiz()">
<Button variant="solid" v-else @click="handleSubmitClick()">
<span>
{{ __('Submit') }}
</span>
@@ -245,8 +308,22 @@
</div>
</div>
</div>
<div v-if="reviewQuestions.length" class="border rounded-lg p-4 mt-4">
<div class="font-semibold">
{{ __('Questions marked for review') }}
</div>
<div class="flex items-center space-x-2 mt-2">
<div
v-for="index in reviewQuestions"
@click="activeQuestion = index"
class="w-6 h-6 rounded-full flex items-center justify-center text-sm cursor-pointer bg-surface-gray-3"
>
{{ index }}
</div>
</div>
</div>
</div>
<div v-else class="border rounded-md p-20 text-center space-y-2">
<div v-else class="border rounded-lg p-20 text-center space-y-2">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Quiz Summary') }}
</div>
@@ -310,30 +387,96 @@
</ListView>
</div>
</div>
<Dialog
v-model="showSubmissionConfirmation"
:options="{
title: __('Are you sure you want to submit the quiz?'),
actions: [
{
size: 'sm',
label: __('Submit'),
variant: 'solid',
onClick() {
submitQuiz()
showSubmissionConfirmation = false
},
},
],
}"
>
<template #body-content>
<div class="border border-outline-gray-modals rounded-lg text-base">
<div class="divide-y divide-outline-gray-modals">
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Total Questions') }}
</div>
<div class="p-2">
{{ questions.length }}
</div>
</div>
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Attempted Questions') }}
</div>
<div class="p-2">
{{ attemptedQuestions.length }}
</div>
</div>
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
<div class="p-2">
{{ __('Unattempted Questions') }}
</div>
<div class="p-2">
{{ questions.length - attemptedQuestions.length }}
</div>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import {
Badge,
Button,
call,
Checkbox,
createResource,
Dialog,
ListView,
TextEditor,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch, reactive, inject, computed } from 'vue'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
import {
computed,
inject,
onMounted,
onUnmounted,
reactive,
ref,
watch,
} from 'vue'
import {
CheckCircle,
ChevronLeft,
ChevronRight,
XCircle,
MinusCircle,
} from 'lucide-vue-next'
import { timeAgo } from '@/utils'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const user = inject('$user')
const activeQuestion = ref(0)
const currentQuestion = ref('')
const selectedOptions = reactive([0, 0, 0, 0])
const selectedOptions = ref([0, 0, 0, 0])
const showAnswers = reactive([])
let questions = reactive([])
const attemptedQuestions = ref([])
const reviewQuestions = ref([])
const showSubmissionConfirmation = ref(false)
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
@@ -353,6 +496,40 @@ const props = defineProps({
},
})
onMounted(() => {
window.addEventListener('pagehide', handlePageHide)
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('pagehide', handlePageHide)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const handlePageHide = () => {
if (activeQuestion.value > 0 && !quizSubmission.data) {
const params = new URLSearchParams({
quiz: quiz.data.name,
results: localStorage.getItem(quiz.data.title),
})
navigator.sendBeacon(
'/api/method/lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz?' +
params.toString()
)
}
}
const handleBeforeUnload = (event) => {
if (activeQuestion.value > 0 && !quizSubmission.data) {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
}
event.preventDefault()
event.returnValue = ''
}
}
const quiz = createResource({
url: 'frappe.client.get',
makeParams(values) {
@@ -465,7 +642,7 @@ watch(
)
const quizSubmission = createResource({
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
url: 'lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz',
makeParams(values) {
return {
quiz: quiz.data.name,
@@ -486,10 +663,58 @@ const questionDetails = createResource({
watch(activeQuestion, (value) => {
if (value > 0) {
currentQuestion.value = quiz.data.questions[value - 1].question
questionDetails.reload()
questionDetails.reload(
{},
{
onSuccess() {
if (!quiz.data.show_answers) {
loadSavedAnswers()
}
},
}
)
}
})
const switchQuestion = (questionNumber) => {
let answers = getAnswers()
if (answers.length) {
if (!attemptedQuestions.value.includes(activeQuestion.value)) {
attemptedQuestions.value.push(activeQuestion.value)
}
addToLocalStorage()
resetQuestion()
}
if (questionNumber < 1 || questionNumber > questions.length) return
activeQuestion.value = questionNumber
}
const loadSavedAnswers = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
if (quizData) {
let localQuestion = quizData.find(
(q) => q.question_name == currentQuestion.value
)
if (localQuestion) {
let localAnswers = localQuestion.answer
if (localAnswers.length) {
if (questionDetails.data.type == 'Choices') {
localAnswers.forEach((answer) => {
for (let i = 1; i <= 4; i++) {
if (questionDetails.data[`option_${i}`] == answer) {
selectedOptions.value[i - 1] = 1
}
}
})
} else {
possibleAnswer.value = localAnswers[0]
}
}
}
}
}
watch(
() => props.quizName,
(newName) => {
@@ -507,17 +732,20 @@ const startQuiz = () => {
const markAnswer = (index) => {
if (!questionDetails.data.multiple)
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
selectedOptions.value.splice(
0,
selectedOptions.value.length,
...[0, 0, 0, 0]
)
selectedOptions.value[index - 1] = selectedOptions.value[index - 1] ? 0 : 1
}
const getAnswers = () => {
let answers = []
const type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((value, index) => {
if (selectedOptions[index])
selectedOptions.value.forEach((value, index) => {
if (selectedOptions.value[index])
answers.push(questionDetails.data[`option_${index + 1}`])
})
} else {
@@ -538,14 +766,14 @@ const checkAnswer = () => {
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
params: {
question: currentQuestion.value,
type: questionDetails.data.type,
question_type: questionDetails.data.type,
answers: JSON.stringify(answers),
},
auto: true,
onSuccess(data) {
let type = questionDetails.data.type
if (type == 'Choices') {
selectedOptions.forEach((option, index) => {
selectedOptions.value.forEach((option, index) => {
if (option) {
showAnswers[index] = option && data[index]
} else if (data[index] == 2) {
@@ -569,17 +797,15 @@ const addToLocalStorage = () => {
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
let questionData = {
question_name: currentQuestion.value,
answer: getAnswers().join(),
is_correct: showAnswers.filter((answer) => {
return answer != undefined
}),
answer: getAnswers(),
}
if (quizData) {
let existingQuestion = quizData.find(
(q) => q.question_name == questionData.question_name
)
if (!existingQuestion) {
if (existingQuestion) {
existingQuestion.answer = questionData.answer
} else {
quizData.push(questionData)
}
} else {
@@ -589,18 +815,15 @@ const addToLocalStorage = () => {
}
const nextQuestion = () => {
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
checkAnswer()
} else {
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
if (!quiz.data.show_answers) return
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
resetQuestion()
}
const resetQuestion = () => {
if (activeQuestion.value == quiz.data.questions.length) return
activeQuestion.value = activeQuestion.value + 1
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
showAnswers.length = 0
possibleAnswer.value = null
}
@@ -608,7 +831,6 @@ const resetQuestion = () => {
const submitQuiz = () => {
if (!quiz.data.show_answers) {
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
else checkAnswer()
setTimeout(() => {
createSubmission()
}, 500)
@@ -642,8 +864,10 @@ const createSubmission = () => {
const resetQuiz = () => {
activeQuestion.value = 0
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
showAnswers.length = 0
possibleAnswer.value = null
attemptedQuestions.value = []
quizSubmission.reset()
populateQuestions()
setupTimer()
@@ -672,6 +896,49 @@ const markLessonProgress = () => {
}
}
const handleSubmitClick = () => {
if (attemptedQuestions.value.length) {
switchQuestion(activeQuestion.value)
}
showSubmissionConfirmation.value = true
}
const paginationWindow = computed(() => {
const total = questions.length
const current = activeQuestion.value
const pages = []
const size = 5
let start = Math.floor((current - 1) / size) * size + 1
let end = Math.min(start + size - 1, total)
if (start > 1) {
pages.push('...')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < total) {
pages.push('...')
}
return pages
})
const markForReview = (event, questionNumber) => {
if (event.target.checked) {
if (!reviewQuestions.value.includes(questionNumber)) {
reviewQuestions.value.push(questionNumber)
}
} else {
reviewQuestions.value = reviewQuestions.value.filter(
(num) => num !== questionNumber
)
}
}
const getSubmissionColumns = () => {
return [
{
@@ -700,8 +967,3 @@ const getSubmissionColumns = () => {
]
}
</script>
<style>
p {
line-height: 1.5rem;
}
</style>
@@ -9,11 +9,7 @@
<template #body-content>
<div class="grid grid-cols-2 gap-x-5">
<div class="space-y-4">
<FormControl
v-model="badge.enabled"
:label="__('Enabled')"
type="checkbox"
/>
<Switch size="sm" v-model="badge.enabled" :label="__('Enabled')" />
<FormControl
v-model="badge.title"
:label="__('Title')"
@@ -41,10 +37,11 @@
</div>
<div class="space-y-4">
<FormControl
<Switch
size="sm"
v-model="badge.grant_only_once"
:label="__('Grant Only Once')"
type="checkbox"
:description="__('Each user can only receive this badge one time.')"
/>
<FormControl
v-model="badge.event"
@@ -82,7 +79,7 @@
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { Button, call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { cleanError } from '@/utils'
import type { Badges, Badge } from '@/components/Settings/types'
@@ -206,7 +203,7 @@ const referenceDoctypeOptions = computed(() => {
})
const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign']
let options = ['New', 'Value Change', 'Manual Assignment']
return options.map((event) => ({ label: __(event), value: event }))
})
@@ -1,9 +1,14 @@
<template>
<div class="flex flex-col h-full">
<div class="flex flex-col h-full text-p-base">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
<div class="space-y-2">
<div class="font-semibold text-xl text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="space-x-2">
<Badge
@@ -21,9 +26,6 @@
</Button>
</div>
</div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :sections="sections" :data="branding.data" />
@@ -45,7 +45,7 @@
</div>
<div class="overflow-y-scroll">
<div class="divide-y space-y-2">
<div class="divide-y divide-outline-gray-modals space-y-2">
<div
v-for="(cat, index) in categories.data"
:key="cat.name"
@@ -53,9 +53,9 @@
>
<div
v-if="editing?.name !== cat.name"
class="flex items-center justify-between group text-sm"
class="flex items-center justify-between group text-sm text-ink-gray-9"
>
<div @dblclick="allowEdit(cat, index)">
<div class="text-ink-gray-9" @dblclick="allowEdit(cat, index)">
{{ cat.category }}
</div>
<Button
@@ -11,10 +11,11 @@
</div>
<div class="space-y-4 overflow-y-auto">
<div>
<FormControl
<Switch
size="sm"
v-model="data.enabled"
:label="__('Enabled')"
type="checkbox"
:description="__('Allow this coupon to be used for discounts.')"
/>
</div>
<div class="grid grid-cols-2 gap-4">
@@ -81,7 +82,7 @@
</div>
</template>
<script setup lang="ts">
import { Button, FormControl, toast } from 'frappe-ui'
import { Button, FormControl, toast, Switch } from 'frappe-ui'
import { ref } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
import type { Coupon, Coupons } from './types'
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
+56 -60
View File
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -10,17 +10,49 @@
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
<Dropdown
placement="right"
side="bottom"
:options="[
{
label: __('New Evaluator'),
icon: 'user-plus',
onClick() {
showNewEvaluator = true
},
},
{
label: __('Existing User'),
icon: 'user-check',
onClick() {
showExistingUser = true
},
},
]"
>
<template v-slot="{ open }">
<Button variant="solid">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('New') }}
<template #suffix>
<ChevronDown
:class="[
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
open ? 'rotate-180' : '',
]"
/>
</template>
</Button>
</template>
{{ __('New') }}
</Button>
</Dropdown>
</div>
</div>
<div class="mt-8 pb-5">
<FormControl
v-if="evaluators.data?.length > 0 || search"
v-model="search"
:placeholder="__('Search')"
type="text"
@@ -31,8 +63,8 @@
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-auto h-[60vh]">
<div class="divide-y">
<div class="overflow-auto max-h-[60vh]">
<div class="divide-y divide-outline-gray-modals">
<div
v-for="evaluator in evaluators.data"
:key="evaluator.evaluator"
@@ -70,11 +102,8 @@
</div>
</div>
</div>
<div
v-if="evaluators.length && hasNextPage"
class="flex justify-center mt-4"
>
<Button @click="evaluators.reload()">
<div v-if="evaluators.hasNextPage" class="flex justify-center mt-4">
<Button @click="evaluators.next()">
<template #prefix>
<RefreshCw class="h-3 w-3 stroke-1.5" />
</template>
@@ -84,33 +113,12 @@
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
size: 'xl',
title: __('Add Evaluator'),
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addEvaluator(close)
},
}]
}"
>
<template #body-content>
<div v-if="showForm" class="flex items-center">
<FormControl
v-model="email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
@keydown.enter="addEvaluator"
/>
</div>
</template>
</Dialog>
<AddEvaluatorModal v-model="showExistingUser" @added="evaluators.reload()" />
<NewMemberModal
v-model="showNewEvaluator"
:defaultRoles="['batch_evaluator']"
@created="onMemberCreated"
/>
</template>
<script setup lang="ts">
import {
@@ -118,18 +126,19 @@ import {
Button,
call,
createListResource,
Dialog,
Dropdown,
FormControl,
toast,
} from 'frappe-ui'
import { ref, watch } from 'vue'
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
import { Plus, Search, Trash2, RefreshCw, ChevronDown } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
import AddEvaluatorModal from '@/components/Modals/AddEvaluatorModal.vue'
const show = defineModel('show')
const search = ref('')
const showForm = ref(false)
const email = ref('')
const showExistingUser = ref(false)
const showNewEvaluator = ref(false)
const router = useRouter()
const props = defineProps({
@@ -150,20 +159,8 @@ const evaluators = createListResource({
orderBy: 'creation desc',
})
const addEvaluator = (close: () => void) => {
call('lms.lms.api.add_an_evaluator', {
email: email.value,
})
.then(() => {
email.value = ''
evaluators.reload()
toast.success(__('Evaluator added successfully'))
close()
})
.catch((error: any) => {
toast.error(__(error.messages[0] || error.messages))
console.error('Error adding evaluator:', error)
})
const onMemberCreated = () => {
evaluators.reload()
}
watch(search, () => {
@@ -176,7 +173,6 @@ watch(search, () => {
})
const openProfile = (username: string) => {
show.value = false
router.push({
name: 'Profile',
params: {
@@ -0,0 +1,200 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new'
? __('New Google Meet Account')
: __('Edit Google Meet Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<Switch
size="sm"
v-model="account.enabled"
:label="__('Enabled')"
:description="
__('Activate this Google Meet account for scheduling meetings.')
"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
:required="true"
/>
<Link
v-model="account.google_calendar"
:label="__('Google Calendar')"
doctype="Google Calendar"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import { useTelemetry } from 'frappe-ui/frappe'
interface GoogleMeetAccount {
name: string
account_name: string
enabled: boolean
member: string
google_calendar: string
}
interface GoogleMeetAccounts {
data: GoogleMeetAccount[]
reload: () => void
insert: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
setValue: {
submit: (
data: GoogleMeetAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const googleMeetAccounts = defineModel<GoogleMeetAccounts>('googleMeetAccounts')
const { capture } = useTelemetry()
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
google_calendar: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val === 'new') {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.google_calendar = ''
} else if (val && val !== 'new') {
const acc = googleMeetAccounts.value?.data.find((acc) => acc.name === val)
if (acc) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.google_calendar = acc.google_calendar
}
}
}
)
const saveAccount = (close: () => void) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close: () => void) => {
googleMeetAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
capture('google_meet_account_linked')
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account created successfully'))
},
onError(err) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error creating Google Meet Account')
)
},
}
)
}
const updateAccount = async (close: () => void) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Google Meet Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close: () => void) => {
googleMeetAccounts.value?.setValue.submit(
{
...account,
name: account.name,
account_name: props.accountID,
},
{
onSuccess() {
googleMeetAccounts.value?.reload()
close()
toast.success(__('Google Meet Account updated successfully'))
},
onError(err: any) {
console.error(err)
close()
toast.error(
cleanError(err.messages[0]) ||
__('Error updating Google Meet Account')
)
},
}
)
}
</script>
@@ -0,0 +1,202 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="googleMeetAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in googleMeetAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="green">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<GoogleMeetAccountModal
v-model="showForm"
v-model:googleMeetAccounts="googleMeetAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import GoogleMeetAccountModal from '@/components/Settings/GoogleMeetAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const googleMeetAccounts = createListResource({
doctype: 'LMS Google Meet Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'member_image',
'google_calendar',
],
cache: ['googleMeetAccounts'],
})
onMounted(() => {
fetchGoogleMeetAccounts()
})
const fetchGoogleMeetAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
googleMeetAccounts.update({
filters: {
member: user.data.name,
},
})
}
googleMeetAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Google Meet Settings',
documents: Array.from(selections),
})
.then(() => {
googleMeetAccounts.reload()
toast.success(__('Google Meet Account deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting Google Meet Account')
)
})
}
const columns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
icon: 'user',
},
{
label: __('Account Name'),
key: 'name',
icon: 'video',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
icon: 'check-square',
},
]
})
</script>
+18 -75
View File
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -10,7 +10,7 @@
</div>
</div>
<div class="flex item-center space-x-2">
<Button variant="solid" @click="() => (showForm = !showForm)">
<Button @click="showNewMember = true">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
@@ -31,8 +31,8 @@
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
</template>
</FormControl>
<div class="overflow-y-scroll h-[60vh]">
<ul class="divide-y">
<div class="overflow-y-scroll max-h-[60vh]">
<ul class="divide-y divide-outline-gray-modals">
<li
v-for="member in memberList"
class="flex items-center justify-between py-2 cursor-pointer"
@@ -58,7 +58,7 @@
</div>
</div>
<div
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
class="flex items-center text-ink-gray-9 space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
v-if="member.role && member.role !== 'LMS Student'"
>
<Shield class="size-4 stroke-1.5" />
@@ -82,47 +82,16 @@
</div>
</div>
</div>
<Dialog
v-model="showForm"
:options="{
title: __('Add a new member'),
size: 'lg',
actions: [{
label: __('Add'),
variant: 'solid',
onClick({ close }: any) {
addMember(close)
}
}]
}"
>
<template #body-content>
<div class="flex items-center space-x-2">
<FormControl
v-model="member.email"
:label="__('Email')"
placeholder="jane@doe.com"
type="email"
class="w-full"
/>
<FormControl
v-model="member.first_name"
:label="__('First Name')"
placeholder="Jane"
type="text"
class="w-full"
/>
</div>
</template>
</Dialog>
<NewMemberModal v-model="showNewMember" @created="onMemberCreated" />
</template>
<script setup lang="ts">
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
import { Avatar, Button, createResource, FormControl } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue'
import { ref, watch, inject } from 'vue'
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
type Member = {
username: string
@@ -138,14 +107,10 @@ const search = ref('')
const start = ref(0)
const memberList = ref<Member[]>([])
const hasNextPage = ref(false)
const showForm = ref(false)
const showNewMember = ref(false)
const user = inject<User | null>('$user')
const { updateOnboardingStep } = useOnboarding('learning')
const member = reactive({
email: '',
first_name: '',
})
const { capture } = useTelemetry()
const props = defineProps({
label: {
@@ -184,34 +149,12 @@ const openProfile = (username: string) => {
})
}
const newMember = createResource({
url: 'frappe.client.insert',
makeParams() {
return {
doc: {
doctype: 'User',
first_name: member.first_name,
email: member.email,
},
}
},
auto: false,
onSuccess(data: Member) {
show.value = false
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
router.push({
name: 'ProfileRoles',
params: {
username: data.username,
},
})
},
})
const addMember = (close: () => void) => {
newMember.reload()
close()
const onMemberCreated = (data: any) => {
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
capture('user_added')
memberList.value = []
start.value = 0
members.reload()
}
watch(search, () => {
@@ -131,7 +131,7 @@ watch(newGateway, () => {
let fields = gatewayFields.data || []
arrangeFields(fields)
newGatewayFields.value = makeSections(fields)
prepareGatewayData()
prepareGatewayData(fields)
})
})
@@ -209,13 +209,11 @@ const allGatewayOptions = computed(() => {
return options.map((gateway: string) => ({ label: gateway, value: gateway }))
})
const prepareGatewayData = () => {
const prepareGatewayData = (fields: any[]) => {
newGatewayData.value = {}
if (newGatewayFields.value.length) {
newGatewayFields.value.forEach((field: any) => {
newGatewayData.value[field.fieldname] = field.default || ''
})
}
fields.forEach((field: any) => {
newGatewayData.value[field.name] = field.default || ''
})
}
const makeSections = (fields: any[]) => {
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -88,6 +88,7 @@
import {
Badge,
Button,
call,
createListResource,
FeatherIcon,
ListView,
@@ -97,10 +98,12 @@ import {
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import PaymentGatewayDetails from '@/components/Settings/PaymentGatewayDetails.vue'
import { cleanError } from '@/utils'
const showForm = ref(false)
const currentGateway = ref(null)
@@ -128,6 +131,23 @@ const openForm = (gatewayID) => {
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'Payment Gateway',
documents: Array.from(selections),
})
.then(() => {
paymentGateways.reload()
toast.success(__('Payment gateways deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting payment gateways')
)
})
}
const columns = computed(() => {
return [
{
@@ -2,23 +2,25 @@
<div class="flex flex-col h-full text-base overflow-y-hidden">
<div class="">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold leading-none text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<div class="space-x-2">
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
@@ -1,5 +1,5 @@
<template>
<div class="mb-5 divide-y overflow-y-auto">
<div class="mb-5 divide-y divide-outline-gray-modals overflow-y-auto">
<div v-for="(section, index) in sections" class="py-5">
<div v-if="section.label" class="font-semibold text-ink-gray-9 mb-4">
{{ section.label }}
@@ -20,6 +20,7 @@
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
:required="field.reqd"
/>
<div v-else-if="field.type == 'Code'">
@@ -65,7 +66,7 @@
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
>
<img
@@ -90,7 +91,7 @@
</div>
<X
@click="data[field.name] = null"
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"
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
@@ -115,6 +116,7 @@
:rows="field.rows"
:options="field.options"
:description="field.description"
:required="field.reqd"
placeholder=""
/>
</div>
+109 -38
View File
@@ -42,8 +42,7 @@
...(activeTab.label == 'Branding'
? { sections: activeTab.sections }
: {}),
...(activeTab.label == 'Evaluators' ||
activeTab.label == 'Members' ||
...(activeTab.label == 'Members' ||
activeTab.label == 'Transactions'
? { 'onUpdate:show': (val) => (show = val), show }
: {}),
@@ -76,6 +75,7 @@ import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
import GoogleMeetSettings from '@/components/Settings/GoogleMeetSettings.vue'
import Badges from '@/components/Settings/Badges.vue'
const show = defineModel()
@@ -219,6 +219,25 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Jobs',
columns: [
{
fields: [
{
label: 'Allow Job Posting',
name: 'allow_job_posting',
type: 'checkbox',
description:
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
},
],
},
{
fields: [],
},
],
},
{
label: '',
columns: [
@@ -249,34 +268,6 @@ const tabsStructure = computed(() => {
},
],
},
],
},
{
label: 'Lists',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'UserRoundPlus',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCheck',
description:
'Add new evaluators or check the slots existing evaluators',
template: markRaw(Evaluators),
},
{
label: 'Zoom Accounts',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Badges',
description:
@@ -298,6 +289,27 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Users',
hideLabel: false,
items: [
{
label: 'Members',
description:
'Add new members or manage roles and permissions of existing members',
icon: 'User',
template: markRaw(Members),
},
{
label: 'Evaluators',
description: '',
icon: 'UserCircle2',
description:
'Add new evaluators or check the slots of existing evaluators',
template: markRaw(Evaluators),
},
],
},
{
label: 'Payment',
hideLabel: false,
@@ -318,29 +330,62 @@ const tabsStructure = computed(() => {
doctype: 'Currency',
},
{
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
type: 'checkbox',
description:
'If enabled, it shows the USD equivalent amount for all transactions based on the current exchange rate.',
},
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
type: 'checkbox',
description:
'If enabled, it applies rounding on the USD equivalent amount.',
},
],
},
{
fields: [
{
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
},
{
label: 'Apply GST for India',
name: 'apply_gst',
type: 'checkbox',
description:
'If enabled, GST will be applied to the price for students from India.',
},
],
},
],
},
{
label: 'Payment Reminders',
columns: [
{
fields: [
{
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
label: 'Send payment reminders for batch',
name: 'send_payment_reminders_for_batch',
type: 'checkbox',
description:
'If enabled, it sends payment reminders to students who left the payment incomplete for a batch.',
},
],
},
{
fields: [
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
label: 'Send payment reminders for course',
name: 'send_payment_reminders_for_course',
type: 'checkbox',
description:
'If enabled, it sends payment reminders to students who left the payment incomplete for a course.',
},
],
},
@@ -368,6 +413,26 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Conferencing',
hideLabel: false,
items: [
{
label: 'Zoom',
description:
'Manage zoom accounts to conduct live classes from batches',
icon: 'Video',
template: markRaw(ZoomSettings),
},
{
label: 'Google Meet',
description:
'Manage Google Meet accounts to conduct live classes from batches',
icon: 'Presentation',
template: markRaw(GoogleMeetSettings),
},
],
},
{
label: 'Customize',
hideLabel: false,
@@ -375,6 +440,8 @@ const tabsStructure = computed(() => {
{
label: 'Branding',
icon: 'Blocks',
description:
'Customize the brand name and logo to make the application your own',
template: markRaw(BrandSettings),
sections: [
{
@@ -463,6 +530,8 @@ const tabsStructure = computed(() => {
{
label: 'Signup',
icon: 'LogIn',
description:
'Manage the settings related to user signup and registration',
sections: [
{
columns: [
@@ -498,6 +567,8 @@ const tabsStructure = computed(() => {
{
label: 'SEO',
icon: 'Search',
description:
'Manage the SEO settings to improve your website ranking on search engines',
sections: [
{
columns: [
@@ -32,14 +32,16 @@
</div>
<div v-if="transactionData" class="overflow-y-auto">
<div class="grid grid-cols-3 gap-5">
<FormControl
<Switch
size="sm"
:label="__('Payment Received')"
type="checkbox"
:description="__('Mark the payment as received.')"
v-model="transactionData.payment_received"
/>
<FormControl
<Switch
size="sm"
:label="__('Payment For Certificate')"
type="checkbox"
:description="__('This payment is for a certificate.')"
v-model="transactionData.payment_for_certificate"
/>
<FormControl
@@ -55,17 +57,18 @@
:label="__('Member')"
doctype="User"
v-model="transactionData.member"
:required="true"
:required="!!fieldMeta.member?.reqd"
/>
<FormControl
:label="__('Billing Name')"
v-model="transactionData.billing_name"
:required="true"
:required="!!fieldMeta.billing_name?.reqd"
/>
<Link
:label="__('Source')"
v-model="transactionData.source"
doctype="LMS Source"
:required="!!fieldMeta.source?.reqd"
/>
<FormControl
type="select"
@@ -73,12 +76,14 @@
:label="__('Payment For Document Type')"
v-model="transactionData.payment_for_document_type"
doctype="DocType"
:required="!!fieldMeta.payment_for_document_type?.reqd"
/>
<Link
v-if="transactionData.payment_for_document_type"
:label="__('Payment For Document')"
v-model="transactionData.payment_for_document"
:doctype="transactionData.payment_for_document_type"
:required="!!fieldMeta.payment_for_document?.reqd"
/>
</div>
@@ -90,17 +95,18 @@
:label="__('Currency')"
v-model="transactionData.currency"
doctype="Currency"
:required="true"
:required="!!fieldMeta.currency?.reqd"
/>
<FormControl
:label="__('Amount')"
v-model="transactionData.amount"
:required="true"
:required="!!fieldMeta.amount?.reqd"
/>
<FormControl
v-if="transactionData.amount_with_gst"
:label="__('Amount with GST')"
v-model="transactionData.amount_with_gst"
:required="!!fieldMeta.amount_with_gst?.reqd"
/>
</div>
@@ -113,21 +119,25 @@
v-if="transactionData.coupon"
:label="__('Coupon Code')"
v-model="transactionData.coupon"
:required="!!fieldMeta.coupon?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Coupon Code')"
v-model="transactionData.coupon_code"
:required="!!fieldMeta.coupon_code?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Discount Amount')"
v-model="transactionData.discount_amount"
:required="!!fieldMeta.discount_amount?.reqd"
/>
<FormControl
v-if="transactionData.coupon"
:label="__('Original Amount')"
v-model="transactionData.original_amount"
:required="!!fieldMeta.original_amount?.reqd"
/>
</div>
</div>
@@ -140,24 +150,34 @@
:label="__('Address')"
v-model="transactionData.address"
doctype="Address"
:required="true"
:required="!!fieldMeta.address?.reqd"
/>
<FormControl
:label="__('GSTIN')"
v-model="transactionData.gstin"
:required="!!fieldMeta.gstin?.reqd"
/>
<FormControl
:label="__('PAN')"
v-model="transactionData.pan"
:required="!!fieldMeta.pan?.reqd"
/>
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
<FormControl
:label="__('Payment ID')"
v-model="transactionData.payment_id"
:required="!!fieldMeta.payment_id?.reqd"
/>
<FormControl
:label="__('Order ID')"
v-model="transactionData.order_id"
:required="!!fieldMeta.order_id?.reqd"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, FormControl, toast } from 'frappe-ui'
import { Button, FormControl, Switch, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
@@ -171,6 +191,10 @@ const show = defineModel('show')
const props = defineProps<{
transactions: any
data: any
fieldMeta: Record<
string,
{ reqd?: number; default?: string; description?: string }
>
}>()
const saveTransaction = () => {
@@ -211,48 +235,49 @@ const updateTransaction = () => {
}
const openDetails = () => {
if (props.data) {
const docType = props.data.payment_for_document_type
const docName = props.data.payment_for_document
if (docType && docName) {
router.push({
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
},
})
}
const docType = transactionData.value?.payment_for_document_type
const docName = transactionData.value?.payment_for_document
if (docType && docName) {
router.push({
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
params: {
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
},
})
show.value = false
}
}
const emptyTransactionData = {
const getDefault = (fieldname: string) =>
props.fieldMeta[fieldname]?.default || null
const getEmptyTransactionData = () => ({
payment_received: false,
payment_for_certificate: false,
member: null,
billing_name: null,
source: null,
payment_for_document_type: null,
payment_for_document: null,
member: getDefault('member'),
billing_name: getDefault('billing_name'),
source: getDefault('source'),
payment_for_document_type: getDefault('payment_for_document_type'),
payment_for_document: getDefault('payment_for_document'),
member_consent: false,
currency: null,
amount: null,
amount_with_gst: null,
coupon: null,
coupon_code: null,
discount_amount: null,
original_amount: null,
order_id: null,
payment_id: null,
gstin: null,
pan: null,
address: null,
}
currency: getDefault('currency'),
amount: getDefault('amount'),
amount_with_gst: getDefault('amount_with_gst'),
coupon: getDefault('coupon'),
coupon_code: getDefault('coupon_code'),
discount_amount: getDefault('discount_amount'),
original_amount: getDefault('original_amount'),
order_id: getDefault('order_id'),
payment_id: getDefault('payment_id'),
gstin: getDefault('gstin'),
pan: getDefault('pan'),
address: getDefault('address'),
})
watch(
() => props.data,
(newVal) => {
transactionData.value = newVal ? { ...newVal } : emptyTransactionData
transactionData.value = newVal ? { ...newVal } : getEmptyTransactionData()
},
{ immediate: true }
)
@@ -2,7 +2,7 @@
<div class="flex min-h-0 flex-col text-base">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
@@ -27,15 +27,17 @@
doctype="User"
:placeholder="__('Filter by Member')"
/>
<FormControl
v-model="paymentReceived"
type="checkbox"
<Switch
size="sm"
:label="__('Payment Received')"
:description="__('Mark the payment as received.')"
v-model="paymentReceived"
/>
<FormControl
<Switch
size="sm"
:label="__('Payment For Certificate')"
:description="__('This payment is for a certificate.')"
v-model="paymentForCertificate"
type="checkbox"
:label="__('Payment for Certificate')"
/>
</div>
@@ -116,6 +118,7 @@ import {
ListRow,
ListRowItem,
FormControl,
Switch,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
@@ -3,6 +3,7 @@
v-if="step == 'new'"
:transactions="transactions"
:data="data"
:fieldMeta="fieldMeta.data || {}"
v-model:show="show"
@updateStep="updateStep"
/>
@@ -17,13 +18,14 @@
v-else-if="step == 'details'"
:transactions="transactions"
:data="data"
:fieldMeta="fieldMeta.data || {}"
v-model:show="show"
@updateStep="updateStep"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { createListResource } from 'frappe-ui'
import { createListResource, createResource } from 'frappe-ui'
import TransactionList from '@/components/Settings/Transactions/TransactionList.vue'
import TransactionDetails from '@/components/Settings/Transactions/TransactionDetails.vue'
@@ -45,6 +47,11 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
}
}
const fieldMeta = createResource({
url: 'lms.lms.api.get_payment_field_meta',
auto: true,
})
const transactions = createListResource({
doctype: 'LMS Payment',
fields: [
@@ -6,7 +6,7 @@
{{ label }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
{{ __(description || '') }}
</div>
</div>
<div class="flex items-center space-x-5">
@@ -90,6 +90,7 @@
</div>
</div>
<ZoomAccountModal
v-if="showForm"
v-model="showForm"
v-model:zoomAccounts="zoomAccounts"
:accountID="currentAccount"
@@ -100,7 +101,6 @@ import {
Avatar,
Button,
Badge,
call,
createListResource,
FeatherIcon,
ListView,
@@ -112,20 +112,18 @@ import {
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const props = defineProps<{
label: string
description?: string
}>()
const zoomAccounts = createListResource({
doctype: 'LMS Zoom Settings',
@@ -147,15 +145,6 @@ onMounted(() => {
})
const fetchZoomAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
zoomAccounts.update({
filters: {
member: user.data.name,
},
})
}
zoomAccounts.reload()
}
@@ -164,21 +153,20 @@ const openForm = (accountID: string) => {
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Zoom Settings',
documents: Array.from(selections),
const removeAccount = (selections: Set<string>, unselectAll: () => void) => {
Array.from(selections).forEach((accountID) => {
zoomAccounts.delete.submit(accountID, {
onSuccess() {
toast.success(__('Zoom account deleted successfully'))
fetchZoomAccounts()
unselectAll()
},
onError(err: any) {
toast.error(cleanError(err.messages[0] || err))
console.error(err)
},
})
})
.then(() => {
zoomAccounts.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const columns = computed(() => {
+112 -7
View File
@@ -4,11 +4,11 @@
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
>
<div
class="flex flex-col overflow-hidden"
class="flex flex-col overflow-y-auto"
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
>
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
<div class="flex flex-col" v-if="sidebarSettings.data">
<div class="flex flex-col overflow-y-auto" v-if="sidebarSettings.data">
<div v-for="link in sidebarLinks" class="mx-2 my-2.5">
<div
v-if="!link.hideLabel"
@@ -37,7 +37,7 @@
>
<div
v-if="!sidebarStore.isSidebarCollapsed"
class="flex items-center text-sm text-ink-gray-5 my-1"
class="flex items-center text-ink-gray-5 my-1"
>
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<ChevronRight
@@ -90,6 +90,56 @@
)
}}
</div>
<div
v-if="
isStudent && !profileIsComplete && !sidebarStore.isSidebarCollapsed
"
class="flex flex-col gap-3 text-ink-gray-9 py-2.5 px-3 bg-surface-white shadow-sm rounded-md"
>
<div class="flex flex-col text-p-sm gap-1">
<div class="inline-flex gap-1">
<User class="h-4 my-0.5 shrink-0" />
<div class="font-medium">
{{ __('Complete your profile') }}
</div>
</div>
<div class="text-ink-gray-7 leading-5">
{{ __('Highlight what makes you unique and show your skills.') }}
</div>
</div>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
>
<Button :label="__('My Profile')" class="w-full">
<template #prefix>
<ChevronsRight class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
</template>
</Button>
</router-link>
</div>
<Tooltip
v-if="
isStudent && !profileIsComplete && sidebarStore.isSidebarCollapsed
"
:text="__('Complete your profile')"
>
<router-link
:to="{
name: 'Profile',
params: {
username: userResource.data?.username,
},
}"
class="flex items-center justify-center"
>
<User class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer" />
</router-link>
</Tooltip>
<TrialBanner
v-if="
userResource.data?.is_system_manager && userResource.data?.is_fc_site
@@ -132,10 +182,13 @@
</div>
</template>
</Tooltip>
<Tooltip :text="__('Powered by Learning')">
<Zap
<Tooltip
v-if="showAppointmentIcon"
:text="__('Book a free onboarding session with the Frappe team')"
>
<Phone
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
@click="redirectToAppointmentScreen()"
/>
</Tooltip>
<Tooltip v-if="showOnboarding" :text="__('Help')">
@@ -149,6 +202,12 @@
"
/>
</Tooltip>
<Tooltip :text="__('Powered by Frappe Learning')">
<Zap
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="redirectToWebsite()"
/>
</Tooltip>
</div>
<Tooltip
:text="
@@ -210,15 +269,19 @@ import {
markRaw,
h,
onUnmounted,
computed,
} from 'vue'
import {
BookOpen,
CircleAlert,
ChevronRight,
Plus,
ChevronsRight,
CircleHelp,
FolderTree,
FileText,
Phone,
Plus,
User,
UserPlus,
Users,
BookText,
@@ -613,6 +676,48 @@ const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
const isStudent = computed(() => {
return userResource.data?.is_student
})
const profileIsComplete = computed(() => {
return (
userResource.data?.user_image &&
userResource.data?.headline &&
userResource.data?.bio
)
})
const showAppointmentIcon = computed(() => {
let isTrialPlan = userResource.data?.site_info?.plan?.is_trial_plan
let trialEndDate = calculateTrialEndDays(
userResource.data?.site_info?.trial_end_date
)
return (
userResource.data?.is_system_manager &&
userResource.data?.is_fc_site &&
isTrialPlan &&
trialEndDate > 0
)
})
const calculateTrialEndDays = (trialEndDate) => {
if (!trialEndDate) return 0
trialEndDate = new Date(trialEndDate)
const today = new Date()
const diffTime = trialEndDate - today
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays
}
const redirectToAppointmentScreen = () => {
window.open(
'https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ0c7Z3XIpW1WgbeIuktSaoX6qudoYuSdRbIlJty5TW7p4IZaOk5viHQGwTNi6HpNVqzOZOTHcle',
'_blank'
)
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
+2 -2
View File
@@ -17,7 +17,7 @@
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-surface-white shadow-xl"
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div v-for="app in apps.data" key="name">
<a
@@ -48,7 +48,7 @@ const apps = createResource({
name: 'frappe',
logo: '/assets/lms/images/desk.png',
title: __('Desk'),
route: '/desk/lms',
route: '/desk/learning',
},
]
data.map((app) => {
@@ -1,26 +1,48 @@
<template>
<div class="grid grid-cols-3 justify-between bg-surface-white">
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
<router-link
:to="{
name: 'DataImportList',
query: {
step: 'list',
},
}"
<Popover placement="right-start" trigger="hover" class="flex w-full">
<template #target="{ togglePopover }">
<button
:class="[
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
]"
>
<div class="flex flex-col items-center space-y-1">
<ArrowDownToLine
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
/>
<div class="text-sm text-ink-gray-7">
{{ __('Import') }}
</div>
<div class="flex gap-2">
<Wrench class="size-4 stroke-1.5" />
<span class="whitespace-nowrap">
{{ __('Configuration') }}
</span>
</div>
</router-link>
</div>
</div>
<ChevronRight class="h-4 w-4 stroke-1.5" />
</button>
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
>
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
<router-link
:to="{
name: 'DataImportList',
query: {
step: 'list',
},
}"
>
<div class="flex flex-col items-center space-y-1">
<ArrowDownToLine
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
/>
<div class="text-sm text-ink-gray-7">
{{ __('Import') }}
</div>
</div>
</router-link>
</div>
</div>
</template>
</Popover>
</template>
<script setup lang="ts">
import { ArrowDownToLine } from 'lucide-vue-next'
import { Popover } from 'frappe-ui'
import { ArrowDownToLine, Wrench, ChevronRight } from 'lucide-vue-next'
</script>
@@ -65,7 +65,7 @@
<script setup>
import { sessionStore } from '@/stores/session'
import { Dropdown } from 'frappe-ui'
import { call, Dropdown, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils'
import { usersStore } from '@/stores/user'
@@ -85,8 +85,7 @@ import {
User,
Settings,
Sun,
Wrench,
Zap,
Trash2,
} from 'lucide-vue-next'
const router = useRouter()
@@ -171,17 +170,24 @@ const userDropdownOptions = computed(() => {
},
},
{
label: 'Configuration',
icon: Wrench,
submenu: [
{
component: markRaw(Configuration),
},
],
component: markRaw(Configuration),
condition: () => {
return userResource.data?.is_moderator
},
},
{
label: 'Clear Demo Data',
icon: Trash2,
onClick: () => {
clearDemoDataConfirmation()
},
condition: () => {
return (
userResource.data?.is_moderator &&
settingsStore.settings.data?.demo_data_present
)
},
},
{
icon: FrappeCloudIcon,
label: 'Login to Frappe Cloud',
@@ -241,4 +247,36 @@ const loginToFrappeCloud = () => {
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
}
const clearDemoDataConfirmation = () => {
$dialog({
title: __('Confirm clearing demo data?'),
message: __(
'Are you sure you want to clear the demo data? This would delete the course "A guide to Frappe Learning" along with all its associated data. This action cannot be undone.'
),
actions: [
{
label: __('Confirm'),
theme: 'red',
variant: 'solid',
onClick(close) {
clearDemoData()
close()
},
},
],
})
}
const clearDemoData = () => {
call('lms.lms.api.clear_demo_data')
.then(() => {
window.location.href = '/lms'
toast.success(__('Demo data cleared successfully'))
})
.catch((error) => {
toast.error(__(error.message || 'Error clearing demo data'))
console.error('Error clearing demo data:', error)
})
}
</script>
+60 -57
View File
@@ -5,7 +5,7 @@
{{ __('Upcoming Evaluations') }}
</div>
<Button v-if="canScheduleEvals" @click="openEvalModal">
{{ __('Schedule Evaluation') }}
{{ __('Schedule') }}
</Button>
</div>
<div
@@ -31,55 +31,38 @@
<div v-if="upcoming_evals.data?.length">
<div
class="grid gap-4"
:class="forHome ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-3'"
:class="forHome ? 'grid-cols-1 md:grid-cols-4' : 'grid-cols-1'"
>
<div v-for="evl in upcoming_evals.data">
<div class="border text-ink-gray-7 rounded-md p-3">
<div
class="border hover:border-outline-gray-3 text-ink-gray-7 rounded-md p-3"
>
<div class="flex justify-between mb-3">
<span class="text-lg font-semibold text-ink-gray-9 leading-5">
<span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }}
</span>
<Menu
<Dropdown
v-if="evl.date > dayjs().format()"
as="div"
class="relative inline-block text-left"
:options="[
{
label: __('Cancel'),
icon: Ban,
onClick() {
cancelEvaluation(evl)
},
},
]"
placement="left"
side="left"
>
<div>
<MenuButton class="inline-flex w-full justify-center">
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</MenuButton>
</div>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
>
<MenuItem v-slot="{ active }">
<Button
variant="ghost"
class="w-full"
@click="cancelEvaluation(evl)"
>
<template #prefix>
<Ban
:active="active"
class="size-4 stroke-1.5"
aria-hidden="true"
/>
</template>
{{ __('Cancel') }}
</Button>
</MenuItem>
</MenuItems>
</transition>
</Menu>
<template v-slot="{ open }">
<Button variant="ghost">
<template #icon>
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Dropdown>
</div>
<div class="flex items-center mb-2">
<Calendar class="w-4 h-4 stroke-1.5" />
@@ -114,7 +97,7 @@
</div>
</div>
</div>
<div v-else-if="!endDateHasPassed" class="text-ink-gray-5">
<div v-else-if="!endDateHasPassed" class="text-ink-gray-7">
{{ __('Schedule an evaluation to get certified.') }}
</div>
</div>
@@ -137,11 +120,11 @@ import {
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '@/utils'
import { Button, createResource, call } from 'frappe-ui'
import { Button, createListResource, call, Dropdown, toast } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const showEvalModal = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
@@ -165,12 +148,28 @@ const props = defineProps({
},
})
const upcoming_evals = createResource({
url: 'lms.lms.utils.get_upcoming_evals',
params: {
courses: props.courses.map((course) => course.course),
batch: props.batch,
const upcoming_evals = createListResource({
doctype: 'LMS Certificate Request',
filters: {
course: props.courses?.length
? ['in', props.courses.map((course) => course.course)]
: undefined,
batch_name: props.batch || undefined,
status: 'Upcoming',
member: user?.data?.name,
date: ['>=', dayjs().format('YYYY-MM-DD')],
},
fields: [
'name',
'date',
'start_time',
'evaluator_name',
'course_title',
'member',
'member_name',
'google_meet_link',
],
orderBy: 'date',
auto: true,
})
@@ -184,7 +183,7 @@ const openEvalCall = (evl) => {
const evaluationCourses = computed(() => {
return props.courses.filter((course) => {
return course.evaluator != ''
return course.evaluator && course.evaluator != ''
})
})
@@ -202,7 +201,7 @@ const endDateHasPassed = computed(() => {
const cancelEvaluation = (evl) => {
$dialog({
title: __('Cancel this evaluation?'),
title: __('Confirm Cancellation?'),
message: __(
'Are you sure you want to cancel this evaluation? This action cannot be undone.'
),
@@ -212,11 +211,15 @@ const cancelEvaluation = (evl) => {
theme: 'red',
variant: 'solid',
onClick(close) {
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
() => {
call('lms.lms.api.cancel_evaluation', { evaluation: evl })
.then(() => {
upcoming_evals.reload()
}
)
toast.success(__('Evaluation cancelled successfully'))
})
.catch((err) => {
toast.error(__(err.messages?.[0] || err))
console.error(err)
})
close()
},
},
+36 -2
View File
@@ -89,6 +89,11 @@
<span class="text-sm font-medium">
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
</span>
<Dropdown :options="dropdownOptions">
<Button>{{ playbackSpeedLabel }}</Button>
</Dropdown>
<Button
variant="ghost"
@click="toggleMute"
@@ -151,9 +156,9 @@
</Dialog>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button, Dialog } from 'frappe-ui'
import { Button, Dialog, Dropdown } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import { useSettings } from '@/stores/settings'
import Play from '@/components/Icons/Play.vue'
@@ -173,6 +178,16 @@ const currentQuiz = ref(null)
const nextQuiz = ref({})
const { settings } = useSettings()
// Speed control states
const playbackSpeed = ref(1)
const playbackSpeedLabel = ref('1x')
const playbackSpeeds = [
{ label: '0.5x', value: 0.5 },
{ label: '1x', value: 1 },
{ label: '1.5x', value: 1.5 },
{ label: '2x', value: 2 },
]
const props = defineProps({
file: {
type: String,
@@ -199,6 +214,9 @@ const props = defineProps({
onMounted(() => {
updateCurrentTime()
updateNextQuiz()
if (videoRef.value) {
videoRef.value.playbackRate = 1
}
})
const updateCurrentTime = () => {
@@ -321,6 +339,22 @@ const getQuizMarkerStyle = (time) => {
left: `${percentage}%`,
}
}
const setPlaybackSpeed = (speed, label) => {
playbackSpeed.value = speed
playbackSpeedLabel.value = label
if (videoRef.value) {
videoRef.value.playbackRate = speed
}
}
const dropdownOptions = computed(() =>
playbackSpeeds.map((speed) => ({
label: speed.label,
active: playbackSpeed.value === speed.value,
onClick: () => setPlaybackSpeed(speed.value, speed.label),
}))
)
</script>
<style scoped>
+1 -1
View File
@@ -59,7 +59,7 @@ onMounted(() => {
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Submissions',
label: __('Submissions'),
route: { name: 'AssignmentSubmissionList' },
},
{
+1 -4
View File
@@ -140,9 +140,6 @@ const assignmentFilter = computed(() => {
if (typeFilter.value) {
filters.type = typeFilter.value
}
if (!user.data?.is_moderator) {
filters.owner = user.data?.email
}
return filters
})
@@ -203,7 +200,7 @@ const assignmentTypes = computed(() => {
const breadcrumbs = computed(() => [
{
label: 'Assignments',
label: __('Assignments'),
route: { name: 'Assignments' },
},
])
-82
View File
@@ -1,82 +0,0 @@
<template>
<div v-if="badge.data">
<div class="p-5 flex flex-col items-center mt-40">
<div class="text-3xl font-semibold">
{{ badge.data.badge }}
</div>
<img
:src="badge.data.badge_image"
:alt="badge.data.badge"
class="h-60 mt-2"
/>
<div class="">
{{
__('This badge has been awarded to {0} on {1}.').format(
badge.data.member_name,
dayjs(badge.data.issued_on).format('DD MMM YYYY')
)
}}
</div>
<div class="mt-2">
{{ badge.data.badge_description }}
</div>
</div>
</div>
</template>
<script setup>
import { createResource, usePageMeta } from 'frappe-ui'
import { computed, inject } from 'vue'
import { sessionStore } from '../stores/session'
const dayjs = inject('$dayjs')
const { brand } = sessionStore()
const props = defineProps({
badgeName: {
type: String,
required: true,
},
email: {
type: String,
required: true,
},
})
const badge = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Badge Assignment',
filters: {
badge: props.badgeName,
member: props.email,
},
}
},
auto: true,
})
const breadcrumbs = computed(() => {
return [
{
label: 'Badges',
},
{
label: badge.data.badge,
route: {
name: 'Badge',
params: {
badge: badge.data.badge,
},
},
},
]
})
usePageMeta(() => {
return {
title: badge.data.badge,
icon: brand.favicon,
}
})
</script>
-395
View File
@@ -1,395 +0,0 @@
<template>
<div v-if="isAdmin || isStudent" class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center space-x-2">
<Button
v-if="isAdmin && batch.data?.certification"
@click="openCertificateDialog = true"
>
{{ __('Generate Certificates') }}
</Button>
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
<span>
{{ __('Make an Announcement') }}
</span>
<template #suffix>
<SendIcon class="h-4 stroke-1.5" />
</template>
</Button>
</div>
</header>
<div
v-if="batch.data"
class="grid grid-cols-1 md:grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
>
<div class="border-r">
<Tabs
v-model="tabIndex"
as="div"
:tabs="tabs"
tablistClass="overflow-y-hidden bg-surface-white"
>
<template #tab="{ tab, selected }" class="overflow-x-hidden">
<div>
<button
class="group -mb-px flex items-center gap-1 border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
:class="{ 'text-ink-gray-9': selected }"
>
<component
v-if="tab.icon"
:is="tab.icon"
class="h-4 stroke-1.5"
/>
{{ __(tab.label) }}
<Badge
v-if="tab.count"
:class="{
'text-ink-gray-9 border border-gray-900': selected,
}"
variant="subtle"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
</div>
</template>
<template #tab-panel="{ tab }">
<div class="pt-5 px-5 pb-10">
<div v-if="tab.label == 'Courses'">
<BatchCourses :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Dashboard' && isStudent">
<BatchDashboard :batch="batch" :isStudent="isStudent" />
</div>
<div v-else-if="tab.label == 'Dashboard'">
<AdminBatchDashboard :batch="batch" />
</div>
<div v-else-if="tab.label == 'Students'">
<BatchStudents :batch="batch" />
</div>
<div v-else-if="tab.label == 'Classes'">
<LiveClass
:batch="batch.data.name"
:zoomAccount="batch.data.zoom_account"
/>
</div>
<div v-else-if="tab.label == 'Assessments'">
<Assessments :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Announcements'">
<Announcements :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Discussions'">
<Discussions
doctype="LMS Batch"
:docname="batch.data.name"
:title="__('Discussions')"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="false"
/>
</div>
</div>
</template>
</Tabs>
</div>
<div class="p-5 border-t md:border-t-0">
<div class="mb-10">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('About this batch') }}
</div>
<div
v-html="batch.data.description"
class="leading-5 mb-4 text-ink-gray-7"
></div>
<div class="flex items-center avatar-group overlap mb-5">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div
v-if="batch.data.timezone"
class="flex items-center mb-3 text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
</div>
</div>
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('Feedback') }}
</div>
<BatchFeedback :batch="batch.data?.name" />
</div>
</div>
<AnnouncementModal
v-model="showAnnouncementModal"
:batch="batch.data.name"
:students="batch.data.students"
/>
</div>
</div>
<div v-else-if="!user.data?.name" class="">
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
<div class="border-b px-5 py-3 font-medium">
<span
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
></span>
{{ __('Not Permitted') }}
</div>
<div class="px-5 py-3">
<div v-if="user.data" class="mb-4 leading-6">
{{
__(
'You are not a member of this batch. Please checkout our upcoming batches.'
)
}}
</div>
<div v-else class="mb-4 leading-6">
{{ __('Please login to access this page.') }}
</div>
<router-link
v-if="user.data"
:to="{
name: 'Batches',
params: {
batchName: batch.data?.name,
},
}"
>
<Button variant="solid" class="w-full">
{{ __('Upcoming Batches') }}
</Button>
</router-link>
<Button
v-else
variant="solid"
class="w-full"
@click="redirectToLogin()"
>
{{ __('Login') }}
</Button>
</div>
</div>
</div>
<BulkCertificates
v-if="batch.data"
v-model="openCertificateDialog"
:batch="batch.data"
/>
</template>
<script setup>
import { computed, inject, ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Breadcrumbs,
Button,
createResource,
Tabs,
Badge,
usePageMeta,
} from 'frappe-ui'
import {
Clock,
LayoutDashboard,
BookOpen,
Laptop,
BookOpenCheck,
Mail,
SendIcon,
MessageCircle,
Globe,
ClipboardPen,
} from 'lucide-vue-next'
import { formatTime } from '@/utils'
import { sessionStore } from '@/stores/session'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import BatchDashboard from '@/components/BatchDashboard.vue'
import BatchCourses from '@/components/BatchCourses.vue'
import LiveClass from '@/components/LiveClass.vue'
import BatchStudents from '@/components/BatchStudents.vue'
import AdminBatchDashboard from '@/components/AdminBatchDashboard.vue'
import Assessments from '@/components/Assessments.vue'
import Announcements from '@/components/Annoucements.vue'
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue'
import dayjs from 'dayjs/esm'
import { getLmsRoute } from '@/utils/basePath'
const user = inject('$user')
const showAnnouncementModal = ref(false)
const openCertificateDialog = ref(false)
const route = useRoute()
const router = useRouter()
const { brand } = sessionStore()
const tabIndex = ref(0)
const readOnlyMode = window.read_only_mode
const tabs = computed(() => {
let batchTabs = []
batchTabs.push({
label: 'Dashboard',
icon: LayoutDashboard,
})
if (isAdmin.value) {
batchTabs.push({
label: 'Students',
icon: ClipboardPen,
})
}
batchTabs.push({
label: 'Courses',
icon: BookOpen,
})
batchTabs.push({
label: 'Classes',
icon: Laptop,
})
if (isAdmin.value) {
batchTabs.push({
label: 'Assessments',
icon: BookOpenCheck,
})
}
batchTabs.push({
label: 'Announcements',
icon: Mail,
})
batchTabs.push({
label: 'Discussions',
icon: MessageCircle,
})
return batchTabs
})
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
onMounted(() => {
const hash = route.hash
if (hash) {
tabs.value.forEach((tab, index) => {
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
tabIndex.value = index
}
})
}
})
const batch = createResource({
url: 'lms.lms.utils.get_batch_details',
cache: ['batch', props.batchName],
params: {
batch: props.batchName,
},
auto: true,
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
if (!isStudent.value) {
crumbs.push({
label: 'Details',
route: {
name: 'BatchDetail',
params: {
batchName: batch.data?.name,
},
},
})
}
crumbs.push({
label: batch?.data?.title,
route: { name: 'Batch', params: { batchName: props.batchName } },
})
return crumbs
})
const isStudent = computed(() => {
return (
user?.data &&
batch.data?.students?.length &&
batch.data?.students.includes(user.data.name)
)
})
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=${getLmsRoute(
`batches/${props.batchName}`
)}`
}
const openAnnouncementModal = () => {
showAnnouncementModal.value = true
}
watch(tabIndex, () => {
const tab = tabs.value[tabIndex.value]
if (tab.label != route.hash.replace('#', '')) {
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
}
})
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
const isAdmin = computed(() => {
return user.data?.is_moderator || user.data?.is_evaluator
})
usePageMeta(() => {
return {
title: batch?.data?.title,
icon: brand.favicon,
}
})
</script>
-158
View File
@@ -1,158 +0,0 @@
<template>
<div v-if="batch.data" class="">
<header
class="sticky top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="m-5 pb-10">
<div class="flex justify-between w-full">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ batch.data.title }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }}
</div>
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
<div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
v-html="batch.data.batch_details"
></div>
</div>
<div class="hidden md:block">
<BatchOverlay :batch="batch" />
</div>
</div>
<div v-if="batch.data.courses.length">
<div class="flex items-center mt-10">
<div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Courses') }}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
<div
v-if="batch.data.courses"
v-for="course in courses.data"
:key="course.course"
>
<router-link
:to="{
name: 'CourseDetail',
params: {
courseName: course.name,
},
}"
>
<CourseCard :course="course" :key="course.name" />
</router-link>
</div>
</div>
<div v-if="batch.data.batch_details_raw">
<div
v-html="batch.data.batch_details_raw"
class="batch-description"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Clock } from 'lucide-vue-next'
import { formatTime } from '@/utils'
import { Breadcrumbs, createResource, usePageMeta } from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import CourseCard from '@/components/CourseCard.vue'
import BatchOverlay from '@/components/BatchOverlay.vue'
import DateRange from '../components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
const batch = createResource({
url: 'lms.lms.utils.get_batch_details',
cache: ['batch', props.batchName],
params: {
batch: props.batchName,
},
auto: true,
onSuccess: (data) => {
if (!data) {
router.push({ name: 'Batches' })
}
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batchName,
},
cache: ['batchCourses', props.batchName],
auto: true,
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
items.push({
label: batch?.data?.title,
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
})
return items
})
usePageMeta(() => {
return {
title: batch?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.batch-description p {
margin-bottom: 1rem;
line-height: 1.7;
}
.batch-description li {
line-height: 1.7;
}
.batch-description ol {
list-style: auto;
margin: revert;
padding: revert;
}
.batch-description strong {
font-weight: 600;
color: theme('colors.gray.900') !important;
}
</style>
-592
View File
@@ -1,592 +0,0 @@
<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center space-x-2">
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
<template #icon>
<Trash2 class="size-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="saveBatch()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="py-5">
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<MultiSelect
v-model="instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
</div>
<FormControl
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
:rows="8"
:placeholder="__('Short description of the batch')"
:required="true"
/>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Date and Time') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.start_date"
:label="__('Batch Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('Batch End Date')"
type="date"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Session Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('Session End Time')"
type="time"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
/>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Configurations') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
:onCreate="
(value, close) => {
openSettings('Email Templates', close)
}
"
/>
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batch.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.medium"
type="select"
:options="[
{
label: 'Online',
value: 'Online',
},
{
label: 'Offline',
value: 'Offline',
},
]"
:label="__('Medium')"
class="mb-4"
/>
<Link
doctype="LMS Category"
:label="__('Category')"
v-model="batch.category"
:onCreate="(value, close) => openSettings('Categories', close)"
/>
</div>
<div class="space-y-5">
<Uploader
v-model="batch.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Pricing') }}
</div>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<div
v-if="batch.paid_batch"
class="grid grid-cols-1 md:grid-cols-3 gap-5"
>
<FormControl
v-model="batch.amount"
:label="__('Amount')"
type="number"
/>
<Link
doctype="Currency"
v-model="batch.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Meta Tags') }}
</div>
<div class="space-y-5">
<Uploader
v-model="batch.meta_image"
:label="__('Meta Image')"
type="image"
:required="false"
/>
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="7"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
computed,
getCurrentInstance,
inject,
onMounted,
onBeforeUnmount,
reactive,
ref,
} from 'vue'
import {
Breadcrumbs,
FormControl,
Button,
TextEditor,
createResource,
usePageMeta,
toast,
call,
} from 'frappe-ui'
import {
escapeHTML,
getMetaInfo,
openSettings,
sanitizeHTML,
updateMetaInfo,
} from '@/utils'
import { useRouter } from 'vue-router'
import { Trash2 } from 'lucide-vue-next'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import Uploader from '@/components/Controls/Uploader.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
const batch = reactive({
title: '',
published: false,
description: '',
batch_details: '',
start_date: '',
end_date: '',
start_time: '',
end_time: '',
timezone: '',
evaluation_end_date: '',
confirmation_email_template: '',
seat_count: '',
medium: '',
category: '',
allow_self_enrollment: false,
certification: false,
meta_image: null,
paid_batch: false,
currency: '',
amount: 0,
zoom_account: '',
video_link: '',
})
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => {
if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') {
fetchBatchInfo()
} else {
capture('batch_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
const fetchBatchInfo = () => {
batchDetail.reload()
getMetaInfo('batches', props.batchName, meta)
}
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
saveBatch()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const newBatch = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Batch',
meta_image: batch.image,
video_link: batch.video_link,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...batch,
},
}
},
})
const batchDetail = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Batch',
name: props.batchName,
}
},
onSuccess(data) {
updateBatchData(data)
},
})
const updateBatchData = (data) => {
Object.keys(data).forEach((key) => {
if (key == 'instructors') {
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (['start_time', 'end_time'].includes(key)) {
batch[key] = formatTime(data[key])
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
})
let checkboxes = [
'published',
'paid_batch',
'allow_self_enrollment',
'certification',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
batch[key] = batch[key] ? true : false
}
}
const formatTime = (timeStr) => {
let [hours, minutes, seconds] = timeStr.split(':')
hours = hours.length == 1 ? '0' + hours : hours
return `${hours}:${minutes}`
}
const editBatch = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'LMS Batch',
name: props.batchName,
fieldname: {
meta_image: batch.meta_image,
video_link: batch.video_link,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...batch,
},
}
},
})
const validateFields = () => {
batch.description = sanitizeHTML(batch.description)
batch.batch_details = sanitizeHTML(batch.batch_details)
Object.keys(batch).forEach((key) => {
if (
!['description', 'batch_details'].includes(key) &&
typeof batch[key] === 'string'
) {
batch[key] = escapeHTML(batch[key])
}
})
}
const saveBatch = () => {
validateFields()
if (batchDetail.data) {
editBatchDetails()
} else {
createNewBatch()
}
}
const createNewBatch = () => {
newBatch.submit(
{},
{
onSuccess(data) {
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_batch', true, false, () => {
localStorage.setItem('firstBatch', data.name)
})
}
updateMetaInfo('batches', data.name, meta)
capture('batch_created')
router.push({
name: 'BatchDetail',
params: {
batchName: data.name,
},
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const editBatchDetails = () => {
editBatch.submit(
{},
{
onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
router.push({
name: 'BatchDetail',
params: {
batchName: data.name,
},
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const deleteBatch = () => {
$dialog({
title: __('Confirm your action to delete'),
message: __(
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick({ close }) {
trashBatch(close)
close()
},
},
],
})
}
const trashBatch = (close) => {
call('lms.lms.api.delete_batch', {
batch: props.batchName,
}).then(() => {
toast.success(__('Batch deleted successfully'))
close()
router.push({
name: 'Batches',
})
})
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Batches',
route: {
name: 'Batches',
},
},
]
if (batchDetail.data) {
crumbs.push({
label: batchDetail.data.title,
route: {
name: 'BatchDetail',
params: {
batchName: props.batchName,
},
},
})
}
crumbs.push({
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
route: { name: 'BatchForm', params: { batchName: props.batchName } },
})
return crumbs
})
usePageMeta(() => {
return {
title: props.batchName == 'new' ? 'New Batch' : batchDetail.data?.title,
icon: brand.favicon,
}
})
</script>
+270
View File
@@ -0,0 +1,270 @@
<template>
<div v-if="batch.data" class="">
<header
class="sticky top-0 z-10 border-b flex items-center justify-between bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center space-x-2">
<Badge v-if="childRef?.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Button @click="childRef.deleteBatch()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="childRef.submitBatch()">
{{ __('Save') }}
</Button>
</div>
<Dropdown
v-else-if="isAdmin && batchMenu.length"
:options="batchMenu"
placement="left"
side="left"
>
<template v-slot="{ open }">
<Button variant="ghost">
<template #icon>
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Dropdown>
</header>
<div>
<BatchOverview v-if="!isAdmin && !isStudent" :batch="batch" />
<div v-else>
<Tabs :tabs="tabs" v-model="tabIndex">
<template #tab-panel="{ tab }">
<div
v-if="tab.label == 'Discussions'"
class="w-[90%] lg:w-[75%] mx-auto mt-5"
>
<Discussions
doctype="LMS Batch"
:docname="batch.data.name"
:title="__('Discussions')"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="false"
/>
</div>
<component
v-else
:is="tab.component"
:batch="batch"
ref="childRef"
/>
</template>
</Tabs>
</div>
</div>
</div>
<BulkCertificates
v-if="batch.data"
v-model="openCertificateDialog"
:batch="batch.data"
/>
<AnnouncementModal
v-if="showAnnouncementModal"
v-model="showAnnouncementModal"
:batch="batch.data.name"
:students="batch.data.students"
/>
</template>
<script setup>
import {
ClipboardPen,
EllipsisVertical,
Laptop,
List,
Mail,
MessageCircle,
SendIcon,
Settings2,
Trash2,
TrendingUp,
} from 'lucide-vue-next'
import { computed, inject, markRaw, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
Badge,
Breadcrumbs,
Button,
createResource,
Dropdown,
Tabs,
usePageMeta,
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import AdminBatchDashboard from '@/pages/Batches/components/AdminBatchDashboard.vue'
import StudentBatchDashboard from '@/pages/Batches/components/BatchDashboard.vue'
import BatchOverview from '@/pages/Batches/BatchOverview.vue'
import LiveClass from '@/pages/Batches/components/LiveClass.vue'
import Announcements from '@/pages/Batches/components/Announcements.vue'
import AnnouncementModal from '@/pages/Batches/components/AnnouncementModal.vue'
import BatchForm from '@/pages/Batches/BatchForm.vue'
import BulkCertificates from '@/pages/Batches/components/BulkCertificates.vue'
import Discussions from '@/components/Discussions.vue'
const router = useRouter()
const route = useRoute()
const { brand } = sessionStore()
const user = inject('$user')
const childRef = ref(null)
const tabIndex = ref(0)
const tabs = ref([])
const openCertificateDialog = ref(false)
const showAnnouncementModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
const updateTabIndex = () => {
const hash = route.hash
if (hash) {
tabs.value.forEach((tab, index) => {
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
tabIndex.value = index
}
})
}
}
watch(tabIndex, () => {
const tab = tabs.value[tabIndex.value]
if (tab.label != route.hash.replace('#', '')) {
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
}
})
const batch = createResource({
url: 'lms.lms.utils.get_batch_details',
cache: ['batch', props.batchName],
params: {
batch: props.batchName,
},
auto: true,
onSuccess: (data) => {
if (!data) {
router.push({ name: 'Batches' })
}
},
})
watch(batch, () => {
updateTabs()
updateTabIndex()
})
const updateTabs = () => {
addToTabs('Overview', markRaw(BatchOverview), List)
if (!user.data) return
if (isAdmin.value) {
addToTabs('Dashboard', markRaw(AdminBatchDashboard), TrendingUp)
} else if (isStudent.value) {
addToTabs('Dashboard', markRaw(StudentBatchDashboard), ClipboardPen)
}
addToTabs('Classes', markRaw(LiveClass), Laptop)
addToTabs('Announcements', markRaw(Announcements), Mail)
addToTabs('Discussions', markRaw(Discussions), MessageCircle)
if (isAdmin.value) {
addToTabs('Settings', markRaw(BatchForm), Settings2)
}
}
const addToTabs = (label, component, icon) => {
if (!tabs.value.some((tab) => tab.label === label)) {
tabs.value.push({
label,
component,
icon,
})
}
}
const isAdmin = computed(() => {
return user.data?.is_moderator || user.data?.is_evaluator
})
const isStudent = computed(() => {
return batch.data?.students?.includes(user.data?.name)
})
const openAnnouncementModal = () => {
showAnnouncementModal.value = true
}
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
const batchMenu = computed(() => {
if (!batch.data?.certification && !canMakeAnnouncement()) {
return []
}
let options = [
{
label: __('Generate Certificates'),
onClick() {
openCertificateDialog.value = true
},
condition: () => batch.data?.certification,
},
{
label: __('Make an Announcement'),
onClick() {
openAnnouncementModal()
},
condition: () => canMakeAnnouncement(),
},
]
return options
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
crumbs.push({
label: batch?.data?.title,
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
})
return crumbs
})
usePageMeta(() => {
return {
title: batch?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.batch-description p {
margin-bottom: 1rem;
line-height: 1.7;
}
.batch-description li {
line-height: 1.7;
}
.batch-description ol {
list-style: auto;
margin: revert;
padding: revert;
}
.batch-description strong {
font-weight: 600;
color: theme('colors.gray.900') !important;
}
</style>
+576
View File
@@ -0,0 +1,576 @@
<template>
<div class="">
<div class="grid grid-cols-1 lg:grid-cols-[3fr,2fr]">
<div v-if="batchDetail.doc" class="py-5 lg:h-[88vh] lg:overflow-y-auto">
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<Switch
size="sm"
v-model="batchDetail.doc.published"
:label="__('Published')"
:description="__('Make the batch visible to all users.')"
/>
<FormControl
v-model="batchDetail.doc.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<FormControl
v-model="batchDetail.doc.start_date"
:label="__('Batch Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.end_date"
:label="__('Batch End Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
</div>
<div class="space-y-5">
<Switch
size="sm"
v-model="batchDetail.doc.allow_self_enrollment"
:label="__('Allow Self Enrollment')"
:description="
__('Allow users to enroll in this batch on their own.')
"
/>
<FormControl
v-model="batchDetail.doc.start_time"
:label="__('Session Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.end_time"
:label="__('Session End Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
<Link
v-model="batchDetail.doc.category"
doctype="LMS Category"
:label="__('Category')"
:inlineCreate="true"
:onCreate="createCategory"
/>
</div>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Certification') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 items-start">
<div class="flex flex-col space-y-5">
<Switch
size="sm"
v-model="batchDetail.doc.evaluation"
:label="__('Evaluation')"
:description="__('Enable evaluations for batch participants.')"
/>
<FormControl
v-if="batchDetail.doc.evaluation"
v-model="batchDetail.doc.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
<div>
<Switch
size="sm"
v-model="batchDetail.doc.certification"
:label="__('Certification')"
:description="__('Issue certificates to batch participants.')"
/>
</div>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="grid grid-cols-2 gap-5">
<MultiSelect
v-model="instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
:onCreate="() => (showMemberModal = true)"
url="lms.lms.api.search_users_by_role"
:searchParams="{ roles: JSON.stringify(['Batch Evaluator']) }"
/>
<FormControl
v-model="batchDetail.doc.description"
:label="__('Short Description')"
type="textarea"
:rows="4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
</div>
<div>
<label class="block text-sm text-ink-gray-5 mb-2">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batchDetail.doc.batch_details"
@change="(val) => (batchDetail.doc.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[16rem] overflow-y-scroll mb-4"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batchDetail.doc.medium"
type="select"
:options="mediumOptions"
:label="__('Medium')"
class="mb-4"
/>
<Link
ref="emailTemplateLinkRef"
doctype="Email Template"
:label="__('Enrollment Confirmation Email Template')"
v-model="batchDetail.doc.confirmation_email_template"
:onCreate="
(value, close) => {
if (close) close()
showEmailTemplateModal = true
}
"
/>
</div>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Conferencing') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="batchDetail.doc.conferencing_provider"
type="select"
:options="conferencingOptions"
:label="__('Conferencing Provider')"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Zoom'"
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Link
v-if="batchDetail.doc.conferencing_provider === 'Google Meet'"
doctype="LMS Google Meet Settings"
:label="__('Google Meet Account')"
v-model="batchDetail.doc.google_meet_account"
:onCreate="
(value, close) => {
openSettings('Google Meet Accounts', close)
}
"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Pricing') }}
</div>
<Switch
size="sm"
v-model="batchDetail.doc.paid_batch"
:label="__('Paid Batch')"
:description="__('Charge a fee for batch enrollment.')"
/>
<div
v-if="batchDetail.doc.paid_batch"
class="grid grid-cols-1 md:grid-cols-2 gap-5"
>
<FormControl
v-model="batchDetail.doc.amount"
:label="__('Amount')"
type="number"
/>
<Link
doctype="Currency"
v-model="batchDetail.doc.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Meta Tags') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="4"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="4"
:placeholder="__('Comma separated keywords')"
/>
<Uploader
v-model="batchDetail.doc.meta_image"
:label="__('Meta Image')"
type="image"
:required="false"
/>
</div>
</div>
</div>
<div class="border-l min-w-0">
<div class="border-b p-4">
<BatchCourses :batch="batch" />
</div>
<div class="p-4">
<Assessments :batch="batch.data?.name" />
</div>
</div>
</div>
</div>
<NewMemberModal
v-model="showMemberModal"
:defaultRoles="['batch_evaluator']"
@created="onInstructorCreated"
/>
<EmailTemplateModal
v-model="showEmailTemplateModal"
v-model:emailTemplates="emailTemplates"
templateID="new"
@created="onEmailTemplateCreated"
/>
</template>
<script setup>
import {
computed,
getCurrentInstance,
inject,
onMounted,
onBeforeUnmount,
reactive,
ref,
toRaw,
watch,
nextTick,
} from 'vue'
import {
FormControl,
Switch,
TextEditor,
createDocumentResource,
toast,
call,
createListResource,
} from 'frappe-ui'
import {
createLMSCategory,
escapeHTML,
getMetaInfo,
openSettings,
sanitizeHTML,
updateMetaInfo,
} from '@/utils'
import { useRouter } from 'vue-router'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '@/stores/session'
import Uploader from '@/components/Controls/Uploader.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
import Assessments from '@/pages/Batches/components/Assessments.vue'
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()
const { $dialog } = app.appContext.config.globalProperties
const isDirty = ref(false)
const originalDoc = ref(null)
const showMemberModal = ref(false)
const showEmailTemplateModal = ref(false)
const emailTemplateLinkRef = ref(null)
const emailTemplates = createListResource({
doctype: 'Email Template',
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
auto: true,
orderBy: 'modified desc',
cache: 'email-templates',
})
const onEmailTemplateCreated = (name) => {
batchDetail.doc.confirmation_email_template = name
emailTemplateLinkRef.value?.reload()
}
const createCategory = (name, done) => {
createLMSCategory(name).then((categoryName) => {
if (!categoryName) return
batchDetail.doc.category = categoryName
done()
})
}
const onInstructorCreated = (user) => {
instructors.value = [...instructors.value, user.name]
}
const meta = reactive({
description: '',
keywords: '',
})
const props = defineProps({
batch: {
type: Object,
required: true,
},
})
onMounted(() => {
if (!user.data) window.location.href = '/login'
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
submitBatch()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const batchDetail = createDocumentResource({
doctype: 'LMS Batch',
name: props.batch.data?.name,
auto: true,
})
watch(
() => batchDetail.doc,
() => {
if (!batchDetail.doc) return
if (originalDoc.value) {
isDirty.value =
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
}
updateBatchData()
getMetaInfo('batches', batchDetail.doc?.name, meta)
},
{ deep: true }
)
const updateBatchData = () => {
Object.keys(batchDetail.doc).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
batchDetail.doc.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (['start_time', 'end_time'].includes(key)) {
batchDetail.doc[key] = formatTime(batchDetail.doc[key])
}
})
let checkboxes = [
'published',
'paid_batch',
'allow_self_enrollment',
'certification',
'evaluation',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
batchDetail.doc[key] = batchDetail.doc[key] ? true : false
}
originalDoc.value = structuredClone(toRaw(batchDetail.doc))
}
const formatTime = (timeStr) => {
let [hours, minutes, seconds] = timeStr.split(':')
hours = hours.length == 1 ? '0' + hours : hours
return `${hours}:${minutes}`
}
const validateFields = () => {
batchDetail.doc.description = sanitizeHTML(batchDetail.doc.description)
batchDetail.doc.batch_details = sanitizeHTML(batchDetail.doc.batch_details)
Object.keys(batchDetail.doc).forEach((key) => {
if (
!['description', 'batch_details'].includes(key) &&
typeof batchDetail.doc[key] === 'string'
) {
batchDetail.doc[key] = escapeHTML(batchDetail.doc[key])
}
})
}
const submitBatch = () => {
validateFields()
updateBatch()
}
const updateBatch = () => {
batchDetail.setValue.submit(
{
...batchDetail.doc,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
},
{
onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
toast.success(__('Batch updated successfully'))
nextTick(() => {
originalDoc.value = structuredClone(data)
isDirty.value = false
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
}
const deleteBatch = () => {
$dialog({
title: __('Confirm your action to delete'),
message: __(
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick({ close }) {
trashBatch(close)
close()
},
},
],
})
}
const trashBatch = (close) => {
call('lms.lms.api.delete_batch', {
batch: props.batch.data.name,
}).then(() => {
toast.success(__('Batch deleted successfully'))
close()
router.push({
name: 'Batches',
})
})
}
const conferencingOptions = computed(() => {
return [
{
label: '',
value: '',
},
{
label: __('Zoom'),
value: 'Zoom',
},
{
label: __('Google Meet'),
value: 'Google Meet',
},
]
})
const mediumOptions = computed(() => {
return [
{
label: __('Online'),
value: 'Online',
},
{
label: __('Offline'),
value: 'Offline',
},
]
})
defineExpose({
submitBatch,
deleteBatch,
isDirty,
})
</script>
@@ -0,0 +1,90 @@
<template>
<div class="m-5 pb-10">
<div class="flex justify-between w-full">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ batch.data.title }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }}
</div>
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
<div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
v-html="batch.data.batch_details"
></div>
</div>
<div class="hidden md:block">
<BatchOverlay :batch="batch" />
</div>
</div>
<div v-if="courses.data?.length">
<div class="flex items-center mt-10">
<div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Courses') }}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
<div
v-if="courses.data?.length"
v-for="course in courses.data"
:key="course.course"
>
<router-link
:to="{
name: 'CourseDetail',
params: {
courseName: course.name,
},
}"
>
<CourseCard :course="course" :key="course.name" />
</router-link>
</div>
</div>
<div v-if="batch.data.batch_details_raw">
<div
v-html="batch.data.batch_details_raw"
class="batch-description"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { createResource } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import BatchOverlay from '@/pages/Batches/components/BatchOverlay.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batch?.data?.name,
},
cache: ['batchCourses', props.batch?.data?.name],
auto: true,
})
</script>
@@ -10,10 +10,7 @@
label: __('New Batch'),
icon: 'users',
onClick() {
router.push({
name: 'BatchForm',
params: { batchName: 'new' },
})
showBatchModal = true
},
},
{
@@ -45,20 +42,6 @@
</Button>
</template>
</Dropdown>
<!-- <router-link
v-if="canCreateBatch()"
:to="{
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Create') }}
</Button>
</router-link> -->
</header>
<div class="p-5 pb-10">
<div
@@ -95,10 +78,11 @@
</div>
</div>
<FormControl
<Switch
size="sm"
v-model="certification"
:label="__('Certification')"
type="checkbox"
:description="__('Only show batches that offer a certificate.')"
@change="updateBatches()"
/>
</div>
@@ -125,6 +109,11 @@
</Button>
</div>
</div>
<NewBatchModal
v-if="showBatchModal"
v-model="showBatchModal"
:batches="batches"
/>
</template>
<script setup>
import {
@@ -134,6 +123,7 @@ import {
Dropdown,
FormControl,
Select,
Switch,
TabButtons,
usePageMeta,
} from 'frappe-ui'
@@ -141,8 +131,9 @@ import { computed, inject, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronDown, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import BatchCard from '@/components/BatchCard.vue'
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewBatchModal from '@/pages/Batches/components/NewBatchModal.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
@@ -155,10 +146,11 @@ const title = ref('')
const certification = ref(false)
const filters = ref({})
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 readOnlyMode = window.read_only_mode
const router = useRouter()
const showBatchModal = ref(false)
onMounted(() => {
setFiltersFromQuery()
@@ -245,7 +237,7 @@ const updateTabFilter = () => {
if (!user.data) {
return
}
if (currentTab.value == 'Enrolled' && is_student.value) {
if (currentTab.value == 'enrolled' && is_student.value) {
filters.value['enrolled'] = 1
delete filters.value['start_date']
delete filters.value['published']
@@ -256,20 +248,20 @@ const updateTabFilter = () => {
delete filters.value['start_date']
delete filters.value['published']
orderBy.value = 'start_date desc'
if (currentTab.value == 'Upcoming') {
if (currentTab.value == 'upcoming') {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
filters.value['published'] = 1
orderBy.value = 'start_date'
} else if (currentTab.value == 'Archived') {
} else if (currentTab.value == 'archived') {
filters.value['start_date'] = ['<=', dayjs().format('YYYY-MM-DD')]
} else if (currentTab.value == 'Unpublished') {
} else if (currentTab.value == 'unpublished') {
filters.value['published'] = 0
}
}
}
const updateStudentFilter = () => {
if (!user.data || (is_student.value && currentTab.value != 'Enrolled')) {
if (!user.data || (is_student.value && currentTab.value != 'enrolled')) {
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
filters.value['published'] = 1
}
@@ -319,6 +311,7 @@ const batchTabs = computed(() => {
let tabs = [
{
label: __('All'),
value: 'all',
},
]
@@ -327,11 +320,11 @@ const batchTabs = computed(() => {
user.data?.is_instructor ||
user.data?.is_evaluator
) {
tabs.push({ label: __('Upcoming') })
tabs.push({ label: __('Archived') })
tabs.push({ label: __('Unpublished') })
tabs.push({ label: __('Upcoming'), value: 'upcoming' })
tabs.push({ label: __('Archived'), value: 'archived' })
tabs.push({ label: __('Unpublished'), value: 'unpublished' })
} else if (user.data) {
tabs.push({ label: __('Enrolled') })
tabs.push({ label: __('Enrolled'), value: 'enrolled' })
}
return tabs
})
@@ -0,0 +1,277 @@
<template>
<div v-if="batch?.data" class="p-5">
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChartGraph
:title="__('Enrolled')"
:value="formatAmount(batch.data?.students?.length) || 0"
/>
<NumberChartGraph
:title="__('Certified')"
:value="certificationCount.data || 0"
/>
<NumberChartGraph
class="border rounded-md"
:title="__('Courses')"
:value="batch?.data?.courses?.length || 0"
/>
<NumberChartGraph
class="border rounded-md"
:title="__('Assessments')"
:value="batch?.data?.assessments?.length || 0"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-5 items-start">
<div class="border rounded-lg py-3 px-4 order-2 lg:order-1">
<div class="flex items-center justify-between space-x-2 mb-3">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Students') }}
</div>
<div class="flex items-center space-x-2">
<FormControl
v-model="searchFilter"
:placeholder="__('Search by name')"
type="text"
/>
<Button @click="showEnrollmentModal = true">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Enroll') }}
</Button>
</div>
</div>
<div
v-if="students.loading || students.data?.length"
class="max-h-[63vh] overflow-y-auto"
>
<ListView
:columns="studentColumns"
:rows="students.data"
rowKey="name"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
currentStudent = row.member
showProgressModal = true
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem
:item="item"
v-for="item in studentColumns"
:key="item.key"
>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in students.data" class="max-h-[500px]">
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
<!-- <ProgressBar
v-else-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
/> -->
</template>
<div v-if="column.key == 'creation'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div
v-else-if="column.key == 'progress'"
class="text-xs !mx-0 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<div
v-if="students.data && students.hasNextPage"
class="flex justify-center my-3"
>
<Button @click="students.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="order-1 lg:order-2">
<AxisChart
v-if="showProgressChart"
class="border rounded-lg p-3 min-h-[300px]"
:config="{
data: filteredChartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
<div class="p-4 border rounded-lg mt-5">
<BatchFeedback v-if="batch.data" :batch="batch.data.name" />
</div>
</div>
</div>
</div>
<StudentModal
v-if="showEnrollmentModal"
v-model="showEnrollmentModal"
:batch="batch"
:students="students"
/>
<BatchStudentProgress
v-if="showProgressModal"
v-model="showProgressModal"
:student="currentStudent"
:batch="batch?.data?.name"
/>
</template>
<script setup lang="ts">
import {
AxisChart,
createResource,
createListResource,
dayjs,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Avatar,
Button,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { formatAmount } from '@/utils'
import { Plus } from 'lucide-vue-next'
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
import BatchStudentProgress from '@/pages/Batches/components/BatchStudentProgress.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
const searchFilter = ref<string | null>(null)
const showEnrollmentModal = ref<boolean>(false)
const showProgressModal = ref<boolean>(false)
const currentStudent = ref<any>(null)
const props = defineProps<{
batch: { [key: string]: any } | null
}>()
const chartData = createResource({
url: 'lms.lms.utils.get_batch_chart_data',
cache: ['batch_chart_data', props.batch?.data?.name],
params: { batch: props.batch?.data?.name },
auto: true,
})
const certificationCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_certificate_count', props.batch?.data?.name],
params: {
doctype: 'LMS Certificate',
filters: { batch_name: props.batch?.data?.name },
},
auto: true,
})
const students = createListResource({
doctype: 'LMS Batch Enrollment',
filters: {
batch: props.batch?.data?.name,
},
fields: [
'name',
'member',
'member_name',
'member_username',
'member_image',
'creation',
],
orderBy: 'creation desc',
auto: true,
})
const filteredChartData = computed(() =>
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
)
watch(searchFilter, () => {
let filters: Record<string, any> = {
batch: props.batch?.data?.name,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
}
students.update({ filters })
students.reload()
})
const studentColumns = computed(() => {
return [
{
label: __('Name'),
key: 'member_name',
width: '40%',
},
{
label: __('Enrolled On'),
key: 'creation',
align: 'right',
},
]
})
const showProgressChart = computed(
() =>
students.data?.length &&
(props.batch?.data?.courses?.length ||
props.batch?.data?.assessments?.length)
)
</script>
@@ -15,20 +15,18 @@
>
<template #body-content>
<div class="flex flex-col gap-4">
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Subject') }}
<span class="text-ink-red-3">*</span>
</div>
<Input type="text" v-model="announcement.subject" />
</div>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Reply To') }}
<span class="text-ink-red-3">*</span>
</div>
<Input type="text" v-model="announcement.replyTo" />
</div>
<FormControl
:label="__('Subject')"
type="text"
v-model="announcement.subject"
:required="true"
/>
<FormControl
:label="__('Reply To')"
type="text"
v-model="announcement.replyTo"
:required="true"
/>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Announcement') }}
@@ -45,7 +43,13 @@
</Dialog>
</template>
<script setup>
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
import {
Dialog,
FormControl,
TextEditor,
createResource,
toast,
} from 'frappe-ui'
import { reactive } from 'vue'
const show = defineModel()
@@ -0,0 +1,58 @@
<template>
<div class="w-[90%] lg:w-[75%] mx-auto mt-5">
<div class="text-ink-gray-9 font-semibold text-lg mb-5">
{{ __('Announcements') }}
</div>
<div v-if="communications.data?.length">
<div v-for="comm in communications.data">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2 text-ink-gray-7">
{{ comm.sender_full_name }}
</div>
</div>
<div class="text-sm">
{{ timeAgo(comm.communication_date) }}
</div>
</div>
<div
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
v-html="comm.content"
></div>
</div>
</div>
</div>
<div v-else class="text-ink-gray-7 leading-5">
{{ __('No announcements have been made yet for this batch') }}
</div>
</div>
</template>
<script setup>
import { createResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
batch: {
type: Object,
required: true,
},
})
const communications = createResource({
url: 'lms.lms.api.get_announcements',
makeParams(value) {
return {
batch: props.batch.data?.name,
}
},
auto: true,
cache: ['announcement', props.batch],
})
</script>
<style>
.prose-sm p {
margin: 0 0 0.5rem;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold text-ink-gray-9">
<div class="text-ink-gray-9 font-semibold">
{{ __('Assessments') }}
</div>
<Button v-if="canAddAssessments()" @click="showModal = true">
@@ -16,6 +16,7 @@
:columns="getAssessmentColumns()"
:rows="assessments.data"
row-key="name"
class="border rounded-lg"
:options="{
showTooltip: false,
getRowRoute: (row) => getRowRoute(row),
@@ -23,20 +24,17 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in assessments.data">
<ListRow
:row="row"
v-for="row in assessments.data"
class="!rounded-none"
>
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'">
@@ -57,7 +55,7 @@
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<ListSelectBanner class="!min-w-0">
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
@@ -71,8 +69,8 @@
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No Assessments') }}
<div v-else class="text-ink-gray-7">
{{ __('No assessments added to this batch') }}
</div>
</div>
<AssessmentModal
@@ -208,20 +206,19 @@ const canAddAssessments = () => {
const getAssessmentColumns = () => {
let columns = [
{
label: 'Assessment',
label: __('Assessment'),
key: 'title',
width: '25rem',
},
{
label: 'Type',
label: __('Type'),
key: 'assessment_type',
width: '15rem',
width: '10rem',
},
]
if (!user.data?.is_moderator) {
columns.push({
label: 'Status/Percentage',
label: __('Status/Percentage'),
key: 'status',
align: 'left',
width: '10rem',
@@ -6,24 +6,26 @@
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
{{ batch.title }}
</div>
<div
<Badge
v-if="batch.seat_count && batch.seats_left > 0"
class="text-xs bg-green-100 text-green-700 self-start px-2 py-0.5 rounded-md"
>
{{ batch.seats_left }}
<span v-if="batch.seats_left > 1">
{{ __('Seats Left') }}
</span>
<span v-else-if="batch.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
variant="subtle"
theme="green"
size="md"
class="self-start"
:label="
batch.seats_left +
' ' +
(batch.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
"
/>
<Badge
v-else-if="batch.seat_count && batch.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 self-start px-2 py-0.5 rounded-md"
>
{{ __('Sold Out') }}
</div>
variant="subtle"
theme="red"
size="md"
class="self-start"
:label="__('Sold Out')"
/>
<div class="short-introduction text-sm text-ink-gray-7">
{{ batch.description }}
</div>
@@ -70,6 +72,7 @@
</div>
</template>
<script setup>
import { Badge } from 'frappe-ui'
import { formatTime } from '@/utils'
import { Clock, Globe } from 'lucide-vue-next'
import DateRange from '@/components/Common/DateRange.vue'
@@ -1,21 +1,22 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="font-medium text-ink-gray-9">
<div class="text-ink-gray-9 font-semibold">
{{ __('Courses') }}
</div>
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
<Button v-if="isAdmin()" @click="openCourseModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="courses.data?.length">
<div v-if="courses.data?.length" class="text-sm">
<ListView
:columns="getCoursesColumns()"
:rows="courses.data"
row-key="batch_course"
row-key="name"
class="border rounded-lg"
:options="{
showTooltip: false,
selectable: user.data?.is_student ? false : true,
@@ -26,20 +27,13 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in courses.data">
<ListRow :row="row" v-for="row in courses.data" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
@@ -49,7 +43,7 @@
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<ListSelectBanner class="!min-w-0">
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
@@ -63,21 +57,21 @@
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No courses added') }}
<div v-else class="text-ink-gray-7">
{{ __('No courses added to this batch') }}
</div>
<BatchCourseModal
v-model="showCourseModal"
:batch="batch"
:batch="batch.data?.name"
v-model:courses="courses"
/>
</div>
</template>
<script setup>
import { ref, inject } from 'vue'
import { ref, inject, nextTick } from 'vue'
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
import {
createResource,
createListResource,
Button,
ListHeader,
ListHeaderItem,
@@ -96,16 +90,20 @@ const user = inject('$user')
const props = defineProps({
batch: {
type: String,
type: Object,
required: true,
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batch,
const courses = createListResource({
doctype: 'Batch Course',
filters: {
parent: props.batch.data?.name,
parenttype: 'LMS Batch',
},
fields: ['name', 'course', 'title', 'evaluator'],
parent: 'LMS Batch',
orderBy: 'idx',
auto: true,
})
@@ -118,47 +116,25 @@ const getCoursesColumns = () => {
{
label: 'Title',
key: 'title',
width: 2,
},
{
label: 'Lessons',
key: 'lessons',
align: 'right',
},
{
label: 'Enrollments',
align: 'right',
key: 'enrollments',
label: 'Evaluator',
key: 'evaluator',
width: '10rem',
},
]
}
const deleteCourses = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'Batch Course',
documents: values.courses,
}
},
})
const removeCourses = async (selections, unselectAll) => {
for (const course of selections) {
await courses.delete.submit(course)
}
const removeCourses = (selections, unselectAll) => {
deleteCourses.submit(
{
courses: Array.from(selections),
},
{
onSuccess(data) {
courses.reload()
toast.success(__('Courses deleted successfully'))
unselectAll()
},
}
)
unselectAll()
toast.success(__('Courses deleted successfully'))
}
const canSeeAddButton = () => {
const isAdmin = () => {
if (readOnlyMode) {
return false
}
@@ -0,0 +1,137 @@
<template>
<div class="h-[88vh]">
<div class="grid grid-cols-1 lg:grid-cols-[2fr,1fr] gap-5">
<div class="p-5">
<div class="mb-8 space-y-2">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Curriculum') }}
</div>
<div class="text-ink-gray-7">
{{
__(
"As a part of this batch's curriculum you will have to complete the following courses and assessments."
)
}}
</div>
</div>
<div class="space-y-10">
<div>
<div class="text-ink-gray-9 font-semibold mb-4">
{{ __('Courses') }}
</div>
<ListView
v-if="batch.data?.courses?.length"
:columns="courseColumns"
:rows="batch.data?.courses"
row-key="name"
class="border rounded-lg"
:options="{
showTooltip: false,
selectable: user.data?.is_student ? false : true,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.course },
}),
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in batch.data?.courses"
class="!rounded-none text-sm"
>
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key === 'progress'">
{{ getProgress(row.course) }}%
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<div v-else class="text-ink-gray-7">
{{ __('No courses added to this batch') }}
</div>
</div>
<!-- <BatchCourses :batch="batch" /> -->
<Assessments :batch="batch.data.name" />
</div>
</div>
<div class="border-l h-[88vh] divide-y">
<div v-if="batch.data?.evaluation" class="p-4 mb-5">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
/>
</div>
<div class="p-5">
<BatchFeedback :batch="batch.data?.name" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
import {
createListResource,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
} from 'frappe-ui'
import Assessments from '@/pages/Batches/components/Assessments.vue'
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const user = inject('$user')
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
member: user.data?.name,
course: ['in', props.batch.data?.courses?.map((c) => c.course)],
},
fields: ['course', 'progress', 'name'],
auto: true,
})
const getProgress = (course) => {
const progress = progressList.data?.find((p) => p.course === course)
return progress ? Math.round(progress.progress) : 0
}
const courseColumns = [
{
key: 'title',
label: __('Course'),
},
{
key: 'progress',
label: __('Progress'),
align: 'right',
},
]
</script>
@@ -1,63 +1,77 @@
<template>
<div v-if="user.data?.is_student">
<div>
<div class="leading-5 mb-4 text-ink-gray-7">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
>
{{ __('to view your feedback.') }}
<div>
<div class="flex justify-between mb-5">
<div class="space-y-1">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Feedback') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
<div
v-if="feedbackList.data?.length && isAdmin"
class="leading-5 text-ink-gray-7 text-sm mb-2 mt-5"
>
{{ __('Average Feedback Received') }}
</div>
</div>
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
<Button
v-if="feedbackList.data?.length && isAdmin"
variant="outline"
@click="showAllFeedback = true"
>
{{ __('View all feedback') }}
</Button>
</div>
<div v-if="user.data?.is_student">
<div>
<div class="leading-5 mb-4 text-ink-gray-7">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
>
{{ __('to view your feedback.') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
</div>
</div>
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
:readonly="readOnly"
/>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly"
/>
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly"
/>
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
</div>
</div>
<div v-else-if="feedbackList.data?.length">
<div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average Feedback Received') }}
<div v-else-if="feedbackList.data?.length">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
:label="__(convertToTitleCase(key))"
:readonly="true"
/>
</div>
</div>
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
:label="__(convertToTitleCase(key))"
:readonly="true"
/>
<div v-else class="text-ink-gray-7 leading-5">
{{ __('No feedback received yet.') }}
</div>
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('View all feedback') }}
</Button>
</div>
<div v-else class="text-ink-gray-7 mt-5 leading-5">
{{ __('No feedback received yet.') }}
</div>
<FeedbackModal
v-if="feedbackList.data?.length"
@@ -66,7 +80,7 @@
/>
</template>
<script setup>
import { inject, onMounted, reactive, ref, watch } from 'vue'
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
@@ -159,10 +173,15 @@ const submitFeedback = () => {
onSuccess: () => {
feedbackList.reload()
showFeedbackForm.value = false
readOnly.value = true
},
}
)
}
const isAdmin = computed(() => {
return user.data?.is_moderator || user.data?.is_evaluator
})
</script>
<style>
.feedback-list > button > div {
@@ -1,31 +1,32 @@
<template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div
<Badge
v-if="batch.data.seat_count && batch.data.seats_left > 0"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
variant="subtle"
theme="green"
size="md"
:class="
batch.data.amount || batch.data.courses.length
? 'float-right'
: 'w-fit mb-4'
"
>
{{ batch.data.seats_left }}
<span v-if="batch.data.seats_left > 1">
{{ __('Seats Left') }}
</span>
<span v-else-if="batch.data.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
:label="
batch.data.seats_left +
' ' +
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
"
/>
<Badge
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
>
{{ __('Sold Out') }}
</div>
variant="subtle"
theme="red"
size="md"
class="float-right"
:label="__('Sold Out')"
/>
<div
v-if="batch.data.amount"
class="text-lg font-semibold mb-3 text-ink-gray-9"
class="text-lg font-semibold mb-5 text-ink-gray-9"
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
@@ -55,26 +56,7 @@
</span>
</div>
<div v-if="!readOnlyMode">
<router-link
v-if="canAccessBatch"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
<Settings v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
</span>
</Button>
</router-link>
<div v-if="!readOnlyMode && !canAccessBatch">
<router-link
:to="{
name: 'Billing',
@@ -83,13 +65,13 @@
name: batch.data.name,
},
}"
v-else-if="
v-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<Button class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
@@ -113,30 +95,12 @@
</template>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="canEditBatch"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
</div>
</template>
<script setup>
import { inject, computed } from 'vue'
import { Button, createResource, toast } from 'frappe-ui'
import { Badge, Button, createResource, toast } from 'frappe-ui'
import {
BookOpen,
Clock,
@@ -173,7 +137,7 @@ const enroll = createResource({
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
window.location.href = `/login?redirect-to=/batches/${props.batch.data.name}`
}
enroll.submit(
{},
@@ -187,6 +151,10 @@ const enrollInBatch = () => {
},
})
},
onError(err) {
toast.error(__(err.messages?.[0] || err))
console.error(err)
},
}
)
}
@@ -205,14 +173,6 @@ const isEvaluator = computed(() => {
return user.data?.is_evaluator
})
const isInstructor = computed(() => {
return (
props.batch.data?.instructors?.filter(
(instructor) => instructor.name === user.data?.name
).length > 0
)
})
const canAccessBatch = computed(() => {
if (!user.data) {
return false
@@ -220,7 +180,7 @@ const canAccessBatch = computed(() => {
return isModerator.value || isStudent.value || isEvaluator.value
})
const canEditBatch = computed(() => {
return isModerator.value || isInstructor.value
const isAdmin = computed(() => {
return isModerator.value || isEvaluator.value
})
</script>
@@ -0,0 +1,222 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div v-if="studentDetails.data" class="p-5 space-y-10 text-sm">
<div class="flex items-center space-x-2">
<Avatar :image="studentDetails.data.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ studentDetails.data.full_name }}
</div>
<Badge
v-if="
Object.keys(studentDetails.data.assessments).length ||
Object.keys(studentDetails.data.courses).length
"
:theme="studentDetails.data.progress === 100 ? 'green' : 'red'"
>
{{ studentDetails.data.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ studentDetails.data.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<ListView
:columns="assessmentColumns"
:rows="studentDetails.data.assessments"
row-key="title"
class="border border-outline-gray-modals rounded-lg"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
redirectToAssessment(row)
}
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows v-for="row in studentDetails.data.assessments">
<ListRow :row="row" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<div
v-if="column.key == 'status' && isAssignment(row.status)"
>
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<!-- Courses -->
<ListView
:columns="courseColumns"
:rows="studentDetails.data.courses"
row-key="title"
class="border border-outline-gray-modals rounded-lg"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
redirectToCourse(row)
}
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows v-for="row in studentDetails.data.courses">
<ListRow :row="row" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<ProgressBar
v-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4 max-w-32"
/>
</template>
<div
v-if="column.key == 'progress'"
class="text-xs !ml-0 !mr-3 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Badge,
createResource,
Dialog,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const show = defineModel()
const router = useRouter()
const props = defineProps<{
student: string
batch: string
}>()
const studentDetails = createResource({
url: 'lms.lms.utils.get_batch_student_progress',
makeParams() {
return {
member: props.student,
batch: props.batch,
}
},
auto: true,
})
const redirectToAssessment = (row: any) => {
console.log(row)
if (!row.submission) return
if (row.type == 'LMS Assignment') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment,
submissionName: row.submission,
},
})
} else if (row.type == 'LMS Programming Exercise') {
router.push({
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment,
submissionID: row.submission,
},
})
} else if (row.type == 'LMS Quiz') {
router.push({
name: 'QuizSubmission',
params: {
submission: row.submission,
},
})
}
}
const redirectToCourse = (row: any) => {
router.push({
name: 'CourseDetail',
params: {
courseName: row.course,
},
})
}
const assessmentColumns = [
{ key: 'title', label: 'Assessment', align: 'left', width: '60%' },
{ key: 'status', label: 'Percentage/Status', align: 'right' },
]
const courseColumns = [
{ key: 'title', label: 'Course', align: 'left', width: '70%' },
{ key: 'progress', label: 'Progress', align: 'right' },
]
const isAssignment = (value: any) => {
return isNaN(value)
}
const getStatusTheme = (status: string) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>
@@ -0,0 +1,242 @@
<template>
<div class="p-5">
<div
v-if="isAdmin() && !hasProviderAccount()"
class="flex lg:items-center space-x-2 mb-5 bg-surface-amber-1 px-3 py-2 rounded-lg text-ink-amber-3"
>
<AlertCircle class="size-7 md:size-4 stroke-1.5" />
<span class="leading-5">
{{
__(
'Please select a conferencing provider and add an account to the batch to create live classes.'
)
}}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }}
</div>
<Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div
v-if="liveClasses.data?.length"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 mt-5"
>
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': isAdmin() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
>
<div class="font-semibold text-ink-gray-9 mb-1">
{{ cls.title }}
</div>
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls) && cls.join_url"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url || cls.join_url"
target="_blank"
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
>
<Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</div>
</div>
</div>
<div v-else class="text-ink-gray-7 mt-5">
{{ __('No live classes scheduled') }}
</div>
</div>
<LiveClassModal
v-if="showLiveClassModal"
v-model="showLiveClassModal"
:batch="batch.data?.name"
:zoomAccount="batch.data?.zoom_account"
:googleMeetAccount="batch.data?.google_meet_account"
:conferencingProvider="batch.data?.conferencing_provider"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance
v-if="showAttendance"
v-model="showAttendance"
:live_class="attendanceFor"
/>
</template>
<script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: Object,
required: true,
},
})
const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch.data?.name,
},
fields: [
'title',
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
'conferencing_provider',
'batch_name',
],
orderBy: 'date',
auto: true,
})
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const hasProviderAccount = () => {
const data = props.batch.data
if (data?.conferencing_provider === 'Zoom' && data?.zoom_account) return true
if (
data?.conferencing_provider === 'Google Meet' &&
data?.google_meet_account
)
return true
return false
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!hasProviderAccount()) return false
return isAdmin()
}
const isAdmin = () => {
return user.data?.is_moderator || user.data?.is_evaluator
}
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassStart = (cls) => {
return new Date(`${cls.date}T${cls.time}`)
}
const getClassEnd = (cls) => {
const classStart = getClassStart(cls)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!isAdmin()) return
if (cls.attendees <= 0) return
attendanceFor.value = cls
showAttendance.value = true
}
</script>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.5rem;
line-height: 1.5;
}
</style>
@@ -0,0 +1,268 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('New Batch'),
size: '3xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-3 gap-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
autocomplete="off"
/>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
:required="true"
/>
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
:required="true"
autocomplete="off"
/>
<Link
v-model="batch.category"
doctype="LMS Category"
:label="__('Category')"
:onCreate="createCategory"
/>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
:required="false"
/>
<FormControl
v-model="batch.medium"
type="select"
:options="mediumOptions"
:label="__('Medium')"
class="mb-4"
/>
</div>
<div class="space-y-5 border-t mt-5 pt-5">
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
:required="true"
:rows="4"
/>
<MultiSelect
v-model="batch.instructors"
doctype="User"
:label="__('Instructors')"
:required="true"
:onCreate="() => (showMemberModal = true)"
url="lms.lms.api.search_users_by_role"
:searchParams="{ roles: JSON.stringify(['Batch Evaluator']) }"
/>
</div>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="batch.batch_details"
@change="(val: string) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[14rem] overflow-auto"
/>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="text-right">
<Button variant="solid" @click="saveBatch(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
<NewMemberModal
v-model="showMemberModal"
:defaultRoles="['batch_evaluator']"
@created="onInstructorCreated"
/>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { computed, inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { sanitizeHTML, escapeHTML, createLMSCategory } from '@/utils'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
const show = defineModel<boolean>({ required: true, default: false })
const router = useRouter()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const user = inject<any>('$user')
const showMemberModal = ref(false)
const props = defineProps<{
batches: any
}>()
type Batch = {
title: string
start_date: string | null
end_date: string | null
start_time: string | null
end_time: string | null
timezone: string | null
description: string
batch_details: string
instructors: string[]
category: string | null
seat_count: number
medium: string | null
}
const batch = ref<Batch>({
title: '',
start_date: null,
end_date: null,
start_time: null,
end_time: null,
timezone: null,
description: '',
batch_details: '',
instructors: [],
category: null,
seat_count: 0,
medium: null,
})
const createCategory = (name: string, done: () => void) => {
createLMSCategory(name).then((categoryName: string) => {
if (!categoryName) return
batch.value.category = categoryName
done()
})
}
const onInstructorCreated = (user: any) => {
batch.value.instructors = [...batch.value.instructors, user.name]
}
const validateFields = () => {
batch.value.description = sanitizeHTML(batch.value.description)
batch.value.batch_details = sanitizeHTML(batch.value.batch_details)
Object.keys(batch.value).forEach((key) => {
if (
key != 'description' &&
key != 'batch_details' &&
typeof batch.value[key as keyof Batch] === 'string'
) {
batch.value[key as keyof Batch] = escapeHTML(
batch.value[key as keyof Batch] as string
)
}
})
}
const saveBatch = (close: () => void = () => {}) => {
validateFields()
props.batches.insert.submit(
{
...batch.value,
instructors: batch.value.instructors.map((instructor) => ({
instructor: instructor,
})),
},
{
onSuccess(data: any) {
toast.success(__('Batch created successfully'))
close()
capture('batch_created')
router.push({
name: 'BatchDetail',
params: { batchName: data.name },
hash: '#settings',
})
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_batch', true, false, () => {
localStorage.setItem('firstBatch', data.name)
})
}
},
onError(err: any) {
toast.error(cleanError(err.messages?.[0]))
console.error(err)
},
}
)
}
const keyboardShortcut = (e: KeyboardEvent) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
e.target &&
e.target instanceof HTMLElement &&
!e.target.classList.contains('ProseMirror')
) {
saveBatch()
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
capture('batch_form_opened')
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
capture('batch_form_closed', {
data: batch.value,
})
})
const mediumOptions = computed(() => {
return [
{
label: __('Online'),
value: 'Online',
},
{
label: __('Offline'),
value: 'Offline',
},
]
})
</script>
+35 -21
View File
@@ -114,25 +114,27 @@
<FormControl
:label="__('Billing Name')"
v-model="billingDetails.billing_name"
:required="true"
:required="!!fieldMeta.billing_name?.reqd"
/>
<FormControl
:label="__('Address Line 1')"
v-model="billingDetails.address_line1"
:required="true"
:required="!!fieldMeta.address_line1?.reqd"
/>
<FormControl
:label="__('Address Line 2')"
v-model="billingDetails.address_line2"
:required="!!fieldMeta.address_line2?.reqd"
/>
<FormControl
:label="__('City')"
v-model="billingDetails.city"
:required="true"
:required="!!fieldMeta.city?.reqd"
/>
<FormControl
:label="__('State/Province')"
v-model="billingDetails.state"
:required="!!fieldMeta.state?.reqd"
/>
</div>
<div class="space-y-4">
@@ -141,34 +143,36 @@
:value="billingDetails.country"
@change="(option) => changeCurrency(option)"
:label="__('Country')"
:required="true"
:required="!!fieldMeta.country?.reqd"
/>
<FormControl
:label="__('Postal Code')"
v-model="billingDetails.pincode"
:required="true"
:required="!!fieldMeta.pincode?.reqd"
/>
<FormControl
:label="__('Phone Number')"
v-model="billingDetails.phone"
:required="true"
:required="!!fieldMeta.phone?.reqd"
/>
<Link
doctype="LMS Source"
:value="billingDetails.source"
@change="(option) => (billingDetails.source = option)"
:label="__('Where did you hear about us?')"
:required="true"
:required="!!fieldMeta.source?.reqd"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('GST Number')"
v-model="billingDetails.gstin"
:required="!!fieldMeta.gstin?.reqd"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('PAN Number')"
v-model="billingDetails.pan"
:required="!!fieldMeta.pan?.reqd"
/>
</div>
</div>
@@ -273,6 +277,7 @@ const access = createResource({
name: props.name,
},
onSuccess(data) {
Object.assign(fieldMeta, data.billing_field_meta || {})
setBillingDetails(data.address)
orderSummary.submit()
},
@@ -295,19 +300,24 @@ const orderSummary = createResource({
const appliedCoupon = ref(null)
const billingDetails = reactive({})
const fieldMeta = reactive({})
const getDefault = (fieldname) => fieldMeta[fieldname]?.default || ''
const setBillingDetails = (data) => {
billingDetails.billing_name = data?.billing_name || ''
billingDetails.address_line1 = data?.address_line1 || ''
billingDetails.address_line2 = data?.address_line2 || ''
billingDetails.city = data?.city || ''
billingDetails.state = data?.state || ''
billingDetails.country = data?.country || ''
billingDetails.pincode = data?.pincode || ''
billingDetails.phone = data?.phone || ''
billingDetails.source = data?.source || ''
billingDetails.gstin = data?.gstin || ''
billingDetails.pan = data?.pan || ''
billingDetails.billing_name = data?.billing_name || getDefault('billing_name')
billingDetails.address_line1 =
data?.address_line1 || getDefault('address_line1')
billingDetails.address_line2 =
data?.address_line2 || getDefault('address_line2')
billingDetails.city = data?.city || getDefault('city')
billingDetails.state = data?.state || getDefault('state')
billingDetails.country = data?.country || getDefault('country')
billingDetails.pincode = data?.pincode || getDefault('pincode')
billingDetails.phone = data?.phone || getDefault('phone')
billingDetails.source = data?.source || getDefault('source')
billingDetails.gstin = data?.gstin || getDefault('gstin')
billingDetails.pan = data?.pan || getDefault('pan')
}
const paymentLink = createResource({
@@ -336,7 +346,7 @@ const generatePaymentLink = () => {
{},
{
validate() {
if (!billingDetails.source) {
if (!billingDetails.source && fieldMeta.source?.reqd) {
return __('Please let us know where you heard about us from.')
}
if (!billingDetails.member_consent) {
@@ -370,15 +380,19 @@ function removeCoupon() {
}
const validateAddress = () => {
let mandatoryFields = [
let billingFields = [
'billing_name',
'address_line1',
'address_line2',
'city',
'state',
'pincode',
'country',
'phone',
'source',
'gstin',
'pan',
]
let mandatoryFields = billingFields.filter((f) => fieldMeta[f]?.reqd)
for (let field of mandatoryFields) {
if (!billingDetails[field])
return (
+5 -4
View File
@@ -41,16 +41,16 @@
</div>
</div>
<div class="flex items-center space-x-4">
<FormControl
<Switch
size="sm"
v-model="openToWork"
:label="__('Open to Work')"
type="checkbox"
@change="updateParticipants()"
/>
<FormControl
<Switch
size="sm"
v-model="hiring"
:label="__('Hiring')"
type="checkbox"
@change="updateParticipants()"
/>
</div>
@@ -129,6 +129,7 @@ import {
createListResource,
FormControl,
Select,
Switch,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
+77 -56
View File
@@ -1,6 +1,6 @@
<template>
<div class="p-5">
<div class="grid grid-cols-4 gap-5 mb-5">
<div class="grid grid-cols-4 gap-5 mb-5 text-ink-gray-9">
<NumberChartGraph
:title="__('Enrolled')"
:value="formatAmount(course.data?.enrollments)"
@@ -20,9 +20,9 @@
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
</div>
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
<div class="border rounded-lg py-3 px-4">
<div class="flex items-center justify-between mb-3">
<div class="text-lg font-semibold">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Students') }}
</div>
<div class="flex items-center space-x-2">
@@ -63,50 +63,52 @@
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
<ListRow
:row="row"
@click="
() => {
showProgressModal = true
currentStudent = row
}
"
class="cursor-pointer"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
<ProgressBar
v-else-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</template>
<div v-if="column.key == 'creation'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div
<ProgressBar
v-else-if="column.key == 'progress'"
class="text-xs !mx-0 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
/>
</template>
<div v-if="column.key == 'creation'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div
v-else-if="column.key == 'progress'"
class="text-xs !mx-0 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<div
@@ -127,10 +129,12 @@
<div class="text-ink-gray-5 mb-4">
{{ __('Progress Summary') }}
</div>
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
<div
class="grid grid-cols-[2fr_1fr] items-center justify-between text-ink-gray-9"
>
<div class="flex flex-col space-y-4 flex-1 text-sm">
<div
class="flex items-center"
class="flex items-center text-ink-gray-7"
v-for="row in chartDetails.data?.progress_distribution"
>
<div
@@ -142,6 +146,8 @@
? 'red'
: row.name.startsWith('In')
? 'amber'
: row.name.startsWith('Adv')
? 'blue'
: 'green'
][400],
}"
@@ -151,11 +157,13 @@
{{ row.name.split('(')[0] }}
</div>
</Tooltip>
<div class="ml-auto">
{{
Math.round((row.value / course.data?.enrollments) * 100)
}}%
</div>
<Tooltip :text="row.value">
<div class="ml-auto">
{{
Math.round((row.value / course.data?.enrollments) * 100)
}}%
</div>
</Tooltip>
</div>
</div>
<ECharts
@@ -205,10 +213,12 @@
class="!w-32"
/>
</div>
<div class="divide-y max-h-[43vh] overflow-y-auto">
<div
class="divide-y max-h-[40vh] divide-outline-gray-modals text-ink-gray-7 overflow-y-auto"
>
<div
v-for="progress in lessonProgress.data"
class="flex justify-between text-sm py-2 my-1"
class="flex justify-between text-sm py-2 my-1 text-ink-gray-9"
>
<div class="">
<span class="mr-3 text-xs">
@@ -238,6 +248,14 @@
v-if="showEnrollmentModal"
v-model="showEnrollmentModal"
:course="course"
:students="progressList"
/>
<StudentCourseProgress
v-if="showProgressModal"
v-model="showProgressModal"
:course="course"
:student="currentStudent"
:lessons="lessonProgress"
/>
</template>
<script setup lang="ts">
@@ -260,12 +278,13 @@ import {
Tooltip,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
import { Plus, Star } from 'lucide-vue-next'
import { formatAmount } from '@/utils'
import colors from '@/utils/frappe-ui-colors.json'
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import StudentCourseProgress from '@/pages/Courses/StudentCourseProgress.vue'
const props = defineProps<{
course: any
@@ -273,6 +292,8 @@ const props = defineProps<{
const showEnrollmentModal = ref(false)
const searchFilter = ref<string | null>(null)
const showProgressModal = ref(false)
const currentStudent = ref<any>(null)
const theme = ref<'darkMode' | 'lightMode'>(
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
)
@@ -307,6 +328,7 @@ const progressList = createListResource({
],
pageLength: 100,
auto: true,
cache: ['courseProgress', props.course.data?.name],
})
const lessonProgress = createResource({
@@ -332,14 +354,12 @@ const updateLessonProgress = (value: string) => {
}
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
course: props.course.data?.name,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
@@ -357,6 +377,7 @@ const progressColors = computed(() => {
let colorList = []
colorList.push(colors[theme.value]['red'][400])
colorList.push(colors[theme.value]['amber'][400])
colorList.push(colors[theme.value]['blue'][400])
colorList.push(colors[theme.value]['green'][400])
return colorList
})
@@ -374,7 +395,7 @@ const progressColumns = computed(() => {
width: '30%',
},
{
label: __('Start Date'),
label: __('Enrolled On'),
key: 'creation',
align: 'right',
},

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